Compare commits

...
Author SHA1 Message Date
nvrl 77ee4a9078 chore: release Anvil 0.3.0, Weld 0.3.0 and Braze 0.1.0
Check and Windows preview / check (anvil) (push) Canceled after 0s
Check and Windows preview / check (braze) (push) Canceled after 0s
Check and Windows preview / check (crucible) (push) Canceled after 0s
Check and Windows preview / check (knurl) (push) Canceled after 0s
Check and Windows preview / check (lathe) (push) Canceled after 0s
Check and Windows preview / check (patina) (push) Canceled after 0s
Check and Windows preview / check (planish) (push) Canceled after 0s
Check and Windows preview / check (rivet) (push) Canceled after 0s
Check and Windows preview / check (temper) (push) Canceled after 0s
Check and Windows preview / check (weld) (push) Canceled after 0s
Check and Windows preview / previews (push) Canceled after 0s
Check and Windows preview / windows (push) Canceled after 0s
Independent plugin release / publish (push) Successful in 8s
Independent plugin release / plan (push) Successful in 8s
Independent plugin release / build (push) Successful in 3m12s
- Anvil 0.3.0: Ghost harmonics, Phone listen, Kick lock and the offset
  audition. Anvil now has a Kick sidechain input.
- Weld 0.3.0: delay spread display.
- Braze 0.1.0: first release.
- Mark the released items in docs/ideas.md and add Braze to the release
  table.
2026-11-06 14:16:42 +01:00
nvrl a42bca0e92 style(weld): format the delay spread tests 2026-11-06 14:16:42 +01:00
nvrl fadea4c471 feat(anvil): add Kick lock with offset audition
Check and Windows preview / check (anvil) (push) Canceled after 0s
Check and Windows preview / check (braze) (push) Canceled after 0s
Check and Windows preview / check (crucible) (push) Canceled after 0s
Check and Windows preview / check (knurl) (push) Canceled after 0s
Check and Windows preview / check (lathe) (push) Canceled after 0s
Check and Windows preview / check (patina) (push) Canceled after 0s
Check and Windows preview / check (planish) (push) Canceled after 0s
Check and Windows preview / check (rivet) (push) Canceled after 0s
Check and Windows preview / check (temper) (push) Canceled after 0s
Check and Windows preview / check (weld) (push) Canceled after 0s
Check and Windows preview / previews (push) Canceled after 0s
Check and Windows preview / windows (push) Canceled after 0s
Anvil gets a sidechain input named Kick. With Kick lock on, kicks are
found on the undelayed sidechain, so with the lookahead each hit ducks
the sub (Duck, Release) when the delayed kick plays and restarts the sub
at Phase, shifted by Offset (-30 to +30 ms, also before the kick).

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

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

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

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

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

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

- Fit::group_delay (analytic, per section) and Fit::smear_ms in weld-core.
- Report::smear_ms, filled by the analyzer from the applied fit.
- Tests against the numeric phase slope and the analog 4Q/w0 peak.
- Display only; the audio path does not change.
2026-11-06 13:17:29 +01:00
55 changed files with 6330 additions and 91 deletions

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]
+7 -5
View File
@@ -22,16 +22,17 @@ note. Each plugin is a separate product with its own version, guide and release.
| --- | --- | --- | --- | --- |
| **[Rivet](docs/rivet/README.md)** | Tells a MIDI motif as a story on a storyboard of roles and joins the seams into one phrase | Channel Rack | none | [Guide](docs/rivet/README.md) · [Changes](docs/rivet/CHANGELOG.md) |
| **[Patina](docs/patina/README.md)** | Smooths harsh, ringing resonances while plain harmonic tone and transients pass | Any mixer track | one analysis frame | [Guide](docs/patina/README.md) · [Changes](docs/patina/CHANGELOG.md) |
| **[Anvil](docs/anvil/README.md)** | Replaces the sub with a clean mono sine: one level and one start phase on every note | Bass bus | 80 ms | [Guide](docs/anvil/README.md) · [Changes](docs/anvil/CHANGELOG.md) |
| **[Anvil](docs/anvil/README.md)** | Replaces the sub with a clean mono sine: one level and one start phase on every note, locked to the kick | Bass bus, kick as sidechain | 80 ms | [Guide](docs/anvil/README.md) · [Changes](docs/anvil/CHANGELOG.md) |
| **[Crucible](docs/crucible/README.md)** | Records the last bars all the time, then slices, cleans and exports key-labelled samples with one button | End of the chain to resample | none | [Guide](docs/crucible/README.md) · [Changes](docs/crucible/CHANGELOG.md) |
| **[Lathe](docs/lathe/README.md)** | EQ with one band on each harmonic of the played note: a cut on harmonic 3 stays on harmonic 3 | Bass, after the synth | ≈ 67 ms | [Guide](docs/lathe/README.md) · [Changes](docs/lathe/CHANGELOG.md) |
| **[Temper](docs/temper/README.md)** | Distortion that drives every note the same: level and tone filters follow the note | Bass | ≈ 67 ms + oversampling | [Guide](docs/temper/README.md) · [Changes](docs/temper/CHANGELOG.md) |
| **[Weld](docs/weld/README.md)** | Phase-aligns one bass layer to another layer through the sidechain, so the layers sum in the mids instead of cancelling | One layer's track, other layer as sidechain | none | [Guide](docs/weld/README.md) · [Changes](docs/weld/CHANGELOG.md) |
| **[Knurl](docs/knurl/README.md)** | Gives every cycle of the note its own level, timing and grit: in-tune subharmonic growl, rattle and fry on every note | Bass | ≈ 71 ms | [Guide](docs/knurl/README.md) · [Changes](docs/knurl/CHANGELOG.md) |
| **[Planish](docs/planish/README.md)** | Upward and downward compression (OTT) in bands set by harmonic number: band 2 holds harmonics 2–4 on every note | Bass | ≈ 67 ms | [Guide](docs/planish/README.md) · [Changes](docs/planish/CHANGELOG.md) |
| **[Braze](docs/braze/README.md)** | Adds a detuned copy whose beating stays on the tempo grid on every note: reese movement in time | Bass | ≈ 67 ms | [Guide](docs/braze/README.md) · [Changes](docs/braze/CHANGELOG.md) |
Plugins with latency report it to the host, so FL Studio compensates the delay.
Anvil, Lathe, Temper, 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
+15
View File
@@ -1,5 +1,20 @@
# Anvil changelog
## 0.3.0
- Add Ghost harmonics: **Ghost** and **Ghost from** add the sub's harmonics 2–10
above a chosen frequency, so phones hear the note. Anvil measures the patch's own
harmonics above the split and fills only the ones that are missing. The sub level
does not change.
- Add **Phone**, a listen mode: mono, band-limited to 400 Hz–7 kHz. Not saved.
- Show the patch's harmonics and the added ones in an inset in the scope.
- Add Kick lock: with the kick in the sidechain, each kick ducks the sub
(**Duck**, **Release**) and restarts it at Phase, shifted by **Offset**
(−30 to +30 ms, also before the kick).
- Add the offset **Audition**: steps the offset from −30 to +30 ms, 4 kicks each,
shows how kick and sub summed per offset, and **Keep** takes the offset heard.
- Anvil now has a sidechain input named Kick. Without Kick lock it is ignored.
## 0.2.0
- Change the CLAP ID to `io.narl.bass-foundry.anvil`. Hosts see this as a new
+63 -2
View File
@@ -1,6 +1,6 @@
# Anvil
Anvil gives every bass note the same solid sub. Current version **0.2.0**.
Anvil gives every bass note the same solid sub. Current version **0.3.0**.
In riddim and dubstep, each switch-up patch brings its own sub: a different level,
a different phase, sometimes two layers that cancel. The drop's weight jumps from
@@ -28,12 +28,73 @@ redistributed packages.
**Retrigger** on, every note starts at this phase.
5. Turn on **Solo sub** to hear only the new sub; the scope shows the original
low band (grey) against the new sub (accent).
6. Turn on **Phone** to check the drop the way phones and laptop speakers play
it: mono, nothing below 400 Hz. A clean sub vanishes there. If the note
disappears with it, raise **Ghost**.
### Ghost harmonics
Phone speakers play almost nothing below 300–500 Hz. A clean 50 Hz sub sends all
its energy below that, so on a phone the note is gone. The ear rebuilds a note's
pitch from its harmonics, so harmonics alone carry the note.
**Ghost** adds the sub's harmonics 2–10 above **Ghost from**, falling 1/k like a
saw. At 100 %, harmonic k sits at 2/k of the sub's level. They fade in over half
an octave above Ghost from, and never reach below Split. The sub itself does not
change, so the weight on a club system stays where Sub level puts it.
Anvil measures each harmonic of the patch above the split and adds only what is
missing. A harmonic the patch already has meets an added one at an unrelated
phase and could cancel it, so Ghost leaves those alone. The inset in the scope
shows both, per harmonic: what the patch carries (grey) and what Ghost adds
(accent). A riddim patch usually needs little; 808s and clean sine subs need the
most. Each new note is measured from its first 60 ms; during that time every
harmonic counts as missing.
Drive works differently: it saturates the sine itself, adds only odd harmonics,
and changes the sub's own shape.
### Kick lock
With **Retrigger**, every note starts at Phase, but a kick that falls in the
middle of a long note meets the sub at whatever phase it has reached. Kick lock
listens to the kick through the sidechain. On each kick it dips the sub by
**Duck** (coming back over **Release**) and restarts the sub at Phase, so the
kick and the sub meet the same way on every hit.
Set it up in FL Studio:
1. Select the kick's mixer track. Right-click the route arrow under the bass
bus and choose **Sidechain to this track**. The kick still goes to the
master on its own; the sidechain only lets Anvil hear it.
2. In Anvil's plugin wrapper, open the settings (gear icon) and set the
sidechain input to the kick's track.
3. Turn on **Kick lock**. The kick count under the switch must rise with the
kicks.
**Offset** moves the restart against the kick, from 30 ms before it to 30 ms
after. Kicks are found on the undelayed sidechain, so with the lookahead the
sub can restart before the kick is heard.
Where the kick and sub sound like one hit is not always where they sum
loudest: offsets one period of the note apart sum the same, and the attack of
the kick decides where the hit is heard. So the offset is set by ear:
1. Turn on **Audition** and play a loop with kicks and a long bass note. Each
offset from −30 to +30 ms plays for 4 kicks; the line under the bars says
which one plays now.
2. Press **Keep** when kick and sub sound like one hit. Offset takes that
value and the audition stops.
The bars show, per offset, how the kick and the sub summed below 150 Hz in the
60 ms after each hit: +3 dB is a full sum of equal levels, 0 dB unrelated,
below 0 dB they cancel. Use them to check the choice, not to make it.
Other controls:
- **Dynamics** at 0 % holds the level for the whole note. Raise it to let the sub
follow the original's volume shape, for example a wobble.
- **Drive** adds upper harmonics so the sub reads on phones and laptops.
- **Drive** saturates the sine: odd harmonics and a thicker sub.
- **Octave** moves the sub against the detected note. Use −1 for a bass played
one octave up.
- **Glide** slides between overlapping notes; 0 jumps.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 304 KiB

+18
View File
@@ -0,0 +1,18 @@
# Braze changelog
## 0.1.0
- Introduce Braze, a detuned copy of the bass whose beating stays on the tempo
grid on every note.
- The chosen harmonic (1–8) beats once per Rate note value (1/1 to 1/32, and
triplets); harmonic n beats n/harmonic times as fast, so every harmonic beats
on the grid. The beat peaks land on the host's beat position.
- The detune follows the tracked note. The copy jumps by exactly one period of
the note, so the jumps are seamless on a steady note.
- Low cut keeps the copy out of the sub, so the sub neither beats nor changes
level.
- Depth, Width (right channel's beat against the left), Phase, Gate, Output,
Snap to semitone, Bypass.
- Detect pitch and note starts from the audio with about 67 ms of latency, or
follow MIDI notes sent to the effect.
- Editor: one bar of the beat per harmonic with the grid and a playhead.
+76
View File
@@ -0,0 +1,76 @@
# Braze
Braze makes a reese's movement play in time. Current version **0.1.0**.
Two layers detuned against each other beat: harmonic k of a note at f0 swells
and fades k·f0·(r − 1) times per second, where r is the detune ratio. With a
fixed detune that rate changes with every note, so the movement of a reese
never lines up with the track. Braze adds a detuned copy of the bass and sets
its detune on every note so that the beating follows the tempo.
![Braze](assets/editor.png)
## Install on Windows
Copy `Braze.clap` into `C:\Program Files\Common Files\CLAP\`. In FL Studio 2024.1
or newer, open Options → Manage plugins, enable Verify plugins, and Find installed
plugins. Load Braze in the mixer slot of the bass. Keep the font license with
redistributed packages.
## Work with it
1. Put Braze on a single bass layer: a plain saw, or a reese whose own detune
you have turned down.
2. Pick **Rate**: the note value one beat lasts. 1/8 at 140 BPM is 4.67 beats
per second.
3. Pick the **harmonic** that should beat at Rate. Harmonic n beats
n/harmonic times as fast, so all of them beat on the grid: with harmonic 4 at
1/8, harmonic 2 beats in quarters, harmonic 8 in sixteenths, harmonic 3 in
quarter triplets.
4. Set **Depth**. At 100 % the beating goes down to full nulls; 50–70 % keeps
the note present.
5. Use **Phase** to move where the beat peaks. At 0° all harmonics peak
together on every beat of the chosen harmonic's cycle.
The display shows one bar: a row per harmonic with the level it gets from the
beating, the grid at Rate, and the playhead.
Other controls:
- **Low cut** removes the copy below a frequency, so the sub does not beat.
The sub keeps its level. Harmonics under the cut show *cut* in the display.
Under 20 Hz the cut is off. If the chosen harmonic is under the cut on a
note, it does not beat: the note panel says so; pick a higher harmonic.
- **Width** moves the right channel's beat against the left, up to half a
cycle. At 100 % one side peaks while the other nulls. Check the mono sum.
- **Gate** is the input level that counts as a note.
- **Snap to semitone** rounds the detected pitch. Leave it off on detuned or
bent notes: the copy jumps by one period of the pitch in use.
### How the detune is made
The copy is the bass read from a delay line. Its delay falls by one period of
the note per beat cycle of the chosen harmonic, which tunes the copy up by
exactly r = 1 + B/(k·f0) (B: beats per second, k: the chosen harmonic). When it
has fallen a whole period, it jumps back by one period: on a steady note both
read positions hold the same waveform, so the jump is crossfaded over 6 ms
without a seam. At a beat peak the copy lines up with the bass sample for
sample.
The low detune that low notes need is large: harmonic 1 at 1/8 and 140 BPM on
F1 (43.7 Hz) is r = 1.107, about 1.8 semitones. Higher harmonics need less:
harmonic 4 asks for about 45 cents on F1.
## Timing and limits
Detection uses about 67 ms of lookahead, reported to the host for delay
compensation. Pitch range is 30–500 Hz, one voice. The beat position comes from
the host while it plays, shifted by the lookahead so the beat stays on the
timeline. When the host is stopped, the beat runs on freely at the last tempo.
Noise, chords and the attack of a note are not periodic, so the copy's jumps
are heard there as a short blur. Before the first note, the copy assumes 55 Hz.
Braze changes the waveform in time, so pitch detection after it can be less
sure: put tracked plugins before Braze, or use MIDI. See
[Signal chain](../chain.md).
Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

+12 -5
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.
@@ -23,7 +29,8 @@ bass bus: Anvil → (bus compression, clipping) → Crucible
layer but cannot move it earlier. If FL Studio does not compensate the
sidechain route, any latency added after Weld's input on the main layer makes
that layer late.
- **Anvil on the bus, in MIDI mode when Knurl is in the chain.**
- **Anvil on the bus, in MIDI mode when Knurl is in the chain.** For Kick
lock, send the kick's track to Anvil as a sidechain.
- **Crucible at the end of the chain you want to resample.**
## Knurl and pitch detection
@@ -45,8 +52,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
+163 -12
View File
@@ -41,6 +41,13 @@ Each entry names the problem first. Entries are grouped as released or idea. Nam
the low end into distortion.
18. Resampled basslines have no MIDI, so tools that need the notes have nothing
to follow.
19. A clean sub carries the note on a club system but vanishes on phones and
laptops. Distortion makes the note readable there, but also changes the
patch.
20. Detuned layers (reese) beat at a rate that changes with every note and every
harmonic, so the movement is never in time with the track.
21. Kick and sub on the same MIDI tick are not heard at the same moment: the
heard hit depends on the attack shapes and on the offset between them.
## Signal flow and suite audit (2026-11)
@@ -84,6 +91,71 @@ Patina. Findings from the audit:
- **Settings handling differs.** Patina has presets and A/B; the others do not.
Lathe's drawn gains and Knurl's drawn pattern cannot be automated.
## Research findings (2026-11)
From the papers in `research/` (local, not committed). Each finding names where
it goes in the suite. They are starting points to test by ear, not rules.
- **A published bass fatness model.** Schmedeke's dissertation (Hamburg 2021,
"BASS DROP!", section 5.4.3) models momentary fatness of dance-music excerpts
over 50 ms windows from two inputs only, with adjusted R² 0.773:
`l = 10 / (1 + 4.5487·exp(0.13·(62.9366 − SPL<200)))`,
`h = 10 / (1 + 1.4317·exp(2.5575·(0.3299 − HR<200)))`,
`Fat = 0.14689·l·h` (0 to 14.7). SPL<200 is the C-weighted level below
200 Hz in dB(C) with the material normalised to −23 LUFS; HR<200 is the
MPEG-7 harmonic ratio (how periodic) of the same band. Chapter 4's earlier
linear model on synthetic tones (R² 0.597) points the same way: a tonal low
band is fatter, a noisy one is not. Anvil's clean sine maximises HR; Quench
keeps it when the top is clipped. The thesis does not state the dB(C) of its
reference, so the dBFS-to-dB(C) offset is an assumption. Measured with
`scripts/fatness.py` on the seven Devath references (offset 100 dB): drops
sit at 4–6, breakdowns near 0, kick gaps show as 50 ms dips; SPL<200 medians
are 67–69 dB(C), a few dB under the thesis's "mostly 70–80", so the offset
may be 2–5 dB low. Goes into Assay as one lane, never as an overall score.
- **Virtual bass from known harmonics.** Moliner, Rämö and Välimäki (DAFx 2020)
boost the harmonics that exist with weights that follow the sound's spectral
envelope (limits 10 dB/oct below, 3 dB/oct above) and generate the missing
ones, so a small speaker suggests the fundamental. Their nonlinear stage fails
intermodulation tests, so they use it on transients only. The suite knows f0,
so it can add harmonics at exactly k·f0 with no intermodulation (problem 19).
Anvil's sub is a pure sine, so Lathe has no harmonics there to boost.
- **Group-delay audibility.** Liski, Mäkivirta and Välimäki (2021): between
500 Hz and 4 kHz, listeners hear a group-delay peak from about 0.64 ms
(positive) or 0.56 ms (negative) on clicks over headphones; a fast-decaying
hi-hat hides it. Weld's allpass sections add positive group delay there.
Done: Weld shows the delay spread (released in 0.3.0). Measured: common fits from
Weld's own tests spread 0.82–1.03 ms, above the click threshold, so the
display gives a note, not a warning.
- **Timbral tension.** Farbood and Price (2017): more roughness, inharmonicity
and spectral flatness raise judged tension; brightness (centroid) gives no
clear result. These are the measures planned for Loupe, and the controls a
tension macro would move.
- **Coupled one-knob controls.** A 2026 distance renderer derives five distance
cues from one control, so they always agree; in its listening test it roughly
halved placement error and every participant preferred it. A pattern for
macros in Forge.
- **Shared motion binds layers.** Temporal coherence (Elhilali et al. 2009) is
a grouping cue: sub, body and top sound like one sound when their main
movements line up. One modulation source inside Forge gives that by
construction; across separate `.clap` files it needs the shared memory
rejected below.
- **Heard timing of layered hits.** Danielsen et al. (2026): attack shape and
the offset between two layers together set when the compound hit is heard
and whether it fuses. Hove et al. (2014): timing deviations are heard more
clearly in lower voices, so the bass sets the grid (problem 21).
- **Loopback FM.** Smyth (DAFx 2026): an oscillator that frequency-modulates
itself shifts its own sounding pitch as feedback rises. Her closed-form
solution gives the sounding frequency, so a growl can stay on the tracked
note at any feedback.
- **Beating depends on note and harmonic.** For two layers detuned by ratio r,
harmonic k beats at k·f0·(r − 1) Hz (Plomp and Levelt 1965 for the roughness
context). Fixed cents beat faster up the keyboard; fixed Hz beats the same on
every harmonic (problem 20).
- **Do not encode as rules:** a best sub frequency, crossover or kick/bass
offset; one number for loudness, roughness, groove and preference; random
jitter sold as humanisation; more harmonics sold as more physical bass;
largest correlation sold as the best alignment.
## Released
### Anvil — sub anchor (problems 1, 6)
@@ -136,14 +208,27 @@ is the same on every note. Slow per-harmonic drift is postponed. See
Upward and downward compression in four bands whose edges are harmonic numbers
of the tracked note (default 1, 2–4, 5–16, 17 and up), so the same setting
squashes the same harmonics on every note. Each band's level is measured over
one period of the note, so heavy settings do not ripple at f0. Built, not yet
released. See [Planish](planish/README.md).
one period of the note, so heavy settings do not ripple at f0. See [Planish](planish/README.md).
## Next for released plugins
- **Anvil**: *Kick lock*: the kick through the sidechain ducks the sub on each
hit and restarts its phase so kick and sub sum (problem 6). A sub shape from
sine to rounded triangle. A note attack counted in cycles.
- **Anvil**: *Kick lock* and the *offset audition* are done (Anvil 0.3.0, see
[Anvil](anvil/README.md)): the kick through the sidechain ducks the sub and
restarts its phase at an offset (problem 6); the audition steps −30 to +30 ms,
4 kicks each, Keep takes the offset heard, and the low-band sum per offset is
shown apart from that choice (problem 21). Measured on a 55 Hz kick burst
against a 55 Hz sub: +2.4 dB at 0 ms, −4.6 dB at −10 ms, −5.9 dB at +10 ms,
and +1.8 to +2.2 dB again one period away (±20 ms), so the sum alone cannot
pick the hit. Open: a finer second sweep around the kept offset; a gate or
sensitivity for kicks with long tails or a kick bus with other drums.
A sub shape from sine to rounded
triangle. A note attack counted in cycles. *Ghost harmonics* and the
*Phone* listen are done (Anvil 0.3.0, see [Anvil](anvil/README.md)): harmonics
2–10 above a "from" frequency, filled only where the patch lacks them, with
the sub level unchanged (problem 19). Open for Ghost: weights that follow the
patch's own harmonic slope instead of a fixed 1/k; measuring a new note's
harmonics from the lookahead, so its first 60 ms do not count them as
missing; a listening check on an 808 over a real phone.
- **Lathe**: A/B gain snapshots with one automatable Morph parameter. A
keyswitch note that selects the drawn shape (EQ switch-ups without
automation). A dynamic mode per harmonic.
@@ -151,11 +236,14 @@ released. See [Planish](planish/README.md).
one Push band. A bias that changes per cycle from Knurl's cycle clock.
- **Knurl**: velocity or note selects the pattern. Stereo grit above a split
only. Drift (planned for 0.3).
- **Patina**: Tone guard (done, unreleased). MIDI as its pitch source. M/S
- **Patina**: Tone guard (done, Patina 0.4.0). MIDI as its pitch source. M/S
processing.
- **Weld**: alignment slots per note or per switch-up. The External
- **Weld**: delay spread display (done, Weld 0.3.0). A *gentle* fit option
that keeps the delay spread under a limit, for plucky layers. Alignment slots
per note or per switch-up. The External
sender/receiver fallback if FL's sidechain does not arrive.
- **Crucible**: a switch-up slicer that cuts where the spectrum changes, for
- **Crucible**: a heard-onset marker per slice next to the technical cut, set
by ear and written to the WAV (problem 21). A switch-up slicer that cuts where the spectrum changes, for
legato basses. Optional retune of slices to one root. Reopening saved takes
from the `takes` folder in the take list.
- **Rivet**: more than two sound roles, one per switch-up patch. Its MIDI
@@ -170,13 +258,18 @@ released. See [Planish](planish/README.md).
([Signal chain](chain.md)), Crucible Keep takes. Open: Weld test with delay
compensation (in FL), MIDI mode without lookahead (needs an FL check that a
latency change at runtime is accepted).
2. Patina pitch-aware mode. Done as **Tone guard** (unreleased): bumps that
2. Patina pitch-aware mode. Done as **Tone guard** (Patina 0.4.0): bumps that
follow the note are protected, bumps fixed in Hz are cut. Measured first:
Patina already cut fixed formants on every note, but it also cut a boosted
harmonic as hard (5.7 dB), at a position that moved with the note.
Open: MIDI as the pitch source.
3. Planish (done, unreleased), then Quench and Ballast: they fill the mix and
master gap.
3. Planish (done, released 0.1.0), then Quench and Ballast: they fill the mix
and master gap.
- In parallel, from the research findings, in this order: Weld delay
spread (done, Weld 0.3.0); the fatness lane, as the first Assay piece
(prototype `scripts/fatness.py`); Anvil Ghost harmonics and Phone listen
(done, Anvil 0.3.0); Braze (done, 0.1.0); Anvil Kick lock with offset
audition (done, Anvil 0.3.0).
4. Forge, when enough modules exist.
5. Assay, one plugin for the display-only ideas.
6. Texture plugins (Anneal, Swarf, Etch, Bellows) and the note-keyed sequencer.
@@ -188,12 +281,47 @@ are all "step grid, note selects the pattern", so they become one sequencer.
### Forge — one tracked channel strip (problems 9, 11, latency)
One tracker, one latency, and Lathe, Temper, Knurl and later Anneal as modules
in an order you choose. It removes the stacked lookahead, the trackers that
in an order you choose. Two further reasons from the research findings: one
modulation source for all modules moves sub, body and top together, which
binds them into one sound; and coupled macros become possible, for example a
*Tension* knob that raises roughness (Knurl), inharmonicity and noise (Anneal)
together at matched loudness, measured by the same descriptors as Loupe. It removes the stacked lookahead, the trackers that
disagree (Knurl's sub-octave cannot confuse a tracker that reads the input
before Knurl) and the repeated MIDI routing. The standalone plugins stay.
Sharing one tracker between separate `.clap` files would need named shared
memory across DLLs, which is fragile; one strip is simpler.
### Braze — tempo-locked beating (problem 20)
Brazing joins two metals with a filler. Braze adds a detuned copy of the bass
whose beating is in time with the track on every note. With the tracked f0, it
sets the detune ratio for each note so that a chosen harmonic beats at a
tempo-synced rate: r = 1 + B/(k·f0), for example an eighth at 140 BPM is
B = 4.67 Hz. A second mode adds a frequency-shifted copy, so every harmonic
beats at the same rate. Controls: rate (synced), target harmonic, depth,
spread across harmonics, mode. It reuses the tracker, and Weld should not
"fix" this beating: put Weld before Braze.
Built (released 0.1.0, see [Braze](braze/README.md)). The detune is a delay
line whose delay falls one period per beat cycle and jumps back one period,
which is seamless on a periodic note, and the delay follows the host's beat
position, so the peaks land on the grid. Harmonic n then beats at n/k · B: every
harmonic is on the grid, so the planned spread control was not needed. A Low
cut (LR4 on the copy, the bass split by the same crossover) keeps the sub out.
Open: the frequency-shift mode. Mixed with the dry bass, a plain single-sideband
shift gives every harmonic the same envelope at the same moment, which is
tremolo; it needs a dispersive allpass on the copy to stagger the harmonics
before it sounds like movement. Also open: a listening check on a real reese,
and how Weld reads a layer after Braze.
### Arc — self-FM growl (research spike)
An arc welds with a sustained spark. Arc runs loopback FM (Smyth, DAFx 2026)
on the tracked note: a time-varying delay line modulated by its own output.
The closed-form sounding frequency lets Arc retune the carrier, so the growl
stays in key while feedback sweeps from clean to torn. Needs listening and an
aliasing check before it becomes a plugin.
### Quench — sub-safe clipper (problem 17)
Quenching hardens hot metal in one fast step. Quench clips only above a split
@@ -245,6 +373,15 @@ grids built in. Each MIDI note selects a different pattern, so a switch-up is a
different note in the piano roll. ShaperBox overlaps, but it has no note-keyed
patterns. No name yet.
### Fatness lane (problems 2, 8)
Momentary fatness per 50 ms from Schmedeke's model (see Research findings),
drawn as one lane next to sub/mid energy, for a patch and a reference. It
explains why two drops at the same LUFS feel different in weight: a quieter or
noisier low band lowers the value. Prototype: `scripts/fatness.py` (offline,
on rendered files). The first piece of Assay. Open: the dB(C) offset, and how
the lane reads on a single bass patch rather than a full mix.
### Drop compare (problem 8)
Loudness-matched A/B of a drop against a reference, with sub, low-mid, mid and
@@ -328,7 +465,21 @@ over time:
- inharmonicity, which shows how metallic a sound is;
- brightness relative to the note (harmonic centroid, not centroid in Hz).
Roughness, inharmonicity and flatness are also the three timbre measures that
raise judged tension (Farbood and Price 2017), so the same lanes show a build-up
getting tenser without a note change.
It places every switch-up of a drop as a point on a texture map. Two patches
that sit on the same point are why a drop sounds repetitive. A reference track
can be loaded for comparison. Display only, so it is a small build, and it fits
with Gauge and Drop compare.
### Further leads
- **Creative extrema stretch** for Crucible: Nielsen (DAFx 2026) drives an
overlap-add stretch by the spacing of local extrema, a free measure of
detail. Cheap and gritty, not transparent; a bass has few extrema per cycle,
so the stretch stays close to cycle-synchronous.
- **Reference to preset:** an offline search over a bounded set of Lathe and
Temper parameters that matches a reference switch-up (ST-ITO, TAPE). Large;
needs learned audio features.
+3 -2
View File
@@ -9,13 +9,14 @@ workflows are in `.gitea/workflows/`. A plugin's version is the `version` in
| --- | --- | --- |
| Rivet | `rivet-v0.5.0` | `rivet-windows-x86_64-0.5.0.zip` |
| Patina | `patina-v0.4.0` | `patina-windows-x86_64-0.4.0.zip` |
| Anvil | `anvil-v0.2.0` | `anvil-windows-x86_64-0.2.0.zip` |
| Anvil | `anvil-v0.3.0` | `anvil-windows-x86_64-0.3.0.zip` |
| Crucible | `crucible-v0.3.0` | `crucible-windows-x86_64-0.3.0.zip` |
| Lathe | `lathe-v0.2.0` | `lathe-windows-x86_64-0.2.0.zip` |
| Temper | `temper-v0.2.0` | `temper-windows-x86_64-0.2.0.zip` |
| Weld | `weld-v0.2.0` | `weld-windows-x86_64-0.2.0.zip` |
| Weld | `weld-v0.3.0` | `weld-windows-x86_64-0.3.0.zip` |
| Knurl | `knurl-v0.2.0` | `knurl-windows-x86_64-0.2.0.zip` |
| Planish | `planish-v0.1.0` | `planish-windows-x86_64-0.1.0.zip` |
| Braze | `braze-v0.1.0` | `braze-windows-x86_64-0.1.0.zip` |
## Make a release
+7
View File
@@ -1,5 +1,12 @@
# Weld changelog
## 0.3.0
- Show the **delay spread**: how much the alignment's group delay changes
between 500 Hz and 4 kHz, where listeners hear it best. Above 0.64 ms (the
threshold Liski et al. 2021 measured on clicks) a note suggests comparing
attacks in SUM and SUM DRY. Display only; the sound does not change.
## 0.2.0
- Change the CLAP ID to `io.narl.bass-foundry.weld`. Hosts see this as a new
+6 -1
View File
@@ -1,7 +1,7 @@
# Weld
Weld fuses two bass layers so they add up instead of cancelling. Current version
**0.2.0**.
**0.3.0**.
Bass patches are layered: a sub, one or more mid growls, a top. Where the layers
overlap in the mids, they partly cancel. The causes are a polarity difference, a
@@ -50,6 +50,11 @@ plugins. Keep the font license with redistributed packages.
- The lower plot shows the phase of this layer against the other one, before
(grey) and after (pink), and Weld's own phase curve (blue). After alignment
the pink line sits near 0°.
- **Delay spread** under the sections is how much Weld's delay changes
between 500 Hz and 4 kHz. On clicks, listeners hear about 0.6 ms of change
there (Liski et al. 2021); sustained bass hides much more, and common fits
reach 0.8–1 ms. Above that threshold Weld adds a note: on a plucky layer,
compare the attacks in SUM and SUM DRY. Fewer sections usually spread less.
3. Compare by ear: mute the other layer's own route to the master, set
**Listen** to **SUM**, then to **SUM DRY**. Set Listen back to **LAYER** and
unmute the route for the mix.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 266 KiB

+427 -14
View File
@@ -4,10 +4,23 @@
//! The audio path is delayed by a fixed lookahead. Detection runs on the undelayed input, so
//! a note's pitch is known before its delayed attack leaves the plugin, and the sine starts
//! exactly on that attack.
//!
//! Ghost harmonics add the sub's own harmonics above a chosen frequency, so the note
//! reads on speakers without sub. The band above the split is measured per harmonic and
//! only the harmonics the patch lacks are filled: a harmonic the patch already has would
//! meet the added one at an unrelated phase and could cancel.
//!
//! Kick lock reads the kick from the sidechain. Hits are found on the undelayed sidechain,
//! so with the lookahead each hit ducks the sub exactly when the delayed kick plays, and
//! restarts the sub's phase at the kick plus an offset, also before the kick. The audition
//! plays each offset of a sweep for a few kicks and measures how kick and sub sum in the
//! low band for each one; what sums loudest is not always what sounds like one hit.
mod settings;
use common_dsp::meter::HarmonicMeter;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{NoteEvent, Tracker, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, hz_to_midi, midi_to_hz, Crossover};
pub use settings::{Settings, Source};
use common_dsp::{coefficient, db_to_gain, hz_to_midi, midi_to_hz, Biquad, Crossover};
pub use settings::{Settings, Source, KICK_OFFSET_MAX};
use std::f32::consts::TAU;
/// Detectable fundamental range. The lowest sets the lookahead (two periods).
@@ -17,11 +30,44 @@ pub const SCOPE: usize = 256;
const ATTACK_MS: f32 = 2.;
const RELEASE_MS: f32 = 15.;
const CROSSFADE_MS: f32 = 3.;
/// Highest Ghost harmonic. Harmonic 10 of a 30 Hz sub is 300 Hz.
pub const GHOST_TOP: usize = 10;
/// Ghost harmonics shown and generated: 2..=GHOST_TOP.
pub const GHOST_COUNT: usize = GHOST_TOP - 1;
/// Measurement window per note. Resolves harmonics of fundamentals down to about 35 Hz.
const GHOST_WINDOW_MS: f32 = 60.;
/// Harmonics fade in over this many octaves above the Ghost edge, so a gliding note
/// does not switch them on with a step.
const GHOST_RAMP_OCT: f32 = 0.5;
/// Offsets the audition steps through, in ms.
pub const AUDITION: [f32; 13] = [
-30., -25., -20., -15., -10., -5., 0., 5., 10., 15., 20., 25., 30.,
];
/// Kicks each audition offset plays for.
pub const AUDITION_HITS: u32 = 4;
/// A kick is a rise of the fast sidechain envelope this far above the slow one...
const KICK_RISE: f32 = 2.8;
/// ...above this level (−45 dBFS)...
const KICK_FLOOR: f32 = 0.0056;
/// ...at least this long after the previous kick.
const KICK_HOLD_MS: f32 = 60.;
/// Kick and sub are compared below this frequency, over this long after each hit.
const KICK_LOW_HZ: f32 = 150.;
const SUM_MS: f32 = 60.;
const DUCK_ATTACK_MS: f32 = 1.;
const PENDING: usize = 16;
/// Small speaker listen: a phone-like band, summed to mono.
pub const PHONE_LOW: f32 = 400.;
pub const PHONE_HIGH: f32 = 7000.;
#[derive(Clone, Copy, Debug, Default)]
pub struct Monitor {
/// Hear only the generated sub.
/// Hear only the generated sub and Ghost harmonics.
pub solo: bool,
/// Hear the output through a phone-like speaker band, in mono.
pub phone: bool,
/// Step the kick offset through `AUDITION`, `AUDITION_HITS` kicks each.
pub audition: bool,
}
#[derive(Clone, Copy, Debug)]
@@ -32,6 +78,21 @@ pub struct AnalyzerFrame {
pub note: Option<u8>,
pub low_db: f32,
pub sub_db: f32,
/// Harmonics 2..=GHOST_TOP of the current note, peak dB relative to the sub: what the
/// patch carries above the split, and what Ghost adds. −120 when absent.
pub patch_harmonics: [f32; GHOST_COUNT],
pub ghost_harmonics: [f32; GHOST_COUNT],
/// Lowest frequency Ghost fills, after the split.
pub ghost_edge: f32,
/// Kicks found in the sidechain since the plugin started.
pub kick_hits: u32,
/// Kick plus sub against kick and sub apart, low band, dB, after the last hit. +3 is a
/// full sum of equal levels, 0 unrelated, below 0 they cancel. NaN before a hit.
pub kick_sum_db: f32,
/// Offset the sub restarted with on the latest kick, in ms.
pub kick_offset: f32,
/// Measured sum per audition offset, dB; NaN where not measured yet.
pub audition_sums: [f32; AUDITION.len()],
/// Original low band and new sub, triggered on a rising zero crossing of the sub.
pub scope_low: [f32; SCOPE],
pub scope_sub: [f32; SCOPE],
@@ -47,6 +108,13 @@ impl Default for AnalyzerFrame {
note: None,
low_db: -120.,
sub_db: -120.,
patch_harmonics: [-120.; GHOST_COUNT],
ghost_harmonics: [-120.; GHOST_COUNT],
ghost_edge: 300.,
kick_hits: 0,
kick_sum_db: f32::NAN,
kick_offset: 0.,
audition_sums: [f32::NAN; AUDITION.len()],
scope_low: [0.; SCOPE],
scope_sub: [0.; SCOPE],
scope_ms: 60.,
@@ -72,6 +140,8 @@ struct Synth {
old: Voice,
old_gain: f32,
note_peak: f32,
/// Set when a note starts or changes pitch; the Ghost measurement starts over.
fresh: bool,
}
impl Synth {
fn event(&mut self, s: &Settings, e: NoteEvent) {
@@ -111,6 +181,112 @@ impl Synth {
self.voice.hz = hz;
}
self.note_peak = 0.;
self.fresh = true;
}
/// Restarts a sounding sub at the start phase, keeping its pitch.
fn kick(&mut self, s: &Settings) {
let v = self.voice;
if v.amp <= 1e-4 || v.releasing {
return;
}
self.old = v;
self.old_gain = 1.;
self.voice = Voice {
phase: s.phase / 360.,
amp: 0.,
..v
};
}
}
#[derive(Clone, Copy, Debug, Default)]
struct Pending {
/// Output-time sample.
at: u64,
/// Restart the sub (true) or duck it and start measuring (false).
restart: bool,
offset: f32,
/// Audition offset index this hit belongs to.
slot: Option<usize>,
}
/// Kick detection, scheduling, ducking and the sum measurement.
struct Kick {
clock: u64,
fast: f32,
slow: f32,
fast_coef: f32,
slow_coef: f32,
hold: usize,
pending: [Option<Pending>; PENDING],
delay: Vec<f32>,
delay_at: usize,
low: Biquad,
duck: f32,
rising: bool,
attack: f32,
measure: usize,
measure_slot: Option<usize>,
energy: [f32; 3],
hits: u32,
sum_db: f32,
offset: f32,
auditioning: bool,
slot: usize,
slot_hits: u32,
sums: [f32; AUDITION.len()],
counts: [u32; AUDITION.len()],
}
impl Kick {
fn new(rate: f32, latency: usize) -> Self {
Self {
clock: 0,
fast: 0.,
slow: 0.,
fast_coef: coefficient(rate, 0.5),
slow_coef: coefficient(rate, 30.),
hold: 0,
pending: [None; PENDING],
delay: vec![0.; latency + 1],
delay_at: 0,
low: Biquad::lowpass(rate, KICK_LOW_HZ, std::f32::consts::FRAC_1_SQRT_2),
duck: 0.,
rising: false,
attack: coefficient(rate, DUCK_ATTACK_MS / 3.),
measure: 0,
measure_slot: None,
energy: [0.; 3],
hits: 0,
sum_db: f32::NAN,
offset: 0.,
auditioning: false,
slot: 0,
slot_hits: 0,
sums: [f32::NAN; AUDITION.len()],
counts: [0; AUDITION.len()],
}
}
fn reset(&mut self) {
self.fast = 0.;
self.slow = 0.;
self.hold = 0;
self.pending = [None; PENDING];
self.delay.fill(0.);
self.low.reset();
self.duck = 0.;
self.rising = false;
self.measure = 0;
self.measure_slot = None;
self.sum_db = f32::NAN;
self.slot = 0;
self.slot_hits = 0;
self.sums = [f32::NAN; AUDITION.len()];
self.counts = [0; AUDITION.len()];
}
fn schedule(&mut self, p: Pending) {
if let Some(free) = self.pending.iter_mut().find(|p| p.is_none()) {
*free = Some(p);
}
}
}
@@ -140,6 +316,14 @@ pub struct Engine {
scope_at: usize,
scope_stride: usize,
scope_count: usize,
meter: HarmonicMeter,
/// Linear peak of the patch's harmonic k (index k − 2) above the split, this note.
patch: [f32; GHOST_COUNT],
/// Smoothed Ghost gain per harmonic, relative to the sub's peak.
fill: [f32; GHOST_COUNT],
phone_coefs: [SvfCoef; 3],
phone: [Svf; 3],
kick: Kick,
}
impl Engine {
@@ -179,6 +363,17 @@ impl Engine {
scope_at: 0,
scope_stride: ((rate * 0.06) / SCOPE as f32).round().max(1.) as usize,
scope_count: 0,
meter: HarmonicMeter::new(rate, GHOST_WINDOW_MS),
patch: [0.; GHOST_COUNT],
fill: [0.; GHOST_COUNT],
phone_coefs: [
// Fourth-order Butterworth high-pass, second-order low-pass.
SvfCoef::highpass(rate, PHONE_LOW, 0.541),
SvfCoef::highpass(rate, PHONE_LOW, 1.307),
SvfCoef::lowpass(rate, PHONE_HIGH.min(0.45 * rate), 0.707),
],
phone: [Svf::default(); 3],
kick: Kick::new(rate, latency),
};
e.set_settings(settings)?;
Ok(e)
@@ -226,6 +421,49 @@ impl Engine {
self.sub_power = 0.;
self.scope_low = [0.; 2 * SCOPE];
self.scope_sub = [0.; 2 * SCOPE];
self.meter.reset();
self.patch = [0.; GHOST_COUNT];
self.fill = [0.; GHOST_COUNT];
for f in &mut self.phone {
f.reset();
}
self.kick.reset();
}
/// Lowest frequency Ghost fills.
fn ghost_edge(&self) -> f32 {
self.settings.ghost_from.max(self.settings.split)
}
/// Moves each Ghost gain toward what the patch lacks at the current note.
fn update_fill(&mut self) {
let s = &self.settings;
let v = self.synth.voice;
let amount = s.ghost * 0.01;
let edge = self.ghost_edge();
let sub = self.level.max(1e-6);
for (i, fill) in self.fill.iter_mut().enumerate() {
let k = (i + 2) as f32;
let f = k * v.hz;
let target = if amount > 0. && v.hz > 0. && f < 0.45 * self.rate {
let ramp = ((f / edge).log2() / GHOST_RAMP_OCT).clamp(0., 1.);
// A 1/k slope, like a saw; at 100 % harmonic k sits at 2/k of the sub.
let want = amount * 2. / k * ramp;
(want - self.patch[i] / sub).max(0.)
} else {
0.
};
*fill += (target - *fill) * self.smooth;
}
}
/// Ghost harmonics for a voice at `phase`, without the voice's amplitude.
#[inline]
fn ghost(&self, phase: f32) -> f32 {
let mut out = 0.;
for (i, &g) in self.fill.iter().enumerate() {
if g > 1e-5 {
out += g * (TAU * ((i + 2) as f32 * phase).fract()).sin();
}
}
out
}
/// MIDI note start at the current input sample. Used when the source is MIDI.
pub fn note_on(&mut self, note: u8) {
@@ -243,7 +481,8 @@ impl Engine {
let k = 1. + 6. * drive;
(k * x).tanh() / k.tanh()
}
fn oscillator(&mut self) -> f32 {
/// The shaped sub and the Ghost harmonics, before level and dynamics.
fn oscillator(&mut self) -> (f32, f32) {
let attack = 1. / (self.rate * ATTACK_MS * 0.001);
let release = 1. / (self.rate * RELEASE_MS * 0.001);
let v = &mut self.synth.voice;
@@ -253,21 +492,145 @@ impl Engine {
(v.amp + attack).min(1.)
};
v.hz += (v.target - v.hz) * self.glide;
let mut out = 0.;
let (mut out, mut ghost) = (0., 0.);
let v = self.synth.voice;
if v.amp > 0. {
out += (TAU * v.phase).sin() * v.amp;
v.phase = (v.phase + v.hz / self.rate).fract();
ghost += self.ghost(v.phase) * v.amp;
self.synth.voice.phase = (v.phase + v.hz / self.rate).fract();
}
let s = &mut self.synth;
let s = self.synth;
if s.old_gain > 0. {
let o = &mut s.old;
let o = s.old;
out += (TAU * o.phase).sin() * o.amp * s.old_gain;
o.phase = (o.phase + o.hz / self.rate).fract();
ghost += self.ghost(o.phase) * o.amp * s.old_gain;
let s = &mut self.synth;
s.old.phase = (o.phase + o.hz / self.rate).fract();
s.old_gain = (s.old_gain - 1. / (self.rate * CROSSFADE_MS * 0.001)).max(0.);
}
self.shape(out)
(self.shape(out), ghost)
}
/// One sample without a sidechain.
pub fn process_sample(&mut self, x: [f32; 2], monitor: Monitor) -> [f32; 2] {
self.process_with_kick(x, 0., monitor)
}
/// Finds kicks in the undelayed sidechain and schedules their duck and restart.
fn detect_kick(&mut self, side: f32, audition: bool) {
let rate = self.rate;
let s = self.settings;
let k = &mut self.kick;
if audition != k.auditioning {
k.auditioning = audition;
k.slot = 0;
k.slot_hits = 0;
k.sums = [f32::NAN; AUDITION.len()];
k.counts = [0; AUDITION.len()];
}
let level = side.abs();
k.fast += (level - k.fast) * k.fast_coef;
k.slow += (level - k.slow) * k.slow_coef;
k.hold = k.hold.saturating_sub(1);
if !s.kick_lock || k.hold > 0 || k.fast < KICK_FLOOR || k.fast < KICK_RISE * k.slow {
return;
}
k.hold = (rate * KICK_HOLD_MS * 0.001) as usize;
let (offset, slot) = if audition {
let slot = k.slot;
k.slot_hits += 1;
if k.slot_hits >= AUDITION_HITS {
k.slot_hits = 0;
k.slot = (k.slot + 1) % AUDITION.len();
}
(AUDITION[slot], Some(slot))
} else {
(s.kick_offset, None)
};
// The delayed kick leaves the plugin `latency` samples from now.
let at = k.clock + self.latency as u64;
let shift = (offset * 0.001 * rate).round() as i64;
let duck = Pending {
at,
restart: false,
offset,
slot,
};
k.schedule(duck);
k.schedule(Pending {
at: (at as i64 + shift).max(0) as u64,
restart: true,
..duck
});
}
/// Runs the kick events due at this output sample. Returns the duck gain for the sub.
fn kick_events(&mut self) -> f32 {
let s = self.settings;
let clock = self.kick.clock;
for i in 0..PENDING {
let Some(p) = self.kick.pending[i] else {
continue;
};
if p.at > clock {
continue;
}
self.kick.pending[i] = None;
if p.restart {
self.synth.kick(&s);
self.kick.offset = p.offset;
} else {
let k = &mut self.kick;
k.hits += 1;
k.rising = true;
k.measure = (self.rate * SUM_MS * 0.001) as usize;
k.measure_slot = p.slot;
k.energy = [0.; 3];
}
}
let k = &mut self.kick;
if k.rising {
k.duck += (1. - k.duck) * k.attack;
if k.duck > 0.98 {
k.rising = false;
}
} else {
k.duck -= k.duck * coefficient(self.rate, s.kick_release / 3.);
}
if !s.kick_lock {
k.duck = 0.;
}
1. - (1. - db_to_gain(-s.kick_duck)) * k.duck
}
/// Adds one sample of the delayed kick's and the sub's low band to the sum measurement.
fn measure_kick(&mut self, kick: f32, sub: f32) {
let k = &mut self.kick;
if k.measure == 0 {
return;
}
k.energy[0] += (kick + sub) * (kick + sub);
k.energy[1] += kick * kick;
k.energy[2] += sub * sub;
k.measure -= 1;
if k.measure > 0 {
return;
}
let apart = k.energy[1] + k.energy[2];
if apart < 1e-9 {
return;
}
k.sum_db = 10. * (k.energy[0] / apart).max(1e-6).log10();
if let Some(slot) = k.measure_slot {
let n = k.counts[slot] as f32;
let old = if n > 0. { k.sums[slot] } else { 0. };
k.sums[slot] = (old * n + k.sum_db) / (n + 1.);
k.counts[slot] += 1;
}
}
/// One sample with the kick's sidechain (mono) for Kick lock.
pub fn process_with_kick(&mut self, x: [f32; 2], kick: f32, monitor: Monitor) -> [f32; 2] {
self.detect_kick(kick, monitor.audition);
let len = self.kick.delay.len();
self.kick.delay[self.kick.delay_at] = kick;
self.kick.delay_at = (self.kick.delay_at + 1) % len;
let kick_late = self.kick.delay[self.kick.delay_at];
let mono = if self.channels == 2 {
0.5 * (x[0] + x[1])
} else {
@@ -288,6 +651,19 @@ impl Engine {
(l0, h0)
};
let low = 0.5 * (l0 + l1);
if std::mem::take(&mut self.synth.fresh) {
// A new note: measure its harmonics from its first sample. Until the first
// window completes they count as missing.
self.meter.reset();
self.patch = [0.; GHOST_COUNT];
}
let v = self.synth.voice;
let hz = if v.amp > 0. && !v.releasing { v.hz } else { 0. };
if self.meter.push(0.5 * (h0 + h1), hz) && self.meter.hz() > 0. {
for (p, db) in self.patch.iter_mut().zip(&self.meter.levels()[1..]) {
*p = if *db > -119. { db_to_gain(*db) } else { 0. };
}
}
self.low_env += (low * low - self.low_env) * self.env_coef;
let env = self.low_env.sqrt();
if !self.synth.voice.releasing {
@@ -304,21 +680,30 @@ impl Engine {
self.keep += (s.keep * 0.01 - self.keep) * self.smooth;
self.output += (db_to_gain(s.output) - self.output) * self.smooth;
self.bypass += (f32::from(s.bypass) - self.bypass) * self.smooth;
let sub = self.oscillator() * self.level * shape;
let duck = self.kick_events();
self.update_fill();
let (sub, ghost) = self.oscillator();
let sub = sub * self.level * shape * duck;
let generated = sub + ghost * self.level * shape * duck;
if self.settings.kick_lock {
let kick_low = self.kick.low.process(kick_late);
self.measure_kick(kick_low, sub);
}
self.kick.clock += 1;
self.low_power += (low * low - self.low_power) * self.meter_coef;
self.sub_power += (sub * sub - self.sub_power) * self.meter_coef;
self.scope_count += 1;
if self.scope_count >= self.scope_stride {
self.scope_count = 0;
self.scope_low[self.scope_at] = low;
self.scope_sub[self.scope_at] = sub;
self.scope_sub[self.scope_at] = generated;
self.scope_at = (self.scope_at + 1) % (2 * SCOPE);
}
let wet = |l: f32, h: f32| {
if monitor.solo {
sub
generated
} else {
h + self.keep * l + sub
h + self.keep * l + generated
}
};
let w = [wet(l0, h0), wet(l1, h1)];
@@ -326,6 +711,17 @@ impl Engine {
for c in 0..self.channels {
out[c] = (w[c] * self.output) * (1. - self.bypass) + y[c] * self.bypass;
}
if monitor.phone {
let mut m = if self.channels == 2 {
0.5 * (out[0] + out[1])
} else {
out[0]
};
for (f, c) in self.phone.iter_mut().zip(&self.phone_coefs) {
m = f.process(c, m);
}
out = [m, if self.channels == 2 { m } else { 0. }];
}
out
}
pub fn take_analyzer(&self) -> AnalyzerFrame {
@@ -335,8 +731,25 @@ impl Engine {
low_db: 10. * self.low_power.max(1e-12).log10() + 3.01,
sub_db: 10. * self.sub_power.max(1e-12).log10() + 3.01,
scope_ms: (self.scope_stride * SCOPE) as f32 / self.rate * 1000.,
ghost_edge: self.ghost_edge(),
kick_hits: self.kick.hits,
kick_sum_db: self.kick.sum_db,
kick_offset: self.kick.offset,
audition_sums: self.kick.sums,
..AnalyzerFrame::default()
};
let sub = self.level.max(1e-6);
let rel = |g: f32| {
if g > 1e-6 {
20. * (g / sub).log10()
} else {
-120.
}
};
for i in 0..GHOST_COUNT {
f.patch_harmonics[i] = rel(self.patch[i]);
f.ghost_harmonics[i] = rel(self.fill[i] * sub);
}
let v = self.synth.voice;
if v.amp > 0. && !v.releasing {
f.hz = v.hz;
+50 -1
View File
@@ -22,6 +22,13 @@ pub struct Settings {
pub dynamics: f32,
/// Soft saturation of the sine, adding upper harmonics for small speakers.
pub drive: f32,
/// Ghost harmonics: adds the sub's harmonics 2–10 above `ghost_from`, so small
/// speakers hear the note. Only harmonics the patch lacks are filled. 0 is off.
#[serde(default)]
pub ghost: f32,
/// Lowest frequency Ghost harmonics reach down to, in Hz (never below `split`).
#[serde(default = "default_ghost_from")]
pub ghost_from: f32,
pub octave: i32,
/// Pitch slide time between overlapping notes.
pub glide: f32,
@@ -37,6 +44,32 @@ pub struct Settings {
/// Round detected pitch to the nearest semitone.
pub snap: bool,
pub bypass: bool,
/// Kick lock: on each kick in the sidechain, duck the sub and restart its phase.
#[serde(default)]
pub kick_lock: bool,
/// How far the sub dips on each kick, in dB.
#[serde(default = "default_kick_duck")]
pub kick_duck: f32,
/// Time for the sub to come back after a kick, in ms.
#[serde(default = "default_kick_release")]
pub kick_release: f32,
/// When the sub restarts against the kick's start, in ms. Negative: before the kick.
#[serde(default)]
pub kick_offset: f32,
}
const GHOST_FROM: f32 = 300.;
const KICK_DUCK: f32 = 6.;
const KICK_RELEASE: f32 = 80.;
/// Range of the kick offset, in ms either side of the kick.
pub const KICK_OFFSET_MAX: f32 = 30.;
fn default_kick_duck() -> f32 {
KICK_DUCK
}
fn default_kick_release() -> f32 {
KICK_RELEASE
}
fn default_ghost_from() -> f32 {
GHOST_FROM
}
impl Default for Settings {
fn default() -> Self {
@@ -46,6 +79,8 @@ impl Default for Settings {
split: 90.,
dynamics: 0.,
drive: 0.,
ghost: 0.,
ghost_from: GHOST_FROM,
octave: 0,
glide: 0.,
phase: 0.,
@@ -55,6 +90,10 @@ impl Default for Settings {
retrigger: true,
snap: true,
bypass: false,
kick_lock: false,
kick_duck: KICK_DUCK,
kick_release: KICK_RELEASE,
kick_offset: 0.,
}
}
}
@@ -65,11 +104,16 @@ impl Settings {
self.split,
self.dynamics,
self.drive,
self.ghost,
self.ghost_from,
self.glide,
self.phase,
self.gate,
self.keep,
self.output,
self.kick_duck,
self.kick_release,
self.kick_offset,
]
.iter()
.all(|v| v.is_finite());
@@ -80,12 +124,17 @@ impl Settings {
&& (40.0..=200.).contains(&self.split)
&& (0.0..=100.).contains(&self.dynamics)
&& (0.0..=100.).contains(&self.drive)
&& (0.0..=100.).contains(&self.ghost)
&& (150.0..=600.).contains(&self.ghost_from)
&& (-2..=1).contains(&self.octave)
&& (0.0..=200.).contains(&self.glide)
&& (0.0..=360.).contains(&self.phase)
&& (-60.0..=-20.).contains(&self.gate)
&& (0.0..=100.).contains(&self.keep)
&& (-12.0..=12.).contains(&self.output);
&& (-12.0..=12.).contains(&self.output)
&& (0.0..=24.).contains(&self.kick_duck)
&& (10.0..=300.).contains(&self.kick_release)
&& (-KICK_OFFSET_MAX..=KICK_OFFSET_MAX).contains(&self.kick_offset);
if ok {
Ok(())
} else {
+279 -2
View File
@@ -19,7 +19,15 @@ fn notes(list: &[(f32, f32, f32, f32)], total: f32) -> Vec<f32> {
}
fn render(e: &mut Engine, x: &[f32], solo: bool) -> Vec<f32> {
x.iter()
.map(|&s| e.process_sample([s, s], Monitor { solo })[0])
.map(|&s| {
e.process_sample(
[s, s],
Monitor {
solo,
..Monitor::default()
},
)[0]
})
.collect()
}
fn rms(x: &[f32]) -> f32 {
@@ -135,7 +143,15 @@ fn midi_notes_set_pitch_and_are_delayed_by_the_latency() {
if i == seconds(0.6) {
e.note_off(33);
}
y.push(e.process_sample([0.; 2], Monitor { solo: true })[0]);
y.push(
e.process_sample(
[0.; 2],
Monitor {
solo: true,
..Monitor::default()
},
)[0],
);
}
assert!(y[..1000 + l].iter().all(|v| *v == 0.));
assert!(y[1000 + l + 50].abs() > 0.);
@@ -211,3 +227,264 @@ fn settings_are_validated_and_serialise() {
assert!(Engine::new(1000., 2).is_err());
assert!(Engine::new(48000., 3).is_err());
}
/// Peak amplitude of the component at `hz`, over a window of whole cycles.
fn amplitude(x: &[f32], hz: f32) -> f32 {
let w = std::f32::consts::TAU * hz / RATE;
let (re, im) = x.iter().enumerate().fold((0., 0.), |(re, im), (n, v)| {
(re + v * (w * n as f32).cos(), im - v * (w * n as f32).sin())
});
2. * (re * re + im * im).sqrt() / x.len() as f32
}
/// Sines (Hz, peak) from 0.1 s to `total` − 0.1 s, with 1 ms fades.
fn tones(list: &[(f32, f32)], total: f32) -> Vec<f32> {
let n = (total * RATE) as usize;
let (a, b, fade) = (seconds(0.1), n - seconds(0.1), seconds(0.001));
(0..n)
.map(|i| {
if i < a || i >= b {
return 0.;
}
let edge = ((i - a).min(b - 1 - i) as f32 / fade as f32).min(1.);
let t = (i - a) as f32 / RATE;
edge * list
.iter()
.map(|&(hz, amp)| amp * (std::f32::consts::TAU * hz * t).sin())
.sum::<f32>()
})
.collect()
}
fn ghost(amount: f32) -> Settings {
Settings {
ghost: amount,
ghost_from: 300.,
..Settings::default()
}
}
#[test]
fn ghost_fills_the_harmonics_a_clean_sub_lacks() {
let (mut e, l) = engine(ghost(100.));
let x = tones(&[(55., 0.5)], 1.);
let y = render(&mut e, &x, true);
// 0.2 s holds exactly 11 cycles of 55 Hz.
let w = &y[seconds(0.5) + l..seconds(0.7) + l];
let sub = amplitude(w, 55.);
assert!((db(sub) + 9.).abs() < 0.5, "{}", db(sub));
for k in 2..=5 {
// Below Ghost from: nothing added, so the sub stays a clean sine there.
let a = amplitude(w, 55. * k as f32);
assert!(db(a / sub) < -60., "harmonic {k}: {}", db(a / sub));
}
for k in 8..=10 {
let a = amplitude(w, 55. * k as f32);
let expect = 20. * (2. / k as f32).log10();
assert!(
(db(a / sub) - expect).abs() < 1.,
"harmonic {k}: {}",
db(a / sub)
);
}
// Harmonic 6 (330 Hz) sits in the fade above 300 Hz: present, but quieter.
let six = db(amplitude(w, 330.) / sub);
assert!(six < 20. * (2f32 / 6.).log10() - 3. && six > -40., "{six}");
}
#[test]
fn ghost_leaves_harmonics_the_patch_already_has() {
let (mut e, l) = engine(ghost(100.));
// The patch carries its 8th harmonic (440 Hz) louder than Ghost would add it.
let x = tones(&[(55., 0.5), (440., 0.2)], 1.);
let y = render(&mut e, &x, false);
let w = &y[seconds(0.5) + l..seconds(0.7) + l];
let eighth = amplitude(w, 440.);
assert!((db(eighth) - db(0.2)).abs() < 0.5, "{}", db(eighth));
// The 9th is missing from the patch, so Ghost fills it.
let ninth = amplitude(w, 495.) / amplitude(w, 55.);
assert!(
(db(ninth) - 20. * (2f32 / 9.).log10()).abs() < 1.,
"{}",
db(ninth)
);
}
#[test]
fn ghost_off_adds_nothing_and_phone_listen_drops_the_sub() {
let x = tones(&[(55., 0.5)], 1.);
let (mut e, l) = engine(ghost(0.));
let y = render(&mut e, &x, true);
let w = &y[seconds(0.5) + l..seconds(0.7) + l];
for k in 2..=10 {
assert!(amplitude(w, 55. * k as f32) < 1e-4, "harmonic {k}");
}
let phone = |amount: f32| {
let (mut e, l) = engine(ghost(amount));
let y: Vec<f32> = x
.iter()
.map(|&s| {
e.process_sample(
[s, s],
Monitor {
solo: true,
phone: true,
audition: false,
},
)[0]
})
.collect();
rms(&y[seconds(0.5) + l..seconds(0.7) + l])
};
// A clean 55 Hz sub all but vanishes on a phone; Ghost makes the note heard.
let (plain, ghosted) = (phone(0.), phone(100.));
assert!(db(plain) < -50., "{}", db(plain));
assert!(
db(ghosted) - db(plain) > 25.,
"{} vs {}",
db(ghosted),
db(plain)
);
}
/// A kick-like burst: a 55 Hz sine starting at phase 0 with a 60 ms decay, at each time.
fn kicks(times: &[f32], total: f32) -> Vec<f32> {
let mut k = vec![0.; seconds(total)];
for &t in times {
for i in 0..seconds(0.2) {
let n = seconds(t) + i;
if n < k.len() {
let t = i as f32 / RATE;
k[n] += 0.8 * (std::f32::consts::TAU * 55. * t).sin() * (-t / 0.06).exp();
}
}
}
k
}
fn render_kick(e: &mut Engine, x: &[f32], k: &[f32], monitor: Monitor) -> Vec<f32> {
x.iter()
.zip(k)
.map(|(&s, &k)| e.process_with_kick([s, s], k, monitor)[0])
.collect()
}
/// Normalised correlation of `y[a..b]` with a sine of `hz` at phase 0 at sample `zero`.
fn sine_match(y: &[f32], a: usize, b: usize, hz: f32, zero: f32) -> f32 {
let w = std::f32::consts::TAU * hz / RATE;
let (mut xy, mut xx, mut yy) = (0., 0., 0.);
for (i, &v) in y.iter().enumerate().take(b).skip(a) {
let r = (w * (i as f32 - zero)).sin();
xy += v * r;
xx += r * r;
yy += v * v;
}
xy / (xx * yy).sqrt()
}
const SOLO: Monitor = Monitor {
solo: true,
phone: false,
audition: false,
};
fn kick_lock(offset: f32) -> Settings {
Settings {
kick_lock: true,
kick_offset: offset,
..Settings::default()
}
}
#[test]
fn a_kick_restarts_the_sub_at_the_start_phase_and_ducks_it() {
// A bass note that started off the kick: before the hit, the sub runs at its own phase.
let x = tones(&[(55., 0.5)], 1.4);
let k = kicks(&[0.613], 1.4);
let (mut e, l) = engine(kick_lock(0.));
let y = render_kick(&mut e, &x, &k, SOLO);
let hit = seconds(0.613) + l;
let after = sine_match(
&y,
hit + seconds(0.004),
hit + seconds(0.04),
55.,
hit as f32,
);
assert!(after > 0.99, "{after}");
let before = rms(&y[hit - seconds(0.1)..hit - seconds(0.01)]);
let ducked = rms(&y[hit + seconds(0.004)..hit + seconds(0.022)]);
let back = rms(&y[hit + seconds(0.4)..hit + seconds(0.5)]);
assert!(
db(before) - db(ducked) > 2.,
"{} vs {}",
db(before),
db(ducked)
);
assert!(
(db(back) - db(before)).abs() < 0.3,
"{} vs {}",
db(back),
db(before)
);
assert_eq!(e.take_analyzer().kick_hits, 1);
}
#[test]
fn the_offset_restarts_the_sub_before_or_after_the_kick() {
let x = tones(&[(55., 0.5)], 1.2);
let k = kicks(&[0.613], 1.2);
for offset in [-10., 7.] {
// No duck: only the phase is checked here.
let (mut e, l) = engine(Settings {
kick_duck: 0.,
..kick_lock(offset)
});
let y = render_kick(&mut e, &x, &k, SOLO);
let start = (seconds(0.613) + l) as f32 + offset * 0.001 * RATE;
let a = start as usize + seconds(0.004);
let m = sine_match(&y, a, a + seconds(0.03), 55., start);
assert!(m > 0.99, "{offset}: {m}");
}
}
#[test]
fn without_kick_lock_the_sidechain_changes_nothing() {
let x = tones(&[(55., 0.5)], 1.);
let k = kicks(&[0.3, 0.6], 1.);
let (mut a, _) = engine(Settings::default());
let (mut b, _) = engine(Settings::default());
let with = render_kick(&mut a, &x, &k, SOLO);
let without = render(&mut b, &x, true);
assert_eq!(with, without);
}
#[test]
fn the_audition_finds_where_kick_and_sub_sum() {
use anvil_core::{AUDITION, AUDITION_HITS};
let hits = AUDITION.len() * AUDITION_HITS as usize;
let total = 0.5 + hits as f32 * 0.2 + 0.4;
let x = tones(&[(55., 0.5)], total);
let times: Vec<f32> = (0..hits).map(|i| 0.5 + i as f32 * 0.2).collect();
let k = kicks(&times, total);
let (mut e, _) = engine(kick_lock(0.));
let monitor = Monitor {
audition: true,
..SOLO
};
render_kick(&mut e, &x, &k, monitor);
let f = e.take_analyzer();
assert_eq!(f.kick_hits as usize, hits);
let sums = f.audition_sums;
assert!(sums.iter().all(|v| v.is_finite()), "{sums:?}");
// The kick is a 55 Hz burst at phase 0, like the sub: they sum fully at 0 ms and
// cancel half a period (9 ms) away.
let best = (0..sums.len())
.max_by(|&a, &b| sums[a].total_cmp(&sums[b]))
.unwrap();
assert_eq!(AUDITION[best], 0., "{sums:?}");
let zero = sums[6];
assert!(zero > 2., "{zero}");
for i in [4, 8] {
assert!(
zero - sums[i] > 4.,
"{} ms: {} vs {zero}",
AUDITION[i],
sums[i]
);
}
}
+16 -2
View File
@@ -34,7 +34,12 @@ static ALLOCATOR: CheckedAllocator = CheckedAllocator;
#[test]
fn processing_notes_settings_and_analysis_do_not_allocate_or_free() {
let mut engine = Engine::new(48000., 2).unwrap();
let mut settings = Settings::default();
let mut settings = Settings {
ghost: 80.,
kick_lock: true,
kick_offset: -12.,
..Settings::default()
};
engine.set_settings(settings).unwrap();
CHECK.with(|v| v.set(true));
for i in 0..96000 {
@@ -58,7 +63,16 @@ fn processing_notes_settings_and_analysis_do_not_allocate_or_free() {
} else {
0.
};
std::hint::black_box(engine.process_sample([x, x], Monitor { solo: i % 3 == 0 }));
let kick = if i % 12000 < 400 { 0.8 } else { 0. };
std::hint::black_box(engine.process_with_kick(
[x, x],
kick,
Monitor {
solo: i % 3 == 0,
phone: i % 5 == 0,
audition: i > 30000,
},
));
if i % 1600 == 0 {
std::hint::black_box(engine.take_analyzer());
}
+1 -1
View File
@@ -72,7 +72,7 @@ checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
[[package]]
name = "anvil"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"anvil-core",
"anvil-ui",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "anvil"
version = "0.2.0"
version = "0.3.0"
edition = "2021"
license = "ISC"
+43 -6
View File
@@ -13,10 +13,12 @@ use std::sync::{
struct Shared {
analyzer: ArrayQueue<AnalyzerFrame>,
solo: AtomicBool,
phone: AtomicBool,
audition: AtomicBool,
}
struct EditorData {
ui: anvil_ui::State,
gestures: [bool; 9],
gestures: [bool; 14],
}
pub struct Anvil {
params: Arc<AnvilParams>,
@@ -32,6 +34,8 @@ impl Default for Anvil {
shared: Arc::new(Shared {
analyzer: ArrayQueue::new(2),
solo: AtomicBool::new(false),
phone: AtomicBool::new(false),
audition: AtomicBool::new(false),
}),
engine: None,
publish_count: 0,
@@ -49,11 +53,21 @@ impl Plugin for Anvil {
AudioIOLayout {
main_input_channels: NonZeroU32::new(2),
main_output_channels: NonZeroU32::new(2),
aux_input_ports: &[new_nonzero_u32(2)],
names: PortNames {
aux_inputs: &["Kick"],
..PortNames::const_default()
},
..AudioIOLayout::const_default()
},
AudioIOLayout {
main_input_channels: NonZeroU32::new(1),
main_output_channels: NonZeroU32::new(1),
aux_input_ports: &[new_nonzero_u32(1)],
names: PortNames {
aux_inputs: &["Kick"],
..PortNames::const_default()
},
..AudioIOLayout::const_default()
},
];
@@ -71,10 +85,12 @@ impl Plugin for Anvil {
params.editor.clone(),
EditorData {
ui: anvil_ui::State::default(),
gestures: [false; 9],
gestures: [false; 14],
},
|ctx, data| {
data.ui.solo = false;
data.ui.phone = false;
data.ui.audition = false;
anvil_ui::style(ctx);
},
move |ctx, setter, data| {
@@ -98,8 +114,11 @@ impl Plugin for Anvil {
data.ui.gesture_active,
&mut data.gestures,
);
// Solo is a monitor only: never saved and never left on after the editor closes.
// Solo and phone listen are monitors only: never saved and never left on
// after the editor closes.
shared.solo.store(data.ui.solo, Ordering::Release);
shared.phone.store(data.ui.phone, Ordering::Release);
shared.audition.store(data.ui.audition, Ordering::Release);
if data.ui.analyzer.note.is_some() {
ctx.request_repaint();
}
@@ -125,6 +144,8 @@ impl Plugin for Anvil {
self.publish_interval = (config.sample_rate / 30.) as usize;
self.engine = Some(engine);
self.shared.solo.store(false, Ordering::Release);
self.shared.phone.store(false, Ordering::Release);
self.shared.audition.store(false, Ordering::Release);
true
}
fn reset(&mut self) {
@@ -136,14 +157,22 @@ impl Plugin for Anvil {
fn process(
&mut self,
buffer: &mut Buffer,
_: &mut AuxiliaryBuffers,
aux: &mut AuxiliaryBuffers,
context: &mut impl ProcessContext<Self>,
) -> ProcessStatus {
let Some(engine) = self.engine.as_mut() else {
return ProcessStatus::Normal;
};
let _ = engine.set_settings(self.params.settings());
let solo = self.shared.solo.load(Ordering::Acquire) && self.params.editor.is_open();
let open = self.params.editor.is_open();
let monitor = Monitor {
solo: self.shared.solo.load(Ordering::Acquire) && open,
phone: self.shared.phone.load(Ordering::Acquire) && open,
audition: self.shared.audition.load(Ordering::Acquire) && open,
};
// A host that does not connect the sidechain still passes a buffer; treat a
// missing one as silence.
let side = aux.inputs.first().map(|b| b.as_slice_immutable());
let mut next = context.next_event();
for (index, mut sample) in buffer.iter_samples().enumerate() {
while let Some(event) = next {
@@ -170,7 +199,15 @@ impl Plugin for Anvil {
0.
},
];
let out = engine.process_sample(x, Monitor { solo });
let kick = side.map_or(0., |side| {
let n = side.len().min(2);
side[..n]
.iter()
.map(|c| c.get(index).copied().unwrap_or(0.))
.sum::<f32>()
/ n.max(1) as f32
});
let out = engine.process_with_kick(x, kick, monitor);
for (c, value) in sample.into_iter().enumerate() {
*value = out[c];
}
+54 -4
View File
@@ -1,4 +1,4 @@
use anvil_core::{Settings, Source};
use anvil_core::{Settings, Source, KICK_OFFSET_MAX};
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use std::sync::Arc;
@@ -28,6 +28,10 @@ pub struct AnvilParams {
pub dynamics: FloatParam,
#[id = "drive"]
pub drive: FloatParam,
#[id = "ghost"]
pub ghost: FloatParam,
#[id = "ghost_from"]
pub ghost_from: FloatParam,
#[id = "octave"]
pub octave: IntParam,
#[id = "glide"]
@@ -46,6 +50,14 @@ pub struct AnvilParams {
pub snap: BoolParam,
#[id = "bypass"]
pub bypass: BoolParam,
#[id = "kick_lock"]
pub kick_lock: BoolParam,
#[id = "kick_duck"]
pub kick_duck: FloatParam,
#[id = "kick_release"]
pub kick_release: FloatParam,
#[id = "kick_offset"]
pub kick_offset: FloatParam,
}
impl Default for AnvilParams {
fn default() -> Self {
@@ -66,6 +78,17 @@ impl Default for AnvilParams {
.with_unit(" Hz"),
dynamics: linear("Dynamics", d.dynamics, 0., 100., " %"),
drive: linear("Drive", d.drive, 0., 100., " %"),
ghost: linear("Ghost", d.ghost, 0., 100., " %"),
ghost_from: FloatParam::new(
"Ghost from",
d.ghost_from,
FloatRange::Skewed {
min: 150.,
max: 600.,
factor: FloatRange::skew_factor(-1.),
},
)
.with_unit(" Hz"),
octave: IntParam::new("Octave", d.octave, IntRange::Linear { min: -2, max: 1 }),
glide: linear("Glide", d.glide, 0., 200., " ms"),
phase: linear("Phase", d.phase, 0., 360., "°"),
@@ -75,6 +98,16 @@ impl Default for AnvilParams {
retrigger: BoolParam::new("Retrigger", d.retrigger),
snap: BoolParam::new("Snap to semitone", d.snap),
bypass: BoolParam::new("Bypass", false).make_bypass(),
kick_lock: BoolParam::new("Kick lock", d.kick_lock),
kick_duck: linear("Kick duck", d.kick_duck, 0., 24., " dB"),
kick_release: linear("Kick release", d.kick_release, 10., 300., " ms"),
kick_offset: linear(
"Kick offset",
d.kick_offset,
-KICK_OFFSET_MAX,
KICK_OFFSET_MAX,
" ms",
),
}
}
}
@@ -89,6 +122,8 @@ impl AnvilParams {
split: self.split.value(),
dynamics: self.dynamics.value(),
drive: self.drive.value(),
ghost: self.ghost.value(),
ghost_from: self.ghost_from.value(),
octave: self.octave.value(),
glide: self.glide.value(),
phase: self.phase.value(),
@@ -98,6 +133,10 @@ impl AnvilParams {
retrigger: self.retrigger.value(),
snap: self.snap.value(),
bypass: self.bypass.value(),
kick_lock: self.kick_lock.value(),
kick_duck: self.kick_duck.value(),
kick_release: self.kick_release.value(),
kick_offset: self.kick_offset.value(),
}
}
/// Pushes every changed editor value to the host. Continuous controls hold one
@@ -108,9 +147,9 @@ impl AnvilParams {
old: &Settings,
new: &Settings,
dragging: bool,
open: &mut [bool; 9],
open: &mut [bool; 14],
) {
let floats: [(&FloatParam, f32, f32); 8] = [
let floats: [(&FloatParam, f32, f32); 13] = [
(&self.level, old.level, new.level),
(&self.split, old.split, new.split),
(&self.dynamics, old.dynamics, new.dynamics),
@@ -119,6 +158,11 @@ impl AnvilParams {
(&self.phase, old.phase, new.phase),
(&self.gate, old.gate, new.gate),
(&self.keep, old.keep, new.keep),
(&self.ghost, old.ghost, new.ghost),
(&self.ghost_from, old.ghost_from, new.ghost_from),
(&self.kick_duck, old.kick_duck, new.kick_duck),
(&self.kick_release, old.kick_release, new.kick_release),
(&self.kick_offset, old.kick_offset, new.kick_offset),
];
let mut continuous = |i: usize, changed: bool, set: &dyn Fn()| {
if changed {
@@ -136,7 +180,7 @@ impl AnvilParams {
for (i, (p, a, b)) in floats.into_iter().enumerate() {
continuous(i, a != b, &|| setter.set_parameter(p, b));
}
continuous(8, old.output != new.output, &|| {
continuous(13, old.output != new.output, &|| {
setter.set_parameter(&self.output, new.output)
});
let once = |changed: bool, f: &dyn Fn()| {
@@ -153,6 +197,7 @@ impl AnvilParams {
(&self.retrigger, old.retrigger, new.retrigger),
(&self.snap, old.snap, new.snap),
(&self.bypass, old.bypass, new.bypass),
(&self.kick_lock, old.kick_lock, new.kick_lock),
] {
once(a != b, &|| {
setter.begin_set_parameter(p);
@@ -180,6 +225,11 @@ impl AnvilParams {
&self.phase,
&self.gate,
&self.keep,
&self.ghost,
&self.ghost_from,
&self.kick_duck,
&self.kick_release,
&self.kick_offset,
&self.output,
][i]
}
+30 -10
View File
@@ -83,8 +83,9 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
let ports = p.get_extension.unwrap()(plugin, CLAP_EXT_AUDIO_PORTS.as_ptr())
as *const clap_plugin_audio_ports;
assert!(!ports.is_null());
// Main input plus the kick sidechain; one output.
assert_eq!((*ports).count.unwrap()(plugin, true), 2);
assert_eq!((*ports).count.unwrap()(plugin, false), 1);
assert_eq!((*ports).count.unwrap()(plugin, true), 1);
let latency = p.get_extension.unwrap()(plugin, CLAP_EXT_LATENCY.as_ptr())
as *const clap_plugin_latency;
assert!(!latency.is_null());
@@ -108,6 +109,13 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
.params
.insert("level".into(), ParamValue::F32(-14.));
envelope.params.insert("octave".into(), ParamValue::I32(-1));
envelope.params.insert("ghost".into(), ParamValue::F32(40.));
envelope
.params
.insert("kick_lock".into(), ParamValue::Bool(true));
envelope
.params
.insert("kick_offset".into(), ParamValue::F32(-7.));
envelope
.params
.insert("snap".into(), ParamValue::Bool(false));
@@ -154,14 +162,26 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
let mut left = [0f32; 512];
let mut right = [0f32; 512];
let mut in_channels = [input_left.as_mut_ptr(), input_right.as_mut_ptr()];
// A connected but silent kick sidechain.
let (mut kick_left, mut kick_right) = ([0f32; 512], [0f32; 512]);
let mut kick_channels = [kick_left.as_mut_ptr(), kick_right.as_mut_ptr()];
let mut out_channels = [left.as_mut_ptr(), right.as_mut_ptr()];
let incoming = clap_audio_buffer {
data32: in_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let incoming = [
clap_audio_buffer {
data32: in_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
},
clap_audio_buffer {
data32: kick_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
},
];
let mut outgoing_audio = clap_audio_buffer {
data32: out_channels.as_mut_ptr(),
data64: ptr::null_mut(),
@@ -182,9 +202,9 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
steady_time: position as i64,
frames_count: frames as u32,
transport: ptr::null(),
audio_inputs: &incoming,
audio_inputs: incoming.as_ptr(),
audio_outputs: &mut outgoing_audio,
audio_inputs_count: 1,
audio_inputs_count: 2,
audio_outputs_count: 1,
in_events: &input,
out_events: &outgoing,
+258 -7
View File
@@ -1,14 +1,21 @@
//! Anvil editor, shared by the CLAP wrapper and the headless renderer.
use anvil_core::{AnalyzerFrame, Settings, Source, SCOPE};
use anvil_core::{
AnalyzerFrame, Settings, Source, AUDITION, AUDITION_HITS, GHOST_COUNT, KICK_OFFSET_MAX,
PHONE_LOW, SCOPE,
};
use common_ui::*;
use egui::{self, Color32, FontId, Pos2, RichText, Sense, Stroke, Vec2};
pub use common_ui::style;
pub const WIDTH: u32 = 980;
pub const HEIGHT: u32 = 704;
pub const HEIGHT: u32 = 872;
pub struct State {
pub solo: bool,
/// Phone-like listen. A monitor, never saved.
pub phone: bool,
/// Kick offset audition. A monitor, never saved.
pub audition: bool,
pub analyzer: AnalyzerFrame,
/// True while a pointer button is held, so knob drags become one host gesture.
pub gesture_active: bool,
@@ -18,6 +25,8 @@ impl Default for State {
fn default() -> Self {
Self {
solo: false,
phone: false,
audition: false,
analyzer: AnalyzerFrame::default(),
gesture_active: false,
help: DEFAULT_HELP.into(),
@@ -64,7 +73,63 @@ fn toggle(ui: &mut egui::Ui, state: &mut State, on: &mut bool, label: &str, help
}
hint(&r, state, help);
}
fn scope(ui: &mut egui::Ui, frame: &AnalyzerFrame, height: f32) {
/// Harmonics 2–10 of the current note: what the patch carries above the split (steel)
/// and what Ghost adds (accent), from −48 dB to 0 dB against the sub.
fn harmonics(p: &egui::Painter, rect: egui::Rect, frame: &AnalyzerFrame) {
p.rect_filled(rect, 2., Color32::from_rgba_unmultiplied(12, 13, 16, 230));
p.rect_stroke(
rect,
2.,
Stroke::new(1.0_f32, EDGE),
egui::StrokeKind::Inside,
);
p.text(
rect.left_top() + Vec2::new(8., 6.),
egui::Align2::LEFT_TOP,
"GHOST",
FontId::proportional(10.),
ACCENT,
);
p.text(
rect.right_top() + Vec2::new(-8., 6.),
egui::Align2::RIGHT_TOP,
format!("from {:.0} Hz", frame.ghost_edge),
FontId::proportional(10.),
MUTED,
);
let area = egui::Rect::from_min_max(
rect.left_top() + Vec2::new(8., 22.),
rect.right_bottom() - Vec2::new(8., 16.),
);
let slot = area.width() / GHOST_COUNT as f32;
let height = |db: f32| ((db + 48.) / 48.).clamp(0., 1.) * area.height();
for i in 0..GHOST_COUNT {
let x = area.left() + slot * i as f32;
let bar = |left: f32, db: f32, color: Color32| {
let h = height(db);
if h > 0.5 {
p.rect_filled(
egui::Rect::from_min_max(
Pos2::new(left, area.bottom() - h),
Pos2::new(left + slot * 0.36, area.bottom()),
),
1.,
color,
);
}
};
bar(x + slot * 0.12, frame.patch_harmonics[i], STEEL);
bar(x + slot * 0.52, frame.ghost_harmonics[i], ACCENT);
p.text(
Pos2::new(x + slot * 0.5, area.bottom() + 2.),
egui::Align2::CENTER_TOP,
format!("{}", i + 2),
FontId::proportional(9.),
DIM,
);
}
}
fn scope(ui: &mut egui::Ui, frame: &AnalyzerFrame, height: f32, ghost: bool) {
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
let p = ui.painter_at(rect);
p.rect_filled(rect, 2., SCREEN);
@@ -123,6 +188,11 @@ fn scope(ui: &mut egui::Ui, frame: &AnalyzerFrame, height: f32) {
FontId::proportional(11.),
MUTED,
);
if ghost {
let size = Vec2::new(200., 92.);
let corner = rect.right_bottom() - Vec2::new(8., 8.);
harmonics(&p, egui::Rect::from_min_max(corner - size, corner), frame);
}
p.rect_stroke(
rect,
2.,
@@ -145,10 +215,18 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
}
let r = ui
.selectable_label(state.solo, RichText::new("SOLO SUB").size(13.))
.on_hover_text("Hear only the generated sub.");
.on_hover_text("Hear only the generated sub and Ghost harmonics.");
if r.clicked() {
state.solo = !state.solo;
}
let r = ui
.selectable_label(state.phone, RichText::new("PHONE").size(13.))
.on_hover_text(format!(
"Listen as on a phone: mono, nothing below {PHONE_LOW:.0} Hz. Not saved."
));
if r.clicked() {
state.phone = !state.phone;
}
ui.separator();
for (source, label) in [(Source::Midi, "MIDI"), (Source::Audio, "AUDIO")] {
let r = ui.selectable_label(settings.source == source, RichText::new(label).size(13.));
@@ -169,7 +247,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
ui.add_space(4.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
scope(ui, &frame, TOP);
scope(ui, &frame, TOP, settings.ghost > 0.);
});
});
ui.add_space(6.);
@@ -177,6 +255,11 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
ui.set_width(ui.available_width());
ui.vertical(|ui| controls(ui, settings, state));
});
ui.add_space(6.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
kick_panel(ui, settings, state);
});
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
@@ -212,7 +295,7 @@ fn note_panel(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame) {
}
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let defaults = Settings::default();
ui.columns(4, |c| {
ui.columns(6, |c| {
knob(
&mut c[0],
state,
@@ -255,7 +338,30 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
defaults.drive,
" %",
88.,
"Adds upper harmonics so the sub reads on small speakers.",
"Saturates the sine: odd harmonics, thicker sub. Changes the sub itself.",
);
knob(
&mut c[4],
state,
"Ghost",
&mut settings.ghost,
0. ..=100.,
defaults.ghost,
" %",
88.,
"Adds the sub's harmonics 2–10 above Ghost from, so phones hear the note. \
Only harmonics the patch lacks are added. The sub itself stays the same.",
);
knob(
&mut c[5],
state,
"Ghost from",
&mut settings.ghost_from,
150. ..=600.,
defaults.ghost_from,
" Hz",
88.,
"Lowest frequency Ghost adds. Phones cut off around 300–500 Hz. Never below Split.",
);
});
ui.add_space(6.);
@@ -348,3 +454,148 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
);
});
}
fn kick_panel(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let defaults = Settings::default();
let frame = state.analyzer;
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(150.);
caption(ui, "KICK LOCK");
toggle(
ui,
state,
&mut settings.kick_lock,
"KICK LOCK",
"Route the kick into Anvil's sidechain. On each kick the sub dips and \
restarts at Phase, so kick and sub hit together.",
);
ui.add_space(6.);
let status = if !settings.kick_lock {
"off".to_string()
} else if frame.kick_hits == 0 {
"no kick in the sidechain".to_string()
} else if frame.kick_sum_db.is_finite() {
format!("{} kicks · sum {:+.1} dB", frame.kick_hits, frame.kick_sum_db)
} else {
format!("{} kicks", frame.kick_hits)
};
ui.label(RichText::new(status).size(12.).color(MUTED));
});
ui.columns(4, |c| {
knob(
&mut c[0],
state,
"Duck",
&mut settings.kick_duck,
0. ..=24.,
defaults.kick_duck,
" dB",
54.,
"How far the sub dips on each kick.",
);
knob(
&mut c[1],
state,
"Release",
&mut settings.kick_release,
10. ..=300.,
defaults.kick_release,
" ms",
54.,
"Time for the sub to come back after a kick.",
);
knob(
&mut c[2],
state,
"Offset",
&mut settings.kick_offset,
-KICK_OFFSET_MAX..=KICK_OFFSET_MAX,
defaults.kick_offset,
" ms",
54.,
"When the sub restarts against the kick. Negative: before it. Use Audition to find it by ear.",
);
c[3].vertical(|ui| audition(ui, settings, state));
});
});
}
/// The audition: each offset plays for a few kicks; bars show how kick and sub summed.
fn audition(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let frame = state.analyzer;
ui.horizontal(|ui| {
let r = ui.selectable_label(state.audition, RichText::new("AUDITION").size(12.));
if r.clicked() {
state.audition = !state.audition;
}
hint(
&r,
state,
&format!(
"Steps the offset from −30 to +30 ms, {AUDITION_HITS} kicks each. Press KEEP \
when kick and sub sound like one hit. Not saved."
),
);
let r = ui.add_enabled(
state.audition && frame.kick_hits > 0,
egui::Button::new(RichText::new("KEEP").size(12.)),
);
if r.clicked() {
settings.kick_offset = frame.kick_offset;
state.audition = false;
}
hint(
&r,
state,
"Set Offset to the one playing now and stop the audition.",
);
});
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 58.), Sense::hover());
let p = ui.painter_at(rect);
p.rect_filled(rect, 2., SCREEN);
let slot = rect.width() / AUDITION.len() as f32;
// −9 dB to +3 dB: +3 is a full sum of equal levels.
let height = |db: f32| ((db + 9.) / 12.).clamp(0.02, 1.) * (rect.height() - 14.);
for (i, &offset) in AUDITION.iter().enumerate() {
let x = rect.left() + slot * i as f32;
let db = frame.audition_sums[i];
let playing = state.audition && (offset - frame.kick_offset).abs() < 0.01;
if db.is_finite() {
let h = height(db);
p.rect_filled(
egui::Rect::from_min_max(
Pos2::new(x + slot * 0.2, rect.bottom() - 12. - h),
Pos2::new(x + slot * 0.8, rect.bottom() - 12.),
),
1.,
if playing { ACCENT } else { STEEL },
);
}
if (offset - settings.kick_offset).abs() < 0.01 {
// The kept offset.
let (cx, base) = (x + slot * 0.5, rect.bottom() - 2.);
p.add(egui::Shape::convex_polygon(
vec![
Pos2::new(cx, base - 7.),
Pos2::new(cx + 4., base),
Pos2::new(cx - 4., base),
],
ACCENT,
Stroke::NONE,
));
} else if i % 2 == 0 {
p.text(
Pos2::new(x + slot * 0.5, rect.bottom() - 1.),
egui::Align2::CENTER_BOTTOM,
format!("{offset:+.0}"),
FontId::proportional(8.),
DIM,
);
}
}
let line = if state.audition {
format!("playing {:+.0} ms", frame.kick_offset)
} else {
"sum per offset, low band".into()
};
ui.label(RichText::new(line).size(11.).color(MUTED));
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "braze-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
common-dsp = { path = "../../common/dsp" }
serde = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+356
View File
@@ -0,0 +1,356 @@
//! Tempo-locked beating: adds a detuned copy of the bass whose beating stays in time with
//! the track on every note.
//!
//! Two layers detuned by a ratio r beat at k·f0·(r − 1) on harmonic k, so a fixed detune
//! beats at a different rate on every note. Braze sets the detune per note from the tracked
//! fundamental: harmonic `harmonic` beats once per `rate` note value, and harmonic n beats
//! n/harmonic times as fast, so every harmonic beats on the tempo grid.
//!
//! The copy is the bass read from a delay line whose delay falls by one period of the note
//! per beat cycle of the chosen harmonic. That shifts the copy up by exactly the wanted
//! ratio. When the delay has fallen by a whole period it jumps back by one period: for a
//! periodic note both read positions hold the same waveform, so the jump is crossfaded over
//! a few milliseconds without a seam. The delay is set from the host's beat position, so
//! the beat's peaks land on the grid, not just at the right rate. At a beat peak the copy
//! lines up with the bass exactly.
//!
//! Low cut removes the copy below a frequency with a 4th-order Linkwitz–Riley high-pass,
//! so the sub does not beat. The bass is split by the same crossover; its two bands are in
//! phase with the copy's high-pass at every frequency, so the beat peaks stay on the grid,
//! and the loudness correction for the copy applies above the cut only, so the sub keeps
//! its level.
mod settings;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{PitchFollower, Tracker, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, hz_to_midi};
pub use settings::{Rate, Settings, Source, HARMONIC_MAX, LOW_CUT_MAX, LOW_CUT_OFF};
pub const MIN_HZ: f32 = TrackerConfig::BASS.min_hz;
pub const MAX_HZ: f32 = TrackerConfig::BASS.max_hz;
/// Fundamental the copy assumes before the first note.
pub const FALLBACK_HZ: f32 = 55.;
/// Tempo when the host reports none.
pub const DEFAULT_TEMPO: f64 = 120.;
/// Crossfade between the old and the new read position.
const FADE_MS: f32 = 6.;
/// A beat position that moves further than this (in cycles of the chosen harmonic) from
/// one sample to the next counts as a jump: a locate, a loop, or a changed control.
const JUMP: f64 = 1e-3;
/// Pitch smoothing for bends and slides.
const PITCH_MS: f32 = 5.;
#[derive(Clone, Copy, Debug)]
pub struct AnalyzerFrame {
/// Fundamental the copy is tuned to, 0 before the first note.
pub hz: f32,
/// Sounding note, rounded; None between notes.
pub note: Option<u8>,
/// Beat position at the output, in quarter notes.
pub beats: f64,
pub tempo: f64,
/// True when the beat position comes from the host's transport.
pub synced: bool,
/// Detune of the copy as a ratio, 1 before the first note.
pub ratio: f32,
pub latency: u32,
pub sample_rate: f32,
}
impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
hz: 0.,
note: None,
beats: 0.,
tempo: DEFAULT_TEMPO,
synced: false,
ratio: 1.,
latency: 0,
sample_rate: 48000.,
}
}
}
/// Beat cycles of the chosen harmonic at `beats` quarter notes, for one channel.
pub fn cycles(s: &Settings, beats: f64, channel: usize) -> f64 {
beats / s.rate.quarters() + s.phase as f64 / 360. + channel as f64 * s.width as f64 * 0.005
}
/// Level of harmonic `n` in the sum, against the bass alone, at `cycles` of the chosen
/// harmonic, with the copy at `depth` (0–1). Includes the output's loudness correction.
pub fn envelope(s: &Settings, n: u32, cycles: f64, depth: f32) -> f32 {
let theta = std::f64::consts::TAU * n as f64 / s.harmonic as f64 * cycles;
let (re, im) = (1. + depth as f64 * theta.cos(), depth as f64 * theta.sin());
((re * re + im * im).sqrt() / (1. + depth as f64 * depth as f64).sqrt()) as f32
}
#[derive(Clone, Copy, Debug, Default)]
struct Tap {
/// Delay in samples.
delay: f32,
/// Delay change per sample, so a fading tap keeps its course.
slope: f32,
}
#[derive(Clone, Copy, Debug, Default)]
struct Channel {
active: Tap,
old: Tap,
/// Weight of the old tap, falling to 0.
fade: f32,
/// Whole cycles the active tap's delay is counted from.
whole: f64,
/// Beat cycle position (`cycles / harmonic + 0.5`) of the previous sample.
last: f64,
started: bool,
copy_high: [Svf; 2],
high: [Svf; 2],
low: [Svf; 2],
}
pub struct Engine {
rate: f32,
channels: usize,
settings: Settings,
latency: usize,
audio: Vec<[f32; 2]>,
audio_at: usize,
tracker: Tracker,
pitch: PitchFollower,
pitch_coef: f32,
hz: f32,
beats: f64,
tempo: f64,
synced: bool,
state: [Channel; 2],
/// High-pass and low-pass of the low cut's crossover; None when off.
low_cut: Option<(SvfCoef, SvfCoef)>,
fade_step: f32,
smooth: f32,
depth: f32,
output: f32,
bypass: f32,
fresh: bool,
}
impl Engine {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
if !(8000.0..=768000.).contains(&rate) {
return Err("unsupported sample rate");
}
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
let tracker = Tracker::new(rate, TrackerConfig::BASS);
let latency = tracker.latency();
// The copy reads up to half a period (plus a fade) on either side of the dry path.
let reach = (rate / MIN_HZ).ceil() as usize + 8;
let mut e = Self {
rate,
channels,
settings: Settings::default(),
latency,
audio: vec![[0.; 2]; latency + reach],
audio_at: 0,
tracker,
pitch: PitchFollower::default(),
pitch_coef: coefficient(rate, PITCH_MS),
hz: 0.,
beats: 0.,
tempo: DEFAULT_TEMPO,
synced: false,
state: [Channel::default(); 2],
low_cut: None,
fade_step: 1. / (rate * FADE_MS * 0.001),
smooth: coefficient(rate, 10.),
depth: 0.,
output: 1.,
bypass: 0.,
fresh: true,
};
e.set_settings(Settings::default())?;
e.fresh = true;
Ok(e)
}
pub fn latency_samples(&self) -> u32 {
self.latency as u32
}
pub fn channels(&self) -> usize {
self.channels
}
pub fn settings(&self) -> Settings {
self.settings
}
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
s.validate()?;
self.tracker.set(s.source == Source::Midi, s.gate, true);
if s.low_cut != self.settings.low_cut || self.fresh {
let q = std::f32::consts::FRAC_1_SQRT_2;
let hz = s.low_cut.min(0.45 * self.rate);
self.low_cut = (s.low_cut >= LOW_CUT_OFF).then(|| {
(
SvfCoef::highpass(self.rate, hz, q),
SvfCoef::lowpass(self.rate, hz, q),
)
});
}
self.settings = s;
if std::mem::take(&mut self.fresh) {
self.depth = s.depth * 0.01;
self.output = db_to_gain(s.output);
self.bypass = f32::from(s.bypass);
}
Ok(())
}
/// Tempo and, while the host plays, its beat position in quarter notes at the first
/// input sample of the next block. Without a position the beat runs on freely.
pub fn set_transport(&mut self, tempo: Option<f64>, position: Option<f64>) {
self.tempo = tempo
.filter(|t| t.is_finite() && *t > 0.)
.unwrap_or(DEFAULT_TEMPO)
.clamp(20., 999.);
match position.filter(|p| p.is_finite()) {
Some(p) => {
// The output lags the input by the lookahead, and the host compensates it:
// what leaves now belongs `latency` samples earlier on the timeline.
self.beats = p - self.latency as f64 / self.rate as f64 * self.tempo / 60.;
self.synced = true;
}
None => self.synced = false,
}
}
pub fn reset(&mut self) {
self.audio.fill([0.; 2]);
self.tracker.reset();
self.pitch = PitchFollower::default();
self.hz = 0.;
self.state = [Channel::default(); 2];
self.fresh = true;
}
pub fn note_on(&mut self, note: u8) {
self.tracker.note_on(note);
}
pub fn note_off(&mut self, note: u8) {
self.tracker.note_off(note);
}
/// The audio `back` samples (fractional) before the newest one, cubic Hermite.
#[inline]
fn read(&self, back: f32, c: usize) -> f32 {
let len = self.audio.len();
let back = back.clamp(1., (len - 3) as f32);
let whole = back.floor();
let t = back - whole;
let base = whole as usize;
let at = |back: usize| self.audio[(self.audio_at + len - back) % len][c];
// y0 is one sample newer than y1; y2 and y3 are older.
let (y0, y1, y2, y3) = (at(base - 1), at(base), at(base + 1), at(base + 2));
let c1 = 0.5 * (y2 - y0);
let c2 = y0 - 2.5 * y1 + 2. * y2 - 0.5 * y3;
let c3 = 0.5 * (y3 - y0) + 1.5 * (y1 - y2);
((c3 * t + c2) * t + c1) * t + y1
}
// Channels index the input, the state and the output at once.
#[allow(clippy::needless_range_loop)]
pub fn process_sample(&mut self, x: [f32; 2]) -> [f32; 2] {
let mono = if self.channels == 2 {
0.5 * (x[0] + x[1])
} else {
x[0]
};
let (pitch, snap) = (&mut self.pitch, self.settings.snap);
self.tracker.process(mono, |e| pitch.event(snap, e));
let (hz, jump) = self.pitch.step(self.pitch_coef);
self.hz = hz;
let len = self.audio.len();
self.audio_at = (self.audio_at + 1) % len;
self.audio[self.audio_at] = x;
let s = self.settings;
approach(&mut self.depth, s.depth * 0.01, self.smooth);
approach(&mut self.output, db_to_gain(s.output), self.smooth);
approach(&mut self.bypass, f32::from(s.bypass), self.smooth);
let period = self.rate / if hz > 0. { hz } else { FALLBACK_HZ };
let step = self.tempo / 60. / self.rate as f64 / s.rate.quarters() / s.harmonic as f64;
let centre = self.latency as f32;
let mut out = [0.; 2];
for c in 0..self.channels {
// Position in beat cycles of the chosen harmonic, shifted so a cycle starts at
// the delay's highest point and the beat peaks (delay = centre) at cycle 0.
let u = cycles(&s, self.beats, c) / s.harmonic as f64 + 0.5;
let ch = self.state[c];
let moved = (u - ch.last - step).abs() > JUMP;
let mut ch = ch;
if !ch.started || jump || moved || u.floor() != ch.whole {
if ch.started {
ch.old = ch.active;
ch.fade = 1.;
}
ch.whole = u.floor();
ch.started = true;
}
let delay = centre + period * (0.5 - (u - ch.whole) as f32);
ch.active.slope = if ch.fade == 1. {
0.
} else {
delay - ch.active.delay
};
ch.active.delay = delay;
ch.last = u;
let mut copy = self.read(ch.active.delay, c);
if ch.fade > 0. {
ch.old.delay += ch.old.slope;
let w = ch.fade * ch.fade * (3. - 2. * ch.fade);
copy += (self.read(ch.old.delay, c) - copy) * w;
ch.fade = (ch.fade - self.fade_step).max(0.);
}
let delayed = self.audio[(self.audio_at + len - self.latency) % len][c];
let g = self.depth;
let norm = 1. / (1. + g * g).sqrt();
let wet = match &self.low_cut {
Some((high, low)) => {
let lr4 = |f: &mut [Svf; 2], c: &SvfCoef, x: f32| {
let first = f[0].process(c, x);
f[1].process(c, first)
};
let copy = lr4(&mut ch.copy_high, high, copy);
let above = lr4(&mut ch.high, high, delayed);
let below = lr4(&mut ch.low, low, delayed);
below + (above + g * copy) * norm
}
None => (delayed + g * copy) * norm,
};
self.state[c] = ch;
out[c] = wet * self.output * (1. - self.bypass) + delayed * self.bypass;
}
self.beats += self.tempo / 60. / self.rate as f64;
out
}
pub fn take_analyzer(&self) -> AnalyzerFrame {
let s = self.settings;
let hz = self.hz;
let mut f = AnalyzerFrame {
hz,
beats: self.beats,
tempo: self.tempo,
synced: self.synced,
latency: self.latency as u32,
sample_rate: self.rate,
..AnalyzerFrame::default()
};
if hz > 0. {
let beat_hz = self.tempo / 60. / s.rate.quarters();
f.ratio = 1. + (beat_hz / (s.harmonic as f64 * hz as f64)) as f32;
}
if self.tracker.sounding() && hz > 0. {
f.note = Some(hz_to_midi(hz).round().clamp(0., 127.) as u8);
}
f
}
}
/// One-pole step that lands exactly on the target, so switched states end bit-exact.
fn approach(value: &mut f32, target: f32, coef: f32) {
*value += (target - *value) * coef;
if (target - *value).abs() < 1e-6 {
*value = target;
}
}
+146
View File
@@ -0,0 +1,146 @@
//! Host-independent controls. Levels in dB, amounts in percent, angles in degrees.
use serde::{Deserialize, Serialize};
/// Highest harmonic the beat rate can be set on.
pub const HARMONIC_MAX: u32 = 8;
pub const LOW_CUT_MAX: f32 = 400.;
/// A low cut below this is off: no filter on either path.
pub const LOW_CUT_OFF: f32 = 20.;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Source {
/// Pitch and note starts are detected from the incoming bass.
#[default]
Audio,
/// Notes come from MIDI note input.
Midi,
}
/// One beat cycle lasts this note value.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Rate {
Whole,
Half,
Quarter,
#[default]
Eighth,
Sixteenth,
ThirtySecond,
QuarterTriplet,
EighthTriplet,
SixteenthTriplet,
}
impl Rate {
pub const ALL: [Rate; 9] = [
Rate::Whole,
Rate::Half,
Rate::Quarter,
Rate::Eighth,
Rate::Sixteenth,
Rate::ThirtySecond,
Rate::QuarterTriplet,
Rate::EighthTriplet,
Rate::SixteenthTriplet,
];
/// Length of one beat cycle in quarter notes.
pub fn quarters(self) -> f64 {
match self {
Rate::Whole => 4.,
Rate::Half => 2.,
Rate::Quarter => 1.,
Rate::Eighth => 0.5,
Rate::Sixteenth => 0.25,
Rate::ThirtySecond => 0.125,
Rate::QuarterTriplet => 2. / 3.,
Rate::EighthTriplet => 1. / 3.,
Rate::SixteenthTriplet => 1. / 6.,
}
}
pub fn label(self) -> &'static str {
match self {
Rate::Whole => "1/1",
Rate::Half => "1/2",
Rate::Quarter => "1/4",
Rate::Eighth => "1/8",
Rate::Sixteenth => "1/16",
Rate::ThirtySecond => "1/32",
Rate::QuarterTriplet => "1/4T",
Rate::EighthTriplet => "1/8T",
Rate::SixteenthTriplet => "1/16T",
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub source: Source,
/// How long one beat cycle of `harmonic` lasts.
pub rate: Rate,
/// The harmonic that beats exactly at `rate`. Harmonic n beats n/harmonic times as fast.
pub harmonic: u32,
/// Level of the detuned copy against the bass. 100 % gives full nulls.
pub depth: f32,
/// Below this frequency the copy is removed, so the sub stays steady and mono-safe.
/// Under `LOW_CUT_OFF` Hz the filter is off.
pub low_cut: f32,
/// Moves the right channel's beat against the left, up to half a cycle.
pub width: f32,
/// Where in the cycle the beat peaks, in degrees. 0 puts the peak on the beat.
pub phase: f32,
/// Detection threshold in dBFS for note starts (Audio source).
pub gate: f32,
/// Round detected pitch to the nearest semitone. Off by default: the copy jumps by one
/// period of the pitch in use, and a period that does not fit the note is heard.
pub snap: bool,
pub output: f32,
pub bypass: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
source: Source::Audio,
rate: Rate::Eighth,
// Harmonic 4 sits above the low cut on every bass note, and on F1 it asks for
// about 45 cents of detune, a usual reese spread.
harmonic: 4,
depth: 70.,
low_cut: 100.,
width: 0.,
phase: 0.,
gate: -48.,
snap: false,
output: 0.,
bypass: false,
}
}
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
let finite = [
self.depth,
self.low_cut,
self.width,
self.phase,
self.gate,
self.output,
]
.iter()
.all(|v| v.is_finite());
if !finite {
return Err("non-finite setting");
}
let ok = (1..=HARMONIC_MAX).contains(&self.harmonic)
&& (0.0..=100.).contains(&self.depth)
&& (0.0..=LOW_CUT_MAX).contains(&self.low_cut)
&& (0.0..=100.).contains(&self.width)
&& (0.0..=360.).contains(&self.phase)
&& (-60.0..=-20.).contains(&self.gate)
&& (-12.0..=12.).contains(&self.output);
if ok {
Ok(())
} else {
Err("setting out of range")
}
}
}
+278
View File
@@ -0,0 +1,278 @@
// Sample indices address several signals at once.
#![allow(clippy::needless_range_loop)]
use braze_core::{cycles, envelope, Engine, Rate, Settings};
use common_dsp::Saw;
use std::f32::consts::TAU;
const RATE: f32 = 48000.;
const TEMPO: f64 = 120.;
fn engine(s: Settings) -> (Engine, usize) {
let mut e = Engine::new(RATE, 2).unwrap();
e.set_settings(s).unwrap();
e.set_transport(Some(TEMPO), Some(0.));
let l = e.latency_samples() as usize;
(e, l)
}
fn seconds(t: f32) -> usize {
(t * RATE) as usize
}
fn render(e: &mut Engine, x: &[f32]) -> Vec<[f32; 2]> {
x.iter().map(|&s| e.process_sample([s, s])).collect()
}
/// A saw at `hz` from 0.05 s on, with a 1 ms fade-in.
fn saw(hz: f32, total: f32) -> Vec<f32> {
let mut osc = Saw::new(RATE, hz);
(0..seconds(total))
.map(|i| {
let start = seconds(0.05);
if i < start {
return 0.;
}
0.5 * osc.sample() * ((i - start) as f32 / seconds(0.001) as f32).min(1.)
})
.collect()
}
/// Amplitude of harmonic `n` of `hz` over one period centred on sample `at`.
fn harmonic(x: &[f32], at: usize, hz: f32, n: u32) -> f32 {
let half = (RATE / hz / 2.).round() as usize;
let w = TAU * hz * n as f32 / RATE;
let (mut re, mut im) = (0f32, 0f32);
for i in at - half..at + half {
re += x[i] * (w * i as f32).cos();
im -= x[i] * (w * i as f32).sin();
}
2. * (re * re + im * im).sqrt() / (2 * half) as f32
}
/// Beat position (quarter notes) of output sample `i`.
fn beats(i: usize, latency: usize) -> f64 {
(i as f64 - latency as f64) / RATE as f64 * TEMPO / 60.
}
/// Harmonics 1–4 of A1 at falling levels, a periodic note the tracker reads exactly.
fn partials(t: f64) -> f64 {
(1..=4)
.map(|n| 0.4 / n as f64 * (std::f64::consts::TAU * 55. * n as f64 * t).sin())
.sum()
}
#[test]
fn every_harmonic_beats_on_the_tempo_grid() {
for (harmonic, rate) in [(1, Rate::Eighth), (2, Rate::Sixteenth), (3, Rate::Quarter)] {
let s = Settings {
harmonic,
rate,
depth: 100.,
snap: true,
low_cut: 0.,
..Settings::default()
};
let (mut e, l) = engine(s);
let x: Vec<f32> = (0..seconds(1.5))
.map(|i| partials(i as f64 / RATE as f64) as f32)
.collect();
let y = render(&mut e, &x);
let mut worst = 0f32;
for i in seconds(0.5)..seconds(1.4) {
// Cycles of the chosen harmonic, from the grid alone: peaks on every multiple
// of `rate`. The copy lags the bass by (0.5 − frac(u)) periods.
let u = beats(i, l) / rate.quarters() / harmonic as f64 + 0.5;
let t = (i - l) as f64 / RATE as f64;
let lag = (0.5 - u.fract()) / 55.;
let ideal = (partials(t) + partials(t - lag)) / 2f64.sqrt();
worst = worst.max((y[i][0] - ideal as f32).abs());
}
assert!(worst < 3e-3, "harmonic {harmonic}, {rate:?}: {worst}");
}
}
#[test]
fn envelope_peaks_on_the_grid_and_harmonic_n_beats_n_over_k_as_fast() {
let s = Settings {
harmonic: 2,
..Settings::default()
};
// At cycle 0 every harmonic peaks: twice the bass, less the loudness correction.
for n in 1..=8 {
assert!((envelope(&s, n, 0., 1.) - 2f32.sqrt()).abs() < 1e-5);
}
// Harmonic 2 nulls half a cycle later; harmonic 1, beating half as fast, a cycle later.
assert!(envelope(&s, 2, 0.5, 1.) < 1e-5);
assert!(envelope(&s, 1, 1., 1.) < 1e-5);
assert!(envelope(&s, 4, 0.25, 1.) < 1e-5);
}
#[test]
fn the_copy_is_seamless_across_its_jumps() {
// A pure sine at exactly A1: the ideal copy is a smooth sine whose phase follows the beat.
let s = Settings {
harmonic: 1,
rate: Rate::Sixteenth,
depth: 100.,
snap: true,
low_cut: 0.,
..Settings::default()
};
let (mut e, l) = engine(s);
let x: Vec<f32> = (0..seconds(1.5))
.map(|i| 0.5 * (TAU * 55. * i as f32 / RATE).sin())
.collect();
let y = render(&mut e, &x);
let mut worst = 0f32;
for i in seconds(0.5)..seconds(1.4) {
let u = cycles(&s, beats(i, l), 0) + 0.5;
let dry = x[i - l];
let phase = TAU as f64 * (55. * (i - l) as f64 / RATE as f64 + u.fract());
let copy = -0.5 * phase.sin() as f32;
let ideal = (dry + copy) / 2f32.sqrt();
worst = worst.max((y[i][0] - ideal).abs());
}
assert!(worst < 2e-3, "{worst}");
}
#[test]
fn depth_zero_is_the_delayed_input() {
let (mut e, l) = engine(Settings {
depth: 0.,
low_cut: 0.,
..Settings::default()
});
let x = saw(55., 0.6);
let y = render(&mut e, &x);
for i in l..x.len() {
assert_eq!(y[i][0], x[i - l]);
}
}
#[test]
fn width_moves_the_right_channel_half_a_cycle() {
let s = Settings {
harmonic: 1,
width: 100.,
depth: 100.,
rate: Rate::Quarter,
low_cut: 0.,
..Settings::default()
};
let (mut e, l) = engine(s);
let x = saw(55., 1.5);
let y = render(&mut e, &x);
let left: Vec<f32> = y.iter().map(|v| v[0]).collect();
let right: Vec<f32> = y.iter().map(|v| v[1]).collect();
// On the beat the left fundamental peaks and the right one nulls; half a beat later
// they swap.
for (beat, loud, quiet) in [
(1., &left, &right),
(1.5, &right, &left),
(2., &left, &right),
] {
let at = l + (beat / (TEMPO / 60.) * RATE as f64) as usize;
let (a, b) = (harmonic(loud, at, 55., 1), harmonic(quiet, at, 55., 1));
assert!(20. * (a / b).log10() > 20., "beat {beat}: {a} vs {b}");
}
}
#[test]
fn note_changes_and_transport_jumps_do_not_click() {
let s = Settings {
depth: 100.,
..Settings::default()
};
let (mut e, _) = engine(s);
// Phase-continuous sine from A1 to D2, no gap.
let mut phase = 0f32;
let x: Vec<f32> = (0..seconds(1.2))
.map(|i| {
let hz = if i < seconds(0.6) { 55. } else { 73.42 };
phase = (phase + hz / RATE).fract();
0.5 * (TAU * phase).sin()
})
.collect();
let mut y = Vec::new();
for (i, &v) in x.iter().enumerate() {
if i == seconds(0.9) {
// A locate to another bar.
e.set_transport(Some(TEMPO), Some(37.3));
}
y.push(e.process_sample([v, v])[0]);
}
// Two sines of peak 0.5 at up to 79 Hz move at most this much per sample (after the
// loudness correction), plus some room for the fades.
let bound = 2. * 0.5 * TAU * 79. / RATE / 2f32.sqrt() * 1.5;
for i in seconds(0.2)..y.len() - 1 {
assert!(
(y[i + 1] - y[i]).abs() < bound,
"{i}: {}",
(y[i + 1] - y[i]).abs()
);
}
}
#[test]
fn low_cut_keeps_the_sub_steady_and_the_beat_on_the_grid() {
let s = Settings {
harmonic: 1,
depth: 100.,
rate: Rate::Half,
low_cut: 150.,
..Settings::default()
};
let (mut e, l) = engine(s);
let x = saw(55., 2.4);
let y: Vec<f32> = render(&mut e, &x).iter().map(|v| v[0]).collect();
// One cycle (a half note, 1 s at 120 BPM): the fundamental, under the cut, does not beat.
let sub: Vec<f32> = (0..50)
.map(|k| harmonic(&y, seconds(1.) + k * seconds(0.02), 55., 1))
.collect();
let (lo, hi) = sub
.iter()
.fold((f32::MAX, 0f32), |(a, b), &v| (a.min(v), b.max(v)));
// Without the cut it would null fully; what remains is leakage of the beating
// harmonics into the one-period window.
assert!(20. * (hi / lo).log10() < 1.5, "{lo} .. {hi}");
// And it keeps its level: the loudness correction for the copy applies above the cut.
let dry = harmonic(&x, seconds(1.) - l, 55., 1);
let mean = sub.iter().sum::<f32>() / sub.len() as f32;
assert!((20. * (mean / dry).log10()).abs() < 0.5, "{mean} vs {dry}");
// Harmonic 6 (330 Hz) beats 6 times per cycle: it peaks on the grid, the high-pass's
// phase is matched on the bass, and nulls 1/12 cycle later.
for beat in [2., 4.] {
let peak = l + (beat / (TEMPO / 60.) * RATE as f64) as usize;
let null = peak + (RATE / 12.) as usize;
let (a, b) = (harmonic(&y, peak, 55., 6), harmonic(&y, null, 55., 6));
assert!(20. * (a / b).log10() > 15., "beat {beat}: {a} vs {b}");
}
}
#[test]
fn the_ratio_follows_the_note() {
let (mut e, _) = engine(Settings {
harmonic: 1,
..Settings::default()
});
for hz in [55., 110.] {
let x = saw(hz, 0.5);
render(&mut e, &x);
let f = e.take_analyzer();
// An eighth at 120 BPM beats 4 times per second: r = 1 + 4 / f0.
assert!((f.ratio - (1. + 4. / hz)).abs() < 2e-3, "{hz}: {}", f.ratio);
}
}
#[test]
fn settings_are_validated_and_serialise() {
let mut e = Engine::new(RATE, 1).unwrap();
assert!(e
.set_settings(Settings {
harmonic: 0,
..Settings::default()
})
.is_err());
let json = serde_json::to_string(&Settings::default()).unwrap();
assert_eq!(
serde_json::from_str::<Settings>(&json).unwrap(),
Settings::default()
);
assert!(Engine::new(1000., 2).is_err());
assert!(Engine::new(48000., 3).is_err());
}
+79
View File
@@ -0,0 +1,79 @@
use braze_core::{Engine, Rate, Settings, Source};
use std::{
alloc::{GlobalAlloc, Layout, System},
cell::Cell,
};
struct CheckedAllocator;
thread_local! {
static CHECK:Cell<bool>=const {Cell::new(false)};
static OPERATIONS:Cell<usize>=const {Cell::new(0)};
}
fn record() {
if CHECK.try_with(Cell::get).unwrap_or(false) {
let _ = OPERATIONS.try_with(|n| n.set(n.get() + 1));
}
}
unsafe impl GlobalAlloc for CheckedAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
record();
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
record();
unsafe { System.dealloc(pointer, layout) }
}
unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 {
record();
unsafe { System.realloc(pointer, layout, size) }
}
}
#[global_allocator]
static ALLOCATOR: CheckedAllocator = CheckedAllocator;
#[test]
fn processing_notes_settings_transport_and_analysis_do_not_allocate_or_free() {
let mut engine = Engine::new(48000., 2).unwrap();
let mut settings = Settings {
width: 50.,
..Settings::default()
};
engine.set_settings(settings).unwrap();
CHECK.with(|v| v.set(true));
for i in 0..96000 {
if i % 512 == 0 {
let playing = (i / 24000) % 2 == 0;
engine.set_transport(
Some(140.),
playing.then_some(i as f64 / 48000. * 140. / 60.),
);
}
if i % 4800 == 0 {
settings.rate = Rate::ALL[(i / 4800) % Rate::ALL.len()];
settings.harmonic = 1 + (i / 4800 % 4) as u32;
settings.source = if i > 48000 {
Source::Midi
} else {
Source::Audio
};
engine.set_settings(settings).unwrap();
engine.note_on(30 + (i / 4800 % 12) as u8);
}
if i % 4800 == 2400 {
engine.note_off(30 + (i / 4800 % 12) as u8);
}
let t = i as f32 / 48000.;
let x = if (i / 2400) % 2 == 0 {
(std::f32::consts::TAU * 55. * t).sin() * 0.5
} else {
0.
};
std::hint::black_box(engine.process_sample([x, x]));
if i % 1600 == 0 {
std::hint::black_box(engine.take_analyzer());
}
}
engine.reset();
CHECK.with(|v| v.set(false));
assert_eq!(OPERATIONS.with(Cell::get), 0);
}
+2381
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "braze"
version = "0.1.0"
edition = "2021"
license = "ISC"
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" }
braze-core = { path = "../core" }
braze-ui = { path = "../ui" }
crossbeam-queue = "0.3"
[dev-dependencies]
clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" }
serde_json = "1"
[profile.release]
lto = "thin"
strip = "symbols"
+193
View File
@@ -0,0 +1,193 @@
//! CLAP audio effect. All DSP state is owned by the processing thread.
mod params;
use braze_core::{AnalyzerFrame, Engine};
use crossbeam_queue::ArrayQueue;
use nih_plug::prelude::*;
use nih_plug_egui::{create_egui_editor, egui};
use params::{BrazeParams, FLOATS};
use std::sync::Arc;
struct EditorData {
ui: braze_ui::State,
gestures: [bool; FLOATS],
}
pub struct Braze {
params: Arc<BrazeParams>,
analyzer: Arc<ArrayQueue<AnalyzerFrame>>,
engine: Option<Engine>,
publish_count: usize,
publish_interval: usize,
}
impl Default for Braze {
fn default() -> Self {
Self {
params: Arc::new(BrazeParams::default()),
analyzer: Arc::new(ArrayQueue::new(2)),
engine: None,
publish_count: 0,
publish_interval: 1600,
}
}
}
impl Plugin for Braze {
const NAME: &'static str = "Braze";
const VENDOR: &'static str = "Bass Foundry";
const URL: &'static str = "";
const EMAIL: &'static str = "";
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[
AudioIOLayout {
main_input_channels: NonZeroU32::new(2),
main_output_channels: NonZeroU32::new(2),
..AudioIOLayout::const_default()
},
AudioIOLayout {
main_input_channels: NonZeroU32::new(1),
main_output_channels: NonZeroU32::new(1),
..AudioIOLayout::const_default()
},
];
const MIDI_INPUT: MidiConfig = MidiConfig::Basic;
const SAMPLE_ACCURATE_AUTOMATION: bool = false;
type SysExMessage = ();
type BackgroundTask = ();
fn params(&self) -> Arc<dyn Params> {
self.params.clone()
}
fn editor(&mut self, _: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
let params = self.params.clone();
let analyzer = self.analyzer.clone();
create_egui_editor(
params.editor.clone(),
EditorData {
ui: braze_ui::State::default(),
gestures: [false; FLOATS],
},
|ctx, _| braze_ui::style(ctx),
move |ctx, setter, data| {
while let Some(frame) = analyzer.pop() {
data.ui.accept_frame(frame);
}
let before = params.settings();
let mut edited = before;
nih_plug_egui::resizable_window::ResizableWindow::new("braze-editor")
.min_size(egui::vec2(
braze_ui::WIDTH as f32 - 80.,
braze_ui::HEIGHT as f32 - 40.,
))
.show(ctx, &params.editor, |ui| {
braze_ui::show(ui, &mut edited, &mut data.ui)
});
params.apply(
setter,
&before,
&edited,
data.ui.gesture_active,
&mut data.gestures,
);
// The playhead follows the transport.
ctx.request_repaint_after(std::time::Duration::from_millis(40));
},
)
}
fn initialize(
&mut self,
layout: &AudioIOLayout,
config: &BufferConfig,
context: &mut impl InitContext<Self>,
) -> bool {
let Ok(mut engine) = Engine::new(
config.sample_rate,
layout.main_input_channels.map_or(0, |v| v.get() as usize),
) else {
return false;
};
let settings = self.params.settings();
if engine.set_settings(settings).is_err() {
return false;
}
context.set_latency_samples(engine.latency_samples());
self.publish_interval = (config.sample_rate / 30.) as usize;
self.engine = Some(engine);
true
}
fn reset(&mut self) {
if let Some(e) = self.engine.as_mut() {
e.reset();
}
self.publish_count = 0;
}
fn process(
&mut self,
buffer: &mut Buffer,
_: &mut AuxiliaryBuffers,
context: &mut impl ProcessContext<Self>,
) -> ProcessStatus {
let Some(engine) = self.engine.as_mut() else {
return ProcessStatus::Normal;
};
let settings = self.params.settings();
let _ = engine.set_settings(settings);
let transport = context.transport();
engine.set_transport(
transport.tempo,
if transport.playing {
transport.pos_beats()
} else {
None
},
);
let mut next = context.next_event();
for (index, mut sample) in buffer.iter_samples().enumerate() {
while let Some(event) = next {
if event.timing() as usize > index {
break;
}
match event {
NoteEvent::NoteOn { note, velocity, .. } if velocity > 0. => {
engine.note_on(note)
}
NoteEvent::NoteOn { note, .. } | NoteEvent::NoteOff { note, .. } => {
engine.note_off(note)
}
NoteEvent::Choke { note, .. } => engine.note_off(note),
_ => {}
}
next = context.next_event();
}
let x = [
*sample.get_mut(0).unwrap(),
if engine.channels() == 2 {
*sample.get_mut(1).unwrap()
} else {
0.
},
];
let out = engine.process_sample(x);
for (c, value) in sample.into_iter().enumerate() {
*value = out[c];
}
self.publish_count += 1;
if self.publish_count >= self.publish_interval {
self.analyzer.force_push(engine.take_analyzer());
self.publish_count = 0;
}
}
// The lookahead delay must drain after input stops.
ProcessStatus::KeepAlive
}
}
impl ClapPlugin for Braze {
const CLAP_ID: &'static str = "io.narl.bass-foundry.braze";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("Adds a detuned copy whose beating stays on the tempo grid on every note");
const CLAP_MANUAL_URL: Option<&'static str> = None;
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::AudioEffect,
ClapFeature::Chorus,
ClapFeature::Stereo,
ClapFeature::Mono,
];
}
nih_export_clap!(Braze);
+219
View File
@@ -0,0 +1,219 @@
use braze_core::{Rate, Settings, Source, HARMONIC_MAX, LOW_CUT_MAX};
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use std::sync::Arc;
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SourceParam {
#[id = "audio"]
Audio,
#[id = "midi"]
#[name = "MIDI"]
Midi,
}
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum RateParam {
#[id = "1-1"]
#[name = "1/1"]
Whole,
#[id = "1-2"]
#[name = "1/2"]
Half,
#[id = "1-4"]
#[name = "1/4"]
Quarter,
#[id = "1-8"]
#[name = "1/8"]
Eighth,
#[id = "1-16"]
#[name = "1/16"]
Sixteenth,
#[id = "1-32"]
#[name = "1/32"]
ThirtySecond,
#[id = "1-4t"]
#[name = "1/4T"]
QuarterTriplet,
#[id = "1-8t"]
#[name = "1/8T"]
EighthTriplet,
#[id = "1-16t"]
#[name = "1/16T"]
SixteenthTriplet,
}
const RATES: [(RateParam, Rate); 9] = [
(RateParam::Whole, Rate::Whole),
(RateParam::Half, Rate::Half),
(RateParam::Quarter, Rate::Quarter),
(RateParam::Eighth, Rate::Eighth),
(RateParam::Sixteenth, Rate::Sixteenth),
(RateParam::ThirtySecond, Rate::ThirtySecond),
(RateParam::QuarterTriplet, Rate::QuarterTriplet),
(RateParam::EighthTriplet, Rate::EighthTriplet),
(RateParam::SixteenthTriplet, Rate::SixteenthTriplet),
];
fn to_param(rate: Rate) -> RateParam {
RATES
.iter()
.find(|(_, r)| *r == rate)
.map_or(RateParam::Eighth, |(p, _)| *p)
}
fn linear(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam {
FloatParam::new(name, v, FloatRange::Linear { min, max }).with_unit(unit)
}
/// Continuous controls, in the order of the gesture slots.
pub const FLOATS: usize = 6;
#[derive(Params)]
pub struct BrazeParams {
#[persist = "editor"]
pub editor: Arc<EguiState>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[id = "rate"]
pub rate: EnumParam<RateParam>,
#[id = "harmonic"]
pub harmonic: IntParam,
#[id = "depth"]
pub depth: FloatParam,
#[id = "low_cut"]
pub low_cut: FloatParam,
#[id = "width"]
pub width: FloatParam,
#[id = "phase"]
pub phase: FloatParam,
#[id = "gate"]
pub gate: FloatParam,
#[id = "snap"]
pub snap: BoolParam,
#[id = "output"]
pub output: FloatParam,
#[id = "bypass"]
pub bypass: BoolParam,
}
impl Default for BrazeParams {
fn default() -> Self {
let d = Settings::default();
Self {
editor: EguiState::from_size(braze_ui::WIDTH, braze_ui::HEIGHT),
source: EnumParam::new("Pitch source", SourceParam::Audio),
rate: EnumParam::new("Rate", to_param(d.rate)),
harmonic: IntParam::new(
"Harmonic",
d.harmonic as i32,
IntRange::Linear {
min: 1,
max: HARMONIC_MAX as i32,
},
),
depth: linear("Depth", d.depth, 0., 100., " %"),
low_cut: FloatParam::new(
"Low cut",
d.low_cut,
FloatRange::Skewed {
min: 0.,
max: LOW_CUT_MAX,
factor: FloatRange::skew_factor(-1.),
},
)
.with_unit(" Hz"),
width: linear("Width", d.width, 0., 100., " %"),
phase: linear("Phase", d.phase, 0., 360., "°"),
gate: linear("Gate", d.gate, -60., -20., " dB"),
snap: BoolParam::new("Snap to semitone", d.snap),
output: linear("Output", d.output, -12., 12., " dB"),
bypass: BoolParam::new("Bypass", false).make_bypass(),
}
}
}
impl BrazeParams {
pub fn settings(&self) -> Settings {
let rate = self.rate.value();
Settings {
source: match self.source.value() {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
},
rate: RATES
.iter()
.find(|(p, _)| *p == rate)
.map_or(Rate::Eighth, |(_, r)| *r),
harmonic: self.harmonic.value() as u32,
depth: self.depth.value(),
low_cut: self.low_cut.value(),
width: self.width.value(),
phase: self.phase.value(),
gate: self.gate.value(),
snap: self.snap.value(),
output: self.output.value(),
bypass: self.bypass.value(),
}
}
fn floats(&self) -> [&FloatParam; FLOATS] {
[
&self.depth,
&self.low_cut,
&self.width,
&self.phase,
&self.gate,
&self.output,
]
}
fn values(s: &Settings) -> [f32; FLOATS] {
[s.depth, s.low_cut, s.width, s.phase, s.gate, s.output]
}
/// Pushes every changed editor value to the host. Continuous controls hold one gesture
/// while the pointer is down; switches are set in a single gesture.
pub fn apply(
&self,
setter: &ParamSetter,
old: &Settings,
new: &Settings,
dragging: bool,
open: &mut [bool; FLOATS],
) {
let pairs = Self::values(old).into_iter().zip(Self::values(new));
for (i, (p, (a, b))) in self.floats().into_iter().zip(pairs).enumerate() {
if a != b {
if !open[i] {
open[i] = true;
setter.begin_set_parameter(p);
}
setter.set_parameter(p, b);
}
if !dragging && open[i] {
setter.end_set_parameter(p);
open[i] = false;
}
}
if new.harmonic != old.harmonic {
setter.begin_set_parameter(&self.harmonic);
setter.set_parameter(&self.harmonic, new.harmonic as i32);
setter.end_set_parameter(&self.harmonic);
}
if new.rate != old.rate {
setter.begin_set_parameter(&self.rate);
setter.set_parameter(&self.rate, to_param(new.rate));
setter.end_set_parameter(&self.rate);
}
for (p, a, b) in [
(&self.snap, old.snap, new.snap),
(&self.bypass, old.bypass, new.bypass),
] {
if a != b {
setter.begin_set_parameter(p);
setter.set_parameter(p, b);
setter.end_set_parameter(p);
}
}
if new.source != old.source {
let v = match new.source {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
};
setter.begin_set_parameter(&self.source);
setter.set_parameter(&self.source, v);
setter.end_set_parameter(&self.source);
}
}
}
+216
View File
@@ -0,0 +1,216 @@
//! Exercise the actual exported CLAP ABI without requiring a GUI or installed DAW.
use clap_sys::{
audio_buffer::*,
events::*,
ext::{audio_ports::*, latency::*, state::*},
factory::plugin_factory::*,
host::*,
process::*,
stream::*,
version::*,
};
use std::{
ffi::{c_char, c_void, CStr},
io::{Cursor, Read},
ptr,
};
unsafe extern "C" fn extension(_: *const clap_host, _: *const c_char) -> *const c_void {
ptr::null()
}
unsafe extern "C" fn request(_: *const clap_host) {}
unsafe extern "C" fn count(_: *const clap_input_events) -> u32 {
0
}
unsafe extern "C" fn event(_: *const clap_input_events, _: u32) -> *const clap_event_header {
ptr::null()
}
unsafe extern "C" fn push(_: *const clap_output_events, _: *const clap_event_header) -> bool {
true
}
unsafe extern "C" fn write(stream: *const clap_ostream, buffer: *const c_void, size: u64) -> i64 {
// SAFETY: The test owns the Vec for the entire synchronous save call.
let bytes = unsafe { &mut *((*stream).ctx as *mut Vec<u8>) };
bytes.extend_from_slice(unsafe {
std::slice::from_raw_parts(buffer as *const u8, size as usize)
});
size as i64
}
unsafe extern "C" fn read(stream: *const clap_istream, buffer: *mut c_void, size: u64) -> i64 {
// SAFETY: The test owns the Cursor; the wrapper supplies writable stream storage.
let cursor = unsafe { &mut *((*stream).ctx as *mut Cursor<Vec<u8>>) };
cursor
.read(unsafe { std::slice::from_raw_parts_mut(buffer as *mut u8, size as usize) })
.unwrap() as i64
}
#[test]
fn effect_ports_state_latency_and_audio_lifecycle() {
// SAFETY: All callbacks, strings, streams, buffers, and the host outlive their
// synchronous ABI calls. Activation/processing/destruction follow CLAP order.
unsafe {
let entry = &braze::clap_entry;
assert!(entry.init.unwrap()(c"braze.clap".as_ptr()));
let factory = entry.get_factory.unwrap()(CLAP_PLUGIN_FACTORY_ID.as_ptr())
as *const clap_plugin_factory;
assert!(!factory.is_null());
let factory = &*factory;
assert_eq!(factory.get_plugin_count.unwrap()(factory), 1);
let descriptor = factory.get_plugin_descriptor.unwrap()(factory, 0);
assert!(!descriptor.is_null());
assert_eq!(CStr::from_ptr((*descriptor).name), c"Braze");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.narl.bass-foundry.braze"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
clap_version: CLAP_VERSION,
host_data: ptr::null_mut(),
name: c"Storyboard test host".as_ptr(),
vendor: c"Tests".as_ptr(),
url: c"".as_ptr(),
version: c"1.0".as_ptr(),
get_extension: Some(extension),
request_restart: Some(request),
request_process: Some(request),
request_callback: Some(request),
};
let plugin = factory.create_plugin.unwrap()(factory, &host, (*descriptor).id);
assert!(!plugin.is_null());
let p = &*plugin;
assert!(p.init.unwrap()(plugin));
let ports = p.get_extension.unwrap()(plugin, CLAP_EXT_AUDIO_PORTS.as_ptr())
as *const clap_plugin_audio_ports;
assert!(!ports.is_null());
assert_eq!((*ports).count.unwrap()(plugin, false), 1);
assert_eq!((*ports).count.unwrap()(plugin, true), 1);
let latency = p.get_extension.unwrap()(plugin, CLAP_EXT_LATENCY.as_ptr())
as *const clap_plugin_latency;
assert!(!latency.is_null());
let state =
p.get_extension.unwrap()(plugin, CLAP_EXT_STATE.as_ptr()) as *const clap_plugin_state;
assert!(!state.is_null());
let state = &*state;
let save = || {
let mut bytes = Vec::new();
let stream = clap_ostream {
ctx: &mut bytes as *mut Vec<u8> as *mut c_void,
write: Some(write),
};
assert!(state.save.unwrap()(plugin, &stream));
bytes
};
let mut envelope: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
use nih_plug::wrapper::state::ParamValue;
// Depth 0 passes the input through the lookahead delay unchanged.
envelope.params.insert("depth".into(), ParamValue::F32(0.));
envelope.params.insert("width".into(), ParamValue::F32(40.));
envelope
.params
.insert("harmonic".into(), ParamValue::I32(3));
envelope
.params
.insert("rate".into(), ParamValue::String("1-16t".into()));
envelope
.params
.insert("snap".into(), ParamValue::Bool(true));
envelope
.params
.insert("source".into(), ParamValue::String("midi".into()));
let encoded = serde_json::to_vec(&envelope).unwrap();
let mut framed = (encoded.len() as u64).to_le_bytes().to_vec();
framed.extend_from_slice(&encoded);
let mut bytes = Cursor::new(framed);
let stream = clap_istream {
ctx: &mut bytes as *mut Cursor<Vec<u8>> as *mut c_void,
read: Some(read),
};
assert!(state.load.unwrap()(plugin, &stream));
let restored: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
assert_eq!(
serde_json::to_value(&restored.params).unwrap(),
serde_json::to_value(&envelope.params).unwrap()
);
for rate in [44100., 48000., 96000.] {
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
let delay = (*latency).get.unwrap()(plugin) as usize;
// About 67 ms lookahead: two periods of the lowest detected note (30 Hz).
assert!((delay as f64 / rate - 0.067).abs() < 0.01, "{delay}");
assert!(p.start_processing.unwrap()(plugin));
let total = delay + 9600;
let mut position = 0;
let (mut energy_in, mut energy_out) = (0f64, 0f64);
while position < total {
let frames = [1, 127, 512, 31][position % 4].min(total - position);
// A 1 kHz tone at depth 0.
let tone = |n: usize| {
0.25 * (std::f64::consts::TAU * 1000. * n as f64 / rate).sin() as f32
};
let mut input_left = [0f32; 512];
let mut input_right = [0f32; 512];
for i in 0..frames {
input_left[i] = tone(position + i);
input_right[i] = -tone(position + i);
}
let mut left = [0f32; 512];
let mut right = [0f32; 512];
let mut in_channels = [input_left.as_mut_ptr(), input_right.as_mut_ptr()];
let mut out_channels = [left.as_mut_ptr(), right.as_mut_ptr()];
let incoming = clap_audio_buffer {
data32: in_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let mut outgoing_audio = clap_audio_buffer {
data32: out_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let input = clap_input_events {
ctx: ptr::null_mut(),
size: Some(count),
get: Some(event),
};
let outgoing = clap_output_events {
ctx: ptr::null_mut(),
try_push: Some(push),
};
let process = clap_process {
steady_time: position as i64,
frames_count: frames as u32,
transport: ptr::null(),
audio_inputs: &incoming,
audio_outputs: &mut outgoing_audio,
audio_inputs_count: 1,
audio_outputs_count: 1,
in_events: &input,
out_events: &outgoing,
};
assert_ne!(p.process.unwrap()(plugin, &process), CLAP_PROCESS_ERROR);
for i in 0..frames {
let n = position + i;
if n < delay {
assert!(left[i].abs() < 1e-6, "output before the lookahead");
} else if n > delay + 4800 {
energy_in += (tone(n - delay) as f64).powi(2);
energy_out += (left[i] as f64).powi(2) + (right[i] as f64).powi(2);
}
}
position += frames;
}
let ratio_db = 10. * (energy_out / (2. * energy_in)).log10();
assert!(ratio_db.abs() < 0.2, "{ratio_db}");
p.stop_processing.unwrap()(plugin);
p.deactivate.unwrap()(plugin);
}
p.destroy.unwrap()(plugin);
entry.deinit.unwrap()();
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "braze-ui"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
egui = "0.31.1"
braze-core = { path = "../core" }
common-ui = { path = "../../common/ui" }
common-dsp = { path = "../../common/dsp" }
+410
View File
@@ -0,0 +1,410 @@
//! Braze editor, shared by the CLAP wrapper and the headless renderer.
use braze_core::{
cycles, envelope, AnalyzerFrame, Rate, Settings, Source, HARMONIC_MAX, LOW_CUT_MAX, LOW_CUT_OFF,
};
use common_ui::*;
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
pub use common_ui::style;
pub const WIDTH: u32 = 980;
pub const HEIGHT: u32 = 716;
pub struct State {
pub analyzer: AnalyzerFrame,
/// True while a pointer button is held, so knob drags become one host gesture.
pub gesture_active: bool,
pub help: String,
}
impl Default for State {
fn default() -> Self {
Self {
analyzer: AnalyzerFrame::default(),
gesture_active: false,
help: DEFAULT_HELP.into(),
}
}
}
const DEFAULT_HELP: &str =
"Put Braze on a reese or a layered bass. Pick the note value the beating should follow.";
impl State {
pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
self.analyzer = frame;
}
}
fn hint(r: &egui::Response, state: &mut State, text: &str) {
if r.hovered() || r.has_focus() {
state.help = text.into();
}
}
#[allow(clippy::too_many_arguments)]
fn knob(
ui: &mut egui::Ui,
state: &mut State,
name: &str,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
default: f32,
unit: &str,
size: f32,
help: &str,
) {
ui.vertical_centered(|ui| {
let r = rotary(ui, name, value, range, default, unit, size, ACCENT);
hint(&r, state, help);
});
}
/// Note value of one beat of harmonic `n`, as a label: "1/8", "1/16T", "1/8D", or "1/40".
pub fn beat_label(s: &Settings, n: u32) -> String {
let quarters = s.rate.quarters() * s.harmonic as f64 / n as f64;
let per_whole = 4. / quarters;
for den in [1u32, 2, 4, 8, 16, 32, 64, 128] {
let d = den as f64;
if (per_whole - d).abs() < 1e-6 {
return format!("1/{den}");
}
if (per_whole - d * 1.5).abs() < 1e-6 {
return format!("1/{den}T");
}
if (per_whole - d / 1.5).abs() < 1e-6 {
return format!("1/{den}D");
}
}
if (per_whole - per_whole.round()).abs() < 1e-6 {
format!("1/{}", per_whole.round())
} else {
format!("{:.2} beats", quarters)
}
}
/// One bar of the beat of harmonics 1–8, with the beat grid and a playhead.
fn beats(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame, height: f32) {
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
let p = ui.painter_at(rect);
p.rect_filled(rect, 2., SCREEN);
let label_w = 86.;
let area = Rect::from_min_max(
rect.left_top() + Vec2::new(label_w, 26.),
rect.right_bottom() - Vec2::new(12., 10.),
);
p.text(
rect.left_top() + Vec2::new(10., 8.),
egui::Align2::LEFT_TOP,
"BEAT PER HARMONIC · ONE BAR",
FontId::proportional(11.),
MUTED,
);
p.text(
rect.right_top() + Vec2::new(-12., 8.),
egui::Align2::RIGHT_TOP,
if frame.synced {
format!("{:.1} BPM · host transport", frame.tempo)
} else {
format!("{:.1} BPM · free running", frame.tempo)
},
FontId::proportional(11.),
MUTED,
);
// Grid: every beat of the chosen rate, quarters brighter.
let step = settings.rate.quarters();
let mut q = 0f64;
while q < 4. - 1e-9 {
let x = area.left() + area.width() * (q / 4.) as f32;
let on_quarter = (q - q.round()).abs() < 1e-6;
let color = if on_quarter {
Color32::from_rgb(58, 62, 70)
} else {
Color32::from_rgb(32, 35, 40)
};
p.line_segment(
[Pos2::new(x, area.top()), Pos2::new(x, area.bottom())],
Stroke::new(1.0_f32, color),
);
q += step;
}
let rows = HARMONIC_MAX as usize;
let row_h = area.height() / rows as f32;
let depth = settings.depth * 0.01;
let points = 240;
let bar_start = (frame.beats / 4.).floor() * 4.;
for row in 0..rows {
let n = row as u32 + 1;
let top = area.top() + row_h * row as f32;
let chosen = n == settings.harmonic;
// Under the low cut the copy is removed: that harmonic keeps the bass's level.
let cut = settings.low_cut >= LOW_CUT_OFF
&& frame.hz > 0.
&& n as f32 * frame.hz < settings.low_cut;
let color = match (cut, chosen) {
(true, _) => DIM,
(false, true) => ACCENT,
(false, false) => STEEL,
};
p.text(
Pos2::new(rect.left() + 10., top + row_h * 0.5),
egui::Align2::LEFT_CENTER,
format!("H{n}"),
FontId::proportional(11.),
if chosen { ACCENT } else { MUTED },
);
p.text(
Pos2::new(rect.left() + 36., top + row_h * 0.5),
egui::Align2::LEFT_CENTER,
if cut {
"cut".to_string()
} else {
beat_label(settings, n)
},
FontId::monospace(11.),
if chosen { TEXT } else { DIM },
);
let line: Vec<Pos2> = (0..=points)
.map(|i| {
let t = i as f64 / points as f64;
let level = if cut {
1.
} else {
envelope(settings, n, cycles(settings, bar_start + 4. * t, 0), depth)
};
// 0 at the bottom of the row, twice the bass (√2 after correction) at the top.
let y = top + row_h * (0.92 - 0.8 * level / 2f32.sqrt());
Pos2::new(area.left() + area.width() * t as f32, y)
})
.collect();
p.add(egui::Shape::line(
line,
Stroke::new(if chosen { 2.0_f32 } else { 1.2_f32 }, color),
));
}
let head = area.left() + area.width() * ((frame.beats - bar_start) / 4.) as f32;
p.line_segment(
[Pos2::new(head, area.top()), Pos2::new(head, area.bottom())],
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 90)),
);
p.rect_stroke(
rect,
2.,
Stroke::new(1.0_f32, EDGE),
egui::StrokeKind::Inside,
);
}
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
backdrop(ui);
state.help = DEFAULT_HELP.into();
state.gesture_active = ui.input(|i| i.pointer.any_down());
ui.add_space(2.);
header(ui, "BRAZE", "TEMPO-LOCKED BEATING", |ui| {
let r = ui
.selectable_label(settings.bypass, RichText::new("BYPASS").size(13.))
.on_hover_text("Crossfade to the delayed dry signal.");
if r.clicked() {
settings.bypass = !settings.bypass;
}
ui.separator();
for (source, label) in [(Source::Midi, "MIDI"), (Source::Audio, "AUDIO")] {
let r = ui.selectable_label(settings.source == source, RichText::new(label).size(13.));
if r.clicked() {
settings.source = source;
}
}
caption(ui, "PITCH FROM");
});
ui.add_space(6.);
let frame = state.analyzer;
ui.horizontal_top(|ui| {
panel(ui, |ui| {
ui.set_width(240.);
ui.set_min_height(TOP);
ui.vertical(|ui| note_panel(ui, settings, &frame));
});
ui.add_space(4.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
beats(ui, settings, &frame, TOP);
});
});
ui.add_space(6.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| controls(ui, settings, state));
});
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
const TOP: f32 = 300.;
fn note_panel(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame) {
caption(ui, "NOTE");
let (name, hz) = match frame.note {
Some(n) if frame.hz > 0. => (common_dsp::note_name(n), format!("{:.1} Hz", frame.hz)),
_ => ("—".into(), "no note".into()),
};
ui.label(
RichText::new(name)
.font(FontId::new(54., egui::FontFamily::Name("medium".into())))
.color(if frame.note.is_some() { TEXT } else { DIM }),
);
ui.label(RichText::new(hz).monospace().size(14.).color(MUTED));
ui.add_space(14.);
let beat_hz = frame.tempo / 60. / settings.rate.quarters();
readout(
ui,
&format!("HARMONIC {} BEATS", settings.harmonic),
format!("{:.2} Hz, every {}", beat_hz, settings.rate.label()),
ACCENT,
);
if settings.low_cut >= LOW_CUT_OFF
&& frame.hz > 0.
&& settings.harmonic as f32 * frame.hz < settings.low_cut
{
ui.label(
RichText::new("Under Low cut on this note: it does not beat. Pick a higher harmonic.")
.size(12.)
.color(ACCENT),
);
ui.add_space(6.);
}
let cents = 1200. * frame.ratio.max(1e-6).log2();
readout(
ui,
"COPY DETUNE ON THIS NOTE",
if frame.hz > 0. {
format!("+{cents:.1} cents")
} else {
"waiting for a note".into()
},
TEXT,
);
ui.label(
RichText::new(format!(
"{:.0} ms lookahead · {}",
frame.latency as f32 / frame.sample_rate * 1000.,
match settings.source {
Source::Audio => "detecting pitch",
Source::Midi => "following MIDI notes",
}
))
.size(12.)
.color(MUTED),
);
}
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let d = Settings::default();
ui.horizontal(|ui| {
caption(ui, "RATE");
ui.add_space(6.);
for rate in Rate::ALL {
let r =
ui.selectable_label(settings.rate == rate, RichText::new(rate.label()).size(13.));
if r.clicked() {
settings.rate = rate;
}
hint(
&r,
state,
"One beat cycle of the chosen harmonic lasts this note value, on every note.",
);
}
ui.add_space(18.);
caption(ui, "ON HARMONIC");
ui.add_space(6.);
for n in 1..=HARMONIC_MAX {
let r = ui.selectable_label(
settings.harmonic == n,
RichText::new(format!("{n}")).size(13.),
);
if r.clicked() {
settings.harmonic = n;
}
hint(
&r,
state,
"This harmonic beats at Rate. Harmonic n beats n/this times as fast. \
Pick the harmonic you hear the movement on.",
);
}
});
ui.add_space(8.);
ui.separator();
ui.columns(6, |c| {
knob(
&mut c[0],
state,
"Depth",
&mut settings.depth,
0. ..=100.,
d.depth,
" %",
72.,
"Level of the detuned copy. 100 % beats down to full nulls; 0 % is the dry bass.",
);
knob(
&mut c[1],
state,
"Low cut",
&mut settings.low_cut,
0. ..=LOW_CUT_MAX,
d.low_cut,
" Hz",
72.,
"No copy below this frequency: the sub does not beat and keeps its level. \
Under 20 Hz the cut is off.",
);
knob(
&mut c[2],
state,
"Width",
&mut settings.width,
0. ..=100.,
d.width,
" %",
72.,
"Moves the right channel's beat against the left. 100 %: one side peaks while \
the other nulls. Check the mono sum.",
);
knob(
&mut c[3],
state,
"Phase",
&mut settings.phase,
0. ..=360.,
d.phase,
"°",
72.,
"Where the beat peaks. 0° puts the peak of the chosen harmonic on the beat.",
);
knob(
&mut c[4],
state,
"Gate",
&mut settings.gate,
-60. ..=-20.,
d.gate,
" dB",
72.,
"Input level that counts as a note. Raise it if tails retrigger.",
);
knob(
&mut c[5],
state,
"Output",
&mut settings.output,
-12. ..=12.,
d.output,
" dB",
72.,
"Output trim.",
);
});
ui.add_space(4.);
let r = ui.selectable_label(settings.snap, RichText::new("SNAP TO SEMITONE").size(13.));
if r.clicked() {
settings.snap = !settings.snap;
}
hint(
&r,
state,
"Round the detected pitch. Off keeps the copy's jumps on the real period, which is \
seamless on detuned patches.",
);
}
+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"
]
}
}
+1
View File
@@ -311,6 +311,7 @@ impl Analyzer {
frames: self.frames,
low: self.range().0,
high: self.range().1,
smear_ms: fit.smear_ms(self.rate),
..Report::default()
};
// Display bands over all bins.
+47
View File
@@ -7,6 +7,16 @@ pub const MIN_FC: f32 = 30.;
pub const MIN_Q: f32 = 0.2;
pub const MAX_Q: f32 = 4.;
/// The range where listeners hear group-delay changes best (Liski, Mäkivirta and
/// Välimäki 2021, "Audibility of Group-Delay Equalization").
pub const SMEAR_LOW: f32 = 500.;
pub const SMEAR_HIGH: f32 = 4000.;
/// Smallest mean threshold that study found for a positive group-delay peak in that
/// range, with a pink impulse over headphones. An allpass only adds positive delay.
/// Sustained bass hides much more; this marks where sharp attacks may soften.
pub const SMEAR_AUDIBLE_MS: f32 = 0.64;
const SMEAR_POINTS: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Section {
/// Centre frequency in Hz, where the section's phase passes −180°.
@@ -67,6 +77,33 @@ impl Fit {
}
(re, im)
}
/// Group delay at `f` Hz in seconds. Polarity adds none.
pub fn group_delay(&self, f: f32, rate: f32) -> f32 {
let w = std::f32::consts::TAU * f / rate;
let (c1, s1, c2, s2) = (w.cos(), w.sin(), (2. * w).cos(), (2. * w).sin());
let samples: f32 = self
.active()
.iter()
.map(|s| Coefs::allpass(s.fc, s.q, rate).group_delay(c1, s1, c2, s2))
.sum();
samples / rate
}
/// How much the group delay varies between `SMEAR_LOW` and `SMEAR_HIGH`, in ms:
/// the largest delay minus the smallest. A constant delay counts as zero.
pub fn smear_ms(&self, rate: f32) -> f32 {
if self.active().is_empty() {
return 0.;
}
let high = SMEAR_HIGH.min(max_fc(rate));
let (mut lo, mut hi) = (f32::MAX, f32::MIN);
for i in 0..SMEAR_POINTS {
let f = SMEAR_LOW * (high / SMEAR_LOW).powf(i as f32 / (SMEAR_POINTS - 1) as f32);
let d = self.group_delay(f, rate);
lo = lo.min(d);
hi = hi.max(d);
}
(hi - lo).max(0.) * 1000.
}
}
pub fn max_fc(rate: f32) -> f32 {
@@ -104,6 +141,16 @@ impl Coefs {
// times z⁻² = c2 − j s2
(qr * c2 + qi * s2, qi * c2 - qr * s2)
}
/// Group delay in samples, given cos/sin of ω and 2ω. With H = z⁻²·conj(D)/D the
/// phase is −2ω − 2·arg D, so the delay is 2 + 2·d(arg D)/dω.
#[inline]
pub fn group_delay(&self, c1: f32, s1: f32, c2: f32, s2: f32) -> f32 {
let x = 1. + self.a1 * c1 + self.a2 * c2;
let y = -(self.a1 * s1 + self.a2 * s2);
let dx = -(self.a1 * s1 + 2. * self.a2 * s2);
let dy = -(self.a1 * c1 + 2. * self.a2 * c2);
2. + 2. * (x * dy - y * dx) / (x * x + y * y)
}
}
/// Transposed direct form II state, in f64 so sections near 30 Hz stay quiet.
+1 -1
View File
@@ -9,7 +9,7 @@ mod report;
mod settings;
mod worker;
pub use analyzer::{Analyzer, Update};
pub use chain::{Chain, Fit, Section, MAX_SECTIONS};
pub use chain::{Chain, Fit, Section, MAX_SECTIONS, SMEAR_AUDIBLE_MS, SMEAR_HIGH, SMEAR_LOW};
pub use report::{
band_edge, curve_frequency, Band, CurvePoint, Report, Status, BANDS, CURVE, DISPLAY_HIGH,
DISPLAY_LOW,
+4
View File
@@ -83,6 +83,9 @@ pub struct Report {
pub lag_ms: f32,
/// How clear the time offset is, 0..1.
pub lag_strength: f32,
/// How much the alignment's group delay varies between `SMEAR_LOW` and `SMEAR_HIGH`,
/// ms. Above `SMEAR_AUDIBLE_MS`, sharp attacks in this layer may soften.
pub smear_ms: f32,
/// Measured frames in the current average.
pub frames: u32,
pub low: f32,
@@ -100,6 +103,7 @@ impl Default for Report {
recovered_db: 0.,
lag_ms: 0.,
lag_strength: 0.,
smear_ms: 0.,
frames: 0,
low: 60.,
high: 6000.,
+76
View File
@@ -0,0 +1,76 @@
use weld_core::{Fit, Section, SMEAR_AUDIBLE_MS};
const RATE: f32 = 48000.;
fn fit(sections: &[(f32, f32)], flip: bool) -> Fit {
let mut fit = Fit {
flip,
count: sections.len(),
..Fit::NONE
};
for (s, &(fc, q)) in fit.sections.iter_mut().zip(sections) {
*s = Section { fc, q };
}
fit
}
/// Phase of the fit at `f`, from its complex response.
fn phase(fit: &Fit, f: f64) -> f64 {
let (re, im) = fit.response(f as f32, RATE);
(im as f64).atan2(re as f64)
}
#[test]
fn group_delay_matches_the_phase_slope() {
let fit = fit(&[(180., 0.9), (1200., 0.6), (3500., 2.)], false);
for f in [80., 250., 700., 1500., 3000., 8000.] {
let df = 0.5;
let mut dphi = phase(&fit, f + df) - phase(&fit, f - df);
while dphi > std::f64::consts::PI {
dphi -= std::f64::consts::TAU;
}
while dphi < -std::f64::consts::PI {
dphi += std::f64::consts::TAU;
}
let numeric = -dphi / (std::f64::consts::TAU * 2. * df);
let analytic = fit.group_delay(f as f32, RATE) as f64;
assert!(
(numeric - analytic).abs() < 2e-6 + 0.01 * numeric.abs(),
"{f} Hz: numeric {numeric}, analytic {analytic}"
);
}
}
#[test]
fn one_section_peaks_near_the_analog_value() {
// A second-order analog allpass delays 4·Q/ω₀ at its centre.
let (fc, q) = (1000., 0.7);
let delay = fit(&[(fc, q)], false).group_delay(fc, RATE);
let analog = 4. * q / (std::f32::consts::TAU * fc);
assert!(
(delay - analog).abs() < 0.05 * analog,
"{delay} vs {analog}"
);
}
#[test]
fn no_sections_and_polarity_do_not_smear() {
assert_eq!(Fit::NONE.smear_ms(RATE), 0.);
assert_eq!(fit(&[], true).smear_ms(RATE), 0.);
let one = fit(&[(1000., 0.7)], false);
assert_eq!(
one.smear_ms(RATE),
fit(&[(1000., 0.7)], true).smear_ms(RATE)
);
}
#[test]
fn smear_separates_gentle_and_sharp_fits() {
// Above 500 Hz a low section has only its tail left: well under the threshold.
let low = fit(&[(80., 0.7)], false).smear_ms(RATE);
assert!(low < 0.25 * SMEAR_AUDIBLE_MS, "{low}");
// A high-Q section at 1 kHz puts a 2.5 ms peak inside the range.
let sharp = fit(&[(1000., 4.)], false).smear_ms(RATE);
assert!(sharp > SMEAR_AUDIBLE_MS, "{sharp}");
assert!(sharp > 2. && sharp < 2.7, "{sharp}");
}
+1 -1
View File
@@ -2056,7 +2056,7 @@ dependencies = [
[[package]]
name = "weld"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"clap-sys",
"crossbeam-queue",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "weld"
version = "0.2.0"
version = "0.3.0"
edition = "2021"
license = "ISC"
+23 -1
View File
@@ -3,7 +3,7 @@ use common_ui::*;
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
use weld_core::{
band_edge, curve_frequency, AnalyzerFrame, Listen, Mode, Report, Settings, Status, CURVE,
DISPLAY_HIGH, DISPLAY_LOW, MAX_SECTIONS,
DISPLAY_HIGH, DISPLAY_LOW, MAX_SECTIONS, SMEAR_AUDIBLE_MS, SMEAR_HIGH, SMEAR_LOW,
};
pub use common_ui::style;
@@ -218,6 +218,28 @@ fn summary(ui: &mut egui::Ui, settings: &Settings, state: &mut State) {
.color(STEEL),
);
}
if fit.count > 0 {
// Typical fits vary 0.8–1 ms here, above the click threshold, so this stays a
// neutral note rather than a warning: sustained bass hides much more.
let r = ui.label(
RichText::new(format!(
"Delay spread {:.2} ms, {}–{}",
report.smear_ms,
hz(SMEAR_LOW),
hz(SMEAR_HIGH)
))
.size(12.)
.color(MUTED),
);
if report.smear_ms > SMEAR_AUDIBLE_MS {
ui.label(
RichText::new("Clicks may soften: compare SUM, SUM DRY")
.size(12.)
.color(DIM),
);
}
hint(&r, state, "How much the alignment's delay changes between 500 Hz and 4 kHz. On clicks, listeners hear changes from about 0.6 ms; sustained bass hides much more. On a plucky layer, compare SUM with SUM DRY on the attacks. Fewer sections usually vary less.");
}
ui.add_space(10.);
let (text, color) = status_text(&report, &frame, settings.mode);
ui.label(RichText::new(text).size(13.).color(color));
+9
View File
@@ -12,3 +12,12 @@ python scripts/analyze-references.py
Measurements are written to `docs/rivet/research/measurements/`.
The script requires ffmpeg, NumPy, and Matplotlib. It measures level, spectral
attacks, and texture changes; it does not transcribe notes or infer emotion.
For the momentary bass fatness lane (Schmedeke's model, see `docs/ideas.md`):
```sh
python scripts/fatness.py --output target/fatness
```
It writes `fatness.png` and `fatness.json`; `--offset` sets the assumed dB(C)
of a 0 dBFS RMS signal (default 100).
+181
View File
@@ -0,0 +1,181 @@
"""Momentary bass fatness of rendered audio, after Schmedeke's dissertation.
Model (L. Schmedeke, "BASS DROP! Bass in modern popular Dance Music",
Hamburg 2021, section 5.4.3, adjusted R² .773 on dance-music excerpts):
l = 10 / (1 + 4.5487 · exp(0.13 · (62.9366 − SPL<200)))
h = 10 / (1 + 1.4317 · exp(2.5575 · (0.3299 − HR<200)))
Fat_mom = 0.14689 · l · h (0 .. 14.7)
SPL<200 is the C-weighted sound-pressure level below 200 Hz in dB(C), HR<200
the MPEG-7 harmonic ratio of the same band, both over 50 ms windows, with the
material normalised to −23 LUFS first.
Assumption: the thesis does not give the dB(C) its reference tone played at.
`--offset` maps dBFS to dB(C); the default 100 dB puts −20 dBFS RMS at 80 dB(C),
a usual studio calibration. The thesis reports most complex excerpts at 70–80
dB(C) below 200 Hz; the summary prints where the material lands so the offset
can be checked.
Requires ffmpeg, numpy and matplotlib. Describes one model's view of the low
band; it is not a quality score.
"""
import os
os.environ.setdefault('MPLCONFIGDIR', '/tmp/fatness-matplotlib')
import argparse
import json
import subprocess
from pathlib import Path
import numpy as np
RATE = 48000
WINDOW_S = 0.05
HR_RATE = 4000
HR_MAX_LAG_S = 0.04
def load(path):
raw = subprocess.check_output(['ffmpeg', '-v', 'error', '-i', str(path), '-f', 'f32le',
'-ac', '2', '-ar', str(RATE), 'pipe:1'])
return np.frombuffer(raw, dtype='<f4').reshape(-1, 2).T.astype(np.float64)
def k_weight_db(f):
"""Magnitude of the BS.1770 K-weighting (shelf + high-pass) at `f` Hz, in dB."""
def biquad(b, a):
z = np.exp(-2j * np.pi * f / RATE)
return (b[0] + b[1] * z + b[2] * z * z) / (a[0] + a[1] * z + a[2] * z * z)
shelf = biquad([1.53512485958697, -2.69169618940638, 1.19839281085285],
[1.0, -1.69065929318241, 0.73248077421585])
high = biquad([1.0, -2.0, 1.0], [1.0, -1.99004745483398, 0.99007225036621])
return 20 * np.log10(np.abs(shelf * high) + 1e-30)
def c_weight_db(f):
"""IEC 61672 C-weighting at `f` Hz, in dB."""
f2 = np.maximum(f, 1e-3) ** 2
r = (12194.0 ** 2 * f2) / ((f2 + 20.6 ** 2) * (f2 + 12194.0 ** 2))
return 20 * np.log10(r) + 0.06
def filtered(x, gain_db):
"""Zero-phase filter by an FFT magnitude response; energy is what is measured."""
n = x.shape[-1]
size = 1 << (n - 1).bit_length() # zero padding: fast FFT sizes only
spec = np.fft.rfft(x, n=size, axis=-1)
f = np.fft.rfftfreq(size, 1 / RATE)
return np.fft.irfft(spec * 10 ** (gain_db(f) / 20), n=size, axis=-1)[..., :n]
def integrated_lufs(x):
"""BS.1770-4 integrated loudness with absolute and relative gates."""
y = filtered(x, k_weight_db)
block, step = int(0.4 * RATE), int(0.1 * RATE)
sq = (y * y).sum(axis=0)
c = np.concatenate([[0.], np.cumsum(sq)])
starts = np.arange(0, len(sq) - block + 1, step)
power = (c[starts + block] - c[starts]) / block
loud = -0.691 + 10 * np.log10(power + 1e-30)
keep = power[loud > -70]
if keep.size == 0:
return -np.inf
relative = -0.691 + 10 * np.log10(keep.mean()) - 10
keep = power[(loud > -70) & (loud > relative)]
return -0.691 + 10 * np.log10(keep.mean())
def band_below(x, hz):
return filtered(x, lambda f: np.where(f < hz, 0.0, -300.0))
def harmonic_ratio(mono, rate):
"""MPEG-7 harmonic ratio per 50 ms window: the largest normalised
autocorrelation after the zero-lag lobe, with lags up to 40 ms."""
n = int(WINDOW_S * rate)
lags = int(HR_MAX_LAG_S * rate)
out = []
for start in range(lags, len(mono) - n + 1, n):
s = mono[start:start + n]
e0 = s @ s
if e0 <= 1e-12:
out.append(0.0)
continue
# Row m − 1 holds the window delayed by m samples.
past = np.lib.stride_tricks.sliding_window_view(mono[start - lags:start - 1 + n], n)[::-1]
gamma = (past @ s) / np.sqrt(e0 * np.einsum('ij,ij->i', past, past) + 1e-30)
below = np.nonzero(gamma <= 0)[0]
if below.size == 0:
out.append(0.0)
continue
out.append(float(max(gamma[below[0]:].max(), 0.0)))
return np.array(out), lags
def fatness(spl, hr):
l = 10 / (1 + 4.5487 * np.exp(0.13 * (62.9366 - spl)))
h = 10 / (1 + 1.4317 * np.exp(2.5575 * (0.3299 - hr)))
return 0.14689 * l * h
def measure(path, offset):
x = load(path)
lufs = integrated_lufs(x)
x = x * 10 ** ((-23 - lufs) / 20)
low = band_below(x, 200.)
mono = low.mean(axis=0)
# HR on a 4 kHz copy; the band is below 200 Hz, so nothing aliases.
hr, skip = harmonic_ratio(mono[::RATE // HR_RATE], HR_RATE)
weighted = filtered(low, c_weight_db)
n = int(WINDOW_S * RATE)
first = skip * (RATE // HR_RATE)
sq = (weighted * weighted).sum(axis=0) / 2
frames = len(hr)
spl = np.array([10 * np.log10(sq[first + i * n:first + (i + 1) * n].mean() + 1e-30)
for i in range(frames)]) + offset
times = (first + np.arange(frames) * n) / RATE
return lufs, times, spl, hr, fatness(spl, hr)
def main():
parser = argparse.ArgumentParser(description=__doc__.split('\n\n')[0])
parser.add_argument('files', nargs='*', type=Path)
parser.add_argument('--folder', type=Path, default=Path('references/devath'))
parser.add_argument('--offset', type=float, default=100.0,
help='dB(C) for a 0 dBFS RMS signal (default 100)')
parser.add_argument('--output', type=Path, required=True,
help='folder for fatness.png and fatness.json')
args = parser.parse_args()
files = args.files or sorted(args.folder.glob('*.flac'))
if not files:
parser.error('no audio files')
args.output.mkdir(parents=True, exist_ok=True)
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
fig, axes = plt.subplots(len(files), 1, squeeze=False, figsize=(15, 2.2 * len(files)),
constrained_layout=True)
summary = []
for ax, path in zip(axes[:, 0], files):
lufs, t, spl, hr, fat = measure(path, args.offset)
smooth = np.convolve(fat, np.ones(8) / 8, mode='same') # 400 ms
pct = lambda v: {str(p): round(float(np.percentile(v, p)), 3) for p in (10, 50, 90)}
row = {'file': path.name, 'lufs': round(float(lufs), 2), 'spl_c_below_200': pct(spl),
'hr_below_200': pct(hr), 'fatness': pct(fat),
'share_spl_70_80': round(float(np.mean((spl >= 70) & (spl <= 80))), 3)}
summary.append(row)
print(json.dumps(row))
ax.plot(t, fat, color='#ccc', lw=0.5)
ax.plot(t, smooth, color='#e0115f', lw=1.2)
ax.set_ylim(0, 14.7)
ax.set_xlim(0, t[-1] if len(t) else 1)
ax.set_title(f'{path.stem} ({lufs:.1f} LUFS, normalised to -23)', loc='left', fontsize=9)
ax.set_ylabel('Fat_mom')
axes[-1, 0].set_xlabel('seconds')
fig.savefig(args.output / 'fatness.png', dpi=110)
(args.output / 'fatness.json').write_text(json.dumps(
{'offset_db': args.offset, 'window_s': WINDOW_S, 'tracks': summary}, indent=2))
if __name__ == '__main__':
main()
+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" }
+50 -7
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)
@@ -93,13 +98,27 @@ fn main() {
let mut anvil_settings = anvil_core::Settings::default();
let mut anvil_state = anvil_ui::State::default();
if anvil {
anvil_settings.ghost = 60.;
anvil_settings.kick_lock = true;
anvil_state.audition = true;
let monitor = anvil_core::Monitor {
audition: true,
..Default::default()
};
let mut engine = anvil_core::Engine::new(48000., 2).unwrap();
engine.set_settings(anvil_settings).unwrap();
let mut saw = common_dsp::Saw::new(48000., 43.65);
for i in 0..36000 {
// A distorted F1 bass whose own sub drifts in phase and level.
let total = 300000;
for i in 0..total {
// A distorted F1 bass whose own sub drifts in phase and level, and a kick every
// 0.2 s in the sidechain while the audition runs.
let x = (saw.sample() * 1.6 + 0.3 * (i as f32 * 0.0009).sin()).tanh() * 0.4;
engine.process_sample([x, x], anvil_core::Monitor::default());
let t = (i % 9600) as f32 / 48000.;
let kick = 0.8 * (std::f32::consts::TAU * 50. * t).sin() * (-t / 0.06).exp();
engine.process_with_kick([x, x], kick, monitor);
if i < total - 36000 {
continue;
}
if i % 1600 == 0 {
anvil_state.accept_frame(engine.take_analyzer());
}
@@ -190,6 +209,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 {
@@ -319,7 +358,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);
@@ -358,7 +399,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 {