Compare commits

...
Author SHA1 Message Date
nvrl 5cc3770737 feat(knurl): add Knurl cycle texture
Check and Windows preview / check (anvil) (push) Successful in 2m1s
Check and Windows preview / check (crucible) (push) Successful in 1m31s
Check and Windows preview / check (knurl) (push) Successful in 2m4s
Check and Windows preview / check (lathe) (push) Successful in 1m27s
Check and Windows preview / check (patina) (push) Successful in 1m54s
Check and Windows preview / check (rivet) (push) Successful in 1m30s
Check and Windows preview / check (temper) (push) Successful in 1m31s
Check and Windows preview / check (weld) (push) Successful in 2m31s
Check and Windows preview / previews (push) Successful in 1m26s
Independent plugin release / plan (push) Successful in 7s
Check and Windows preview / windows (push) Successful in 2m26s
Independent plugin release / build (push) Successful in 3m0s
Independent plugin release / publish (push) Successful in 8s
Knurl follows the played note and treats every cycle of the wave on its
own: a repeating level pattern over 1-16 cycles makes subharmonics in tune
with every note, random level and timing per cycle add rattle, and a noise
burst per cycle, highpassed at a multiple of f0, adds grit. Amounts are
counted in cycles, so the texture is the same on F1 and on C2.

- Cycle clock from the shared note tracker, restarted at each note; the
  audio is delayed by the lookahead plus 4.2 ms of timing-jitter headroom.
- Per-cycle randomness from a hash of the cycle number: Same every note
  or Free.
- Patterns Off, Double, Triple, Fry (default), Gallop, Stutter or drawn;
  grit switchable per step. Pattern persisted as knurl-pattern.
- Editor: last eight cycles overlaid, pattern bars with the playing step.
- Tests match the prototype: Double gives f0/2 at -8.19 dB, Triple f0/3
  at -10.68 dB, equal on F1 and C2, no added clicks, bit-exact when off,
  no allocation on the audio thread.
2026-11-06 08:49:11 +01:00
nvrl f4c0a833e6 ci: check editor-check formatting in the product check
Check and Windows preview / check (anvil) (push) Successful in 39s
Check and Windows preview / check (crucible) (push) Successful in 44s
Check and Windows preview / check (lathe) (push) Successful in 40s
Check and Windows preview / check (patina) (push) Successful in 55s
Check and Windows preview / check (rivet) (push) Successful in 46s
Check and Windows preview / check (temper) (push) Successful in 44s
Check and Windows preview / check (weld) (push) Successful in 1m18s
Check and Windows preview / previews (push) Successful in 55s
Check and Windows preview / windows (push) Successful in 1m24s
Every product adds a fixture to tools/editor-check, but only the CI
previews job checked its formatting, so an unformatted fixture passed
the local product check and failed CI.
2026-11-06 08:34:14 +01:00
28 changed files with 4701 additions and 33 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]
plugin: [rivet, patina, anvil, crucible, lathe, temper, weld, knurl]
steps:
- uses: https://github.com/actions/checkout@v4
with:
@@ -61,6 +61,7 @@ jobs:
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/lathe.png --plugin lathe
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/temper.png --plugin temper
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/weld.png --plugin weld
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/knurl.png --plugin knurl
- uses: https://github.com/actions/upload-artifact@v3-node20
with:
name: editor-previews
@@ -91,6 +92,7 @@ jobs:
python3 scripts/build-plugin.py --plugin lathe --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin temper --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin weld --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin knurl --target x86_64-pc-windows-gnu
python3 scripts/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*"]
tags: ["rivet-v*", "patina-v*", "anvil-v*", "crucible-v*", "lathe-v*", "temper-v*", "weld-v*", "knurl-v*"]
permissions:
contents: read
Generated
+19
View File
@@ -170,6 +170,25 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "knurl-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"serde",
"serde_json",
]
[[package]]
name = "knurl-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"common-ui",
"egui",
"knurl-core",
]
[[package]]
name = "lathe-core"
version = "0.1.0"
+5 -2
View File
@@ -1,7 +1,7 @@
[workspace]
resolver = "2"
members = ["plugins/common/ui", "plugins/common/dsp", "plugins/anvil/core", "plugins/anvil/ui", "plugins/lathe/core", "plugins/lathe/ui", "plugins/temper/core", "plugins/temper/ui", "plugins/weld/core", "plugins/weld/ui", "plugins/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"]
members = ["plugins/common/ui", "plugins/common/dsp", "plugins/anvil/core", "plugins/anvil/ui", "plugins/lathe/core", "plugins/lathe/ui", "plugins/temper/core", "plugins/temper/ui", "plugins/weld/core", "plugins/weld/ui", "plugins/knurl/core", "plugins/knurl/ui", "plugins/crucible/core", "plugins/crucible/ui", "plugins/rivet/core", "plugins/rivet/midi", "plugins/rivet/preview", "plugins/rivet/app", "plugins/patina/core", "plugins/patina/render", "plugins/patina/ui"]
exclude = ["plugins/rivet/plugin", "tools/editor-check", "plugins/patina/plugin", "plugins/anvil/plugin", "plugins/crucible/plugin", "plugins/lathe/plugin", "plugins/temper/plugin", "plugins/weld/plugin", "plugins/knurl/plugin"]
[workspace.package]
edition = "2021"
@@ -24,5 +24,8 @@ opt-level = 2
opt-level = 2
[profile.dev.package.temper-core]
opt-level = 2
[profile.dev.package.knurl-core]
opt-level = 2
[profile.dev.package.weld-core]
opt-level = 2
+7 -4
View File
@@ -25,16 +25,18 @@ note. Each plugin is a separate product with its own version, guide and release.
| **[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) |
Plugins with latency report it to the host, so FL Studio compensates the delay.
Anvil, Lathe and Temper detect pitch from the audio. They can also take the
Anvil, Lathe, Temper and Knurl detect pitch from the audio. They can also take the
pitch from MIDI.
The names come from metalwork: a **rivet** joins pieces; **patina** changes a
surface and keeps its character; an **anvil** is the solid base that everything
is struck on; a **crucible** melts material down to be cast again; a **lathe**
turns a piece so each cut lands the same all the way round; **tempering** sets
how hard and how tough a metal ends up; a **weld** fuses two pieces into one.
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.
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*.
@@ -55,7 +57,7 @@ finished. Hosts list the plugins under the vendor name *Bass Foundry*.
</tr>
<tr>
<td><a href="docs/weld/README.md"><img src="docs/weld/assets/editor.png" alt="Weld editor"></a><br><b>Weld</b>: layer phase alignment</td>
<td></td>
<td><a href="docs/knurl/README.md"><img src="docs/knurl/assets/editor.png" alt="Knurl editor"></a><br><b>Knurl</b>: cycle texture</td>
</tr>
</table>
@@ -115,7 +117,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` and `weld`. Builds go to `dist/<target>/<Name>.clap`. No
`lathe`, `temper`, `weld` and `knurl`. 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.
@@ -152,6 +154,7 @@ plugins/
lathe/ core · ui · plugin
temper/ core · ui · plugin
weld/ core · ui · plugin
knurl/ core · ui · plugin
common/
ui/ Shared look: palette, embedded fonts, knobs, panels, meters
dsp/ Shared filters, pitch tracking, oversampling, meters, WAV files
+4 -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 and Temper, 4× oversampling, meters and WAV files.
Lathe, Temper and Knurl, 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
@@ -44,7 +44,8 @@ python3 scripts/check-plugin.py --plugin <product>
python3 -m unittest discover -s scripts/tests -v
```
Products: `rivet`, `patina`, `anvil`, `crucible`, `lathe`, `temper`, `weld`.
Products: `rivet`, `patina`, `anvil`, `crucible`, `lathe`, `temper`, `weld`,
`knurl`.
`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
@@ -142,6 +143,7 @@ identify plugins by CLAP ID, so the vendor name does not affect saved projects.
| Lathe | (new) | `io.storyboard.harmonic-eq` |
| Temper | (new) | `io.storyboard.tracked-distortion` |
| Weld | (new) | `io.storyboard.layer-align` |
| Knurl | (new) | `io.storyboard.cycle-texture` |
The `io.storyboard.` prefix is historical and stays, because changing a CLAP ID
breaks existing projects. Rivet keeps `story-session-v1` and its session
+10 -18
View File
@@ -75,6 +75,14 @@ delay), so the layers sum instead of cancel. A display shows how much level the
cancellation cost and how much Weld recovered. Anvil solves the sub band; Weld
solves the mids. See [Weld](weld/README.md).
### Knurl — cycle texture (problems 11, 13)
Follows the played note and treats every cycle on its own: a repeating level
pattern (in-tune subharmonics), random level and timing per cycle, and a noise
burst per cycle tuned to the note. Amounts are counted in cycles, so the texture
is the same on every note. Slow per-harmonic drift is postponed. See
[Knurl](knurl/README.md).
## Ideas
### Ballast — switch-up loudness leveller (problem 2)
@@ -113,27 +121,11 @@ bars 2–4". No name yet.
## Texture ideas
These ideas target problems 11–15. Most of them reuse the note tracker in
These ideas target problems 11–15; Knurl, the first of them, is released above.
Most of them reuse the note tracker in
`common-dsp`, so a texture setting means the same thing on every note, the same
way Lathe and Temper work.
### Knurl — pitch-synchronous grit (problems 11, 13)
Knurling presses a ridged pattern into metal. Knurl cuts the signal into single
pitch periods with the note tracker and changes each period on its own:
- level and timing jitter per cycle;
- short noise bursts locked to the cycle;
- period doubling and tripling (a cycle pattern such as "loud, quiet, quiet"),
which gives the subharmonic rattle and growl of riddim basses;
- slow random drift of each harmonic's level and phase, which brings movement
into a printed resample without widening it.
Rates are in cycles, not in Hz or tempo, so the grit has the same grain on F1
and on C2. A pattern editor sets 1–16 cycles. Everything is mono-safe. Latency
is the tracker lookahead, about 67 ms. Recommended first texture build: it is
new, small, and reuses Lathe's tracking.
### Anneal — tone and grit split (problem 12)
Annealing changes how a metal behaves without changing its shape. Anneal
+14
View File
@@ -0,0 +1,14 @@
# Knurl changelog
## 0.1.0
- Introduce Knurl, texture per cycle of the played note.
- Step patterns over 1–16 cycles make subharmonics in tune with every note:
Off, Double, Triple, Fry, Gallop, Stutter, or a drawn pattern.
- Random level and timing per cycle, and a noise burst per cycle tuned to the
note (Tone, Position, Length), switchable per step.
- Same every note or Free random sequences.
- Detect pitch and note starts from the audio with about 71 ms of latency, or
follow MIDI notes sent to the effect.
- Editor shows the last eight cycles overlaid and the pattern with the playing
step.
+86
View File
@@ -0,0 +1,86 @@
# Knurl
Knurl gives every cycle of the note its own level, timing and grit. Current
version **0.1.0**.
Knurling presses a ridged pattern into metal. Riddim and dubstep basses get their
character the same way, from surface texture: rattle, growl, fry, grit. Usually
that texture comes from long chains of distortion, comb filters, formants and
resampling, and it changes from note to note. Knurl works on single cycles of the
wave instead. It follows the played note and treats each cycle on its own, and
every amount is counted in cycles, not in Hz or tempo. So the texture has the
same grain on F1 and on C2.
![Knurl](assets/editor.png)
## Install on Windows
Copy `Knurl.clap` into `C:\Program Files\Common Files\CLAP\`. In FL Studio 2024.1
or newer, open Options → Manage plugins, enable Verify plugins, and Find installed
plugins. Load Knurl in a mixer slot of the bass. Keep the font license with
redistributed packages.
## Work with it
1. Play the bass. The note readout shows what Knurl tracks; the scope overlays
the last eight cycles of the output, so the texture is visible cycle by cycle.
2. Choose a **Pattern**. The pattern sets the level of each cycle and repeats:
- **Double** (0, −9 dB): every second cycle quieter. This makes a sub-octave
growl, exactly in tune, about 8 dB under the fundamental.
- **Triple** (0, −12, −12 dB): a growl an octave and a fifth down.
- **Fry** (0, −14, −5, −14 dB, the default): an uneven vocal-fry rattle.
- **Gallop** and **Stutter**: rhythmic patterns over six and eight cycles.
- **Off**: every cycle at full level.
3. Draw your own pattern on the bars: drag a bar to set the level of that cycle,
double-click to reset it. **Steps** sets the length (1–16 cycles).
4. Add surface:
- **Level** gives every cycle a random level (standard deviation in dB).
- **Time** moves every cycle a little in time (percent of a period): rattle
and scrape.
- **Grit** adds a short noise burst inside every cycle. **Tone** sets its
lowest frequency in harmonics of the note, **Position** where in the cycle
it sits, and **Length** how long it is. The squares under the pattern bars
switch the burst per step.
5. **Edge** sets how soft each step change is. Low values are sharper and
buzzier; high values smoother.
**Random**: **Same every note** (default) restarts every random sequence at each
note, so identical notes sound identical, which keeps a riddim line tight.
**Free** lets the sequences run on, so every note differs.
**Mix** blends with the delayed dry signal; **Output** trims the level.
### Pitch from MIDI
For basses whose fundamental is unclear (heavy FM, formant or noise patches),
select **MIDI** and route the bass's MIDI notes to Knurl. In FL Studio, set the
bass channel's MIDI output port and Knurl's input port to the same number.
**Gate** sets the input level that counts as a note in AUDIO mode; **Snap to
semitone** rounds the detected pitch (turn it off for slides).
## Timing and limits
Knurl delays the audio by about 71 ms: 67 ms of pitch-detection lookahead plus
4 ms of room for the timing jitter. The delay is reported to the host for delay
compensation, so the cycle clock knows every note before its attack leaves the
plugin. The cycle clock restarts at every note start, so the pattern always
begins with step 1 on the attack. Pitch range is 30–500 Hz, one note at a time.
Between notes the effects fade out over 20 ms, so tails stay clean.
Knurl works per cycle of the tracked pitch; it does not look for cycle borders in
the waveform. With a stable pitch that is the same thing. With heavy pitch
modulation inside a note the pattern follows the tracked pitch, which can lag a
fast bend. Both channels get the same treatment, so the effect is mono-safe.
## How it works
A note tracker (shared with Anvil, Lathe and Temper) detects pitch and note
starts ahead of the delayed audio. A cycle clock starts at 0 on every note and
advances by f0 / sample rate. Each cycle gets its step gain and its random level
and time offset from a hash of the cycle number, so the result is deterministic.
Step changes are raised-cosine crossfades over the last **Edge** of a cycle;
level jitter crossfades over half a cycle and time jitter over the whole cycle,
read from the delay line with cubic interpolation. The grit is white noise
through a state-variable highpass at **Tone** × f0, shaped by a sin² window and
scaled to the input level. There is no nonlinearity, so no oversampling is
needed.
Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

+1
View File
@@ -14,6 +14,7 @@ workflows are in `.gitea/workflows/`. A plugin's version is the `version` in
| Lathe | `lathe-v0.1.0` | `lathe-windows-x86_64-0.1.0.zip` |
| Temper | `temper-v0.1.0` | `temper-windows-x86_64-0.1.0.zip` |
| Weld | `weld-v0.1.0` | `weld-windows-x86_64-0.1.0.zip` |
| Knurl | `knurl-v0.1.0` | `knurl-windows-x86_64-0.1.0.zip` |
## Make a release
+16
View File
@@ -114,5 +114,21 @@
"weld-core",
"weld-ui"
]
},
"knurl": {
"name": "Knurl",
"description": "Give every cycle of the note its own level, timing and grit",
"manifest": "plugins/knurl/plugin/Cargo.toml",
"library": "knurl",
"documentation": "docs/knurl",
"clap_id": "io.storyboard.cycle-texture",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"knurl-core",
"knurl-ui"
]
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "knurl-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
common-dsp = { path = "../../common/dsp" }
serde = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+406
View File
@@ -0,0 +1,406 @@
//! Knurl: texture per cycle of the played note. A cycle clock follows the tracked pitch and
//! restarts at every note; each cycle gets its own step gain (a repeating pattern that
//! makes subharmonics), a random level and timing, and a noise burst tuned to the note.
//! Every amount is counted in cycles, so the texture has the same grain on every note.
//!
//! The audio path is delayed by the tracker lookahead plus the timing-jitter headroom, so
//! the clock already knows each note when its attack leaves the plugin. Allocation-free
//! after `new`; mono-safe (both channels get the same treatment).
mod settings;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{PitchFollower, Tracker, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, hz_to_midi};
pub use settings::{Seed, Settings, Source, GRIT_OFF, PATTERNS, STEPS, STEP_MIN};
/// Cycles kept for the editor's scope, and points per cycle.
pub const SCOPE_CYCLES: usize = 8;
pub const SCOPE_POINTS: usize = 64;
/// Timing jitter can move a cycle by at most this share of the lowest period.
const JITTER_HEADROOM: f32 = 0.125;
/// Effects fade in and out with the note over this time.
const FADE_MS: f32 = 20.;
/// The grit filter is retuned this often, in samples.
const RETUNE: usize = 16;
/// Level follower for scaling the grit to the input.
const LEVEL_MS: f32 = 30.;
#[derive(Clone, Copy, Debug)]
pub struct AnalyzerFrame {
/// Fundamental the clock follows, 0 before the first note.
pub hz: f32,
/// Sounding note, rounded; None between notes.
pub note: Option<u8>,
/// Pattern step of the newest cycle.
pub step: usize,
/// Output of the last `SCOPE_CYCLES` cycles, oldest first, each resampled to
/// `SCOPE_POINTS` points.
pub scope: [[f32; SCOPE_POINTS]; SCOPE_CYCLES],
/// True when the scope holds cycles of a sounding note.
pub scope_valid: bool,
pub input_db: f32,
pub output_db: f32,
pub latency: u32,
pub sample_rate: f32,
}
impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
hz: 0.,
note: None,
step: 0,
scope: [[0.; SCOPE_POINTS]; SCOPE_CYCLES],
scope_valid: false,
input_db: -120.,
output_db: -120.,
latency: 0,
sample_rate: 48000.,
}
}
}
/// Deterministic per-cycle randomness: a hash of (stream, note seed, cycle).
fn hash(stream: u64, seed: u64, cycle: u64) -> u64 {
let mut z = stream
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(seed.wrapping_mul(0xBF58_476D_1CE4_E5B9))
.wrapping_add(cycle.wrapping_mul(0x94D0_49BB_1331_11EB));
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn uniform(stream: u64, seed: u64, cycle: u64) -> f32 {
(hash(stream, seed, cycle) >> 40) as f32 / (1u64 << 24) as f32
}
/// Approximately normal, clamped to ±3 (sum of four uniforms, unit variance).
fn normal(stream: u64, seed: u64, cycle: u64) -> f32 {
let s: f32 = (0..4).map(|i| uniform(stream, seed, cycle * 4 + i)).sum();
((s - 2.) * 3f32.sqrt()).clamp(-3., 3.)
}
/// Raised-cosine weight of the next cycle's value: 0 until `1 − width` of the cycle, then
/// rising to 1 at the boundary.
fn edge(frac: f32, width: f32) -> f32 {
let t = ((frac - (1. - width)) / width).clamp(0., 1.);
0.5 - 0.5 * (std::f32::consts::PI * t).cos()
}
const LEVEL: u64 = 1;
const TIME: u64 = 2;
const NOISE: u64 = 3;
/// What the clock needs, delayed by the jitter headroom so it lines up with the audio.
#[derive(Clone, Copy, Default)]
struct Pitch {
hz: f32,
start: bool,
sounding: bool,
}
pub struct Engine {
rate: f32,
channels: usize,
settings: Settings,
/// Tracker lookahead.
lookahead: usize,
/// Largest timing offset in samples, either way.
headroom: usize,
tracker: Tracker,
pitch: PitchFollower,
pitch_line: Vec<Pitch>,
pitch_at: usize,
audio: Vec<[f32; 2]>,
audio_at: usize,
// Cycle clock.
phase: f64,
cycle: u64,
note_seed: u64,
notes: u64,
noise_at: u64,
amount: f32,
fade: f32,
// Grit.
grit_coef: SvfCoef,
grit_filter: Svf,
countdown: usize,
level: f32,
level_coef: f32,
// Smoothed controls.
smooth: f32,
mix: f32,
output: f32,
bypass: f32,
fresh: bool,
// Editor.
scope: [[f32; SCOPE_POINTS]; SCOPE_CYCLES],
scope_valid: bool,
meter_in: f64,
meter_out: f64,
meter_count: usize,
}
impl Engine {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
if !(rate.is_finite() && (8000.0..=768000.).contains(&rate)) {
return Err("unsupported sample rate");
}
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
let tracker = Tracker::new(rate, TrackerConfig::BASS);
let lookahead = tracker.latency();
let headroom = (JITTER_HEADROOM * rate / TrackerConfig::BASS.min_hz).ceil() as usize;
let settings = Settings::default();
let mut e = Self {
rate,
channels,
settings,
lookahead,
headroom,
tracker,
pitch: PitchFollower::default(),
pitch_line: vec![Pitch::default(); headroom + 1],
pitch_at: 0,
// Reads reach `lookahead + 2·headroom` back, plus the interpolation taps.
audio: vec![[0.; 2]; lookahead + 2 * headroom + 4],
audio_at: 0,
phase: 0.,
cycle: 0,
note_seed: 0,
notes: 0,
noise_at: 0,
amount: 0.,
fade: coefficient(rate, FADE_MS),
grit_coef: SvfCoef::IDENTITY,
grit_filter: Svf::default(),
countdown: 0,
level: 0.,
level_coef: coefficient(rate, LEVEL_MS),
smooth: coefficient(rate, 10.),
mix: 1.,
output: 1.,
bypass: 0.,
fresh: true,
scope: [[0.; SCOPE_POINTS]; SCOPE_CYCLES],
scope_valid: false,
meter_in: 0.,
meter_out: 0.,
meter_count: 0,
};
e.set_settings(settings)?;
e.fresh = true;
Ok(e)
}
pub fn latency_samples(&self) -> u32 {
(self.lookahead + self.headroom) as u32
}
pub fn channels(&self) -> usize {
self.channels
}
pub fn settings(&self) -> Settings {
self.settings
}
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
s.validate()?;
self.tracker.set(s.source == Source::Midi, s.gate, false);
self.settings = s;
if std::mem::take(&mut self.fresh) {
self.mix = s.mix * 0.01;
self.output = db_to_gain(s.output);
self.bypass = f32::from(s.bypass);
}
Ok(())
}
pub fn reset(&mut self) {
self.audio.fill([0.; 2]);
self.pitch_line.fill(Pitch::default());
self.pitch = PitchFollower::default();
self.tracker.reset();
self.grit_filter.reset();
self.phase = 0.;
self.cycle = 0;
self.notes = 0;
self.note_seed = 0;
self.noise_at = 0;
self.amount = 0.;
self.level = 0.;
self.countdown = 0;
self.scope_valid = false;
self.fresh = true;
}
pub fn note_on(&mut self, note: u8) {
self.tracker.note_on(note);
}
pub fn note_off(&mut self, note: u8) {
self.tracker.note_off(note);
}
/// The audio `back` samples (fractional) before the newest one, cubic Hermite.
#[inline]
fn read(&self, back: f32) -> [f32; 2] {
let len = self.audio.len();
let whole = back.floor();
let t = back - whole;
let base = whole as usize;
let at = |back: usize| self.audio[(self.audio_at + len - back % len) % len];
// y0 is one sample newer than y1; y2 and y3 are older.
let (newer, p1, p2, p3) = (at(base.max(1) - 1), at(base), at(base + 1), at(base + 2));
let mut out = [0.; 2];
for c in 0..2 {
let (y0, y1, y2, y3) = (newer[c], p1[c], p2[c], p3[c]);
let c1 = 0.5 * (y2 - y0);
let c2 = y0 - 2.5 * y1 + 2. * y2 - 0.5 * y3;
let c3 = 0.5 * (y3 - y0) + 1.5 * (y1 - y2);
out[c] = ((c3 * t + c2) * t + c1) * t + y1;
}
out
}
pub fn process_sample(&mut self, x: [f32; 2]) -> [f32; 2] {
let mono = if self.channels == 2 {
0.5 * (x[0] + x[1])
} else {
x[0]
};
let (pitch, snap) = (&mut self.pitch, self.settings.snap);
self.tracker.process(mono, |e| pitch.event(snap, e));
let (hz, jump) = self.pitch.step(1.);
// Tracker events are timed for `lookahead`; the audio leaves `headroom` later.
let len = self.pitch_line.len();
self.pitch_line[self.pitch_at] = Pitch {
hz,
start: jump,
sounding: self.tracker.sounding(),
};
self.pitch_at = (self.pitch_at + 1) % len;
let p = self.pitch_line[self.pitch_at];
let len = self.audio.len();
self.audio_at = (self.audio_at + 1) % len;
self.audio[self.audio_at] = x;
let centre = (self.lookahead + self.headroom) as f32;
let dry = self.read(centre);
// Cycle clock.
if p.start {
self.phase = 0.;
self.cycle = 0;
self.notes += 1;
self.noise_at = 0;
self.note_seed = match self.settings.seed {
Seed::Same => 0,
Seed::Free => self.notes,
};
self.countdown = 0;
}
let active = p.sounding && p.hz > 0.;
self.amount += (f32::from(active) - self.amount) * self.fade;
let s = self.settings;
let frac = self.phase.fract() as f32;
let c = self.cycle;
let seed = self.note_seed;
let step = |k: u64| (k % s.steps as u64) as usize;
// Pattern and level jitter, crossfaded into the next cycle.
let w = edge(frac, s.edge * 0.01);
let jitter_w = edge(frac, 0.5);
let pattern = |k: u64| db_to_gain(s.pattern[step(k)]);
let wobble = |k: u64| db_to_gain(s.level_jitter * normal(LEVEL, seed, k));
let gain = (pattern(c) * (1. - w) + pattern(c + 1) * w)
* (wobble(c) * (1. - jitter_w) + wobble(c + 1) * jitter_w);
let gain = 1. + self.amount * (gain - 1.);
// Timing jitter: an offset per cycle, blended across the whole cycle.
let period = if p.hz > 0. { self.rate / p.hz } else { 0. };
let shift = |k: u64| normal(TIME, seed, k) * s.time_jitter * 0.01 * period;
let tw = edge(frac, 1.);
let offset = (shift(c) * (1. - tw) + shift(c + 1) * tw) * self.amount;
let offset = offset.clamp(-(self.headroom as f32), self.headroom as f32);
let moved = if offset == 0. {
dry
} else {
self.read(centre + offset)
};
// Grit: noise above `tone` harmonics, in a sin² window at `position` of each cycle.
let mut grit = 0.;
if s.grit > GRIT_OFF && p.hz > 0. && s.grit_steps[step(c)] {
if self.countdown == 0 {
let f = (s.tone * p.hz).min(0.45 * self.rate);
self.grit_coef = SvfCoef::highpass(self.rate, f, std::f32::consts::FRAC_1_SQRT_2);
self.countdown = RETUNE;
}
self.countdown -= 1;
self.noise_at = self.noise_at.wrapping_add(1);
let n = 2. * uniform(NOISE, seed, self.noise_at) - 1.;
let hp = self.grit_filter.process(&self.grit_coef, n * 3f32.sqrt());
let d = (frac - s.position * 0.01).rem_euclid(1.);
let l = s.length * 0.01;
if d < l {
let window = (std::f32::consts::PI * d / l).sin().powi(2);
grit = hp * window * self.level.sqrt() * db_to_gain(s.grit) * self.amount;
}
}
let m = if self.channels == 2 {
0.5 * (dry[0] + dry[1])
} else {
dry[0]
};
self.level += (m * m - self.level) * self.level_coef;
// Advance the clock.
if p.hz > 0. {
self.phase += (p.hz / self.rate) as f64;
let whole = self.phase.floor() as u64;
if whole > 0 {
self.phase -= whole as f64;
self.cycle += whole;
self.scope
.rotate_left(whole.min(SCOPE_CYCLES as u64) as usize);
self.scope[SCOPE_CYCLES - 1] = [0.; SCOPE_POINTS];
}
}
self.mix += (s.mix * 0.01 - self.mix) * self.smooth;
self.output += (db_to_gain(s.output) - self.output) * self.smooth;
self.bypass += (f32::from(s.bypass) - self.bypass) * self.smooth;
let mut out = [0.; 2];
for ch in 0..self.channels {
let wet = moved[ch] * gain + grit;
let mixed = (self.mix * wet + (1. - self.mix) * dry[ch]) * self.output;
out[ch] = mixed * (1. - self.bypass) + dry[ch] * self.bypass;
}
let o = if self.channels == 2 {
0.5 * (out[0] + out[1])
} else {
out[0]
};
if active {
let i = ((frac * SCOPE_POINTS as f32) as usize).min(SCOPE_POINTS - 1);
self.scope[SCOPE_CYCLES - 1][i] = o;
self.scope_valid = true;
}
self.meter_in += (m * m) as f64;
self.meter_out += (o * o) as f64;
self.meter_count += 1;
out
}
/// Editor data; levels are averaged since the previous call.
pub fn take_analyzer(&mut self) -> AnalyzerFrame {
let n = self.meter_count.max(1) as f64;
let db = |p: f64| (10. * (p / n).max(1e-12).log10()).max(-120.) as f32;
let mut f = AnalyzerFrame {
hz: self.pitch.hz(),
note: None,
step: (self.cycle % self.settings.steps as u64) as usize,
scope: self.scope,
scope_valid: self.scope_valid,
input_db: db(self.meter_in),
output_db: db(self.meter_out),
latency: self.latency_samples(),
sample_rate: self.rate,
};
if self.tracker.sounding() && f.hz > 0. {
f.note = Some(hz_to_midi(f.hz).round().clamp(0., 127.) as u8);
}
self.meter_in = 0.;
self.meter_out = 0.;
self.meter_count = 0;
f
}
}
+166
View File
@@ -0,0 +1,166 @@
//! Host-independent controls. Gains in dB; amounts relative to one cycle in percent.
use serde::{Deserialize, Serialize};
/// Pattern steps: one per cycle, repeating.
pub const STEPS: usize = 16;
pub const STEP_MIN: f32 = -24.;
/// Grit at or below this level is off.
pub const GRIT_OFF: f32 = -60.;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Source {
/// Pitch and note starts are detected from the incoming bass.
#[default]
Audio,
/// Notes come from MIDI note input.
Midi,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Seed {
/// Every note gets the same random sequence, so identical notes sound identical.
#[default]
Same,
/// The random sequences keep running from note to note.
Free,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub source: Source,
/// Gain per pattern step, 0 down to `STEP_MIN`.
pub pattern: [f32; STEPS],
/// Steps that get a grit burst.
pub grit_steps: [bool; STEPS],
/// Pattern length in cycles, 1..=STEPS.
pub steps: usize,
/// Crossfade into the next step, as a share of a cycle.
pub edge: f32,
/// Random level per cycle, standard deviation in dB.
pub level_jitter: f32,
/// Random timing per cycle, standard deviation in percent of a period.
pub time_jitter: f32,
/// Grit burst level relative to the input, dB; `GRIT_OFF` or lower is off.
pub grit: f32,
/// Grit highpass, in harmonics of the note.
pub tone: f32,
/// Where in the cycle the grit burst sits, percent.
pub position: f32,
/// Grit burst length, percent of a cycle.
pub length: f32,
pub seed: Seed,
/// Detection threshold in dBFS for note starts (Audio source).
pub gate: f32,
/// Round detected pitch to the nearest semitone.
pub snap: bool,
pub mix: f32,
pub output: f32,
pub bypass: bool,
}
/// Named step patterns: (name, help, gains). Missing steps are 0 dB.
pub const PATTERNS: [(&str, &str, &[f32]); 6] = [
("Off", "Every cycle at full level.", &[0.]),
(
"Double",
"Every second cycle quieter: a sub-octave growl.",
&[0., -9.],
),
(
"Triple",
"One loud cycle, two quiet: a growl an octave and a fifth down.",
&[0., -12., -12.],
),
(
"Fry",
"Uneven four-cycle pattern: vocal-fry rattle.",
&[0., -14., -5., -14.],
),
(
"Gallop",
"Loud, quiet, loud, then three quiet: a rolling gallop.",
&[0., -18., 0., -18., -18., -18.],
),
(
"Stutter",
"Bursts of cycles with gaps: a gated stutter.",
&[0., 0., -24., -24., 0., -24., -24., -24.],
),
];
impl Settings {
/// Applies a named pattern: its gains, its length, grit on every step.
pub fn apply_pattern(&mut self, gains: &[f32]) {
self.pattern = [0.; STEPS];
for (p, g) in self.pattern.iter_mut().zip(gains) {
*p = *g;
}
self.steps = gains.len().clamp(1, STEPS);
}
}
impl Default for Settings {
fn default() -> Self {
let mut s = Self {
source: Source::Audio,
pattern: [0.; STEPS],
grit_steps: [true; STEPS],
steps: 1,
edge: 15.,
level_jitter: 1.,
time_jitter: 3.,
grit: -9.,
tone: 12.,
position: 10.,
length: 12.,
seed: Seed::Same,
gate: -42.,
snap: true,
mix: 100.,
output: 0.,
bypass: false,
};
s.apply_pattern(PATTERNS[3].2);
s
}
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
let finite = [
self.edge,
self.level_jitter,
self.time_jitter,
self.grit,
self.tone,
self.position,
self.length,
self.gate,
self.mix,
self.output,
]
.iter()
.chain(self.pattern.iter())
.all(|v| v.is_finite());
if !finite {
return Err("non-finite setting");
}
let ok = self.pattern.iter().all(|g| (STEP_MIN..=0.).contains(g))
&& (1..=STEPS).contains(&self.steps)
&& (5.0..=50.).contains(&self.edge)
&& (0.0..=6.).contains(&self.level_jitter)
&& (0.0..=10.).contains(&self.time_jitter)
&& (GRIT_OFF..=0.).contains(&self.grit)
&& (4.0..=64.).contains(&self.tone)
&& (0.0..=100.).contains(&self.position)
&& (2.0..=50.).contains(&self.length)
&& (-60.0..=-20.).contains(&self.gate)
&& (0.0..=100.).contains(&self.mix)
&& (-12.0..=12.).contains(&self.output);
if ok {
Ok(())
} else {
Err("setting out of range")
}
}
}
+232
View File
@@ -0,0 +1,232 @@
use common_dsp::{midi_to_hz, Saw};
use knurl_core::{Engine, Seed, Settings, Source, GRIT_OFF, PATTERNS};
const RATE: f32 = 48000.;
/// A tanh saw bass at `note`, as the prototype used.
fn bass(note: u8, n: usize) -> Vec<f32> {
let mut saw = Saw::new(RATE, midi_to_hz(note as f32));
(0..n).map(|_| (2.2 * saw.sample()).tanh() * 0.5).collect()
}
fn plain() -> Settings {
let mut s = Settings {
source: Source::Midi,
level_jitter: 0.,
time_jitter: 0.,
grit: GRIT_OFF,
..Settings::default()
};
s.apply_pattern(PATTERNS[0].2);
s
}
/// Runs one held note from sample 0 and returns the output, aligned to the input.
fn run(settings: Settings, note: u8, input: &[f32]) -> Vec<f32> {
let mut engine = Engine::new(RATE, 1).unwrap();
engine.set_settings(settings).unwrap();
let latency = engine.latency_samples() as usize;
engine.note_on(note);
let mut out: Vec<f32> = input
.iter()
.chain(std::iter::repeat_n(&0., latency))
.map(|&x| engine.process_sample([x, 0.])[0])
.collect();
out.drain(..latency);
out
}
/// Level of the component at `hz` (single-bin DFT with a Hann window), dB.
fn level(x: &[f32], hz: f32) -> f64 {
let n = x.len();
let (mut re, mut im) = (0f64, 0f64);
for (i, &v) in x.iter().enumerate() {
let w = 0.5 - 0.5 * (std::f64::consts::TAU * i as f64 / n as f64).cos();
let a = std::f64::consts::TAU * hz as f64 * i as f64 / RATE as f64;
re += w * v as f64 * a.cos();
im -= w * v as f64 * a.sin();
}
10. * (re * re + im * im).max(1e-30).log10()
}
fn subharmonic(out: &[f32], note: u8, divisor: f32) -> f64 {
let f = midi_to_hz(note as f32);
let seg = &out[4800..out.len() - 4800];
level(seg, f / divisor) - level(seg, f)
}
fn max_step(x: &[f32]) -> f32 {
x.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0., f32::max)
}
#[test]
fn everything_off_is_the_delayed_input() {
let input = bass(29, 24000);
let out = run(plain(), 29, &input);
assert_eq!(out, input);
}
#[test]
fn patterns_make_tuned_subharmonics_without_clicks() {
let input = bass(29, 48000);
let base = run(plain(), 29, &input);
assert!(subharmonic(&base, 29, 2.) < -50.);
for (pattern, divisor, expected) in [(1, 2., -8.2), (2, 3., -10.7)] {
let mut s = plain();
s.apply_pattern(PATTERNS[pattern].2);
let out = run(s, 29, &input);
let sub = subharmonic(&out, 29, divisor);
assert!(
(sub - expected).abs() < 0.5,
"{}: {sub}",
PATTERNS[pattern].0
);
assert!(
max_step(&out) <= 1.05 * max_step(&input),
"{}",
PATTERNS[pattern].0
);
}
}
#[test]
fn the_texture_is_the_same_on_every_note() {
let mut s = plain();
s.apply_pattern(PATTERNS[1].2);
let low = run(s, 29, &bass(29, 48000));
let high = run(s, 36, &bass(36, 48000));
let (a, b) = (subharmonic(&low, 29, 2.), subharmonic(&high, 36, 2.));
assert!((a - b).abs() < 0.3, "F1 {a} C2 {b}");
}
#[test]
fn same_seed_repeats_every_note_and_free_does_not() {
for (seed, identical) in [(Seed::Same, true), (Seed::Free, false)] {
let s = Settings {
source: Source::Midi,
seed,
..Settings::default()
};
let mut engine = Engine::new(RATE, 2).unwrap();
engine.set_settings(s).unwrap();
let latency = engine.latency_samples() as usize;
let note = bass(29, 12000);
let mut out = Vec::new();
let starts = [0, 36000];
for i in 0..72000 + latency {
if starts.contains(&i) {
engine.note_on(29);
}
if i == starts[0] + 12000 || i == starts[1] + 12000 {
engine.note_off(29);
}
let k = starts
.iter()
.filter(|&&s| i >= s)
.map(|&s| i - s)
.min()
.unwrap();
let x = note.get(k).copied().unwrap_or(0.);
out.push(engine.process_sample([x, x])[0]);
}
let first = &out[latency..latency + 12000];
let second = &out[starts[1] + latency..starts[1] + latency + 12000];
let largest = first
.iter()
.zip(second)
.map(|(a, b)| (a - b).abs())
.fold(0f32, f32::max);
if identical {
assert!(largest < 1e-4, "{seed:?}: {largest}");
} else {
assert!(largest > 1e-2, "{seed:?}: {largest}");
}
assert!(out.iter().all(|v| v.is_finite()));
}
}
#[test]
fn grit_follows_the_input_level_and_stops_with_the_note() {
let mut s = plain();
s.grit = -6.;
let input = bass(29, 24000);
let out = run(s, 29, &input);
let noise: f64 = out
.iter()
.zip(&input)
.skip(4800)
.map(|(o, i)| ((o - i) as f64).powi(2))
.sum::<f64>()
/ (out.len() - 4800) as f64;
let signal: f64 = input
.iter()
.skip(4800)
.map(|v| (*v as f64).powi(2))
.sum::<f64>()
/ (out.len() - 4800) as f64;
let ratio = 10. * (noise / signal).log10();
// −6 dB bursts over 12 % of each cycle; the sin² window keeps 3/8 of the power:
// −6 + 10·log10(0.12 · 0.375) = −19.5 dB.
assert!((ratio + 19.5).abs() < 1.5, "{ratio}");
// Silence in, silence out: no grit without a note, and no NaN.
let mut engine = Engine::new(RATE, 2).unwrap();
engine.set_settings(Settings::default()).unwrap();
for _ in 0..48000 {
let out = engine.process_sample([0., 0.]);
assert_eq!(out, [0., 0.]);
}
}
#[test]
fn audio_source_finds_the_note_and_reports_latency() {
let mut s = plain();
s.source = Source::Audio;
s.apply_pattern(PATTERNS[1].2);
let mut engine = Engine::new(RATE, 1).unwrap();
engine.set_settings(s).unwrap();
let latency = engine.latency_samples() as usize;
// About 67 ms of lookahead plus 4.2 ms of jitter headroom.
assert!((latency as f32 / RATE - 0.071).abs() < 0.005, "{latency}");
let input = bass(29, 48000);
let mut out: Vec<f32> = input
.iter()
.map(|&x| engine.process_sample([x, 0.])[0])
.collect();
out.drain(..latency);
let frame = engine.take_analyzer();
assert_eq!(frame.note, Some(29));
assert!(frame.scope_valid);
let sub = subharmonic(&out[..out.len() - 4800], 29, 2.);
assert!(sub > -14., "{sub}");
}
#[test]
fn invalid_settings_are_rejected() {
let mut engine = Engine::new(RATE, 2).unwrap();
for bad in [
Settings {
steps: 0,
..Settings::default()
},
Settings {
edge: 60.,
..Settings::default()
},
Settings {
grit: f32::NAN,
..Settings::default()
},
Settings {
time_jitter: 11.,
..Settings::default()
},
] {
assert!(engine.set_settings(bad).is_err());
}
let mut s = Settings::default();
s.pattern[3] = -30.;
assert!(engine.set_settings(s).is_err());
assert!(Engine::new(1000., 2).is_err());
assert!(Engine::new(48000., 3).is_err());
let json = serde_json::to_string(&Settings::default()).unwrap();
assert_eq!(
serde_json::from_str::<Settings>(&json).unwrap(),
Settings::default()
);
}
+70
View File
@@ -0,0 +1,70 @@
use knurl_core::{Engine, Seed, Settings, Source, PATTERNS};
use std::{
alloc::{GlobalAlloc, Layout, System},
cell::Cell,
};
struct CheckedAllocator;
thread_local! {
static CHECK:Cell<bool>=const {Cell::new(false)};
static OPERATIONS:Cell<usize>=const {Cell::new(0)};
}
fn record() {
if CHECK.try_with(Cell::get).unwrap_or(false) {
let _ = OPERATIONS.try_with(|n| n.set(n.get() + 1));
}
}
unsafe impl GlobalAlloc for CheckedAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
record();
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
record();
unsafe { System.dealloc(pointer, layout) }
}
unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 {
record();
unsafe { System.realloc(pointer, layout, size) }
}
}
#[global_allocator]
static ALLOCATOR: CheckedAllocator = CheckedAllocator;
#[test]
fn notes_patterns_grit_and_jitter_do_not_allocate_or_free() {
let mut engine = Engine::new(48000., 2).unwrap();
let mut settings = Settings::default();
engine.set_settings(settings).unwrap();
CHECK.with(|v| v.set(true));
for i in 0..96000 {
if i % 4800 == 0 {
settings.apply_pattern(PATTERNS[(i / 4800) % PATTERNS.len()].2);
settings.seed = if i > 48000 { Seed::Free } else { Seed::Same };
settings.source = if (i / 4800) % 3 == 0 {
Source::Midi
} else {
Source::Audio
};
settings.time_jitter = ((i / 4800) % 10) as f32;
engine.set_settings(settings).unwrap();
engine.note_on(30 + (i / 4800 % 12) as u8);
}
if i % 4800 == 2400 {
engine.note_off(30 + (i / 4800 % 12) as u8);
}
let t = i as f32 / 48000.;
let x = if (i / 2400) % 2 == 0 {
(std::f32::consts::TAU * 55. * t).sin() * 0.5
} else {
0.
};
std::hint::black_box(engine.process_sample([x, x]));
if i % 1600 == 0 {
std::hint::black_box(engine.take_analyzer());
}
}
engine.reset();
CHECK.with(|v| v.set(false));
assert_eq!(OPERATIONS.with(Cell::get), 0);
}
+2382
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "knurl"
version = "0.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" }
knurl-core = { path = "../core" }
knurl-ui = { path = "../ui" }
crossbeam-queue = "0.3"
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" }
serde_json = "1"
[profile.release]
lto = "thin"
strip = "symbols"
+195
View File
@@ -0,0 +1,195 @@
//! CLAP audio effect. All DSP state is owned by the processing thread.
mod params;
use crossbeam_queue::ArrayQueue;
use knurl_core::{AnalyzerFrame, Engine};
use nih_plug::prelude::*;
use nih_plug_egui::{create_egui_editor, egui};
use params::{KnurlParams, Pattern, GESTURES};
use std::sync::Arc;
struct EditorData {
ui: knurl_ui::State,
gestures: [bool; GESTURES],
}
pub struct Knurl {
params: Arc<KnurlParams>,
analyzer: Arc<ArrayQueue<AnalyzerFrame>>,
engine: Option<Engine>,
pattern: Pattern,
publish_count: usize,
publish_interval: usize,
}
impl Default for Knurl {
fn default() -> Self {
Self {
params: Arc::new(KnurlParams::default()),
analyzer: Arc::new(ArrayQueue::new(2)),
engine: None,
pattern: Pattern::default(),
publish_count: 0,
publish_interval: 1600,
}
}
}
impl Plugin for Knurl {
const NAME: &'static str = "Knurl";
const VENDOR: &'static str = "Bass Foundry";
const URL: &'static str = "";
const EMAIL: &'static str = "";
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[
AudioIOLayout {
main_input_channels: NonZeroU32::new(2),
main_output_channels: NonZeroU32::new(2),
..AudioIOLayout::const_default()
},
AudioIOLayout {
main_input_channels: NonZeroU32::new(1),
main_output_channels: NonZeroU32::new(1),
..AudioIOLayout::const_default()
},
];
const MIDI_INPUT: MidiConfig = MidiConfig::Basic;
const SAMPLE_ACCURATE_AUTOMATION: bool = false;
type SysExMessage = ();
type BackgroundTask = ();
fn params(&self) -> Arc<dyn Params> {
self.params.clone()
}
fn editor(&mut self, _: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
let params = self.params.clone();
let analyzer = self.analyzer.clone();
create_egui_editor(
params.editor.clone(),
EditorData {
ui: knurl_ui::State::default(),
gestures: [false; GESTURES],
},
|ctx, _| knurl_ui::style(ctx),
move |ctx, setter, data| {
while let Some(frame) = analyzer.pop() {
data.ui.accept_frame(frame);
}
let current = params.pattern();
let before = params.settings(&current);
let mut edited = before;
nih_plug_egui::resizable_window::ResizableWindow::new("knurl-editor")
.min_size(egui::vec2(
knurl_ui::WIDTH as f32 - 80.,
knurl_ui::HEIGHT as f32 - 40.,
))
.show(ctx, &params.editor, |ui| {
knurl_ui::show(ui, &mut edited, &mut data.ui)
});
params.apply(
setter,
&before,
&edited,
data.ui.gesture_active,
&mut data.gestures,
);
// The scope follows the audio.
ctx.request_repaint_after(std::time::Duration::from_millis(40));
},
)
}
fn initialize(
&mut self,
layout: &AudioIOLayout,
config: &BufferConfig,
context: &mut impl InitContext<Self>,
) -> bool {
let Ok(mut engine) = Engine::new(
config.sample_rate,
layout.main_input_channels.map_or(0, |v| v.get() as usize),
) else {
return false;
};
let settings = self.params.settings(&self.pattern);
self.pattern = Pattern {
gains: settings.pattern,
grit: settings.grit_steps,
};
if engine.set_settings(settings).is_err() {
return false;
}
context.set_latency_samples(engine.latency_samples());
self.publish_interval = (config.sample_rate / 30.) as usize;
self.engine = Some(engine);
true
}
fn reset(&mut self) {
if let Some(e) = self.engine.as_mut() {
e.reset();
}
self.publish_count = 0;
}
fn process(
&mut self,
buffer: &mut Buffer,
_: &mut AuxiliaryBuffers,
context: &mut impl ProcessContext<Self>,
) -> ProcessStatus {
let Some(engine) = self.engine.as_mut() else {
return ProcessStatus::Normal;
};
let settings = self.params.settings(&self.pattern);
self.pattern = Pattern {
gains: settings.pattern,
grit: settings.grit_steps,
};
let _ = engine.set_settings(settings);
let mut next = context.next_event();
for (index, mut sample) in buffer.iter_samples().enumerate() {
while let Some(event) = next {
if event.timing() as usize > index {
break;
}
match event {
NoteEvent::NoteOn { note, velocity, .. } if velocity > 0. => {
engine.note_on(note)
}
NoteEvent::NoteOn { note, .. } | NoteEvent::NoteOff { note, .. } => {
engine.note_off(note)
}
NoteEvent::Choke { note, .. } => engine.note_off(note),
_ => {}
}
next = context.next_event();
}
let x = [
*sample.get_mut(0).unwrap(),
if engine.channels() == 2 {
*sample.get_mut(1).unwrap()
} else {
0.
},
];
let out = engine.process_sample(x);
for (c, value) in sample.into_iter().enumerate() {
*value = out[c];
}
self.publish_count += 1;
if self.publish_count >= self.publish_interval {
self.analyzer.force_push(engine.take_analyzer());
self.publish_count = 0;
}
}
// The lookahead delay and filter tails must drain after input stops.
ProcessStatus::KeepAlive
}
}
impl ClapPlugin for Knurl {
const CLAP_ID: &'static str = "io.storyboard.cycle-texture";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("Texture per cycle of the played note: patterns, jitter and grit that fit every note");
const CLAP_MANUAL_URL: Option<&'static str> = None;
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::AudioEffect,
ClapFeature::Distortion,
ClapFeature::Stereo,
ClapFeature::Mono,
];
}
nih_export_clap!(Knurl);
+255
View File
@@ -0,0 +1,255 @@
use knurl_core::{Seed, Settings, Source, GRIT_OFF, STEPS, STEP_MIN};
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use std::sync::{Arc, RwLock};
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SourceParam {
#[id = "audio"]
Audio,
#[id = "midi"]
#[name = "MIDI"]
Midi,
}
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SeedParam {
#[id = "same"]
#[name = "Same every note"]
Same,
#[id = "free"]
Free,
}
fn linear(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam {
FloatParam::new(name, v, FloatRange::Linear { min, max }).with_unit(unit)
}
/// Continuous controls, in the order of the gesture slots.
const FLOATS: usize = 10;
pub const GESTURES: usize = FLOATS;
/// The drawn pattern: step gains and grit switches. Saved with the project, not automation.
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Pattern {
pub gains: [f32; STEPS],
pub grit: [bool; STEPS],
}
impl Default for Pattern {
fn default() -> Self {
let d = Settings::default();
Self {
gains: d.pattern,
grit: d.grit_steps,
}
}
}
#[derive(Params)]
pub struct KnurlParams {
#[persist = "editor"]
pub editor: Arc<EguiState>,
#[persist = "knurl-pattern"]
pub pattern: RwLock<Pattern>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[id = "seed"]
pub seed: EnumParam<SeedParam>,
#[id = "steps"]
pub steps: IntParam,
#[id = "edge"]
pub edge: FloatParam,
#[id = "level-jitter"]
pub level_jitter: FloatParam,
#[id = "time-jitter"]
pub time_jitter: FloatParam,
#[id = "grit"]
pub grit: FloatParam,
#[id = "tone"]
pub tone: FloatParam,
#[id = "position"]
pub position: FloatParam,
#[id = "length"]
pub length: FloatParam,
#[id = "gate"]
pub gate: FloatParam,
#[id = "mix"]
pub mix: FloatParam,
#[id = "output"]
pub output: FloatParam,
#[id = "snap"]
pub snap: BoolParam,
#[id = "bypass"]
pub bypass: BoolParam,
}
impl Default for KnurlParams {
fn default() -> Self {
let d = Settings::default();
Self {
editor: EguiState::from_size(knurl_ui::WIDTH, knurl_ui::HEIGHT),
pattern: RwLock::new(Pattern::default()),
source: EnumParam::new("Pitch source", SourceParam::Audio),
seed: EnumParam::new("Random", SeedParam::Same),
steps: IntParam::new(
"Steps",
d.steps as i32,
IntRange::Linear {
min: 1,
max: STEPS as i32,
},
),
edge: linear("Edge", d.edge, 5., 50., " %"),
level_jitter: linear("Level jitter", d.level_jitter, 0., 6., " dB"),
time_jitter: linear("Time jitter", d.time_jitter, 0., 10., " %"),
grit: linear("Grit", d.grit, GRIT_OFF, 0., " dB").with_value_to_string(Arc::new(|v| {
if v <= GRIT_OFF {
"Off".into()
} else {
format!("{v:.1}")
}
})),
tone: FloatParam::new(
"Tone",
d.tone,
FloatRange::Skewed {
min: 4.,
max: 64.,
factor: FloatRange::skew_factor(-1.),
},
)
.with_unit(" ×f0"),
position: linear("Position", d.position, 0., 100., " %"),
length: linear("Length", d.length, 2., 50., " %"),
gate: linear("Gate", d.gate, -60., -20., " dB"),
mix: linear("Mix", d.mix, 0., 100., " %"),
output: linear("Output", d.output, -12., 12., " dB"),
snap: BoolParam::new("Snap to semitone", d.snap),
bypass: BoolParam::new("Bypass", false).make_bypass(),
}
}
}
impl KnurlParams {
/// Current settings. The pattern falls back to `last` when the editor holds the lock,
/// so the audio thread never waits.
pub fn settings(&self, last: &Pattern) -> Settings {
let pattern = self.pattern.try_read().map(|p| *p).unwrap_or(*last);
Settings {
source: match self.source.value() {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
},
pattern: pattern.gains.map(|g| {
if g.is_finite() {
g.clamp(STEP_MIN, 0.)
} else {
0.
}
}),
grit_steps: pattern.grit,
steps: self.steps.value().clamp(1, STEPS as i32) as usize,
edge: self.edge.value(),
level_jitter: self.level_jitter.value(),
time_jitter: self.time_jitter.value(),
grit: self.grit.value(),
tone: self.tone.value(),
position: self.position.value(),
length: self.length.value(),
seed: match self.seed.value() {
SeedParam::Same => Seed::Same,
SeedParam::Free => Seed::Free,
},
gate: self.gate.value(),
snap: self.snap.value(),
mix: self.mix.value(),
output: self.output.value(),
bypass: self.bypass.value(),
}
}
pub fn pattern(&self) -> Pattern {
self.pattern.read().map(|p| *p).unwrap_or_default()
}
fn floats(&self) -> [&FloatParam; FLOATS] {
[
&self.edge,
&self.level_jitter,
&self.time_jitter,
&self.grit,
&self.tone,
&self.position,
&self.length,
&self.gate,
&self.mix,
&self.output,
]
}
/// Pushes every changed editor value to the host. Continuous controls hold one gesture
/// while the pointer is down; switches are set in a single gesture. The pattern is
/// stored directly.
pub fn apply(
&self,
setter: &ParamSetter,
old: &Settings,
new: &Settings,
dragging: bool,
open: &mut [bool; FLOATS],
) {
if new.pattern != old.pattern || new.grit_steps != old.grit_steps {
if let Ok(mut p) = self.pattern.write() {
*p = Pattern {
gains: new.pattern,
grit: new.grit_steps,
};
}
}
let values = [
(old.edge, new.edge),
(old.level_jitter, new.level_jitter),
(old.time_jitter, new.time_jitter),
(old.grit, new.grit),
(old.tone, new.tone),
(old.position, new.position),
(old.length, new.length),
(old.gate, new.gate),
(old.mix, new.mix),
(old.output, new.output),
];
for (i, (p, (a, b))) in self.floats().into_iter().zip(values).enumerate() {
if a != b {
if !open[i] {
open[i] = true;
setter.begin_set_parameter(p);
}
setter.set_parameter(p, b);
}
if !dragging && open[i] {
setter.end_set_parameter(p);
open[i] = false;
}
}
fn switch<P: Param>(setter: &ParamSetter, p: &P, v: P::Plain) {
setter.begin_set_parameter(p);
setter.set_parameter(p, v);
setter.end_set_parameter(p);
}
if new.steps != old.steps {
switch(setter, &self.steps, new.steps as i32);
}
if new.snap != old.snap {
switch(setter, &self.snap, new.snap);
}
if new.bypass != old.bypass {
switch(setter, &self.bypass, new.bypass);
}
if new.source != old.source {
let v = match new.source {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
};
switch(setter, &self.source, v);
}
if new.seed != old.seed {
let v = match new.seed {
Seed::Same => SeedParam::Same,
Seed::Free => SeedParam::Free,
};
switch(setter, &self.seed, v);
}
}
}
+227
View File
@@ -0,0 +1,227 @@
//! Exercise the actual exported CLAP ABI without requiring a GUI or installed DAW.
use clap_sys::{
audio_buffer::*,
events::*,
ext::{audio_ports::*, latency::*, state::*},
factory::plugin_factory::*,
host::*,
process::*,
stream::*,
version::*,
};
use std::{
ffi::{c_char, c_void, CStr},
io::{Cursor, Read},
ptr,
};
unsafe extern "C" fn extension(_: *const clap_host, _: *const c_char) -> *const c_void {
ptr::null()
}
unsafe extern "C" fn request(_: *const clap_host) {}
unsafe extern "C" fn count(_: *const clap_input_events) -> u32 {
0
}
unsafe extern "C" fn event(_: *const clap_input_events, _: u32) -> *const clap_event_header {
ptr::null()
}
unsafe extern "C" fn push(_: *const clap_output_events, _: *const clap_event_header) -> bool {
true
}
unsafe extern "C" fn write(stream: *const clap_ostream, buffer: *const c_void, size: u64) -> i64 {
// SAFETY: The test owns the Vec for the entire synchronous save call.
let bytes = unsafe { &mut *((*stream).ctx as *mut Vec<u8>) };
bytes.extend_from_slice(unsafe {
std::slice::from_raw_parts(buffer as *const u8, size as usize)
});
size as i64
}
unsafe extern "C" fn read(stream: *const clap_istream, buffer: *mut c_void, size: u64) -> i64 {
// SAFETY: The test owns the Cursor; the wrapper supplies writable stream storage.
let cursor = unsafe { &mut *((*stream).ctx as *mut Cursor<Vec<u8>>) };
cursor
.read(unsafe { std::slice::from_raw_parts_mut(buffer as *mut u8, size as usize) })
.unwrap() as i64
}
#[test]
fn effect_ports_state_latency_and_audio_lifecycle() {
// SAFETY: All callbacks, strings, streams, buffers, and the host outlive their
// synchronous ABI calls. Activation/processing/destruction follow CLAP order.
unsafe {
let entry = &knurl::clap_entry;
assert!(entry.init.unwrap()(c"knurl.clap".as_ptr()));
let factory = entry.get_factory.unwrap()(CLAP_PLUGIN_FACTORY_ID.as_ptr())
as *const clap_plugin_factory;
assert!(!factory.is_null());
let factory = &*factory;
assert_eq!(factory.get_plugin_count.unwrap()(factory), 1);
let descriptor = factory.get_plugin_descriptor.unwrap()(factory, 0);
assert!(!descriptor.is_null());
assert_eq!(CStr::from_ptr((*descriptor).name), c"Knurl");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.storyboard.cycle-texture"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
clap_version: CLAP_VERSION,
host_data: ptr::null_mut(),
name: c"Storyboard test host".as_ptr(),
vendor: c"Tests".as_ptr(),
url: c"".as_ptr(),
version: c"1.0".as_ptr(),
get_extension: Some(extension),
request_restart: Some(request),
request_process: Some(request),
request_callback: Some(request),
};
let plugin = factory.create_plugin.unwrap()(factory, &host, (*descriptor).id);
assert!(!plugin.is_null());
let p = &*plugin;
assert!(p.init.unwrap()(plugin));
let ports = p.get_extension.unwrap()(plugin, CLAP_EXT_AUDIO_PORTS.as_ptr())
as *const clap_plugin_audio_ports;
assert!(!ports.is_null());
assert_eq!((*ports).count.unwrap()(plugin, false), 1);
assert_eq!((*ports).count.unwrap()(plugin, true), 1);
let latency = p.get_extension.unwrap()(plugin, CLAP_EXT_LATENCY.as_ptr())
as *const clap_plugin_latency;
assert!(!latency.is_null());
let state =
p.get_extension.unwrap()(plugin, CLAP_EXT_STATE.as_ptr()) as *const clap_plugin_state;
assert!(!state.is_null());
let state = &*state;
let save = || {
let mut bytes = Vec::new();
let stream = clap_ostream {
ctx: &mut bytes as *mut Vec<u8> as *mut c_void,
write: Some(write),
};
assert!(state.save.unwrap()(plugin, &stream));
bytes
};
let mut envelope: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
use nih_plug::wrapper::state::ParamValue;
envelope.params.insert("edge".into(), ParamValue::F32(30.));
envelope.params.insert("steps".into(), ParamValue::I32(3));
envelope
.params
.insert("seed".into(), ParamValue::String("free".into()));
envelope
.params
.insert("source".into(), ParamValue::String("midi".into()));
// Pitch from MIDI and no note arrives, so the audio check below sees the dry input
// even with a pattern set.
envelope
.params
.insert("level-jitter".into(), ParamValue::F32(0.));
envelope
.params
.insert("time-jitter".into(), ParamValue::F32(0.));
envelope.params.insert("grit".into(), ParamValue::F32(-60.));
let (gains, grit) = ([-6f32; 16], [true; 16]);
let mut pattern = serde_json::json!({ "gains": gains, "grit": grit });
pattern["grit"][5] = false.into();
envelope
.fields
.insert("knurl-pattern".into(), pattern.to_string());
let encoded = serde_json::to_vec(&envelope).unwrap();
let mut framed = (encoded.len() as u64).to_le_bytes().to_vec();
framed.extend_from_slice(&encoded);
let mut bytes = Cursor::new(framed);
let stream = clap_istream {
ctx: &mut bytes as *mut Cursor<Vec<u8>> as *mut c_void,
read: Some(read),
};
assert!(state.load.unwrap()(plugin, &stream));
let restored: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
assert_eq!(
serde_json::to_value(&restored.params).unwrap(),
serde_json::to_value(&envelope.params).unwrap()
);
let saved: serde_json::Value =
serde_json::from_str(&restored.fields["knurl-pattern"]).unwrap();
assert_eq!(saved, pattern);
for rate in [44100., 48000., 96000.] {
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
let delay = (*latency).get.unwrap()(plugin) as usize;
// About 67 ms lookahead plus 4.2 ms of timing-jitter headroom.
assert!((delay as f64 / rate - 0.071).abs() < 0.01, "{delay}");
assert!(p.start_processing.unwrap()(plugin));
let total = delay + 9600;
let mut position = 0;
let (mut energy_in, mut energy_out) = (0f64, 0f64);
while position < total {
let frames = [1, 127, 512, 31][position % 4].min(total - position);
// With no MIDI note and every effect off, a tone passes through unchanged.
let tone = |n: usize| {
0.25 * (std::f64::consts::TAU * 1000. * n as f64 / rate).sin() as f32
};
let mut input_left = [0f32; 512];
let mut input_right = [0f32; 512];
for i in 0..frames {
input_left[i] = tone(position + i);
input_right[i] = -tone(position + i);
}
let mut left = [0f32; 512];
let mut right = [0f32; 512];
let mut in_channels = [input_left.as_mut_ptr(), input_right.as_mut_ptr()];
let mut out_channels = [left.as_mut_ptr(), right.as_mut_ptr()];
let incoming = clap_audio_buffer {
data32: in_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let mut outgoing_audio = clap_audio_buffer {
data32: out_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let input = clap_input_events {
ctx: ptr::null_mut(),
size: Some(count),
get: Some(event),
};
let outgoing = clap_output_events {
ctx: ptr::null_mut(),
try_push: Some(push),
};
let process = clap_process {
steady_time: position as i64,
frames_count: frames as u32,
transport: ptr::null(),
audio_inputs: &incoming,
audio_outputs: &mut outgoing_audio,
audio_inputs_count: 1,
audio_outputs_count: 1,
in_events: &input,
out_events: &outgoing,
};
assert_ne!(p.process.unwrap()(plugin, &process), CLAP_PROCESS_ERROR);
for i in 0..frames {
let n = position + i;
if n < delay {
assert!(left[i].abs() < 1e-6, "output before the lookahead");
} else if n > delay + 4800 {
energy_in += (tone(n - delay) as f64).powi(2);
energy_out += (left[i] as f64).powi(2) + (right[i] as f64).powi(2);
}
}
position += frames;
}
let ratio_db = 10. * (energy_out / (2. * energy_in)).log10();
assert!(ratio_db.abs() < 0.2, "{ratio_db}");
p.stop_processing.unwrap()(plugin);
p.deactivate.unwrap()(plugin);
}
p.destroy.unwrap()(plugin);
entry.deinit.unwrap()();
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "knurl-ui"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
egui = "0.31.1"
knurl-core = { path = "../core" }
common-ui = { path = "../../common/ui" }
common-dsp = { path = "../../common/dsp" }
+501
View File
@@ -0,0 +1,501 @@
//! Knurl editor, shared by the CLAP wrapper and the headless renderer.
use common_ui::*;
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
use knurl_core::{
AnalyzerFrame, Seed, Settings, Source, GRIT_OFF, PATTERNS, SCOPE_CYCLES, SCOPE_POINTS, STEPS,
STEP_MIN,
};
pub use common_ui::style;
pub const WIDTH: u32 = 1040;
pub const HEIGHT: u32 = 650;
const GRID: Color32 = Color32::from_rgb(24, 26, 30);
const TOP: f32 = 316.;
pub struct State {
pub analyzer: AnalyzerFrame,
/// True while a pointer button is held, so knob drags become one host gesture.
pub gesture_active: bool,
pub help: String,
}
impl Default for State {
fn default() -> Self {
Self {
analyzer: AnalyzerFrame::default(),
gesture_active: false,
help: DEFAULT_HELP.into(),
}
}
}
const DEFAULT_HELP: &str =
"Every cycle of the note gets its own level, timing and grit. Counted in cycles, so it fits every note.";
impl State {
pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
let old = self.analyzer;
self.analyzer = frame;
self.analyzer.input_db = frame.input_db.max(old.input_db - 3.);
self.analyzer.output_db = frame.output_db.max(old.output_db - 3.);
// Keep the last scope between notes.
if !frame.scope_valid && old.scope_valid {
self.analyzer.scope = old.scope;
self.analyzer.scope_valid = true;
}
}
}
fn hint(r: &egui::Response, state: &mut State, text: &str) {
if r.hovered() || r.has_focus() {
state.help = text.into();
}
}
#[allow(clippy::too_many_arguments)]
fn knob(
ui: &mut egui::Ui,
state: &mut State,
name: &str,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
default: f32,
unit: &str,
help: &str,
) {
ui.vertical_centered(|ui| {
let r = rotary(ui, name, value, range, default, unit, 58., ACCENT);
hint(&r, state, help);
});
}
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
backdrop(ui);
state.help = DEFAULT_HELP.into();
state.gesture_active = ui.input(|i| i.pointer.any_down());
ui.add_space(2.);
header(ui, "KNURL", "CYCLE TEXTURE", |ui| {
let r = ui
.selectable_label(settings.bypass, RichText::new("BYPASS").size(13.))
.on_hover_text("Crossfade to the delayed dry signal.");
if r.clicked() {
settings.bypass = !settings.bypass;
}
ui.separator();
for (source, label) in [(Source::Midi, "MIDI"), (Source::Audio, "AUDIO")] {
let r = ui.selectable_label(settings.source == source, RichText::new(label).size(13.));
if r.clicked() {
settings.source = source;
}
}
caption(ui, "PITCH FROM");
});
ui.add_space(6.);
ui.horizontal_top(|ui| {
panel(ui, |ui| {
ui.set_width(210.);
ui.set_min_height(TOP);
ui.vertical(|ui| note_panel(ui, settings, state));
});
ui.add_space(4.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| {
let frame = state.analyzer;
scope(ui, &frame, 140.);
ui.add_space(4.);
pattern_editor(ui, settings, state, TOP - 144.);
});
});
});
ui.add_space(6.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| controls(ui, settings, state));
});
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
fn note_panel(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let frame = state.analyzer;
caption(ui, "NOTE");
let (name, hz) = match frame.note {
Some(n) if frame.hz > 0. => (common_dsp::note_name(n), format!("{:.1} Hz", frame.hz)),
_ if frame.hz > 0. => ("—".into(), format!("holding {:.1} Hz", frame.hz)),
_ => ("—".into(), "no note yet".into()),
};
ui.label(
RichText::new(name)
.font(FontId::new(54., egui::FontFamily::Name("medium".into())))
.color(if frame.note.is_some() { TEXT } else { DIM }),
);
ui.label(RichText::new(hz).size(13.).color(MUTED));
if frame.hz > 0. {
ui.label(
RichText::new(format!("one cycle = {:.1} ms", 1000. / frame.hz))
.size(12.)
.color(DIM),
);
}
ui.add_space(10.);
meter(ui, "IN", frame.input_db, STEEL, 110.);
meter(ui, "OUT", frame.output_db, ACCENT, 110.);
ui.add_space(10.);
caption(ui, "RANDOM");
ui.horizontal(|ui| {
for (seed, label, help) in [
(
Seed::Same,
"SAME EVERY NOTE",
"Every note gets the same random sequence: identical notes sound identical.",
),
(
Seed::Free,
"FREE",
"The random sequences keep running, so every note differs.",
),
] {
let r = ui.selectable_label(settings.seed == seed, RichText::new(label).size(12.));
if r.clicked() {
settings.seed = seed;
}
hint(&r, state, help);
}
});
ui.add_space(6.);
let r = ui.checkbox(
&mut settings.snap,
RichText::new("Snap to semitone").size(12.),
);
hint(
&r,
state,
"Round the detected pitch. Turn it off for slides.",
);
}
/// The last cycles of the output, overlaid; the newest is brightest.
fn scope(ui: &mut egui::Ui, frame: &AnalyzerFrame, height: f32) {
let (outer, _) =
ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
let p = ui.painter_at(outer);
p.rect_filled(outer, 2., SCREEN);
p.text(
outer.left_top() + Vec2::new(8., 5.),
egui::Align2::LEFT_TOP,
format!("LAST {SCOPE_CYCLES} CYCLES, OVERLAID"),
FontId::proportional(10.),
MUTED,
);
let plot = Rect::from_min_max(
outer.left_top() + Vec2::new(10., 20.),
outer.right_bottom() - Vec2::new(10., 8.),
);
for t in [0.25f32, 0.5, 0.75] {
let x = plot.left() + plot.width() * t;
p.line_segment(
[Pos2::new(x, plot.top()), Pos2::new(x, plot.bottom())],
Stroke::new(1.0_f32, GRID),
);
}
p.line_segment(
[
Pos2::new(plot.left(), plot.center().y),
Pos2::new(plot.right(), plot.center().y),
],
Stroke::new(1.0_f32, EDGE),
);
if !frame.scope_valid {
p.text(
plot.center(),
egui::Align2::CENTER_CENTER,
"play a note",
FontId::proportional(12.),
DIM,
);
return;
}
let peak = frame
.scope
.iter()
.flatten()
.fold(1e-6f32, |m, v| m.max(v.abs()));
// The newest cycle is still being written; draw the complete ones.
for (age, cycle) in frame.scope[..SCOPE_CYCLES - 1].iter().enumerate() {
let alpha = 0.2 + 0.8 * age as f32 / (SCOPE_CYCLES - 2) as f32;
let color = if age == SCOPE_CYCLES - 2 {
ACCENT
} else {
STEEL.gamma_multiply(alpha * 0.6)
};
let points: Vec<Pos2> = cycle
.iter()
.enumerate()
.map(|(i, v)| {
Pos2::new(
plot.left() + plot.width() * i as f32 / (SCOPE_POINTS - 1) as f32,
plot.center().y - plot.height() * 0.48 * v / peak,
)
})
.collect();
p.add(egui::Shape::line(
points,
Stroke::new(
if age == SCOPE_CYCLES - 2 {
2.0_f32
} else {
1.0
},
color,
),
));
}
}
fn step_y(rect: Rect, db: f32) -> f32 {
rect.top() + rect.height() * (db / STEP_MIN).clamp(0., 1.)
}
/// One bar per step: drag to set its level, double-click to reset; the square below a bar
/// switches its grit burst.
fn pattern_editor(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, height: f32) {
let (outer, response) = ui.allocate_exact_size(
Vec2::new(ui.available_width(), height),
Sense::click_and_drag(),
);
let p = ui.painter_at(outer);
p.rect_filled(outer, 2., SCREEN);
p.text(
outer.left_top() + Vec2::new(8., 5.),
egui::Align2::LEFT_TOP,
"PATTERN: LEVEL PER CYCLE",
FontId::proportional(10.),
MUTED,
);
let bars = Rect::from_min_max(
outer.left_top() + Vec2::new(34., 22.),
Pos2::new(outer.right() - 10., outer.bottom() - 26.),
);
let toggles_y = outer.bottom() - 16.;
let col = bars.width() / STEPS as f32;
let column =
|x: f32| (((x - bars.left()) / col).floor() as isize).clamp(0, STEPS as isize - 1) as usize;
if response.hovered() {
state.help = "Drag a bar to set the level of that cycle; double-click resets it. The squares switch grit per step.".into();
}
if let Some(pos) = response.interact_pointer_pos() {
let k = column(pos.x);
if pos.y > bars.bottom() + 2. {
if response.clicked() {
settings.grit_steps[k] = !settings.grit_steps[k];
}
} else if response.double_clicked() {
settings.pattern[k] = 0.;
} else if response.dragged() || response.clicked() {
let t = ((pos.y - bars.top()) / bars.height()).clamp(0., 1.);
let db = t * STEP_MIN;
settings.pattern[k] = if db > -0.5 { 0. } else { db.round() };
if k >= settings.steps {
settings.steps = k + 1;
}
}
}
for db in [0f32, -6., -12., -18., -24.] {
let y = step_y(bars, db);
p.line_segment(
[Pos2::new(bars.left(), y), Pos2::new(bars.right(), y)],
Stroke::new(1.0_f32, GRID),
);
p.text(
Pos2::new(outer.left() + 6., y),
egui::Align2::LEFT_CENTER,
if db == 0. {
"0".into()
} else {
format!("{db:.0}")
},
FontId::proportional(10.),
DIM,
);
}
let current = state.analyzer.step;
let playing = state.analyzer.note.is_some();
for k in 0..STEPS {
let on = k < settings.steps;
let x0 = bars.left() + k as f32 * col + 3.;
let x1 = x0 + col - 6.;
let top = step_y(bars, settings.pattern[k]);
let color = if !on {
DIM.gamma_multiply(0.5)
} else if playing && k == current {
TEXT
} else {
ACCENT
};
p.rect_filled(
Rect::from_min_max(
Pos2::new(x0, top.min(bars.bottom() - 2.)),
Pos2::new(x1, bars.bottom()),
),
1.,
color.gamma_multiply(if on { 0.85 } else { 0.4 }),
);
let grit = settings.grit_steps[k] && settings.grit > GRIT_OFF;
let square = Rect::from_center_size(Pos2::new((x0 + x1) / 2., toggles_y), Vec2::splat(9.));
if settings.grit_steps[k] {
p.rect_filled(square, 1., if grit && on { FOCUS } else { DIM });
} else {
p.rect_stroke(
square,
1.,
Stroke::new(1.0_f32, DIM),
egui::StrokeKind::Inside,
);
}
}
p.text(
Pos2::new(outer.left() + 6., toggles_y),
egui::Align2::LEFT_CENTER,
"grit",
FontId::proportional(10.),
DIM,
);
}
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let d = Settings::default();
ui.horizontal(|ui| {
caption(ui, "PATTERN");
for (name, help, gains) in PATTERNS {
let selected = settings.steps == gains.len()
&& settings.pattern[..gains.len()]
.iter()
.zip(gains)
.all(|(a, b)| a == b);
let r = ui.selectable_label(selected, RichText::new(name.to_uppercase()).size(13.));
if r.clicked() {
settings.apply_pattern(gains);
}
hint(&r, state, help);
}
ui.add_space(24.);
caption(ui, "STEPS");
let minus = ui.button(RichText::new("−").size(14.));
if minus.clicked() && settings.steps > 1 {
settings.steps -= 1;
}
ui.label(
RichText::new(format!("{:>2}", settings.steps))
.monospace()
.size(14.)
.color(TEXT),
);
let plus = ui.button(RichText::new("+").size(14.));
if plus.clicked() && settings.steps < STEPS {
settings.steps += 1;
}
let help = "Pattern length in cycles. 2 makes a sub-octave, 3 an octave and a fifth down.";
hint(&minus, state, help);
hint(&plus, state, help);
});
ui.add_space(4.);
ui.columns(10, |c| {
knob(
&mut c[0],
state,
"Edge",
&mut settings.edge,
5. ..=50.,
d.edge,
" %",
"How soft each step change is, as a share of a cycle. Low is sharper and buzzier.",
);
knob(
&mut c[1],
state,
"Level",
&mut settings.level_jitter,
0. ..=6.,
d.level_jitter,
" dB",
"Random level per cycle: a rough, living surface.",
);
knob(
&mut c[2],
state,
"Time",
&mut settings.time_jitter,
0. ..=10.,
d.time_jitter,
" %",
"Random timing per cycle, in percent of a period: rattle and scrape.",
);
knob(
&mut c[3],
state,
"Grit",
&mut settings.grit,
GRIT_OFF..=0.,
d.grit,
" dB",
"Level of the noise burst in each cycle, relative to the input. Fully left is off.",
);
knob(
&mut c[4],
state,
"Tone",
&mut settings.tone,
4. ..=64.,
d.tone,
" ×f0",
"Lowest frequency of the grit, in harmonics of the note.",
);
knob(
&mut c[5],
state,
"Position",
&mut settings.position,
0. ..=100.,
d.position,
" %",
"Where in the cycle the grit burst sits.",
);
knob(
&mut c[6],
state,
"Length",
&mut settings.length,
2. ..=50.,
d.length,
" %",
"Length of the grit burst, as a share of a cycle.",
);
knob(
&mut c[7],
state,
"Gate",
&mut settings.gate,
-60. ..=-20.,
d.gate,
" dB",
"Input level that counts as a note (AUDIO). Raise it if tails retrigger.",
);
knob(
&mut c[8],
state,
"Mix",
&mut settings.mix,
0. ..=100.,
d.mix,
" %",
"Blend with the delayed dry signal.",
);
knob(
&mut c[9],
state,
"Output",
&mut settings.output,
-12. ..=12.,
d.output,
" dB",
"Output trim.",
);
});
}
+2
View File
@@ -15,6 +15,8 @@ commands = [
["cargo", "test", *packages, "--locked"],
["cargo", "clippy", *packages, "--all-targets", "--locked", "--", "-D", "warnings"],
["cargo", "fmt", "--manifest-path", product["manifest"], "--", "--check"],
# Every product has a fixture in the editor renderer; CI checks its formatting too.
["cargo", "fmt", "--manifest-path", "tools/editor-check/Cargo.toml", "--", "--check"],
["cargo", "test", "--manifest-path", product["manifest"], "--locked"],
["cargo", "clippy", "--manifest-path", product["manifest"], "--all-targets", "--locked", "--", "-D", "warnings"],
]
+20
View File
@@ -158,6 +158,8 @@ dependencies = [
"crucible-ui",
"egui",
"image",
"knurl-core",
"knurl-ui",
"lathe-core",
"lathe-ui",
"patina-core",
@@ -252,6 +254,24 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "knurl-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"serde",
]
[[package]]
name = "knurl-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"common-ui",
"egui",
"knurl-core",
]
[[package]]
name = "lathe-core"
version = "0.1.0"
+2
View File
@@ -23,5 +23,7 @@ lathe-core = { path = "../../plugins/lathe/core" }
lathe-ui = { path = "../../plugins/lathe/ui" }
temper-core = { path = "../../plugins/temper/core" }
temper-ui = { path = "../../plugins/temper/ui" }
knurl-core = { path = "../../plugins/knurl/core" }
knurl-ui = { path = "../../plugins/knurl/ui" }
weld-core = { path = "../../plugins/weld/core" }
weld-ui = { path = "../../plugins/weld/ui" }
+28 -5
View File
@@ -11,11 +11,11 @@ fn main() {
.map(|a| a[1].as_str())
.unwrap_or("rivet");
if ![
"rivet", "patina", "anvil", "crucible", "lathe", "temper", "weld",
"rivet", "patina", "anvil", "crucible", "lathe", "temper", "weld", "knurl",
]
.contains(&plugin)
{
eprintln!("Use --plugin rivet, patina, anvil, crucible, lathe, temper or weld");
eprintln!("Use --plugin rivet, patina, anvil, crucible, lathe, temper, weld or knurl");
std::process::exit(2);
}
let patina = plugin == "patina" || args.iter().any(|arg| arg == "--resonance");
@@ -25,7 +25,10 @@ fn main() {
let lathe = plugin == "lathe";
let temper = plugin == "temper";
let weld = plugin == "weld";
let (width, height) = if weld {
let knurl = plugin == "knurl";
let (width, height) = if knurl {
(knurl_ui::WIDTH, knurl_ui::HEIGHT)
} else if weld {
(weld_ui::WIDTH, weld_ui::HEIGHT)
} else if temper {
(temper_ui::WIDTH, temper_ui::HEIGHT)
@@ -146,6 +149,22 @@ fn main() {
}
temper_state.accept_frame(engine.take_analyzer());
}
let mut knurl_settings = knurl_core::Settings::default();
let mut knurl_state = knurl_ui::State::default();
if knurl {
// An F1 saw bass with the default Fry pattern, jitter and grit.
let mut engine = knurl_core::Engine::new(48000., 2).unwrap();
engine.set_settings(knurl_settings).unwrap();
let mut saw = common_dsp::Saw::new(48000., 43.65);
for i in 0..36000 {
let x = (saw.sample() * 2.2).tanh() * 0.5;
engine.process_sample([x, x]);
if i % 1600 == 0 {
knurl_state.accept_frame(engine.take_analyzer());
}
}
knurl_state.accept_frame(engine.take_analyzer());
}
let mut weld_settings = weld_core::Settings::default();
let mut weld_state = weld_ui::State::default();
if weld {
@@ -275,7 +294,9 @@ fn main() {
let session_motif = session.motifs[0].id;
shared.replace(session).unwrap();
let ctx = egui::Context::default();
if weld {
if knurl {
knurl_ui::style(&ctx);
} else if weld {
weld_ui::style(&ctx);
} else if temper {
temper_ui::style(&ctx);
@@ -310,7 +331,9 @@ fn main() {
},
|ui| {
egui::CentralPanel::default().show(ui, |ui| {
if weld {
if knurl {
knurl_ui::show(ui, &mut knurl_settings, &mut knurl_state);
} else if weld {
weld_ui::show(ui, &mut weld_settings, &mut weld_state);
} else if temper {
temper_ui::show(ui, &mut temper_settings, &mut temper_state);