Compare commits

..
Author SHA1 Message Date
nvrl 444b9d93da feat!: change CLAP IDs to io.narl.bass-foundry.<product>, release all plugins
Check and Windows preview / check (anvil) (push) Successful in 2m11s
Check and Windows preview / check (crucible) (push) Successful in 1m43s
Check and Windows preview / check (knurl) (push) Successful in 1m37s
Check and Windows preview / check (lathe) (push) Successful in 1m39s
Check and Windows preview / check (patina) (push) Successful in 2m25s
Check and Windows preview / check (rivet) (push) Successful in 1m50s
Check and Windows preview / check (temper) (push) Successful in 1m45s
Check and Windows preview / check (weld) (push) Successful in 3m9s
Check and Windows preview / previews (push) Successful in 1m50s
Check and Windows preview / windows (push) Successful in 2m46s
Independent plugin release / publish (push) Successful in 9s
Independent plugin release / plan (push) Successful in 8s
Independent plugin release / build (push) Successful in 2m10s
The io.storyboard.* IDs were a leftover from Motif Storyboard and named
old functions (motif, sub-anchor, layer-align) instead of the products.
Every plugin now uses io.narl.bass-foundry.<product>, and IDs are frozen
from here on, even across renames.

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

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

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

- Cycle clock from the shared note tracker, restarted at each note; the
  audio is delayed by the lookahead plus 4.2 ms of timing-jitter headroom.
- Per-cycle randomness from a hash of the cycle number: Same every note
  or Free.
- Patterns Off, Double, Triple, Fry (default), Gallop, Stutter or drawn;
  grit switchable per step. Pattern persisted as knurl-pattern.
- Editor: last eight cycles overlaid, pattern bars with the playing step.
- Tests match the prototype: Double gives f0/2 at -8.19 dB, Triple f0/3
  at -10.68 dB, equal on F1 and C2, no added clicks, bit-exact when off,
  no allocation on the audio thread.
2026-11-06 08:49:11 +01:00
nvrl f4c0a833e6 ci: check editor-check formatting in the product check
Check and Windows preview / check (anvil) (push) Successful in 39s
Check and Windows preview / check (crucible) (push) Successful in 44s
Check and Windows preview / check (lathe) (push) Successful in 40s
Check and Windows preview / check (patina) (push) Successful in 55s
Check and Windows preview / check (rivet) (push) Successful in 46s
Check and Windows preview / check (temper) (push) Successful in 44s
Check and Windows preview / check (weld) (push) Successful in 1m18s
Check and Windows preview / previews (push) Successful in 55s
Check and Windows preview / windows (push) Successful in 1m24s
Every product adds a fixture to tools/editor-check, but only the CI
previews job checked its formatting, so an unformatted fixture passed
the local product check and failed CI.
2026-11-06 08:34:14 +01:00
104 changed files with 13347 additions and 103 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
+15 -9
View File
@@ -2,6 +2,8 @@
**Rust CLAP plugins for writing, designing and mixing bass music in FL Studio.**
Website: [fl.narl.io](https://fl.narl.io)
![CLAP](https://img.shields.io/badge/format-CLAP-6c5ce7)
![FL Studio 2024.1+](https://img.shields.io/badge/FL%20Studio-2024.1%2B-ff8a00)
![Windows x64](https://img.shields.io/badge/platform-Windows%20x64-0078d4)
@@ -25,16 +27,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 +59,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>
@@ -83,11 +87,11 @@ Keep the font license (`licenses/`) with redistributed packages.
Every branch and pull-request pipeline also builds a combined Windows preview
ZIP, `bass-foundry-windows-x86_64.zip`, in its **windows** job. Use it to test unreleased changes.
> **Upgrading from Motif Storyboard or Resonance Compressor?** These are now
> Rivet and Patina. The CLAP IDs and saved state are unchanged, so projects
> still load. Delete the old `.clap` files and rescan. The vendor name changed
> from "Rivet & Patina" to "Bass Foundry", so FL Studio may list the plugins
> in a new vendor folder after the rescan.
> **Upgrading from an earlier build?** The CLAP IDs changed from
> `io.storyboard.*` to `io.narl.bass-foundry.<product>`, so hosts see each
> plugin as new. Delete the old `.clap` files, rescan, and add the plugins again
> in existing projects. Motif Storyboard and Resonance Compressor are now Rivet
> and Patina, and the vendor is "Bass Foundry".
## Build from source
@@ -115,7 +119,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 +156,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
@@ -160,6 +165,7 @@ scripts/ Build, check, packaging, release and analysis tools
docs/<plugin>/ Guide, changelog and editor preview for each plugin
docs/ideas.md Problems in bass-music production and the plugin ideas for them
references/ Local reference audio; never distributed
website/ The fl.narl.io site: Astro, nginx container, reads catalog and previews
```
Each product follows the same split:
+6
View File
@@ -1,5 +1,11 @@
# Anvil changelog
## 0.2.0
- Change the CLAP ID to `io.narl.bass-foundry.anvil`. Hosts see this as a new
plugin: delete the old `.clap` file, rescan, and add the plugin again in
existing projects.
## 0.1.0
- Introduce Anvil, a sub anchor for bass music.
+6
View File
@@ -1,5 +1,11 @@
# Crucible changelog
## 0.2.0
- Change the CLAP ID to `io.narl.bass-foundry.crucible`. Hosts see this as a new
plugin: delete the old `.clap` file, rescan, and add the plugin again in
existing projects.
## 0.1.0
- Introduce Crucible, a resampler that always records its input.
+19 -14
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
@@ -135,19 +136,23 @@ identify plugins by CLAP ID, so the vendor name does not affect saved projects.
| Product | Former display name | CLAP ID |
| --- | --- | --- |
| Rivet | Motif Storyboard | `io.storyboard.motif` |
| Patina | Resonance Compressor | `io.storyboard.resonance-compressor` |
| Anvil | (new) | `io.storyboard.sub-anchor` |
| Crucible | (new) | `io.storyboard.resampler` |
| Lathe | (new) | `io.storyboard.harmonic-eq` |
| Temper | (new) | `io.storyboard.tracked-distortion` |
| Weld | (new) | `io.storyboard.layer-align` |
| Rivet | Motif Storyboard | `io.narl.bass-foundry.rivet` |
| Patina | Resonance Compressor | `io.narl.bass-foundry.patina` |
| Anvil | (new) | `io.narl.bass-foundry.anvil` |
| Crucible | (new) | `io.narl.bass-foundry.crucible` |
| Lathe | (new) | `io.narl.bass-foundry.lathe` |
| Temper | (new) | `io.narl.bass-foundry.temper` |
| Weld | (new) | `io.narl.bass-foundry.weld` |
| Knurl | (new) | `io.narl.bass-foundry.knurl` |
The `io.storyboard.` prefix is historical and stays, because changing a CLAP ID
breaks existing projects. Rivet keeps `story-session-v1` and its session
migration. Patina keeps all parameter IDs. The copyright notice keeps its
original holder name. Replace old plugin files before rescanning, so no CLAP ID
is installed twice, and check that existing projects still load in FL Studio.
CLAP IDs use the form `io.narl.bass-foundry.<product>`. Builds tagged before
this scheme used `io.storyboard.*` IDs; hosts treat the new IDs as different
plugins, so projects saved with those builds need the plugin added again. From now on an ID is frozen: if a product is renamed, its CLAP ID
stays. A new plugin gets `io.narl.bass-foundry.<slug>` with its product slug.
Rivet keeps `story-session-v1` and its session migration. Patina keeps all
parameter IDs. The copyright notice keeps its original holder name. Delete old
plugin files before rescanning, so FL Studio does not list both builds.
See [releases](releases.md) for tagged publishing. Runs on the real Gitea runner
and checks in FL Studio are separate from the local checks.
+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
+20
View File
@@ -0,0 +1,20 @@
# Knurl changelog
## 0.2.0
- Change the CLAP ID to `io.narl.bass-foundry.knurl`. Hosts see this as a new
plugin: delete the old `.clap` file, rescan, and add the plugin again in
existing projects.
## 0.1.0
- Introduce Knurl, texture per cycle of the played note.
- Step patterns over 1–16 cycles make subharmonics in tune with every note:
Off, Double, Triple, Fry, Gallop, Stutter, or a drawn pattern.
- Random level and timing per cycle, and a noise burst per cycle tuned to the
note (Tone, Position, Length), switchable per step.
- Same every note or Free random sequences.
- Detect pitch and note starts from the audio with about 71 ms of latency, or
follow MIDI notes sent to the effect.
- Editor shows the last eight cycles overlaid and the pattern with the playing
step.
+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

+6
View File
@@ -1,5 +1,11 @@
# Lathe changelog
## 0.2.0
- Change the CLAP ID to `io.narl.bass-foundry.lathe`. Hosts see this as a new
plugin: delete the old `.clap` file, rescan, and add the plugin again in
existing projects.
## 0.1.0
- Introduce Lathe, a harmonic EQ for bass sound design.
+6
View File
@@ -1,5 +1,11 @@
# Patina changelog
## 0.3.0
- Change the CLAP ID to `io.narl.bass-foundry.patina`. Hosts see this as a new
plugin: delete the old `.clap` file, rescan, and add the plugin again in
existing projects.
## 0.2.0
- Rebuild the detector so it finds resonant bumps instead of cutting individual
+6 -6
View File
@@ -116,12 +116,12 @@ not a worst-case multi-instance guarantee.
## Existing projects and source layout
The CLAP ID `io.storyboard.resonance-compressor` is unchanged. Version 0.2
replaces Amount, Sensitivity, Max cut, Neighborhood, Cut width, and Knee with
Depth, Sharpness, and Selectivity, so projects saved with 0.1 load those
controls at their defaults. Replace the former plugin file with `Patina.clap` and
rescan; avoid two scanned copies with the same ID. Host project recall still
needs verification in FL Studio.
The CLAP ID is now `io.narl.bass-foundry.patina` (earlier builds used
`io.storyboard.resonance-compressor`). Hosts treat Patina as a new plugin, so
add it again in projects saved with an earlier build. Version 0.2 replaced
Amount, Sensitivity, Max cut, Neighborhood, Cut width, and Knee with Depth,
Sharpness, and Selectivity. Delete the old plugin file, place `Patina.clap`, and
rescan FL Studio.
Source is grouped into `plugins/patina/{core,ui,render,plugin}`. See
[research](research.md), [development](../development.md), and the
+8 -7
View File
@@ -7,13 +7,14 @@ workflows are in `.gitea/workflows/`. A plugin's version is the `version` in
| Plugin | Latest tag | Windows download |
| --- | --- | --- |
| Rivet | `rivet-v0.4.0` | `rivet-windows-x86_64-0.4.0.zip` |
| Patina | `patina-v0.2.0` | `patina-windows-x86_64-0.2.0.zip` |
| Anvil | `anvil-v0.1.0` | `anvil-windows-x86_64-0.1.0.zip` |
| Crucible | `crucible-v0.1.0` | `crucible-windows-x86_64-0.1.0.zip` |
| Lathe | `lathe-v0.1.0` | `lathe-windows-x86_64-0.1.0.zip` |
| Temper | `temper-v0.1.0` | `temper-windows-x86_64-0.1.0.zip` |
| Weld | `weld-v0.1.0` | `weld-windows-x86_64-0.1.0.zip` |
| Rivet | `rivet-v0.5.0` | `rivet-windows-x86_64-0.5.0.zip` |
| Patina | `patina-v0.3.0` | `patina-windows-x86_64-0.3.0.zip` |
| Anvil | `anvil-v0.2.0` | `anvil-windows-x86_64-0.2.0.zip` |
| Crucible | `crucible-v0.2.0` | `crucible-windows-x86_64-0.2.0.zip` |
| Lathe | `lathe-v0.2.0` | `lathe-windows-x86_64-0.2.0.zip` |
| Temper | `temper-v0.2.0` | `temper-windows-x86_64-0.2.0.zip` |
| Weld | `weld-v0.2.0` | `weld-windows-x86_64-0.2.0.zip` |
| Knurl | `knurl-v0.2.0` | `knurl-windows-x86_64-0.2.0.zip` |
## Make a release
+6
View File
@@ -1,5 +1,11 @@
# Rivet changelog
## 0.5.0
- Change the CLAP ID to `io.narl.bass-foundry.rivet`. Hosts see this as a new
plugin: delete the old `.clap` file, rescan, and add the plugin again in
existing projects.
## 0.4.0
- Storyboard: a timeline of blocks with roles (statement, answer, departure,
+5 -5
View File
@@ -145,8 +145,8 @@ and silent startup at several sample rates and buffer sizes.
## Existing projects
The name, Cargo packages, source paths, and output filename have changed.
The CLAP ID `io.storyboard.motif`, `story-session-v1` host field, and session
JSON format are preserved. Rescan FL Studio after replacing the old plugin file
with `Rivet.clap`; remove the old copy from scanned folders to avoid duplicate
instances with the same ID. Project recall still needs a check in the target host.
The CLAP ID is now `io.narl.bass-foundry.rivet` (earlier builds used
`io.storyboard.motif`). Hosts treat Rivet as a new plugin, so add it again in
projects saved with an earlier build. The `story-session-v1` host field and the
session JSON format are preserved, so saved sessions still open. Delete the old
plugin file, place `Rivet.clap`, and rescan FL Studio.
+6
View File
@@ -1,5 +1,11 @@
# Temper changelog
## 0.2.0
- Change the CLAP ID to `io.narl.bass-foundry.temper`. Hosts see this as a new
plugin: delete the old `.clap` file, rescan, and add the plugin again in
existing projects.
## 0.1.0
- Introduce Temper, a note-tracked distortion for bass sound design.
+6
View File
@@ -1,5 +1,11 @@
# Weld changelog
## 0.2.0
- Change the CLAP ID to `io.narl.bass-foundry.weld`. Hosts see this as a new
plugin: delete the old `.clap` file, rescan, and add the plugin again in
existing projects.
## 0.1.0
- Introduce Weld, a phase aligner for layered bass.
+1 -1
View File
@@ -72,7 +72,7 @@ checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
[[package]]
name = "anvil"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"anvil-core",
"anvil-ui",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "anvil"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
license = "ISC"
+1 -1
View File
@@ -185,7 +185,7 @@ impl Plugin for Anvil {
}
}
impl ClapPlugin for Anvil {
const CLAP_ID: &'static str = "io.storyboard.sub-anchor";
const CLAP_ID: &'static str = "io.narl.bass-foundry.anvil";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("Replace the sub with a clean sine at one level and phase for every note");
const CLAP_MANUAL_URL: Option<&'static str> = None;
+1 -1
View File
@@ -61,7 +61,7 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
assert_eq!(CStr::from_ptr((*descriptor).name), c"Anvil");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.storyboard.sub-anchor"
c"io.narl.bass-foundry.anvil"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
+23 -7
View File
@@ -6,7 +6,7 @@
"library": "rivet",
"documentation": "docs/rivet",
"standalone": "rivet",
"clap_id": "io.storyboard.motif",
"clap_id": "io.narl.bass-foundry.rivet",
"packages": [
"rivet-core",
"rivet-midi",
@@ -20,7 +20,7 @@
"manifest": "plugins/patina/plugin/Cargo.toml",
"library": "patina",
"documentation": "docs/patina",
"clap_id": "io.storyboard.resonance-compressor",
"clap_id": "io.narl.bass-foundry.patina",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
@@ -37,7 +37,7 @@
"manifest": "plugins/anvil/plugin/Cargo.toml",
"library": "anvil",
"documentation": "docs/anvil",
"clap_id": "io.storyboard.sub-anchor",
"clap_id": "io.narl.bass-foundry.anvil",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
@@ -54,7 +54,7 @@
"manifest": "plugins/crucible/plugin/Cargo.toml",
"library": "crucible",
"documentation": "docs/crucible",
"clap_id": "io.storyboard.resampler",
"clap_id": "io.narl.bass-foundry.crucible",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
@@ -71,7 +71,7 @@
"manifest": "plugins/lathe/plugin/Cargo.toml",
"library": "lathe",
"documentation": "docs/lathe",
"clap_id": "io.storyboard.harmonic-eq",
"clap_id": "io.narl.bass-foundry.lathe",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
@@ -88,7 +88,7 @@
"manifest": "plugins/temper/plugin/Cargo.toml",
"library": "temper",
"documentation": "docs/temper",
"clap_id": "io.storyboard.tracked-distortion",
"clap_id": "io.narl.bass-foundry.temper",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
@@ -105,7 +105,7 @@
"manifest": "plugins/weld/plugin/Cargo.toml",
"library": "weld",
"documentation": "docs/weld",
"clap_id": "io.storyboard.layer-align",
"clap_id": "io.narl.bass-foundry.weld",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
@@ -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.narl.bass-foundry.knurl",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"knurl-core",
"knurl-ui"
]
}
}
+1 -1
View File
@@ -450,7 +450,7 @@ checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crucible"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"clap-sys",
"crucible-core",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "crucible"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
license = "ISC"
+1 -1
View File
@@ -185,7 +185,7 @@ impl Plugin for Crucible {
}
}
impl ClapPlugin for Crucible {
const CLAP_ID: &'static str = "io.storyboard.resampler";
const CLAP_ID: &'static str = "io.narl.bass-foundry.crucible";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("Capture the last bars, slice them and export clean, key-labelled samples");
const CLAP_MANUAL_URL: Option<&'static str> = None;
+4 -1
View File
@@ -59,7 +59,10 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
let descriptor = factory.get_plugin_descriptor.unwrap()(factory, 0);
assert!(!descriptor.is_null());
assert_eq!(CStr::from_ptr((*descriptor).name), c"Crucible");
assert_eq!(CStr::from_ptr((*descriptor).id), c"io.storyboard.resampler");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.narl.bass-foundry.crucible"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
clap_version: CLAP_VERSION,
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "knurl-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
common-dsp = { path = "../../common/dsp" }
serde = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+406
View File
@@ -0,0 +1,406 @@
//! Knurl: texture per cycle of the played note. A cycle clock follows the tracked pitch and
//! restarts at every note; each cycle gets its own step gain (a repeating pattern that
//! makes subharmonics), a random level and timing, and a noise burst tuned to the note.
//! Every amount is counted in cycles, so the texture has the same grain on every note.
//!
//! The audio path is delayed by the tracker lookahead plus the timing-jitter headroom, so
//! the clock already knows each note when its attack leaves the plugin. Allocation-free
//! after `new`; mono-safe (both channels get the same treatment).
mod settings;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{PitchFollower, Tracker, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, hz_to_midi};
pub use settings::{Seed, Settings, Source, GRIT_OFF, PATTERNS, STEPS, STEP_MIN};
/// Cycles kept for the editor's scope, and points per cycle.
pub const SCOPE_CYCLES: usize = 8;
pub const SCOPE_POINTS: usize = 64;
/// Timing jitter can move a cycle by at most this share of the lowest period.
const JITTER_HEADROOM: f32 = 0.125;
/// Effects fade in and out with the note over this time.
const FADE_MS: f32 = 20.;
/// The grit filter is retuned this often, in samples.
const RETUNE: usize = 16;
/// Level follower for scaling the grit to the input.
const LEVEL_MS: f32 = 30.;
#[derive(Clone, Copy, Debug)]
pub struct AnalyzerFrame {
/// Fundamental the clock follows, 0 before the first note.
pub hz: f32,
/// Sounding note, rounded; None between notes.
pub note: Option<u8>,
/// Pattern step of the newest cycle.
pub step: usize,
/// Output of the last `SCOPE_CYCLES` cycles, oldest first, each resampled to
/// `SCOPE_POINTS` points.
pub scope: [[f32; SCOPE_POINTS]; SCOPE_CYCLES],
/// True when the scope holds cycles of a sounding note.
pub scope_valid: bool,
pub input_db: f32,
pub output_db: f32,
pub latency: u32,
pub sample_rate: f32,
}
impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
hz: 0.,
note: None,
step: 0,
scope: [[0.; SCOPE_POINTS]; SCOPE_CYCLES],
scope_valid: false,
input_db: -120.,
output_db: -120.,
latency: 0,
sample_rate: 48000.,
}
}
}
/// Deterministic per-cycle randomness: a hash of (stream, note seed, cycle).
fn hash(stream: u64, seed: u64, cycle: u64) -> u64 {
let mut z = stream
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(seed.wrapping_mul(0xBF58_476D_1CE4_E5B9))
.wrapping_add(cycle.wrapping_mul(0x94D0_49BB_1331_11EB));
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn uniform(stream: u64, seed: u64, cycle: u64) -> f32 {
(hash(stream, seed, cycle) >> 40) as f32 / (1u64 << 24) as f32
}
/// Approximately normal, clamped to ±3 (sum of four uniforms, unit variance).
fn normal(stream: u64, seed: u64, cycle: u64) -> f32 {
let s: f32 = (0..4).map(|i| uniform(stream, seed, cycle * 4 + i)).sum();
((s - 2.) * 3f32.sqrt()).clamp(-3., 3.)
}
/// Raised-cosine weight of the next cycle's value: 0 until `1 − width` of the cycle, then
/// rising to 1 at the boundary.
fn edge(frac: f32, width: f32) -> f32 {
let t = ((frac - (1. - width)) / width).clamp(0., 1.);
0.5 - 0.5 * (std::f32::consts::PI * t).cos()
}
const LEVEL: u64 = 1;
const TIME: u64 = 2;
const NOISE: u64 = 3;
/// What the clock needs, delayed by the jitter headroom so it lines up with the audio.
#[derive(Clone, Copy, Default)]
struct Pitch {
hz: f32,
start: bool,
sounding: bool,
}
pub struct Engine {
rate: f32,
channels: usize,
settings: Settings,
/// Tracker lookahead.
lookahead: usize,
/// Largest timing offset in samples, either way.
headroom: usize,
tracker: Tracker,
pitch: PitchFollower,
pitch_line: Vec<Pitch>,
pitch_at: usize,
audio: Vec<[f32; 2]>,
audio_at: usize,
// Cycle clock.
phase: f64,
cycle: u64,
note_seed: u64,
notes: u64,
noise_at: u64,
amount: f32,
fade: f32,
// Grit.
grit_coef: SvfCoef,
grit_filter: Svf,
countdown: usize,
level: f32,
level_coef: f32,
// Smoothed controls.
smooth: f32,
mix: f32,
output: f32,
bypass: f32,
fresh: bool,
// Editor.
scope: [[f32; SCOPE_POINTS]; SCOPE_CYCLES],
scope_valid: bool,
meter_in: f64,
meter_out: f64,
meter_count: usize,
}
impl Engine {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
if !(rate.is_finite() && (8000.0..=768000.).contains(&rate)) {
return Err("unsupported sample rate");
}
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
let tracker = Tracker::new(rate, TrackerConfig::BASS);
let lookahead = tracker.latency();
let headroom = (JITTER_HEADROOM * rate / TrackerConfig::BASS.min_hz).ceil() as usize;
let settings = Settings::default();
let mut e = Self {
rate,
channels,
settings,
lookahead,
headroom,
tracker,
pitch: PitchFollower::default(),
pitch_line: vec![Pitch::default(); headroom + 1],
pitch_at: 0,
// Reads reach `lookahead + 2·headroom` back, plus the interpolation taps.
audio: vec![[0.; 2]; lookahead + 2 * headroom + 4],
audio_at: 0,
phase: 0.,
cycle: 0,
note_seed: 0,
notes: 0,
noise_at: 0,
amount: 0.,
fade: coefficient(rate, FADE_MS),
grit_coef: SvfCoef::IDENTITY,
grit_filter: Svf::default(),
countdown: 0,
level: 0.,
level_coef: coefficient(rate, LEVEL_MS),
smooth: coefficient(rate, 10.),
mix: 1.,
output: 1.,
bypass: 0.,
fresh: true,
scope: [[0.; SCOPE_POINTS]; SCOPE_CYCLES],
scope_valid: false,
meter_in: 0.,
meter_out: 0.,
meter_count: 0,
};
e.set_settings(settings)?;
e.fresh = true;
Ok(e)
}
pub fn latency_samples(&self) -> u32 {
(self.lookahead + self.headroom) as u32
}
pub fn channels(&self) -> usize {
self.channels
}
pub fn settings(&self) -> Settings {
self.settings
}
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
s.validate()?;
self.tracker.set(s.source == Source::Midi, s.gate, false);
self.settings = s;
if std::mem::take(&mut self.fresh) {
self.mix = s.mix * 0.01;
self.output = db_to_gain(s.output);
self.bypass = f32::from(s.bypass);
}
Ok(())
}
pub fn reset(&mut self) {
self.audio.fill([0.; 2]);
self.pitch_line.fill(Pitch::default());
self.pitch = PitchFollower::default();
self.tracker.reset();
self.grit_filter.reset();
self.phase = 0.;
self.cycle = 0;
self.notes = 0;
self.note_seed = 0;
self.noise_at = 0;
self.amount = 0.;
self.level = 0.;
self.countdown = 0;
self.scope_valid = false;
self.fresh = true;
}
pub fn note_on(&mut self, note: u8) {
self.tracker.note_on(note);
}
pub fn note_off(&mut self, note: u8) {
self.tracker.note_off(note);
}
/// The audio `back` samples (fractional) before the newest one, cubic Hermite.
#[inline]
fn read(&self, back: f32) -> [f32; 2] {
let len = self.audio.len();
let whole = back.floor();
let t = back - whole;
let base = whole as usize;
let at = |back: usize| self.audio[(self.audio_at + len - back % len) % len];
// y0 is one sample newer than y1; y2 and y3 are older.
let (newer, p1, p2, p3) = (at(base.max(1) - 1), at(base), at(base + 1), at(base + 2));
let mut out = [0.; 2];
for c in 0..2 {
let (y0, y1, y2, y3) = (newer[c], p1[c], p2[c], p3[c]);
let c1 = 0.5 * (y2 - y0);
let c2 = y0 - 2.5 * y1 + 2. * y2 - 0.5 * y3;
let c3 = 0.5 * (y3 - y0) + 1.5 * (y1 - y2);
out[c] = ((c3 * t + c2) * t + c1) * t + y1;
}
out
}
pub fn process_sample(&mut self, x: [f32; 2]) -> [f32; 2] {
let mono = if self.channels == 2 {
0.5 * (x[0] + x[1])
} else {
x[0]
};
let (pitch, snap) = (&mut self.pitch, self.settings.snap);
self.tracker.process(mono, |e| pitch.event(snap, e));
let (hz, jump) = self.pitch.step(1.);
// Tracker events are timed for `lookahead`; the audio leaves `headroom` later.
let len = self.pitch_line.len();
self.pitch_line[self.pitch_at] = Pitch {
hz,
start: jump,
sounding: self.tracker.sounding(),
};
self.pitch_at = (self.pitch_at + 1) % len;
let p = self.pitch_line[self.pitch_at];
let len = self.audio.len();
self.audio_at = (self.audio_at + 1) % len;
self.audio[self.audio_at] = x;
let centre = (self.lookahead + self.headroom) as f32;
let dry = self.read(centre);
// Cycle clock.
if p.start {
self.phase = 0.;
self.cycle = 0;
self.notes += 1;
self.noise_at = 0;
self.note_seed = match self.settings.seed {
Seed::Same => 0,
Seed::Free => self.notes,
};
self.countdown = 0;
}
let active = p.sounding && p.hz > 0.;
self.amount += (f32::from(active) - self.amount) * self.fade;
let s = self.settings;
let frac = self.phase.fract() as f32;
let c = self.cycle;
let seed = self.note_seed;
let step = |k: u64| (k % s.steps as u64) as usize;
// Pattern and level jitter, crossfaded into the next cycle.
let w = edge(frac, s.edge * 0.01);
let jitter_w = edge(frac, 0.5);
let pattern = |k: u64| db_to_gain(s.pattern[step(k)]);
let wobble = |k: u64| db_to_gain(s.level_jitter * normal(LEVEL, seed, k));
let gain = (pattern(c) * (1. - w) + pattern(c + 1) * w)
* (wobble(c) * (1. - jitter_w) + wobble(c + 1) * jitter_w);
let gain = 1. + self.amount * (gain - 1.);
// Timing jitter: an offset per cycle, blended across the whole cycle.
let period = if p.hz > 0. { self.rate / p.hz } else { 0. };
let shift = |k: u64| normal(TIME, seed, k) * s.time_jitter * 0.01 * period;
let tw = edge(frac, 1.);
let offset = (shift(c) * (1. - tw) + shift(c + 1) * tw) * self.amount;
let offset = offset.clamp(-(self.headroom as f32), self.headroom as f32);
let moved = if offset == 0. {
dry
} else {
self.read(centre + offset)
};
// Grit: noise above `tone` harmonics, in a sin² window at `position` of each cycle.
let mut grit = 0.;
if s.grit > GRIT_OFF && p.hz > 0. && s.grit_steps[step(c)] {
if self.countdown == 0 {
let f = (s.tone * p.hz).min(0.45 * self.rate);
self.grit_coef = SvfCoef::highpass(self.rate, f, std::f32::consts::FRAC_1_SQRT_2);
self.countdown = RETUNE;
}
self.countdown -= 1;
self.noise_at = self.noise_at.wrapping_add(1);
let n = 2. * uniform(NOISE, seed, self.noise_at) - 1.;
let hp = self.grit_filter.process(&self.grit_coef, n * 3f32.sqrt());
let d = (frac - s.position * 0.01).rem_euclid(1.);
let l = s.length * 0.01;
if d < l {
let window = (std::f32::consts::PI * d / l).sin().powi(2);
grit = hp * window * self.level.sqrt() * db_to_gain(s.grit) * self.amount;
}
}
let m = if self.channels == 2 {
0.5 * (dry[0] + dry[1])
} else {
dry[0]
};
self.level += (m * m - self.level) * self.level_coef;
// Advance the clock.
if p.hz > 0. {
self.phase += (p.hz / self.rate) as f64;
let whole = self.phase.floor() as u64;
if whole > 0 {
self.phase -= whole as f64;
self.cycle += whole;
self.scope
.rotate_left(whole.min(SCOPE_CYCLES as u64) as usize);
self.scope[SCOPE_CYCLES - 1] = [0.; SCOPE_POINTS];
}
}
self.mix += (s.mix * 0.01 - self.mix) * self.smooth;
self.output += (db_to_gain(s.output) - self.output) * self.smooth;
self.bypass += (f32::from(s.bypass) - self.bypass) * self.smooth;
let mut out = [0.; 2];
for ch in 0..self.channels {
let wet = moved[ch] * gain + grit;
let mixed = (self.mix * wet + (1. - self.mix) * dry[ch]) * self.output;
out[ch] = mixed * (1. - self.bypass) + dry[ch] * self.bypass;
}
let o = if self.channels == 2 {
0.5 * (out[0] + out[1])
} else {
out[0]
};
if active {
let i = ((frac * SCOPE_POINTS as f32) as usize).min(SCOPE_POINTS - 1);
self.scope[SCOPE_CYCLES - 1][i] = o;
self.scope_valid = true;
}
self.meter_in += (m * m) as f64;
self.meter_out += (o * o) as f64;
self.meter_count += 1;
out
}
/// Editor data; levels are averaged since the previous call.
pub fn take_analyzer(&mut self) -> AnalyzerFrame {
let n = self.meter_count.max(1) as f64;
let db = |p: f64| (10. * (p / n).max(1e-12).log10()).max(-120.) as f32;
let mut f = AnalyzerFrame {
hz: self.pitch.hz(),
note: None,
step: (self.cycle % self.settings.steps as u64) as usize,
scope: self.scope,
scope_valid: self.scope_valid,
input_db: db(self.meter_in),
output_db: db(self.meter_out),
latency: self.latency_samples(),
sample_rate: self.rate,
};
if self.tracker.sounding() && f.hz > 0. {
f.note = Some(hz_to_midi(f.hz).round().clamp(0., 127.) as u8);
}
self.meter_in = 0.;
self.meter_out = 0.;
self.meter_count = 0;
f
}
}
+166
View File
@@ -0,0 +1,166 @@
//! Host-independent controls. Gains in dB; amounts relative to one cycle in percent.
use serde::{Deserialize, Serialize};
/// Pattern steps: one per cycle, repeating.
pub const STEPS: usize = 16;
pub const STEP_MIN: f32 = -24.;
/// Grit at or below this level is off.
pub const GRIT_OFF: f32 = -60.;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Source {
/// Pitch and note starts are detected from the incoming bass.
#[default]
Audio,
/// Notes come from MIDI note input.
Midi,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Seed {
/// Every note gets the same random sequence, so identical notes sound identical.
#[default]
Same,
/// The random sequences keep running from note to note.
Free,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub source: Source,
/// Gain per pattern step, 0 down to `STEP_MIN`.
pub pattern: [f32; STEPS],
/// Steps that get a grit burst.
pub grit_steps: [bool; STEPS],
/// Pattern length in cycles, 1..=STEPS.
pub steps: usize,
/// Crossfade into the next step, as a share of a cycle.
pub edge: f32,
/// Random level per cycle, standard deviation in dB.
pub level_jitter: f32,
/// Random timing per cycle, standard deviation in percent of a period.
pub time_jitter: f32,
/// Grit burst level relative to the input, dB; `GRIT_OFF` or lower is off.
pub grit: f32,
/// Grit highpass, in harmonics of the note.
pub tone: f32,
/// Where in the cycle the grit burst sits, percent.
pub position: f32,
/// Grit burst length, percent of a cycle.
pub length: f32,
pub seed: Seed,
/// Detection threshold in dBFS for note starts (Audio source).
pub gate: f32,
/// Round detected pitch to the nearest semitone.
pub snap: bool,
pub mix: f32,
pub output: f32,
pub bypass: bool,
}
/// Named step patterns: (name, help, gains). Missing steps are 0 dB.
pub const PATTERNS: [(&str, &str, &[f32]); 6] = [
("Off", "Every cycle at full level.", &[0.]),
(
"Double",
"Every second cycle quieter: a sub-octave growl.",
&[0., -9.],
),
(
"Triple",
"One loud cycle, two quiet: a growl an octave and a fifth down.",
&[0., -12., -12.],
),
(
"Fry",
"Uneven four-cycle pattern: vocal-fry rattle.",
&[0., -14., -5., -14.],
),
(
"Gallop",
"Loud, quiet, loud, then three quiet: a rolling gallop.",
&[0., -18., 0., -18., -18., -18.],
),
(
"Stutter",
"Bursts of cycles with gaps: a gated stutter.",
&[0., 0., -24., -24., 0., -24., -24., -24.],
),
];
impl Settings {
/// Applies a named pattern: its gains, its length, grit on every step.
pub fn apply_pattern(&mut self, gains: &[f32]) {
self.pattern = [0.; STEPS];
for (p, g) in self.pattern.iter_mut().zip(gains) {
*p = *g;
}
self.steps = gains.len().clamp(1, STEPS);
}
}
impl Default for Settings {
fn default() -> Self {
let mut s = Self {
source: Source::Audio,
pattern: [0.; STEPS],
grit_steps: [true; STEPS],
steps: 1,
edge: 15.,
level_jitter: 1.,
time_jitter: 3.,
grit: -9.,
tone: 12.,
position: 10.,
length: 12.,
seed: Seed::Same,
gate: -42.,
snap: true,
mix: 100.,
output: 0.,
bypass: false,
};
s.apply_pattern(PATTERNS[3].2);
s
}
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
let finite = [
self.edge,
self.level_jitter,
self.time_jitter,
self.grit,
self.tone,
self.position,
self.length,
self.gate,
self.mix,
self.output,
]
.iter()
.chain(self.pattern.iter())
.all(|v| v.is_finite());
if !finite {
return Err("non-finite setting");
}
let ok = self.pattern.iter().all(|g| (STEP_MIN..=0.).contains(g))
&& (1..=STEPS).contains(&self.steps)
&& (5.0..=50.).contains(&self.edge)
&& (0.0..=6.).contains(&self.level_jitter)
&& (0.0..=10.).contains(&self.time_jitter)
&& (GRIT_OFF..=0.).contains(&self.grit)
&& (4.0..=64.).contains(&self.tone)
&& (0.0..=100.).contains(&self.position)
&& (2.0..=50.).contains(&self.length)
&& (-60.0..=-20.).contains(&self.gate)
&& (0.0..=100.).contains(&self.mix)
&& (-12.0..=12.).contains(&self.output);
if ok {
Ok(())
} else {
Err("setting out of range")
}
}
}
+232
View File
@@ -0,0 +1,232 @@
use common_dsp::{midi_to_hz, Saw};
use knurl_core::{Engine, Seed, Settings, Source, GRIT_OFF, PATTERNS};
const RATE: f32 = 48000.;
/// A tanh saw bass at `note`, as the prototype used.
fn bass(note: u8, n: usize) -> Vec<f32> {
let mut saw = Saw::new(RATE, midi_to_hz(note as f32));
(0..n).map(|_| (2.2 * saw.sample()).tanh() * 0.5).collect()
}
fn plain() -> Settings {
let mut s = Settings {
source: Source::Midi,
level_jitter: 0.,
time_jitter: 0.,
grit: GRIT_OFF,
..Settings::default()
};
s.apply_pattern(PATTERNS[0].2);
s
}
/// Runs one held note from sample 0 and returns the output, aligned to the input.
fn run(settings: Settings, note: u8, input: &[f32]) -> Vec<f32> {
let mut engine = Engine::new(RATE, 1).unwrap();
engine.set_settings(settings).unwrap();
let latency = engine.latency_samples() as usize;
engine.note_on(note);
let mut out: Vec<f32> = input
.iter()
.chain(std::iter::repeat_n(&0., latency))
.map(|&x| engine.process_sample([x, 0.])[0])
.collect();
out.drain(..latency);
out
}
/// Level of the component at `hz` (single-bin DFT with a Hann window), dB.
fn level(x: &[f32], hz: f32) -> f64 {
let n = x.len();
let (mut re, mut im) = (0f64, 0f64);
for (i, &v) in x.iter().enumerate() {
let w = 0.5 - 0.5 * (std::f64::consts::TAU * i as f64 / n as f64).cos();
let a = std::f64::consts::TAU * hz as f64 * i as f64 / RATE as f64;
re += w * v as f64 * a.cos();
im -= w * v as f64 * a.sin();
}
10. * (re * re + im * im).max(1e-30).log10()
}
fn subharmonic(out: &[f32], note: u8, divisor: f32) -> f64 {
let f = midi_to_hz(note as f32);
let seg = &out[4800..out.len() - 4800];
level(seg, f / divisor) - level(seg, f)
}
fn max_step(x: &[f32]) -> f32 {
x.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0., f32::max)
}
#[test]
fn everything_off_is_the_delayed_input() {
let input = bass(29, 24000);
let out = run(plain(), 29, &input);
assert_eq!(out, input);
}
#[test]
fn patterns_make_tuned_subharmonics_without_clicks() {
let input = bass(29, 48000);
let base = run(plain(), 29, &input);
assert!(subharmonic(&base, 29, 2.) < -50.);
for (pattern, divisor, expected) in [(1, 2., -8.2), (2, 3., -10.7)] {
let mut s = plain();
s.apply_pattern(PATTERNS[pattern].2);
let out = run(s, 29, &input);
let sub = subharmonic(&out, 29, divisor);
assert!(
(sub - expected).abs() < 0.5,
"{}: {sub}",
PATTERNS[pattern].0
);
assert!(
max_step(&out) <= 1.05 * max_step(&input),
"{}",
PATTERNS[pattern].0
);
}
}
#[test]
fn the_texture_is_the_same_on_every_note() {
let mut s = plain();
s.apply_pattern(PATTERNS[1].2);
let low = run(s, 29, &bass(29, 48000));
let high = run(s, 36, &bass(36, 48000));
let (a, b) = (subharmonic(&low, 29, 2.), subharmonic(&high, 36, 2.));
assert!((a - b).abs() < 0.3, "F1 {a} C2 {b}");
}
#[test]
fn same_seed_repeats_every_note_and_free_does_not() {
for (seed, identical) in [(Seed::Same, true), (Seed::Free, false)] {
let s = Settings {
source: Source::Midi,
seed,
..Settings::default()
};
let mut engine = Engine::new(RATE, 2).unwrap();
engine.set_settings(s).unwrap();
let latency = engine.latency_samples() as usize;
let note = bass(29, 12000);
let mut out = Vec::new();
let starts = [0, 36000];
for i in 0..72000 + latency {
if starts.contains(&i) {
engine.note_on(29);
}
if i == starts[0] + 12000 || i == starts[1] + 12000 {
engine.note_off(29);
}
let k = starts
.iter()
.filter(|&&s| i >= s)
.map(|&s| i - s)
.min()
.unwrap();
let x = note.get(k).copied().unwrap_or(0.);
out.push(engine.process_sample([x, x])[0]);
}
let first = &out[latency..latency + 12000];
let second = &out[starts[1] + latency..starts[1] + latency + 12000];
let largest = first
.iter()
.zip(second)
.map(|(a, b)| (a - b).abs())
.fold(0f32, f32::max);
if identical {
assert!(largest < 1e-4, "{seed:?}: {largest}");
} else {
assert!(largest > 1e-2, "{seed:?}: {largest}");
}
assert!(out.iter().all(|v| v.is_finite()));
}
}
#[test]
fn grit_follows_the_input_level_and_stops_with_the_note() {
let mut s = plain();
s.grit = -6.;
let input = bass(29, 24000);
let out = run(s, 29, &input);
let noise: f64 = out
.iter()
.zip(&input)
.skip(4800)
.map(|(o, i)| ((o - i) as f64).powi(2))
.sum::<f64>()
/ (out.len() - 4800) as f64;
let signal: f64 = input
.iter()
.skip(4800)
.map(|v| (*v as f64).powi(2))
.sum::<f64>()
/ (out.len() - 4800) as f64;
let ratio = 10. * (noise / signal).log10();
// −6 dB bursts over 12 % of each cycle; the sin² window keeps 3/8 of the power:
// −6 + 10·log10(0.12 · 0.375) = −19.5 dB.
assert!((ratio + 19.5).abs() < 1.5, "{ratio}");
// Silence in, silence out: no grit without a note, and no NaN.
let mut engine = Engine::new(RATE, 2).unwrap();
engine.set_settings(Settings::default()).unwrap();
for _ in 0..48000 {
let out = engine.process_sample([0., 0.]);
assert_eq!(out, [0., 0.]);
}
}
#[test]
fn audio_source_finds_the_note_and_reports_latency() {
let mut s = plain();
s.source = Source::Audio;
s.apply_pattern(PATTERNS[1].2);
let mut engine = Engine::new(RATE, 1).unwrap();
engine.set_settings(s).unwrap();
let latency = engine.latency_samples() as usize;
// About 67 ms of lookahead plus 4.2 ms of jitter headroom.
assert!((latency as f32 / RATE - 0.071).abs() < 0.005, "{latency}");
let input = bass(29, 48000);
let mut out: Vec<f32> = input
.iter()
.map(|&x| engine.process_sample([x, 0.])[0])
.collect();
out.drain(..latency);
let frame = engine.take_analyzer();
assert_eq!(frame.note, Some(29));
assert!(frame.scope_valid);
let sub = subharmonic(&out[..out.len() - 4800], 29, 2.);
assert!(sub > -14., "{sub}");
}
#[test]
fn invalid_settings_are_rejected() {
let mut engine = Engine::new(RATE, 2).unwrap();
for bad in [
Settings {
steps: 0,
..Settings::default()
},
Settings {
edge: 60.,
..Settings::default()
},
Settings {
grit: f32::NAN,
..Settings::default()
},
Settings {
time_jitter: 11.,
..Settings::default()
},
] {
assert!(engine.set_settings(bad).is_err());
}
let mut s = Settings::default();
s.pattern[3] = -30.;
assert!(engine.set_settings(s).is_err());
assert!(Engine::new(1000., 2).is_err());
assert!(Engine::new(48000., 3).is_err());
let json = serde_json::to_string(&Settings::default()).unwrap();
assert_eq!(
serde_json::from_str::<Settings>(&json).unwrap(),
Settings::default()
);
}
+70
View File
@@ -0,0 +1,70 @@
use knurl_core::{Engine, Seed, Settings, Source, PATTERNS};
use std::{
alloc::{GlobalAlloc, Layout, System},
cell::Cell,
};
struct CheckedAllocator;
thread_local! {
static CHECK:Cell<bool>=const {Cell::new(false)};
static OPERATIONS:Cell<usize>=const {Cell::new(0)};
}
fn record() {
if CHECK.try_with(Cell::get).unwrap_or(false) {
let _ = OPERATIONS.try_with(|n| n.set(n.get() + 1));
}
}
unsafe impl GlobalAlloc for CheckedAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
record();
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
record();
unsafe { System.dealloc(pointer, layout) }
}
unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 {
record();
unsafe { System.realloc(pointer, layout, size) }
}
}
#[global_allocator]
static ALLOCATOR: CheckedAllocator = CheckedAllocator;
#[test]
fn notes_patterns_grit_and_jitter_do_not_allocate_or_free() {
let mut engine = Engine::new(48000., 2).unwrap();
let mut settings = Settings::default();
engine.set_settings(settings).unwrap();
CHECK.with(|v| v.set(true));
for i in 0..96000 {
if i % 4800 == 0 {
settings.apply_pattern(PATTERNS[(i / 4800) % PATTERNS.len()].2);
settings.seed = if i > 48000 { Seed::Free } else { Seed::Same };
settings.source = if (i / 4800) % 3 == 0 {
Source::Midi
} else {
Source::Audio
};
settings.time_jitter = ((i / 4800) % 10) as f32;
engine.set_settings(settings).unwrap();
engine.note_on(30 + (i / 4800 % 12) as u8);
}
if i % 4800 == 2400 {
engine.note_off(30 + (i / 4800 % 12) as u8);
}
let t = i as f32 / 48000.;
let x = if (i / 2400) % 2 == 0 {
(std::f32::consts::TAU * 55. * t).sin() * 0.5
} else {
0.
};
std::hint::black_box(engine.process_sample([x, x]));
if i % 1600 == 0 {
std::hint::black_box(engine.take_analyzer());
}
}
engine.reset();
CHECK.with(|v| v.set(false));
assert_eq!(OPERATIONS.with(Cell::get), 0);
}
+2382
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "knurl"
version = "0.2.0"
edition = "2021"
license = "ISC"
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" }
knurl-core = { path = "../core" }
knurl-ui = { path = "../ui" }
crossbeam-queue = "0.3"
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" }
serde_json = "1"
[profile.release]
lto = "thin"
strip = "symbols"
+195
View File
@@ -0,0 +1,195 @@
//! CLAP audio effect. All DSP state is owned by the processing thread.
mod params;
use crossbeam_queue::ArrayQueue;
use knurl_core::{AnalyzerFrame, Engine};
use nih_plug::prelude::*;
use nih_plug_egui::{create_egui_editor, egui};
use params::{KnurlParams, Pattern, GESTURES};
use std::sync::Arc;
struct EditorData {
ui: knurl_ui::State,
gestures: [bool; GESTURES],
}
pub struct Knurl {
params: Arc<KnurlParams>,
analyzer: Arc<ArrayQueue<AnalyzerFrame>>,
engine: Option<Engine>,
pattern: Pattern,
publish_count: usize,
publish_interval: usize,
}
impl Default for Knurl {
fn default() -> Self {
Self {
params: Arc::new(KnurlParams::default()),
analyzer: Arc::new(ArrayQueue::new(2)),
engine: None,
pattern: Pattern::default(),
publish_count: 0,
publish_interval: 1600,
}
}
}
impl Plugin for Knurl {
const NAME: &'static str = "Knurl";
const VENDOR: &'static str = "Bass Foundry";
const URL: &'static str = "";
const EMAIL: &'static str = "";
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[
AudioIOLayout {
main_input_channels: NonZeroU32::new(2),
main_output_channels: NonZeroU32::new(2),
..AudioIOLayout::const_default()
},
AudioIOLayout {
main_input_channels: NonZeroU32::new(1),
main_output_channels: NonZeroU32::new(1),
..AudioIOLayout::const_default()
},
];
const MIDI_INPUT: MidiConfig = MidiConfig::Basic;
const SAMPLE_ACCURATE_AUTOMATION: bool = false;
type SysExMessage = ();
type BackgroundTask = ();
fn params(&self) -> Arc<dyn Params> {
self.params.clone()
}
fn editor(&mut self, _: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
let params = self.params.clone();
let analyzer = self.analyzer.clone();
create_egui_editor(
params.editor.clone(),
EditorData {
ui: knurl_ui::State::default(),
gestures: [false; GESTURES],
},
|ctx, _| knurl_ui::style(ctx),
move |ctx, setter, data| {
while let Some(frame) = analyzer.pop() {
data.ui.accept_frame(frame);
}
let current = params.pattern();
let before = params.settings(&current);
let mut edited = before;
nih_plug_egui::resizable_window::ResizableWindow::new("knurl-editor")
.min_size(egui::vec2(
knurl_ui::WIDTH as f32 - 80.,
knurl_ui::HEIGHT as f32 - 40.,
))
.show(ctx, &params.editor, |ui| {
knurl_ui::show(ui, &mut edited, &mut data.ui)
});
params.apply(
setter,
&before,
&edited,
data.ui.gesture_active,
&mut data.gestures,
);
// The scope follows the audio.
ctx.request_repaint_after(std::time::Duration::from_millis(40));
},
)
}
fn initialize(
&mut self,
layout: &AudioIOLayout,
config: &BufferConfig,
context: &mut impl InitContext<Self>,
) -> bool {
let Ok(mut engine) = Engine::new(
config.sample_rate,
layout.main_input_channels.map_or(0, |v| v.get() as usize),
) else {
return false;
};
let settings = self.params.settings(&self.pattern);
self.pattern = Pattern {
gains: settings.pattern,
grit: settings.grit_steps,
};
if engine.set_settings(settings).is_err() {
return false;
}
context.set_latency_samples(engine.latency_samples());
self.publish_interval = (config.sample_rate / 30.) as usize;
self.engine = Some(engine);
true
}
fn reset(&mut self) {
if let Some(e) = self.engine.as_mut() {
e.reset();
}
self.publish_count = 0;
}
fn process(
&mut self,
buffer: &mut Buffer,
_: &mut AuxiliaryBuffers,
context: &mut impl ProcessContext<Self>,
) -> ProcessStatus {
let Some(engine) = self.engine.as_mut() else {
return ProcessStatus::Normal;
};
let settings = self.params.settings(&self.pattern);
self.pattern = Pattern {
gains: settings.pattern,
grit: settings.grit_steps,
};
let _ = engine.set_settings(settings);
let mut next = context.next_event();
for (index, mut sample) in buffer.iter_samples().enumerate() {
while let Some(event) = next {
if event.timing() as usize > index {
break;
}
match event {
NoteEvent::NoteOn { note, velocity, .. } if velocity > 0. => {
engine.note_on(note)
}
NoteEvent::NoteOn { note, .. } | NoteEvent::NoteOff { note, .. } => {
engine.note_off(note)
}
NoteEvent::Choke { note, .. } => engine.note_off(note),
_ => {}
}
next = context.next_event();
}
let x = [
*sample.get_mut(0).unwrap(),
if engine.channels() == 2 {
*sample.get_mut(1).unwrap()
} else {
0.
},
];
let out = engine.process_sample(x);
for (c, value) in sample.into_iter().enumerate() {
*value = out[c];
}
self.publish_count += 1;
if self.publish_count >= self.publish_interval {
self.analyzer.force_push(engine.take_analyzer());
self.publish_count = 0;
}
}
// The lookahead delay and filter tails must drain after input stops.
ProcessStatus::KeepAlive
}
}
impl ClapPlugin for Knurl {
const CLAP_ID: &'static str = "io.narl.bass-foundry.knurl";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("Texture per cycle of the played note: patterns, jitter and grit that fit every note");
const CLAP_MANUAL_URL: Option<&'static str> = None;
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::AudioEffect,
ClapFeature::Distortion,
ClapFeature::Stereo,
ClapFeature::Mono,
];
}
nih_export_clap!(Knurl);
+255
View File
@@ -0,0 +1,255 @@
use knurl_core::{Seed, Settings, Source, GRIT_OFF, STEPS, STEP_MIN};
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use std::sync::{Arc, RwLock};
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SourceParam {
#[id = "audio"]
Audio,
#[id = "midi"]
#[name = "MIDI"]
Midi,
}
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SeedParam {
#[id = "same"]
#[name = "Same every note"]
Same,
#[id = "free"]
Free,
}
fn linear(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam {
FloatParam::new(name, v, FloatRange::Linear { min, max }).with_unit(unit)
}
/// Continuous controls, in the order of the gesture slots.
const FLOATS: usize = 10;
pub const GESTURES: usize = FLOATS;
/// The drawn pattern: step gains and grit switches. Saved with the project, not automation.
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Pattern {
pub gains: [f32; STEPS],
pub grit: [bool; STEPS],
}
impl Default for Pattern {
fn default() -> Self {
let d = Settings::default();
Self {
gains: d.pattern,
grit: d.grit_steps,
}
}
}
#[derive(Params)]
pub struct KnurlParams {
#[persist = "editor"]
pub editor: Arc<EguiState>,
#[persist = "knurl-pattern"]
pub pattern: RwLock<Pattern>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[id = "seed"]
pub seed: EnumParam<SeedParam>,
#[id = "steps"]
pub steps: IntParam,
#[id = "edge"]
pub edge: FloatParam,
#[id = "level-jitter"]
pub level_jitter: FloatParam,
#[id = "time-jitter"]
pub time_jitter: FloatParam,
#[id = "grit"]
pub grit: FloatParam,
#[id = "tone"]
pub tone: FloatParam,
#[id = "position"]
pub position: FloatParam,
#[id = "length"]
pub length: FloatParam,
#[id = "gate"]
pub gate: FloatParam,
#[id = "mix"]
pub mix: FloatParam,
#[id = "output"]
pub output: FloatParam,
#[id = "snap"]
pub snap: BoolParam,
#[id = "bypass"]
pub bypass: BoolParam,
}
impl Default for KnurlParams {
fn default() -> Self {
let d = Settings::default();
Self {
editor: EguiState::from_size(knurl_ui::WIDTH, knurl_ui::HEIGHT),
pattern: RwLock::new(Pattern::default()),
source: EnumParam::new("Pitch source", SourceParam::Audio),
seed: EnumParam::new("Random", SeedParam::Same),
steps: IntParam::new(
"Steps",
d.steps as i32,
IntRange::Linear {
min: 1,
max: STEPS as i32,
},
),
edge: linear("Edge", d.edge, 5., 50., " %"),
level_jitter: linear("Level jitter", d.level_jitter, 0., 6., " dB"),
time_jitter: linear("Time jitter", d.time_jitter, 0., 10., " %"),
grit: linear("Grit", d.grit, GRIT_OFF, 0., " dB").with_value_to_string(Arc::new(|v| {
if v <= GRIT_OFF {
"Off".into()
} else {
format!("{v:.1}")
}
})),
tone: FloatParam::new(
"Tone",
d.tone,
FloatRange::Skewed {
min: 4.,
max: 64.,
factor: FloatRange::skew_factor(-1.),
},
)
.with_unit(" ×f0"),
position: linear("Position", d.position, 0., 100., " %"),
length: linear("Length", d.length, 2., 50., " %"),
gate: linear("Gate", d.gate, -60., -20., " dB"),
mix: linear("Mix", d.mix, 0., 100., " %"),
output: linear("Output", d.output, -12., 12., " dB"),
snap: BoolParam::new("Snap to semitone", d.snap),
bypass: BoolParam::new("Bypass", false).make_bypass(),
}
}
}
impl KnurlParams {
/// Current settings. The pattern falls back to `last` when the editor holds the lock,
/// so the audio thread never waits.
pub fn settings(&self, last: &Pattern) -> Settings {
let pattern = self.pattern.try_read().map(|p| *p).unwrap_or(*last);
Settings {
source: match self.source.value() {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
},
pattern: pattern.gains.map(|g| {
if g.is_finite() {
g.clamp(STEP_MIN, 0.)
} else {
0.
}
}),
grit_steps: pattern.grit,
steps: self.steps.value().clamp(1, STEPS as i32) as usize,
edge: self.edge.value(),
level_jitter: self.level_jitter.value(),
time_jitter: self.time_jitter.value(),
grit: self.grit.value(),
tone: self.tone.value(),
position: self.position.value(),
length: self.length.value(),
seed: match self.seed.value() {
SeedParam::Same => Seed::Same,
SeedParam::Free => Seed::Free,
},
gate: self.gate.value(),
snap: self.snap.value(),
mix: self.mix.value(),
output: self.output.value(),
bypass: self.bypass.value(),
}
}
pub fn pattern(&self) -> Pattern {
self.pattern.read().map(|p| *p).unwrap_or_default()
}
fn floats(&self) -> [&FloatParam; FLOATS] {
[
&self.edge,
&self.level_jitter,
&self.time_jitter,
&self.grit,
&self.tone,
&self.position,
&self.length,
&self.gate,
&self.mix,
&self.output,
]
}
/// Pushes every changed editor value to the host. Continuous controls hold one gesture
/// while the pointer is down; switches are set in a single gesture. The pattern is
/// stored directly.
pub fn apply(
&self,
setter: &ParamSetter,
old: &Settings,
new: &Settings,
dragging: bool,
open: &mut [bool; FLOATS],
) {
if new.pattern != old.pattern || new.grit_steps != old.grit_steps {
if let Ok(mut p) = self.pattern.write() {
*p = Pattern {
gains: new.pattern,
grit: new.grit_steps,
};
}
}
let values = [
(old.edge, new.edge),
(old.level_jitter, new.level_jitter),
(old.time_jitter, new.time_jitter),
(old.grit, new.grit),
(old.tone, new.tone),
(old.position, new.position),
(old.length, new.length),
(old.gate, new.gate),
(old.mix, new.mix),
(old.output, new.output),
];
for (i, (p, (a, b))) in self.floats().into_iter().zip(values).enumerate() {
if a != b {
if !open[i] {
open[i] = true;
setter.begin_set_parameter(p);
}
setter.set_parameter(p, b);
}
if !dragging && open[i] {
setter.end_set_parameter(p);
open[i] = false;
}
}
fn switch<P: Param>(setter: &ParamSetter, p: &P, v: P::Plain) {
setter.begin_set_parameter(p);
setter.set_parameter(p, v);
setter.end_set_parameter(p);
}
if new.steps != old.steps {
switch(setter, &self.steps, new.steps as i32);
}
if new.snap != old.snap {
switch(setter, &self.snap, new.snap);
}
if new.bypass != old.bypass {
switch(setter, &self.bypass, new.bypass);
}
if new.source != old.source {
let v = match new.source {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
};
switch(setter, &self.source, v);
}
if new.seed != old.seed {
let v = match new.seed {
Seed::Same => SeedParam::Same,
Seed::Free => SeedParam::Free,
};
switch(setter, &self.seed, v);
}
}
}
+227
View File
@@ -0,0 +1,227 @@
//! Exercise the actual exported CLAP ABI without requiring a GUI or installed DAW.
use clap_sys::{
audio_buffer::*,
events::*,
ext::{audio_ports::*, latency::*, state::*},
factory::plugin_factory::*,
host::*,
process::*,
stream::*,
version::*,
};
use std::{
ffi::{c_char, c_void, CStr},
io::{Cursor, Read},
ptr,
};
unsafe extern "C" fn extension(_: *const clap_host, _: *const c_char) -> *const c_void {
ptr::null()
}
unsafe extern "C" fn request(_: *const clap_host) {}
unsafe extern "C" fn count(_: *const clap_input_events) -> u32 {
0
}
unsafe extern "C" fn event(_: *const clap_input_events, _: u32) -> *const clap_event_header {
ptr::null()
}
unsafe extern "C" fn push(_: *const clap_output_events, _: *const clap_event_header) -> bool {
true
}
unsafe extern "C" fn write(stream: *const clap_ostream, buffer: *const c_void, size: u64) -> i64 {
// SAFETY: The test owns the Vec for the entire synchronous save call.
let bytes = unsafe { &mut *((*stream).ctx as *mut Vec<u8>) };
bytes.extend_from_slice(unsafe {
std::slice::from_raw_parts(buffer as *const u8, size as usize)
});
size as i64
}
unsafe extern "C" fn read(stream: *const clap_istream, buffer: *mut c_void, size: u64) -> i64 {
// SAFETY: The test owns the Cursor; the wrapper supplies writable stream storage.
let cursor = unsafe { &mut *((*stream).ctx as *mut Cursor<Vec<u8>>) };
cursor
.read(unsafe { std::slice::from_raw_parts_mut(buffer as *mut u8, size as usize) })
.unwrap() as i64
}
#[test]
fn effect_ports_state_latency_and_audio_lifecycle() {
// SAFETY: All callbacks, strings, streams, buffers, and the host outlive their
// synchronous ABI calls. Activation/processing/destruction follow CLAP order.
unsafe {
let entry = &knurl::clap_entry;
assert!(entry.init.unwrap()(c"knurl.clap".as_ptr()));
let factory = entry.get_factory.unwrap()(CLAP_PLUGIN_FACTORY_ID.as_ptr())
as *const clap_plugin_factory;
assert!(!factory.is_null());
let factory = &*factory;
assert_eq!(factory.get_plugin_count.unwrap()(factory), 1);
let descriptor = factory.get_plugin_descriptor.unwrap()(factory, 0);
assert!(!descriptor.is_null());
assert_eq!(CStr::from_ptr((*descriptor).name), c"Knurl");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.narl.bass-foundry.knurl"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
clap_version: CLAP_VERSION,
host_data: ptr::null_mut(),
name: c"Storyboard test host".as_ptr(),
vendor: c"Tests".as_ptr(),
url: c"".as_ptr(),
version: c"1.0".as_ptr(),
get_extension: Some(extension),
request_restart: Some(request),
request_process: Some(request),
request_callback: Some(request),
};
let plugin = factory.create_plugin.unwrap()(factory, &host, (*descriptor).id);
assert!(!plugin.is_null());
let p = &*plugin;
assert!(p.init.unwrap()(plugin));
let ports = p.get_extension.unwrap()(plugin, CLAP_EXT_AUDIO_PORTS.as_ptr())
as *const clap_plugin_audio_ports;
assert!(!ports.is_null());
assert_eq!((*ports).count.unwrap()(plugin, false), 1);
assert_eq!((*ports).count.unwrap()(plugin, true), 1);
let latency = p.get_extension.unwrap()(plugin, CLAP_EXT_LATENCY.as_ptr())
as *const clap_plugin_latency;
assert!(!latency.is_null());
let state =
p.get_extension.unwrap()(plugin, CLAP_EXT_STATE.as_ptr()) as *const clap_plugin_state;
assert!(!state.is_null());
let state = &*state;
let save = || {
let mut bytes = Vec::new();
let stream = clap_ostream {
ctx: &mut bytes as *mut Vec<u8> as *mut c_void,
write: Some(write),
};
assert!(state.save.unwrap()(plugin, &stream));
bytes
};
let mut envelope: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
use nih_plug::wrapper::state::ParamValue;
envelope.params.insert("edge".into(), ParamValue::F32(30.));
envelope.params.insert("steps".into(), ParamValue::I32(3));
envelope
.params
.insert("seed".into(), ParamValue::String("free".into()));
envelope
.params
.insert("source".into(), ParamValue::String("midi".into()));
// Pitch from MIDI and no note arrives, so the audio check below sees the dry input
// even with a pattern set.
envelope
.params
.insert("level-jitter".into(), ParamValue::F32(0.));
envelope
.params
.insert("time-jitter".into(), ParamValue::F32(0.));
envelope.params.insert("grit".into(), ParamValue::F32(-60.));
let (gains, grit) = ([-6f32; 16], [true; 16]);
let mut pattern = serde_json::json!({ "gains": gains, "grit": grit });
pattern["grit"][5] = false.into();
envelope
.fields
.insert("knurl-pattern".into(), pattern.to_string());
let encoded = serde_json::to_vec(&envelope).unwrap();
let mut framed = (encoded.len() as u64).to_le_bytes().to_vec();
framed.extend_from_slice(&encoded);
let mut bytes = Cursor::new(framed);
let stream = clap_istream {
ctx: &mut bytes as *mut Cursor<Vec<u8>> as *mut c_void,
read: Some(read),
};
assert!(state.load.unwrap()(plugin, &stream));
let restored: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
assert_eq!(
serde_json::to_value(&restored.params).unwrap(),
serde_json::to_value(&envelope.params).unwrap()
);
let saved: serde_json::Value =
serde_json::from_str(&restored.fields["knurl-pattern"]).unwrap();
assert_eq!(saved, pattern);
for rate in [44100., 48000., 96000.] {
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
let delay = (*latency).get.unwrap()(plugin) as usize;
// About 67 ms lookahead plus 4.2 ms of timing-jitter headroom.
assert!((delay as f64 / rate - 0.071).abs() < 0.01, "{delay}");
assert!(p.start_processing.unwrap()(plugin));
let total = delay + 9600;
let mut position = 0;
let (mut energy_in, mut energy_out) = (0f64, 0f64);
while position < total {
let frames = [1, 127, 512, 31][position % 4].min(total - position);
// With no MIDI note and every effect off, a tone passes through unchanged.
let tone = |n: usize| {
0.25 * (std::f64::consts::TAU * 1000. * n as f64 / rate).sin() as f32
};
let mut input_left = [0f32; 512];
let mut input_right = [0f32; 512];
for i in 0..frames {
input_left[i] = tone(position + i);
input_right[i] = -tone(position + i);
}
let mut left = [0f32; 512];
let mut right = [0f32; 512];
let mut in_channels = [input_left.as_mut_ptr(), input_right.as_mut_ptr()];
let mut out_channels = [left.as_mut_ptr(), right.as_mut_ptr()];
let incoming = clap_audio_buffer {
data32: in_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let mut outgoing_audio = clap_audio_buffer {
data32: out_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let input = clap_input_events {
ctx: ptr::null_mut(),
size: Some(count),
get: Some(event),
};
let outgoing = clap_output_events {
ctx: ptr::null_mut(),
try_push: Some(push),
};
let process = clap_process {
steady_time: position as i64,
frames_count: frames as u32,
transport: ptr::null(),
audio_inputs: &incoming,
audio_outputs: &mut outgoing_audio,
audio_inputs_count: 1,
audio_outputs_count: 1,
in_events: &input,
out_events: &outgoing,
};
assert_ne!(p.process.unwrap()(plugin, &process), CLAP_PROCESS_ERROR);
for i in 0..frames {
let n = position + i;
if n < delay {
assert!(left[i].abs() < 1e-6, "output before the lookahead");
} else if n > delay + 4800 {
energy_in += (tone(n - delay) as f64).powi(2);
energy_out += (left[i] as f64).powi(2) + (right[i] as f64).powi(2);
}
}
position += frames;
}
let ratio_db = 10. * (energy_out / (2. * energy_in)).log10();
assert!(ratio_db.abs() < 0.2, "{ratio_db}");
p.stop_processing.unwrap()(plugin);
p.deactivate.unwrap()(plugin);
}
p.destroy.unwrap()(plugin);
entry.deinit.unwrap()();
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "knurl-ui"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
egui = "0.31.1"
knurl-core = { path = "../core" }
common-ui = { path = "../../common/ui" }
common-dsp = { path = "../../common/dsp" }
+501
View File
@@ -0,0 +1,501 @@
//! Knurl editor, shared by the CLAP wrapper and the headless renderer.
use common_ui::*;
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
use knurl_core::{
AnalyzerFrame, Seed, Settings, Source, GRIT_OFF, PATTERNS, SCOPE_CYCLES, SCOPE_POINTS, STEPS,
STEP_MIN,
};
pub use common_ui::style;
pub const WIDTH: u32 = 1040;
pub const HEIGHT: u32 = 650;
const GRID: Color32 = Color32::from_rgb(24, 26, 30);
const TOP: f32 = 316.;
pub struct State {
pub analyzer: AnalyzerFrame,
/// True while a pointer button is held, so knob drags become one host gesture.
pub gesture_active: bool,
pub help: String,
}
impl Default for State {
fn default() -> Self {
Self {
analyzer: AnalyzerFrame::default(),
gesture_active: false,
help: DEFAULT_HELP.into(),
}
}
}
const DEFAULT_HELP: &str =
"Every cycle of the note gets its own level, timing and grit. Counted in cycles, so it fits every note.";
impl State {
pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
let old = self.analyzer;
self.analyzer = frame;
self.analyzer.input_db = frame.input_db.max(old.input_db - 3.);
self.analyzer.output_db = frame.output_db.max(old.output_db - 3.);
// Keep the last scope between notes.
if !frame.scope_valid && old.scope_valid {
self.analyzer.scope = old.scope;
self.analyzer.scope_valid = true;
}
}
}
fn hint(r: &egui::Response, state: &mut State, text: &str) {
if r.hovered() || r.has_focus() {
state.help = text.into();
}
}
#[allow(clippy::too_many_arguments)]
fn knob(
ui: &mut egui::Ui,
state: &mut State,
name: &str,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
default: f32,
unit: &str,
help: &str,
) {
ui.vertical_centered(|ui| {
let r = rotary(ui, name, value, range, default, unit, 58., ACCENT);
hint(&r, state, help);
});
}
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
backdrop(ui);
state.help = DEFAULT_HELP.into();
state.gesture_active = ui.input(|i| i.pointer.any_down());
ui.add_space(2.);
header(ui, "KNURL", "CYCLE TEXTURE", |ui| {
let r = ui
.selectable_label(settings.bypass, RichText::new("BYPASS").size(13.))
.on_hover_text("Crossfade to the delayed dry signal.");
if r.clicked() {
settings.bypass = !settings.bypass;
}
ui.separator();
for (source, label) in [(Source::Midi, "MIDI"), (Source::Audio, "AUDIO")] {
let r = ui.selectable_label(settings.source == source, RichText::new(label).size(13.));
if r.clicked() {
settings.source = source;
}
}
caption(ui, "PITCH FROM");
});
ui.add_space(6.);
ui.horizontal_top(|ui| {
panel(ui, |ui| {
ui.set_width(210.);
ui.set_min_height(TOP);
ui.vertical(|ui| note_panel(ui, settings, state));
});
ui.add_space(4.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| {
let frame = state.analyzer;
scope(ui, &frame, 140.);
ui.add_space(4.);
pattern_editor(ui, settings, state, TOP - 144.);
});
});
});
ui.add_space(6.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| controls(ui, settings, state));
});
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
fn note_panel(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let frame = state.analyzer;
caption(ui, "NOTE");
let (name, hz) = match frame.note {
Some(n) if frame.hz > 0. => (common_dsp::note_name(n), format!("{:.1} Hz", frame.hz)),
_ if frame.hz > 0. => ("—".into(), format!("holding {:.1} Hz", frame.hz)),
_ => ("—".into(), "no note yet".into()),
};
ui.label(
RichText::new(name)
.font(FontId::new(54., egui::FontFamily::Name("medium".into())))
.color(if frame.note.is_some() { TEXT } else { DIM }),
);
ui.label(RichText::new(hz).size(13.).color(MUTED));
if frame.hz > 0. {
ui.label(
RichText::new(format!("one cycle = {:.1} ms", 1000. / frame.hz))
.size(12.)
.color(DIM),
);
}
ui.add_space(10.);
meter(ui, "IN", frame.input_db, STEEL, 110.);
meter(ui, "OUT", frame.output_db, ACCENT, 110.);
ui.add_space(10.);
caption(ui, "RANDOM");
ui.horizontal(|ui| {
for (seed, label, help) in [
(
Seed::Same,
"SAME EVERY NOTE",
"Every note gets the same random sequence: identical notes sound identical.",
),
(
Seed::Free,
"FREE",
"The random sequences keep running, so every note differs.",
),
] {
let r = ui.selectable_label(settings.seed == seed, RichText::new(label).size(12.));
if r.clicked() {
settings.seed = seed;
}
hint(&r, state, help);
}
});
ui.add_space(6.);
let r = ui.checkbox(
&mut settings.snap,
RichText::new("Snap to semitone").size(12.),
);
hint(
&r,
state,
"Round the detected pitch. Turn it off for slides.",
);
}
/// The last cycles of the output, overlaid; the newest is brightest.
fn scope(ui: &mut egui::Ui, frame: &AnalyzerFrame, height: f32) {
let (outer, _) =
ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
let p = ui.painter_at(outer);
p.rect_filled(outer, 2., SCREEN);
p.text(
outer.left_top() + Vec2::new(8., 5.),
egui::Align2::LEFT_TOP,
format!("LAST {SCOPE_CYCLES} CYCLES, OVERLAID"),
FontId::proportional(10.),
MUTED,
);
let plot = Rect::from_min_max(
outer.left_top() + Vec2::new(10., 20.),
outer.right_bottom() - Vec2::new(10., 8.),
);
for t in [0.25f32, 0.5, 0.75] {
let x = plot.left() + plot.width() * t;
p.line_segment(
[Pos2::new(x, plot.top()), Pos2::new(x, plot.bottom())],
Stroke::new(1.0_f32, GRID),
);
}
p.line_segment(
[
Pos2::new(plot.left(), plot.center().y),
Pos2::new(plot.right(), plot.center().y),
],
Stroke::new(1.0_f32, EDGE),
);
if !frame.scope_valid {
p.text(
plot.center(),
egui::Align2::CENTER_CENTER,
"play a note",
FontId::proportional(12.),
DIM,
);
return;
}
let peak = frame
.scope
.iter()
.flatten()
.fold(1e-6f32, |m, v| m.max(v.abs()));
// The newest cycle is still being written; draw the complete ones.
for (age, cycle) in frame.scope[..SCOPE_CYCLES - 1].iter().enumerate() {
let alpha = 0.2 + 0.8 * age as f32 / (SCOPE_CYCLES - 2) as f32;
let color = if age == SCOPE_CYCLES - 2 {
ACCENT
} else {
STEEL.gamma_multiply(alpha * 0.6)
};
let points: Vec<Pos2> = cycle
.iter()
.enumerate()
.map(|(i, v)| {
Pos2::new(
plot.left() + plot.width() * i as f32 / (SCOPE_POINTS - 1) as f32,
plot.center().y - plot.height() * 0.48 * v / peak,
)
})
.collect();
p.add(egui::Shape::line(
points,
Stroke::new(
if age == SCOPE_CYCLES - 2 {
2.0_f32
} else {
1.0
},
color,
),
));
}
}
fn step_y(rect: Rect, db: f32) -> f32 {
rect.top() + rect.height() * (db / STEP_MIN).clamp(0., 1.)
}
/// One bar per step: drag to set its level, double-click to reset; the square below a bar
/// switches its grit burst.
fn pattern_editor(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, height: f32) {
let (outer, response) = ui.allocate_exact_size(
Vec2::new(ui.available_width(), height),
Sense::click_and_drag(),
);
let p = ui.painter_at(outer);
p.rect_filled(outer, 2., SCREEN);
p.text(
outer.left_top() + Vec2::new(8., 5.),
egui::Align2::LEFT_TOP,
"PATTERN: LEVEL PER CYCLE",
FontId::proportional(10.),
MUTED,
);
let bars = Rect::from_min_max(
outer.left_top() + Vec2::new(34., 22.),
Pos2::new(outer.right() - 10., outer.bottom() - 26.),
);
let toggles_y = outer.bottom() - 16.;
let col = bars.width() / STEPS as f32;
let column =
|x: f32| (((x - bars.left()) / col).floor() as isize).clamp(0, STEPS as isize - 1) as usize;
if response.hovered() {
state.help = "Drag a bar to set the level of that cycle; double-click resets it. The squares switch grit per step.".into();
}
if let Some(pos) = response.interact_pointer_pos() {
let k = column(pos.x);
if pos.y > bars.bottom() + 2. {
if response.clicked() {
settings.grit_steps[k] = !settings.grit_steps[k];
}
} else if response.double_clicked() {
settings.pattern[k] = 0.;
} else if response.dragged() || response.clicked() {
let t = ((pos.y - bars.top()) / bars.height()).clamp(0., 1.);
let db = t * STEP_MIN;
settings.pattern[k] = if db > -0.5 { 0. } else { db.round() };
if k >= settings.steps {
settings.steps = k + 1;
}
}
}
for db in [0f32, -6., -12., -18., -24.] {
let y = step_y(bars, db);
p.line_segment(
[Pos2::new(bars.left(), y), Pos2::new(bars.right(), y)],
Stroke::new(1.0_f32, GRID),
);
p.text(
Pos2::new(outer.left() + 6., y),
egui::Align2::LEFT_CENTER,
if db == 0. {
"0".into()
} else {
format!("{db:.0}")
},
FontId::proportional(10.),
DIM,
);
}
let current = state.analyzer.step;
let playing = state.analyzer.note.is_some();
for k in 0..STEPS {
let on = k < settings.steps;
let x0 = bars.left() + k as f32 * col + 3.;
let x1 = x0 + col - 6.;
let top = step_y(bars, settings.pattern[k]);
let color = if !on {
DIM.gamma_multiply(0.5)
} else if playing && k == current {
TEXT
} else {
ACCENT
};
p.rect_filled(
Rect::from_min_max(
Pos2::new(x0, top.min(bars.bottom() - 2.)),
Pos2::new(x1, bars.bottom()),
),
1.,
color.gamma_multiply(if on { 0.85 } else { 0.4 }),
);
let grit = settings.grit_steps[k] && settings.grit > GRIT_OFF;
let square = Rect::from_center_size(Pos2::new((x0 + x1) / 2., toggles_y), Vec2::splat(9.));
if settings.grit_steps[k] {
p.rect_filled(square, 1., if grit && on { FOCUS } else { DIM });
} else {
p.rect_stroke(
square,
1.,
Stroke::new(1.0_f32, DIM),
egui::StrokeKind::Inside,
);
}
}
p.text(
Pos2::new(outer.left() + 6., toggles_y),
egui::Align2::LEFT_CENTER,
"grit",
FontId::proportional(10.),
DIM,
);
}
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let d = Settings::default();
ui.horizontal(|ui| {
caption(ui, "PATTERN");
for (name, help, gains) in PATTERNS {
let selected = settings.steps == gains.len()
&& settings.pattern[..gains.len()]
.iter()
.zip(gains)
.all(|(a, b)| a == b);
let r = ui.selectable_label(selected, RichText::new(name.to_uppercase()).size(13.));
if r.clicked() {
settings.apply_pattern(gains);
}
hint(&r, state, help);
}
ui.add_space(24.);
caption(ui, "STEPS");
let minus = ui.button(RichText::new("−").size(14.));
if minus.clicked() && settings.steps > 1 {
settings.steps -= 1;
}
ui.label(
RichText::new(format!("{:>2}", settings.steps))
.monospace()
.size(14.)
.color(TEXT),
);
let plus = ui.button(RichText::new("+").size(14.));
if plus.clicked() && settings.steps < STEPS {
settings.steps += 1;
}
let help = "Pattern length in cycles. 2 makes a sub-octave, 3 an octave and a fifth down.";
hint(&minus, state, help);
hint(&plus, state, help);
});
ui.add_space(4.);
ui.columns(10, |c| {
knob(
&mut c[0],
state,
"Edge",
&mut settings.edge,
5. ..=50.,
d.edge,
" %",
"How soft each step change is, as a share of a cycle. Low is sharper and buzzier.",
);
knob(
&mut c[1],
state,
"Level",
&mut settings.level_jitter,
0. ..=6.,
d.level_jitter,
" dB",
"Random level per cycle: a rough, living surface.",
);
knob(
&mut c[2],
state,
"Time",
&mut settings.time_jitter,
0. ..=10.,
d.time_jitter,
" %",
"Random timing per cycle, in percent of a period: rattle and scrape.",
);
knob(
&mut c[3],
state,
"Grit",
&mut settings.grit,
GRIT_OFF..=0.,
d.grit,
" dB",
"Level of the noise burst in each cycle, relative to the input. Fully left is off.",
);
knob(
&mut c[4],
state,
"Tone",
&mut settings.tone,
4. ..=64.,
d.tone,
" ×f0",
"Lowest frequency of the grit, in harmonics of the note.",
);
knob(
&mut c[5],
state,
"Position",
&mut settings.position,
0. ..=100.,
d.position,
" %",
"Where in the cycle the grit burst sits.",
);
knob(
&mut c[6],
state,
"Length",
&mut settings.length,
2. ..=50.,
d.length,
" %",
"Length of the grit burst, as a share of a cycle.",
);
knob(
&mut c[7],
state,
"Gate",
&mut settings.gate,
-60. ..=-20.,
d.gate,
" dB",
"Input level that counts as a note (AUDIO). Raise it if tails retrigger.",
);
knob(
&mut c[8],
state,
"Mix",
&mut settings.mix,
0. ..=100.,
d.mix,
" %",
"Blend with the delayed dry signal.",
);
knob(
&mut c[9],
state,
"Output",
&mut settings.output,
-12. ..=12.,
d.output,
" dB",
"Output trim.",
);
});
}
+1 -1
View File
@@ -861,7 +861,7 @@ dependencies = [
[[package]]
name = "lathe"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"clap-sys",
"crossbeam-queue",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "lathe"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
license = "ISC"
+1 -1
View File
@@ -174,7 +174,7 @@ impl Plugin for Lathe {
}
}
impl ClapPlugin for Lathe {
const CLAP_ID: &'static str = "io.storyboard.harmonic-eq";
const CLAP_ID: &'static str = "io.narl.bass-foundry.lathe";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("EQ the harmonics of the played note: a cut on harmonic 3 stays on harmonic 3");
const CLAP_MANUAL_URL: Option<&'static str> = None;
+1 -1
View File
@@ -61,7 +61,7 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
assert_eq!(CStr::from_ptr((*descriptor).name), c"Lathe");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.storyboard.harmonic-eq"
c"io.narl.bass-foundry.lathe"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
+1 -1
View File
@@ -1419,7 +1419,7 @@ dependencies = [
[[package]]
name = "patina"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"clap-sys",
"crossbeam-queue",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "patina"
version = "0.2.0"
version = "0.3.0"
edition = "2021"
license = "ISC"
+1 -1
View File
@@ -187,7 +187,7 @@ fn store_epoch(ctx: &egui::Context, e: u64) {
ctx.data_mut(|d| d.insert_temp(egui::Id::new("reset-epoch"), e));
}
impl ClapPlugin for Patina {
const CLAP_ID: &'static str = "io.storyboard.resonance-compressor";
const CLAP_ID: &'static str = "io.narl.bass-foundry.patina";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("Dynamic resonance attenuation with explicit tone protection");
const CLAP_MANUAL_URL: Option<&'static str> = None;
+1 -1
View File
@@ -61,7 +61,7 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
assert_eq!(CStr::from_ptr((*descriptor).name), c"Patina");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.storyboard.resonance-compressor"
c"io.narl.bass-foundry.patina"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
+1 -1
View File
@@ -2054,7 +2054,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rivet"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"clap-sys",
"nih_plug",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rivet"
version = "0.4.0"
version = "0.5.0"
edition = "2021"
license = "ISC"
+1 -1
View File
@@ -177,7 +177,7 @@ impl Plugin for Rivet {
}
}
impl ClapPlugin for Rivet {
const CLAP_ID: &'static str = "io.storyboard.motif";
const CLAP_ID: &'static str = "io.narl.bass-foundry.rivet";
const CLAP_DESCRIPTION: Option<&'static str> = Some(
"Tell a MIDI motif as a story: a storyboard of statement, answer, departure and return",
);
+4 -1
View File
@@ -59,7 +59,10 @@ fn exported_factory_state_and_silent_audio_lifecycle() {
let descriptor = factory.get_plugin_descriptor.unwrap()(factory, 0);
assert!(!descriptor.is_null());
assert_eq!(CStr::from_ptr((*descriptor).name), c"Rivet");
assert_eq!(CStr::from_ptr((*descriptor).id), c"io.storyboard.motif");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.narl.bass-foundry.rivet"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
clap_version: CLAP_VERSION,
+1 -1
View File
@@ -1734,7 +1734,7 @@ dependencies = [
[[package]]
name = "temper"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"clap-sys",
"crossbeam-queue",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "temper"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
license = "ISC"
+1 -1
View File
@@ -167,7 +167,7 @@ impl Plugin for Temper {
}
}
impl ClapPlugin for Temper {
const CLAP_ID: &'static str = "io.storyboard.tracked-distortion";
const CLAP_ID: &'static str = "io.narl.bass-foundry.temper";
const CLAP_DESCRIPTION: Option<&'static str> = Some(
"Distortion whose drive and tone filters follow the note, so every note distorts the same",
);
+1 -1
View File
@@ -61,7 +61,7 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
assert_eq!(CStr::from_ptr((*descriptor).name), c"Temper");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.storyboard.tracked-distortion"
c"io.narl.bass-foundry.temper"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
+1 -1
View File
@@ -2056,7 +2056,7 @@ dependencies = [
[[package]]
name = "weld"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"clap-sys",
"crossbeam-queue",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "weld"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
license = "ISC"
+1 -1
View File
@@ -198,7 +198,7 @@ impl Plugin for Weld {
}
}
impl ClapPlugin for Weld {
const CLAP_ID: &'static str = "io.storyboard.layer-align";
const CLAP_ID: &'static str = "io.narl.bass-foundry.weld";
const CLAP_DESCRIPTION: Option<&'static str> = Some(
"Align the phase of a bass layer to a sidechain layer so the two sum instead of cancel",
);
+1 -1
View File
@@ -61,7 +61,7 @@ fn sidechain_port_state_latency_and_audio_lifecycle() {
assert_eq!(CStr::from_ptr((*descriptor).name), c"Weld");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.storyboard.layer-align"
c"io.narl.bass-foundry.weld"
);
let host = clap_host {
clap_version: CLAP_VERSION,
+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);
+21
View File
@@ -0,0 +1,21 @@
# Copy to .env and fill in. The container refuses to start while a required value is missing,
# so the site never goes live without an Impressum.
# Public URL of the site, used for canonical links, the sitemap and robots.txt (build time).
SITE_URL=https://fl.narl.io
# Impressum (§ 5 DDG) and controller in the privacy policy. Required.
# The address must be one where legal documents can be served: no P.O. box.
IMPRESSUM_NAME=Max Mustermann
IMPRESSUM_STREET=Musterstraße 1
IMPRESSUM_CITY=12345 Musterstadt
IMPRESSUM_EMAIL=kontakt@example.org
# Optional. Defaults to "Deutschland".
IMPRESSUM_COUNTRY=Deutschland
# Optional. Leave empty to hide the phone line.
IMPRESSUM_PHONE=
# Optional. Name and address of the hosting provider, if one processes visitor data for you
# (Art. 28 GDPR), e.g. "Hetzner Online GmbH, Industriestr. 25, 91710 Gunzenhausen".
# Leave empty to hide that paragraph.
HOSTER=
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.astro/
.env
+30
View File
@@ -0,0 +1,30 @@
# syntax=docker/dockerfile:1
# Build context is the repository root, so the site can read plugins/catalog.json, the
# plugin versions and the editor screenshots. Dockerfile.dockerignore keeps the context small.
FROM node:24-alpine AS build
WORKDIR /repo/website
COPY website/package.json website/package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . /repo
ARG SITE_URL=https://fl.narl.io
ENV SITE_URL=$SITE_URL
RUN npm run build
FROM nginxinc/nginx-unprivileged:stable-alpine
COPY website/docker/nginx.conf /etc/nginx/nginx.conf
COPY website/docker/security-headers.conf /etc/nginx/security-headers.conf
COPY website/docker/40-legal-notice.sh /docker-entrypoint.d/40-legal-notice.sh
COPY --from=build /repo/website/dist /usr/share/nginx/html
COPY --from=build /repo/website/dist/impressum/index.html /opt/legal-templates/impressum/index.html
COPY --from=build /repo/website/dist/datenschutz/index.html /opt/legal-templates/datenschutz/index.html
# Only the two legal pages are rewritten at start-up; everything else stays read-only.
USER root
RUN rm -f /etc/nginx/conf.d/default.conf \
&& chown 101:101 /usr/share/nginx/html/impressum/index.html /usr/share/nginx/html/datenschutz/index.html
USER 101
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1
+10
View File
@@ -0,0 +1,10 @@
# Allowlist for the repository-root build context: only what the site reads.
*
!website/
!plugins/catalog.json
!plugins/*/plugin/Cargo.toml
!docs/*/assets/editor.png
website/node_modules
website/dist
website/.astro
website/.env
+98
View File
@@ -0,0 +1,98 @@
# Bass Foundry website
The site at [fl.narl.io](https://fl.narl.io): one static [Astro](https://astro.build) site
with a home page, one page per plugin and the legal pages. An unprivileged nginx container
serves it behind Traefik.
The site reads its plugin data from the repository. When a plugin is added or released, the
next build picks it up:
- names, descriptions and CLAP IDs come from `plugins/catalog.json`;
- versions come from `plugins/<plugin>/plugin/Cargo.toml`, and the download links point to
`<plugin>-v<version>` on Gitea;
- editor previews come from `docs/<plugin>/assets/editor.png`.
The site's own copy for each plugin (role, problem, highlights, where to load it, latency) is in
`src/data/plugins.ts`. The build fails if a catalog entry has no copy there. The "On the bench"
ideas are copied from `docs/ideas.md`, so update them by hand when that list changes.
Download links go to the release tag of the version in `Cargo.toml`. Deploy the site after the
release is published, not after the version bump, or the link for that plugin is dead until
the release exists.
The site sets no cookies, stores nothing in the browser and loads nothing from third parties.
Fonts, images and styles all come from the same server, there is no JavaScript, and nginx
writes no access logs. The privacy policy (`src/pages/datenschutz.astro`) says exactly this,
so keep the two in step.
## Development
```sh
cd website
npm ci
npm run dev # http://localhost:4321/
npm run build # astro check, then a static build into dist/
npm run preview
npm run og # re-renders public/og.png
```
`npm run og` sets its text in Roboto Condensed from `plugins/common/ui/assets`, so no font
needs to be installed. The logo is `public/favicon.svg` (copied to `src/assets/logo.svg`): a
riveted steel plate with a knurled saw wave.
The Impressum and the privacy policy contain `%%IMPRESSUM_…%%` placeholders. The container
fills them in when it starts, so the operator's name and address never end up in git.
## Running with Docker
The build context is the repository root, so the image can read the catalog, the versions and
the screenshots. `Dockerfile.dockerignore` keeps the context to those files and `website/`.
```sh
cd website
cp .env.example .env # fill in the Impressum details
docker compose up -d --build
curl -I http://127.0.0.1:8080/
```
| Variable | Required | Used for |
| ------------------- | -------- | -------------------------------------------------------------- |
| `SITE_URL` | build | Canonical links, sitemap, robots.txt, `og:image`. Rebuild to change. |
| `IMPRESSUM_NAME` | yes | Impressum and controller in the privacy policy |
| `IMPRESSUM_STREET` | yes | Street and number (no P.O. box) |
| `IMPRESSUM_CITY` | yes | Postcode and city |
| `IMPRESSUM_EMAIL` | yes | Contact address, written out as HTML entities |
| `IMPRESSUM_COUNTRY` | no | Defaults to `Deutschland` |
| `IMPRESSUM_PHONE` | no | Hides the phone line when empty |
| `HOSTER` | no | Names the hosting provider (Art. 28 GDPR); hidden when empty |
The container refuses to start while a required variable is missing, so the site cannot go
live without an Impressum. The values are escaped and applied to fresh copies of the two pages
on every start, so after changing `.env`, recreating the container (`docker compose up -d`) is
enough. The container listens on 8080 as user 101 with all capabilities dropped. `/healthz`
answers `ok` for health checks.
### Behind Traefik
`compose.override.yaml` puts the site on `fl.narl.io`: router labels, the external `narl`
network, the `websecure` entry point with the `https_resolver` certificate resolver, and
`ports: !reset []` so nothing is published on the host. Docker Compose reads the override file
automatically. TLS ends at Traefik. Redirects are relative, so they keep the proxy's scheme and
host.
**If Traefik writes access logs for this router, the privacy policy is wrong.** It promises no
access logs. Either turn them off for the router, or edit section 3 of `datenschutz.astro` (both
languages): what is logged, why, and for how long.
## Before going live
- **Legal texts.** The Impressum and the privacy policy are written for a private,
non-commercial project hosted in Germany. They are no substitute for legal advice. Update the
"Stand" date when you change them.
- **git.narl.io.** The privacy policy says that the downloads and the source code are on
git.narl.io and that its own privacy information applies there. If you run that Gitea
instance yourself, make sure it has that information.
## License
ISC, like the plugins. Roboto Condensed and Inter are under the SIL Open Font License.
+23
View File
@@ -0,0 +1,23 @@
// @ts-check
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: process.env.SITE_URL || 'https://fl.narl.io',
trailingSlash: 'always',
build: {
// Everything goes into files under /_astro/ so the CSP can stay at 'self'
// without hashes or 'unsafe-inline'.
inlineStylesheets: 'never',
},
vite: {
build: { assetsInlineLimit: 0 },
// The plugin data and editor screenshots live in the repository above this folder.
server: { fs: { allow: ['..'] } },
},
integrations: [
sitemap({
filter: (page) => !/\/(impressum|datenschutz)\/$/.test(page),
}),
],
});
+21
View File
@@ -0,0 +1,21 @@
# fl.narl.io behind Traefik on the external "narl" network. TLS ends at Traefik;
# the container itself is not published on any host port.
services:
web:
build:
args:
SITE_URL: https://fl.narl.io
ports: !reset []
networks:
- narl
labels:
- "traefik.enable=true"
- "traefik.docker.network=narl"
- "traefik.http.routers.bass-foundry-website.rule=Host(`fl.narl.io`)"
- "traefik.http.routers.bass-foundry-website.entrypoints=websecure"
- "traefik.http.routers.bass-foundry-website.tls.certresolver=https_resolver"
- "traefik.http.services.bass-foundry-website.loadbalancer.server.port=8080"
networks:
narl:
external: true
+27
View File
@@ -0,0 +1,27 @@
# Generic setup: the site on 127.0.0.1:8080. Put the reverse proxy (Traefik labels, networks)
# in a compose.override.yaml and drop the port mapping there if the proxy reaches the
# container over a Docker network. The build context is the repository root, because the
# site reads the plugin catalog, versions and screenshots from it.
services:
web:
build:
context: ..
dockerfile: website/Dockerfile
args:
SITE_URL: ${SITE_URL:-https://fl.narl.io}
image: bass-foundry-website:latest
env_file:
- path: .env
required: false
ports:
- "127.0.0.1:8080:8080"
restart: unless-stopped
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
logging:
driver: json-file
options:
max-size: "1m"
max-file: "3"
+74
View File
@@ -0,0 +1,74 @@
#!/bin/sh
# Fills the Impressum and the privacy policy with the operator's details from the environment.
# Runs from the nginx image's /docker-entrypoint.d/ hook on every start. It works from pristine
# copies, so changing a variable and restarting the container is enough.
set -eu
HTML=/usr/share/nginx/html
TEMPLATES=/opt/legal-templates
PAGES="impressum/index.html datenschutz/index.html"
missing=""
for var in IMPRESSUM_NAME IMPRESSUM_STREET IMPRESSUM_CITY IMPRESSUM_EMAIL; do
eval "value=\${$var:-}"
[ -n "$value" ] || missing="$missing $var"
done
if [ -n "$missing" ]; then
echo "40-legal-notice: missing required variables:$missing" >&2
echo "40-legal-notice: refusing to serve the site without an Impressum (see .env.example)" >&2
exit 1
fi
case "$IMPRESSUM_EMAIL" in
*@*.*) ;;
*)
echo "40-legal-notice: IMPRESSUM_EMAIL does not look like an email address" >&2
exit 1
;;
esac
# One line, HTML-escaped.
html() {
printf '%s' "$1" | tr '\r\n' ' ' |
sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g' -e 's/"/\&quot;/g' -e "s/'/\&#39;/g"
}
# Every character as a numeric entity, which keeps the address away from naive scrapers.
entities() {
printf '%s' "$1" | od -An -tu1 | tr -s ' \n' '\n\n' | sed -e '/^$/d' -e 's/.*/\&#&;/' | tr -d '\n'
}
# Escapes a replacement for sed's s|…|…| (backslash, the | delimiter and &).
repl() {
printf '%s' "$1" | sed -e 's/[\\|&]/\\&/g'
}
script=$(mktemp)
trap 'rm -f "$script"' EXIT
{
echo "s|%%IMPRESSUM_NAME%%|$(repl "$(html "$IMPRESSUM_NAME")")|g"
echo "s|%%IMPRESSUM_STREET%%|$(repl "$(html "$IMPRESSUM_STREET")")|g"
echo "s|%%IMPRESSUM_CITY%%|$(repl "$(html "$IMPRESSUM_CITY")")|g"
echo "s|%%IMPRESSUM_COUNTRY%%|$(repl "$(html "${IMPRESSUM_COUNTRY:-Deutschland}")")|g"
echo "s|%%IMPRESSUM_EMAIL%%|$(repl "$(entities "$IMPRESSUM_EMAIL")")|g"
for pair in phone:IMPRESSUM_PHONE hoster:HOSTER; do
key=${pair%%:*}
var=${pair#*:}
eval "value=\${$var:-}"
if [ -n "$value" ]; then
echo "s|%%$var%%|$(repl "$(html "$value")")|g"
else
echo "s|<p data-optional=\"$key\"[^>]*>[^<]*</p>||g"
fi
done
} >"$script"
for page in $PAGES; do
sed -f "$script" "$TEMPLATES/$page" >"$HTML/$page"
if grep -q '%%[A-Z_]*%%' "$HTML/$page"; then
echo "40-legal-notice: unfilled placeholder left in $page" >&2
exit 1
fi
done
echo "40-legal-notice: filled in the Impressum and the privacy policy"
+63
View File
@@ -0,0 +1,63 @@
# The privacy policy (src/pages/datenschutz.astro) promises that no access logs are written.
# Keep access_log off and the error log at "crit" unless that text changes with it.
worker_processes auto;
error_log /dev/stderr crit;
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
http {
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log off;
log_not_found off;
server_tokens off;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
charset utf-8;
charset_types text/css application/javascript image/svg+xml application/xml text/plain;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/css application/javascript image/svg+xml application/xml text/plain application/json;
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
# Relative Location headers, so redirects keep the scheme and host of the reverse proxy.
absolute_redirect off;
error_page 404 /404.html;
include /etc/nginx/security-headers.conf;
add_header Cache-Control "no-cache" always;
location /_astro/ {
include /etc/nginx/security-headers.conf;
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
location = /healthz {
default_type text/plain;
return 200 "ok\n";
}
location / {
try_files $uri $uri/ =404;
}
}
}
+8
View File
@@ -0,0 +1,8 @@
# Included in every location that sets its own headers, because add_header is not inherited
# into a location that has any add_header of its own.
add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; manifest-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
+5243
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "bass-foundry-website",
"private": true,
"type": "module",
"version": "1.0.0",
"description": "Website for the Bass Foundry plugin suite",
"license": "ISC",
"scripts": {
"dev": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"check": "astro check",
"og": "node scripts/og-image.mjs"
},
"dependencies": {
"@astrojs/sitemap": "^3.7.4",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/roboto-condensed": "^5.3.0",
"astro": "^7.3.5",
"sharp": "^0.35.5"
},
"devDependencies": {
"@astrojs/check": "^0.9.10",
"typescript": "^6.0.3"
}
}
+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="p" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#22252a"/>
<stop offset="1" stop-color="#121417"/>
</linearGradient>
</defs>
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#p)" stroke="#3a3e45" stroke-width="2"/>
<g fill="#5a6069">
<circle cx="12" cy="12" r="2.6"/>
<circle cx="52" cy="12" r="2.6"/>
<circle cx="12" cy="52" r="2.6"/>
<circle cx="52" cy="52" r="2.6"/>
</g>
<path d="M11 42 L24 20 L24 42 L36 29 L36 42 L50 20 L50 42" fill="none" stroke="#ff0160" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 695 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

+82
View File
@@ -0,0 +1,82 @@
// Renders public/og.png (1200×630), the preview image for link shares: the name, the
// waveform from the hero and three editor previews. Run with `npm run og` after the look
// changes. The text is set in Roboto Condensed, loaded from the plugins' own font folder.
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const root = new URL('../', import.meta.url);
const repo = new URL('../', root);
const W = 1200;
const H = 630;
// librsvg finds fonts through fontconfig; point it at the embedded editor fonts.
const fonts = await mkdtemp(join(tmpdir(), 'og-fonts-'));
await writeFile(
join(fonts, 'fonts.conf'),
`<?xml version="1.0"?><!DOCTYPE fontconfig SYSTEM "fonts.dtd"><fontconfig>
<dir>${new URL('plugins/common/ui/assets/', repo).pathname}</dir><cachedir>${fonts}</cachedir>
</fontconfig>`,
);
process.env.FONTCONFIG_FILE = join(fonts, 'fonts.conf');
const { default: sharp } = await import('sharp');
// Knurl's Fry pattern on a tanh saw, as in src/components/Hero.astro.
const pattern = [0, -14, -5, -14].map((db) => 10 ** (db / 20));
const points = [];
for (let i = 0; i <= 700; i++) {
const phase = (i / 700) * 6;
const cycle = Math.floor(phase);
const frac = phase - cycle;
let g = pattern[cycle % 4];
if (frac > 0.85) {
const t = (frac - 0.85) / 0.15;
g += (pattern[(cycle + 1) % 4] - g) * (0.5 - 0.5 * Math.cos(Math.PI * t));
}
const y = Math.tanh(2.2 * (2 * frac - 1)) / Math.tanh(2.2);
points.push(`${(70 + (i / 700) * 1060).toFixed(1)},${(492 - y * g * 68).toFixed(1)}`);
}
const background = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">
<defs>
<radialGradient id="glow" cx="0.5" cy="1" r="0.75">
<stop offset="0" stop-color="#ff0160" stop-opacity="0.32"/>
<stop offset="1" stop-color="#ff0160" stop-opacity="0"/>
</radialGradient>
<linearGradient id="title" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#ff0160"/>
<stop offset="0.6" stop-color="#ff5c95"/>
<stop offset="1" stop-color="#b8c0cc"/>
</linearGradient>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M40 0H0V40" fill="none" stroke="#b8c0cc" stroke-opacity="0.06"/>
</pattern>
<filter id="blur"><feGaussianBlur stdDeviation="7"/></filter>
</defs>
<rect width="${W}" height="${H}" fill="#0b0c0e"/>
<rect width="${W}" height="${H}" fill="url(#grid)"/>
<rect width="${W}" height="${H}" fill="url(#glow)"/>
<text x="70" y="118" font-family="Roboto Condensed" font-weight="500" font-size="26" letter-spacing="6" fill="#ff0160">CLAP PLUGINS FOR FL STUDIO</text>
<text x="66" y="236" font-family="Roboto Condensed" font-weight="500" font-size="128" fill="#e8e9ec">BASS</text>
<text x="66" y="348" font-family="Roboto Condensed" font-weight="500" font-size="128" fill="url(#title)">FOUNDRY</text>
<path d="M${points.join('L')}" fill="none" stroke="#ff0160" stroke-width="9" opacity="0.4" filter="url(#blur)"/>
<path d="M${points.join('L')}" fill="none" stroke="#ff0160" stroke-width="3" stroke-linejoin="round"/>
<text x="70" y="596" font-family="Roboto Condensed" font-size="24" letter-spacing="3" fill="#8c929c">RIDDIM · DUBSTEP · BASS MUSIC — FL.NARL.IO</text>
</svg>`);
const shot = async (name, width) => {
const img = sharp(await readFile(new URL(`docs/${name}/assets/editor.png`, repo))).resize(width);
const { height } = await img.clone().toBuffer({ resolveWithObject: true }).then((r) => r.info);
const mask = Buffer.from(`<svg width="${width}" height="${height}"><rect width="${width}" height="${height}" rx="10"/></svg>`);
return img.composite([{ input: mask, blend: 'dest-in' }]).png().toBuffer();
};
await sharp(background)
.composite([
{ input: await shot('lathe', 430), left: 700, top: 36 },
{ input: await shot('knurl', 430), left: 640, top: 116 },
])
.png({ compressionLevel: 9 })
.toFile(new URL('public/og.png', root).pathname);
await rm(fonts, { recursive: true });
console.log('wrote public/og.png');
+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="p" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#22252a"/>
<stop offset="1" stop-color="#121417"/>
</linearGradient>
</defs>
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#p)" stroke="#3a3e45" stroke-width="2"/>
<g fill="#5a6069">
<circle cx="12" cy="12" r="2.6"/>
<circle cx="52" cy="12" r="2.6"/>
<circle cx="12" cy="52" r="2.6"/>
<circle cx="52" cy="52" r="2.6"/>
</g>
<path d="M11 42 L24 20 L24 42 L36 29 L36 42 L50 20 L50 42" fill="none" stroke="#ff0160" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 695 B

+80
View File
@@ -0,0 +1,80 @@
---
import { IDEAS, REPO } from '../data/plugins';
---
<section id="bench" class="section">
<div class="container">
<div class="section-head">
<p class="eyebrow">On the bench</p>
<h2>What could come next</h2>
<p>
Ideas from the <a href={`${REPO}/src/branch/main/docs/ideas.md`}>ideas list</a>, each written against a problem
seen in bass-music production. Most of the new ones are about texture. These are ideas, not promises.
</p>
</div>
<ul class="grid" role="list">
{
IDEAS.map((idea) => (
<li class={idea.texture ? 'texture' : undefined}>
<div class="head">
<h3>{idea.name}</h3>
{idea.texture && <span class="badge badge-accent">Texture</span>}
</div>
<p class="label">{idea.role}</p>
<p class="text">{idea.text}</p>
</li>
))
}
</ul>
</div>
</section>
<style>
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
margin: 0;
padding: 0;
list-style: none;
}
li {
display: grid;
align-content: start;
gap: 0.45rem;
padding: 1.4rem 1.5rem 1.5rem;
border: 1px dashed var(--line-strong);
border-radius: var(--radius);
background: rgb(255 255 255 / 0.012);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
h3 {
font-size: 1.4rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.text {
font-size: 0.975rem;
}
@media (max-width: 960px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 600px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>
+138
View File
@@ -0,0 +1,138 @@
---
import logo from '../assets/logo.svg';
import { PLUGINS, REPO } from '../data/plugins';
const year = new Date().getFullYear();
---
<footer class="site-footer">
<div class="knurl" aria-hidden="true"></div>
<div class="container grid">
<div class="about">
<a class="brand" href="/">
<img src={logo.src} alt="" width="30" height="30" />
<span>Bass <b>Foundry</b></span>
</a>
<p>Open-source CLAP plugins for writing, designing and mixing bass music in FL Studio.</p>
</div>
<nav aria-label="Plugins">
<p class="label">Plugins</p>
<ul role="list">
{
PLUGINS.map((p) => (
<li>
<a href={`/plugins/${p.slug}/`}>{p.name}</a>
</li>
))
}
</ul>
</nav>
<nav aria-label="Project">
<p class="label">Project</p>
<ul role="list">
<li><a href={REPO}>Source code</a></li>
<li><a href={`${REPO}/releases`}>All releases</a></li>
<li><a href={`${REPO}/src/branch/main/docs/ideas.md`}>Ideas</a></li>
<li><a href={`${REPO}/src/branch/main/LICENSE`}>License (ISC)</a></li>
</ul>
</nav>
<nav aria-label="Legal">
<p class="label">Legal</p>
<ul role="list">
<li><a href="/impressum/">Impressum</a></li>
<li><a href="/datenschutz/">Datenschutz</a></li>
</ul>
</nav>
</div>
<div class="container fine">
<p>
FL Studio is a trademark of Image-Line. Bass Foundry is an independent, non-commercial project and is not
affiliated with Image-Line.
</p>
<p>© {year} Nils Pukropp · No cookies, no tracking.</p>
</div>
</footer>
<style>
.site-footer {
margin-top: 2rem;
background: var(--bg-2);
font-size: 0.95rem;
}
.grid {
display: grid;
grid-template-columns: 2fr repeat(3, 1fr);
gap: 2.5rem;
padding-block: 3.5rem 2.5rem;
}
.about {
display: grid;
align-content: start;
gap: 1rem;
max-width: 340px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.65rem;
color: var(--text);
font-family: var(--display);
font-size: 1.1rem;
letter-spacing: 0.14em;
text-decoration: none;
text-transform: uppercase;
}
.brand b {
color: var(--accent);
}
ul {
display: grid;
gap: 0.4rem;
margin: 0.8rem 0 0;
padding: 0;
list-style: none;
}
nav a {
color: var(--muted);
text-decoration: none;
}
nav a:hover {
color: var(--text);
}
.fine {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.75rem 2rem;
padding-block: 1.5rem 2.25rem;
border-top: 1px solid var(--line);
color: var(--faint);
font-size: 0.85rem;
}
.fine p:first-child {
max-width: 640px;
}
@media (max-width: 860px) {
.grid {
grid-template-columns: 1fr 1fr;
}
.about {
grid-column: 1 / -1;
}
}
</style>
+91
View File
@@ -0,0 +1,91 @@
---
import { PLUGINS } from '../data/plugins';
---
<section id="foundry" class="section foundry">
<div class="container cols">
<div class="section-head">
<p class="eyebrow">The foundry</p>
<h2>Named after metalwork</h2>
<p>
The suite is the foundry: the workshop where the bass gets cast, shaped and finished. Each plugin is named after
the craft that does the same job to metal.
</p>
</div>
<dl class="names">
{
PLUGINS.map((p) => (
<div>
<dt>
<a href={`/plugins/${p.slug}/`}>{p.name}</a>
</dt>
<dd>{p.metal}</dd>
</div>
))
}
</dl>
</div>
</section>
<style>
.foundry {
border-block: 1px solid var(--line);
background:
radial-gradient(60% 80% at 100% 0%, rgb(255 1 96 / 0.07), transparent 60%),
var(--bg-2);
}
.cols {
display: grid;
grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
gap: clamp(2rem, 5vw, 4rem);
align-items: start;
}
.section-head {
margin-bottom: 0;
}
.names {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
margin: 0;
}
.names div {
padding: 1.1rem 1.25rem 1.1rem 0;
border-top: 1px solid var(--line);
}
dt a {
color: var(--text);
font-family: var(--display);
font-size: 1.25rem;
font-weight: 600;
letter-spacing: 0.08em;
text-decoration: none;
text-transform: uppercase;
}
dt a:hover {
color: var(--accent);
}
dd {
margin: 0.2rem 0 0;
font-size: 0.975rem;
}
@media (max-width: 860px) {
.cols {
grid-template-columns: 1fr;
}
}
@media (max-width: 540px) {
.names {
grid-template-columns: 1fr;
}
}
</style>
+110
View File
@@ -0,0 +1,110 @@
---
import logo from '../assets/logo.svg';
import { REPO } from '../data/plugins';
import Icon from './Icon.astro';
interface Props {
/** Without section links, for the legal pages and the 404 page. */
minimal?: boolean;
}
const { minimal = false } = Astro.props;
---
<header class="site-header">
<div class="container bar">
<a class="brand" href="/" aria-label="Bass Foundry, home">
<img src={logo.src} alt="" width="34" height="34" />
<span>Bass <b>Foundry</b></span>
</a>
{
!minimal && (
<nav aria-label="Sections">
<a href="/#plugins">Plugins</a>
<a href="/#install">Install</a>
<a href="/#principles">Principles</a>
<a href="/#bench">On the bench</a>
</nav>
)
}
<a class="btn btn-ghost btn-sm" href={REPO}><Icon name="code" />Source</a>
</div>
</header>
<style>
.site-header {
position: sticky;
top: 0;
z-index: 50;
border-bottom: 1px solid var(--line);
background: rgb(11 12 14 / 0.78);
backdrop-filter: blur(14px) saturate(1.4);
}
.bar {
display: flex;
align-items: center;
gap: 2rem;
min-height: 4.25rem;
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.7rem;
color: var(--text);
font-family: var(--display);
font-size: 1.2rem;
font-weight: 500;
letter-spacing: 0.14em;
text-decoration: none;
text-transform: uppercase;
}
.brand b {
color: var(--accent);
font-weight: 700;
}
.brand:hover {
color: var(--text);
}
nav {
display: flex;
gap: 1.75rem;
margin-left: auto;
}
nav a {
color: var(--muted);
font-family: var(--display);
font-size: 0.95rem;
font-weight: 500;
letter-spacing: 0.12em;
text-decoration: none;
text-transform: uppercase;
}
nav a:hover {
color: var(--text);
}
.btn {
margin-left: auto;
}
nav + .btn {
margin-left: 0;
}
@media (max-width: 860px) {
nav {
display: none;
}
nav + .btn {
margin-left: auto;
}
}
</style>
+244
View File
@@ -0,0 +1,244 @@
---
import { PLUGINS, REPO } from '../data/plugins';
import Icon from './Icon.astro';
// The scope shows what Knurl's "Fry" pattern does to a saw bass: every cycle gets its own
// level (0 / −14 / −5 / −14 dB), with a raised-cosine crossfade over 15 % of a cycle.
const W = 1200;
const H = 300;
const CYCLES = 8;
const STEPS = 900;
const pattern = [0, -14, -5, -14].map((db) => 10 ** (db / 20));
const edge = 0.15;
function gainAt(phase: number): number {
const cycle = Math.floor(phase);
const frac = phase - cycle;
const g = pattern[cycle % pattern.length];
if (frac < 1 - edge) return g;
const next = pattern[(cycle + 1) % pattern.length];
const t = (frac - (1 - edge)) / edge;
return g + (next - g) * (0.5 - 0.5 * Math.cos(Math.PI * t));
}
function wave(gain: (phase: number) => number): string {
const points: string[] = [];
for (let i = 0; i <= STEPS; i++) {
const phase = (i / STEPS) * CYCLES;
// A tanh-shaped saw, as in the Knurl prototype.
const saw = 2 * (phase - Math.floor(phase)) - 1;
const y = Math.tanh(2.2 * saw) / Math.tanh(2.2);
const x = (i / STEPS) * W;
points.push(`${x.toFixed(1)},${(H / 2 - y * gain(phase) * (H * 0.4)).toFixed(1)}`);
}
return `M${points.join('L')}`;
}
const dry = wave(() => 1);
const knurled = wave(gainAt);
const count = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten'][PLUGINS.length] ?? PLUGINS.length;
---
<section class="hero">
<div class="glow" aria-hidden="true"></div>
<div class="container inner">
<p class="eyebrow">CLAP plugins for FL Studio</p>
<h1>Bass <span>Foundry</span></h1>
<p class="lead">
{count[0].toUpperCase() + count.slice(1)} open-source plugins for writing, designing and mixing riddim, dubstep and
bass music. Most of them follow the played note, so a setting means the same thing on every note.
</p>
<div class="ctas">
<a class="btn btn-primary" href="#plugins">Browse the plugins<Icon name="arrow" /></a>
<a class="btn btn-ghost" href={`${REPO}/releases`}><Icon name="download" />All releases</a>
</div>
<ul class="facts" role="list">
<li>Free &amp; open source</li>
<li>CLAP</li>
<li>Windows x64</li>
<li>FL Studio 2024.1+</li>
<li>No accounts, no telemetry</li>
</ul>
<figure class="scope plate">
<figcaption>
<span class="label"><b>Knurl</b> / cycle texture · Fry pattern</span>
<span class="label readout">F1 · 43.7 Hz · 8 cycles</span>
</figcaption>
<svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label="A saw bass in grey, and the same bass in pink after Knurl gives each cycle its own level">
<defs>
<filter id="hero-glow" x="-5%" y="-20%" width="110%" height="140%">
<feGaussianBlur stdDeviation="6" />
</filter>
</defs>
<g class="grid">
{Array.from({ length: CYCLES + 1 }, (_, i) => <line x1={(i * W) / CYCLES} x2={(i * W) / CYCLES} y1="0" y2={H} />)}
<line class="axis" x1="0" x2={W} y1={H / 2} y2={H / 2} />
</g>
<path class="dry" d={dry} />
<path class="wave halo" d={knurled} pathLength="1" filter="url(#hero-glow)" />
<path class="wave" d={knurled} pathLength="1" />
</svg>
</figure>
</div>
</section>
<style>
.hero {
position: relative;
overflow: hidden;
padding-block: clamp(4rem, 9vw, 7rem) clamp(1rem, 3vw, 2rem);
}
.hero::before {
content: '';
position: absolute;
inset: 0;
background:
linear-gradient(rgb(184 192 204 / 0.045) 1px, transparent 1px) 0 0 / 48px 48px,
linear-gradient(90deg, rgb(184 192 204 / 0.045) 1px, transparent 1px) 0 0 / 48px 48px;
mask-image: linear-gradient(180deg, #000 40%, transparent);
pointer-events: none;
}
.glow {
position: absolute;
inset: auto -20% -10% -20%;
height: 70%;
background: radial-gradient(50% 60% at 50% 70%, rgb(255 1 96 / 0.16), transparent 70%);
pointer-events: none;
}
.inner {
position: relative;
display: grid;
gap: 1.5rem;
}
h1 {
font-size: clamp(3.6rem, 1.5rem + 9vw, 8.5rem);
font-weight: 700;
letter-spacing: -0.01em;
line-height: 0.92;
text-transform: uppercase;
}
h1 span {
display: block;
color: transparent;
background: linear-gradient(90deg, var(--accent), #ff5c95 55%, var(--steel));
background-clip: text;
}
.lead {
max-width: 640px;
color: #c3c7ce;
font-size: clamp(1.1rem, 1rem + 0.45vw, 1.3rem);
}
.ctas {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
margin-top: 0.5rem;
}
.facts {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1.4rem;
margin: 0.25rem 0 0;
padding: 0;
color: var(--faint);
font-family: var(--display);
font-size: 0.9rem;
letter-spacing: 0.1em;
list-style: none;
text-transform: uppercase;
}
.facts li {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.facts li::before {
content: '';
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent);
}
.scope {
margin: clamp(2rem, 5vw, 3.5rem) 0 0;
padding: 1rem 1.25rem 1.25rem;
}
figcaption {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0 0.6rem 0.75rem;
}
figcaption b {
color: var(--text);
font-weight: 600;
}
.readout {
color: var(--steel);
}
svg {
width: 100%;
height: auto;
border-radius: var(--radius-sm);
background: var(--bg);
}
.grid line {
stroke: rgb(184 192 204 / 0.08);
stroke-width: 1;
}
.grid .axis {
stroke: rgb(184 192 204 / 0.16);
}
.dry {
fill: none;
stroke: var(--dim);
stroke-width: 1.5;
stroke-dasharray: 5 5;
}
.wave {
fill: none;
stroke: var(--accent);
stroke-width: 2.25;
stroke-linejoin: round;
stroke-dasharray: 1;
stroke-dashoffset: 1;
animation: draw 2.6s cubic-bezier(0.3, 0.6, 0.2, 1) 0.2s forwards;
}
.halo {
stroke-width: 8;
opacity: 0.45;
}
@keyframes draw {
to {
stroke-dashoffset: 0;
}
}
@media (max-width: 640px) {
.readout {
display: none;
}
}
</style>
+42
View File
@@ -0,0 +1,42 @@
---
// A small stroke icon set, 24 × 24, drawn in currentColor.
const PATHS = {
download: ['M12 4v11', 'M7 10l5 5 5-5', 'M5 20h14'],
code: ['M8 7l-5 5 5 5', 'M16 7l5 5-5 5', 'M14 4l-4 16'],
book: ['M4 5a2 2 0 0 1 2-2h13v16H6a2 2 0 0 0-2 2z', 'M4 21V5', 'M8 7h7'],
arrow: ['M5 12h14', 'M13 6l6 6-6 6'],
back: ['M19 12H5', 'M11 6l-6 6 6 6'],
tag: ['M3 12V4a1 1 0 0 1 1-1h8l9 9-9 9z', 'M8 8h.01'],
list: ['M8 6h12', 'M8 12h12', 'M8 18h12', 'M4 6h.01', 'M4 12h.01', 'M4 18h.01'],
note: ['M9 18V5l11-2v13', 'M9 18a3 3 0 1 1-3-3 3 3 0 0 1 3 3z', 'M20 16a3 3 0 1 1-3-3 3 3 0 0 1 3 3z'],
shield: ['M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z', 'M9 12l2 2 4-4'],
clock: ['M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z', 'M12 7v5l3 2'],
plug: ['M9 3v5', 'M15 3v5', 'M6 8h12v3a6 6 0 0 1-12 0z', 'M12 17v4'],
open: ['M12 3l2.5 5.5L20 9l-4 4 1 6-5-3-5 3 1-6-4-4 5.5-.5z'],
cpu: ['M7 7h10v10H7z', 'M10 3v4', 'M14 3v4', 'M10 17v4', 'M14 17v4', 'M3 10h4', 'M3 14h4', 'M17 10h4', 'M17 14h4'],
eye: ['M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z', 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z'],
} as const;
interface Props {
name: keyof typeof PATHS;
class?: string;
}
const { name, class: className } = Astro.props;
---
<svg
class={className}
viewBox="0 0 24 24"
width="24"
height="24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
focusable="false"
>
{PATHS[name].map((d) => <path d={d} />)}
</svg>
+182
View File
@@ -0,0 +1,182 @@
---
// Two harmonic ladders: a fixed 130 Hz cut lands on a different harmonic of every note,
// while Lathe's "harmonic 3" stays on harmonic 3.
const NOTES = [
{ name: 'F1', hz: 43.65 },
{ name: 'C2', hz: 65.41 },
{ name: 'C3', hz: 130.81 },
];
const LO = 30;
const HI = 1000;
const W = 560;
const PAD = 54;
const ROW = 46;
const H = NOTES.length * ROW + 40;
const x = (hz: number) => PAD + ((Math.log(hz) - Math.log(LO)) / (Math.log(HI) - Math.log(LO))) * (W - PAD - 12);
const FIXED = 130;
function rows(pick: (f0: number) => number) {
return NOTES.map((n, i) => {
const target = pick(n.hz);
const harmonics = [];
for (let k = 1; k * n.hz <= HI; k++) harmonics.push({ k, x: x(k * n.hz), hit: k === target });
return { ...n, y: 24 + i * ROW, harmonics, target };
});
}
const fixed = rows((f0) => Math.round(FIXED / f0));
const tracked = rows(() => 3);
const ticks = [50, 100, 200, 500, 1000];
---
<section id="idea" class="section idea">
<div class="container">
<div class="section-head">
<p class="eyebrow">The idea</p>
<h2>One setting, every note</h2>
<p>
Riddim and dubstep basses change patch on every switch-up and move across an octave or two. Fixed-frequency tools
then act differently on every note: the sub jumps, an EQ cut lands on another harmonic, the distortion fizzes on
one note and goes dull on the next. Anvil, Lathe, Temper and Knurl track the pitch, from the audio or from MIDI,
and put their work on the note itself.
</p>
</div>
<div class="ladders">
{
[
{ title: 'A normal EQ: −12 dB at 130 Hz', note: 'hits harmonic', data: fixed, cls: 'fixed' },
{ title: 'Lathe: −12 dB on harmonic 3', note: 'hits harmonic', data: tracked, cls: 'tracked' },
].map((panel) => (
<figure class={`plate ladder ${panel.cls}`}>
<figcaption class="label">{panel.title}</figcaption>
<svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label={`${panel.title}: ${panel.data.map((r) => `${r.name} harmonic ${r.target}`).join(', ')}`}>
{ticks.map((t) => (
<g class="tick">
<line x1={x(t)} x2={x(t)} y1="6" y2={H - 22} />
<text x={x(t)} y={H - 6}>{t >= 1000 ? '1k' : t}</text>
</g>
))}
{panel.cls === 'fixed' && <line class="cut" x1={x(FIXED)} x2={x(FIXED)} y1="4" y2={H - 22} />}
{panel.data.map((r) => (
<g>
<text class="note" x="8" y={r.y + 5}>{r.name}</text>
<line class="rail" x1={PAD} x2={W - 12} y1={r.y} y2={r.y} />
{r.harmonics.map((h) => (
<circle class={h.hit ? 'hit' : 'harm'} cx={h.x} cy={r.y} r={h.hit ? 7 : 3.2} />
))}
{r.harmonics
.filter((h) => h.hit)
.map((h) => (
<text class="hit-label" x={h.x + 12} y={r.y - 8}>
h{h.k}
</text>
))}
</g>
))}
</svg>
</figure>
))
}
</div>
<p class="caption">
Dots are the harmonics of each note on a log frequency axis (Hz). The fixed cut takes the 3rd harmonic of F1, the
2nd of C2 and the fundamental of C3. The tracked cut stays on the 3rd.
</p>
</div>
</section>
<style>
.idea {
padding-bottom: 1rem;
}
.ladders {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.25rem;
}
.ladder {
margin: 0;
padding: 1.25rem 1.5rem 1rem;
}
figcaption {
margin-bottom: 0.75rem;
color: var(--text);
}
.tick line {
stroke: rgb(184 192 204 / 0.08);
}
.tick text {
fill: var(--faint);
font-family: var(--display);
font-size: 12px;
text-anchor: middle;
}
.note {
fill: var(--steel);
font-family: var(--display);
font-size: 15px;
font-weight: 600;
letter-spacing: 0.05em;
}
.rail {
stroke: rgb(184 192 204 / 0.16);
}
.harm {
fill: var(--dim);
}
.cut {
stroke: var(--accent);
stroke-width: 2;
stroke-dasharray: 5 4;
}
.fixed .hit {
fill: var(--accent);
}
.tracked .hit {
fill: var(--focus);
}
.hit {
stroke: var(--bg);
stroke-width: 2;
}
.hit-label {
font-family: var(--display);
font-size: 13px;
font-weight: 600;
}
.fixed .hit-label {
fill: #ff6b9f;
}
.tracked .hit-label {
fill: var(--focus);
}
.caption {
max-width: 720px;
margin-top: 1rem;
color: var(--faint);
font-size: 0.925rem;
}
@media (max-width: 860px) {
.ladders {
grid-template-columns: 1fr;
}
}
</style>
+136
View File
@@ -0,0 +1,136 @@
---
import { REPO } from '../data/plugins';
---
<section id="install" class="section install">
<div class="container cols">
<div class="section-head">
<p class="eyebrow">Install</p>
<h2>Into the rack in a minute</h2>
<p>
The plugins are built for FL Studio 2024.1 or newer on Windows x64. Nothing to register, no installer, no
account.
</p>
</div>
<ol class="steps" role="list">
<li class="plate">
<span class="n">01</span>
<div>
<h3>Download</h3>
<p>
Pick a plugin above, or open its release on <a href={`${REPO}/releases`}>Gitea</a>. Each ZIP has a
<code>.sha256</code> file next to it to check the download.
</p>
</div>
</li>
<li class="plate">
<span class="n">02</span>
<div>
<h3>Copy the .clap file</h3>
<p>Extract the ZIP and copy the <code>.clap</code> file into</p>
<pre><code>C:\Program Files\Common Files\CLAP\</code></pre>
</div>
</li>
<li class="plate">
<span class="n">03</span>
<div>
<h3>Rescan in FL Studio</h3>
<p>
Open <b>Options → Manage plugins</b>, enable <b>Verify plugins</b> and click <b>Find installed plugins</b>.
The plugins appear under the vendor <b>Bass Foundry</b>.
</p>
<p>
Upgrading from a build before November 2026? The plugin IDs changed, so delete the old
<code>.clap</code> files first and add the plugins again in existing projects.
</p>
</div>
</li>
<li class="plate">
<span class="n">04</span>
<div>
<h3>Load it</h3>
<p>
Rivet goes in the Channel Rack. All the others go in mixer effect slots; each plugin’s page says where it
sits best.
</p>
</div>
</li>
</ol>
</div>
</section>
<style>
.install {
border-block: 1px solid var(--line);
background: linear-gradient(180deg, var(--bg-2), var(--bg));
}
.cols {
display: grid;
grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
gap: clamp(2rem, 5vw, 4rem);
align-items: start;
}
.section-head {
position: sticky;
top: 6.5rem;
margin-bottom: 0;
}
.steps {
display: grid;
gap: 1rem;
margin: 0;
padding: 0;
list-style: none;
}
.steps li {
display: grid;
grid-template-columns: auto 1fr;
gap: 1.25rem;
padding: 1.5rem 1.75rem;
}
.n {
color: var(--accent);
font-family: var(--display);
font-size: 1.6rem;
font-weight: 700;
line-height: 1.1;
}
h3 {
margin-bottom: 0.4rem;
font-size: 1.35rem;
text-transform: uppercase;
}
b {
color: var(--text);
font-weight: 600;
}
pre {
margin: 0.7rem 0 0;
overflow-x: auto;
}
pre code {
display: block;
padding: 0.75rem 1rem;
background: var(--bg);
}
@media (max-width: 860px) {
.cols {
grid-template-columns: 1fr;
}
.section-head {
position: static;
}
}
</style>
+145
View File
@@ -0,0 +1,145 @@
---
import { Picture } from 'astro:assets';
import type { Plugin } from '../data/plugins';
import Icon from './Icon.astro';
interface Props {
plugin: Plugin;
}
const { plugin: p } = Astro.props;
---
<li class="card plate">
<a class="shot" href={`/plugins/${p.slug}/`} tabindex="-1" aria-hidden="true">
<Picture
src={p.shot}
alt=""
formats={['avif', 'webp']}
fallbackFormat="webp"
widths={[480, 720, 1040]}
sizes="(max-width: 860px) 100vw, 560px"
loading="lazy"
/>
</a>
<div class="body">
<div class="title">
<h3><a href={`/plugins/${p.slug}/`}>{p.name}</a></h3>
<span class="label">{p.role}</span>
<span class="badge">v{p.version}</span>
</div>
<p class="text">{p.description}.</p>
<dl class="meta">
<div><dt class="label">Load on</dt><dd>{p.loadOn}</dd></div>
<div><dt class="label">Latency</dt><dd>{p.latency}</dd></div>
</dl>
<div class="links">
<a class="btn btn-primary btn-sm" href={p.download}>
<Icon name="download" />Download<span class="visually-hidden"> {p.name} {p.version} for Windows</span>
</a>
<a class="btn btn-ghost btn-sm" href={`/plugins/${p.slug}/`}>Details<span class="visually-hidden"> on {p.name}</span></a>
{p.tracksNotes && <span class="badge badge-accent"><Icon name="note" />Follows the note</span>}
</div>
</div>
</li>
<style>
.card {
display: grid;
grid-template-rows: auto 1fr;
overflow: hidden;
transition:
border-color 0.2s,
transform 0.2s;
}
.card:hover {
border-color: var(--line-strong);
transform: translateY(-2px);
}
.shot {
display: block;
margin: 0.85rem 0.85rem 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--bg);
aspect-ratio: 16 / 10;
}
.shot :global(img) {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top center;
transition: transform 0.4s;
}
.card:hover .shot :global(img) {
transform: scale(1.025);
}
.body {
display: grid;
align-content: start;
gap: 0.9rem;
padding: 1.25rem 1.5rem 1.5rem;
}
.title {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.4rem 0.9rem;
}
h3 {
font-size: 1.85rem;
text-transform: uppercase;
}
h3 a {
text-decoration: none;
}
.title .badge {
margin-left: auto;
}
.text {
color: #c3c7ce;
}
.meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin: 0;
padding-block: 0.75rem;
border-block: 1px solid var(--line);
}
dd {
margin: 0.15rem 0 0;
color: var(--text);
font-size: 0.95rem;
}
.links {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
}
.links .badge-accent {
margin-left: auto;
}
.badge :global(svg) {
width: 0.95em;
height: 0.95em;
}
</style>
+37
View File
@@ -0,0 +1,37 @@
---
import { PLUGINS } from '../data/plugins';
import PluginCard from './PluginCard.astro';
---
<section id="plugins" class="section">
<div class="container">
<div class="section-head">
<p class="eyebrow">The plugins</p>
<h2>Cast, shaped and finished</h2>
<p>
Each plugin is a separate product with its own version, guide and release. Every download is a ZIP with one
<code>.clap</code> file and a checksum. The editor previews below are rendered from the plugins’ own code.
</p>
</div>
<ul class="grid" role="list">
{PLUGINS.map((plugin) => <PluginCard plugin={plugin} />)}
</ul>
</div>
</section>
<style>
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.5rem;
margin: 0;
padding: 0;
list-style: none;
}
@media (max-width: 860px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>
+116
View File
@@ -0,0 +1,116 @@
---
import Icon from './Icon.astro';
const ITEMS = [
{
icon: 'note',
title: 'Follows the note',
text: 'Pitch comes from the audio with a short lookahead, or from MIDI. Settings are counted in harmonics and cycles, not in Hz.',
},
{
icon: 'cpu',
title: 'Realtime-safe',
text: 'Every audio effect has a test that fails if the audio thread allocates memory. The audio thread never waits on a lock.',
},
{
icon: 'clock',
title: 'Honest latency',
text: 'Plugins that look ahead report it to the host, so FL Studio compensates the delay and the mix stays in time.',
},
{
icon: 'shield',
title: 'Tested without a DAW',
text: 'CI loads each plugin through the CLAP ABI and checks ports, latency, state and audio at 44.1, 48 and 96 kHz.',
},
{
icon: 'eye',
title: 'Shows its work',
text: 'The editors draw what the plugin hears and what it changes: cuts, harmonics, recovered level, cycle shapes.',
},
{
icon: 'open',
title: 'Open and private',
text: 'ISC-licensed Rust. No accounts, no licence keys, no network access and no telemetry.',
},
] as const;
---
<section id="principles" class="section">
<div class="container">
<div class="section-head">
<p class="eyebrow">Principles</p>
<h2>Built like tools</h2>
<p>The same rules hold for every plugin in the suite. CI checks the testable ones on every push.</p>
</div>
<ul class="grid" role="list">
{
ITEMS.map((item) => (
<li>
<span class="icon">
<Icon name={item.icon} />
</span>
<h3>{item.title}</h3>
<p>{item.text}</p>
</li>
))
}
</ul>
</div>
</section>
<style>
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
margin: 0;
padding: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--line);
list-style: none;
}
li {
display: grid;
align-content: start;
gap: 0.6rem;
padding: 1.75rem;
background: var(--panel);
}
.icon {
display: inline-grid;
place-items: center;
width: 2.6rem;
height: 2.6rem;
margin-bottom: 0.4rem;
border: 1px solid rgb(255 1 96 / 0.35);
border-radius: 10px;
background: var(--accent-soft);
color: var(--accent);
}
.icon :global(svg) {
width: 1.3rem;
height: 1.3rem;
}
h3 {
font-size: 1.3rem;
text-transform: uppercase;
}
@media (max-width: 960px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 600px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>
+267
View File
@@ -0,0 +1,267 @@
// Plugin data for the site. Names, descriptions and CLAP IDs come from plugins/catalog.json,
// versions from each wrapper's Cargo.toml and screenshots from docs/<plugin>/assets, so the
// site follows the repository. The texts below are the site's own copy.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { ImageMetadata } from 'astro';
export const REPO = 'https://git.narl.io/nvrl/bass-foundry';
// Astro runs from website/, both locally and in the Docker build.
const ROOT = resolve(process.cwd(), '..');
interface CatalogEntry {
name: string;
description: string;
manifest: string;
documentation: string;
clap_id: string;
}
const catalog: Record<string, CatalogEntry> = JSON.parse(readFileSync(resolve(ROOT, 'plugins/catalog.json'), 'utf8'));
const shots = import.meta.glob<ImageMetadata>('../../../docs/*/assets/editor.png', {
eager: true,
import: 'default',
});
function version(manifest: string): string {
const text = readFileSync(resolve(ROOT, manifest), 'utf8');
const package_ = text.split(/^\[/m).find((s) => s.startsWith('package]'));
const match = package_?.match(/^version\s*=\s*"([^"]+)"/m);
if (!match) throw new Error(`no version in ${manifest}`);
return match[1];
}
interface Copy {
/** Short role, shown like the editor header ("LATHE / HARMONIC EQ"). */
role: string;
/** What the name means in metalwork. */
metal: string;
/** The problem in a bass-music session. */
problem: string;
points: string[];
loadOn: string;
latency: string;
/** Takes its pitch from the audio or from MIDI. */
tracksNotes: boolean;
}
const COPY: Record<string, Copy> = {
rivet: {
role: 'Storyboard composition',
metal: 'A rivet joins separate pieces into one.',
problem:
'A good four-bar motif rarely becomes a phrase on its own. Copying and varying it by hand gives loops, not a line that goes somewhere.',
points: [
'Lay out a storyboard of roles: statement, answer, departure, return, rest.',
'Lock the notes, pitches or rhythms that define the motif; they survive into every block.',
'Join seams with a cut, a breath, a pickup or an approach note.',
'Audition with the internal voice or send MIDI to your synths, then export the phrase as MIDI.',
],
loadOn: 'Channel Rack',
latency: 'none',
tracksNotes: false,
},
patina: {
role: 'Resonance smoothing',
metal: 'A patina changes the surface of a metal and keeps its character.',
problem:
'Distortion chains build up harsh, ringing resonances. A static EQ cut takes out the tone along with the ring.',
points: [
'Finds ringing regions as they happen and pulls only those down.',
'Plain harmonic tone and transients pass; Selectivity sets what counts as harsh.',
'Four focus points on the plot to cut more or protect a region.',
'Delta monitoring to hear exactly what is removed.',
],
loadOn: 'Any mixer track',
latency: 'one analysis frame',
tracksNotes: false,
},
anvil: {
role: 'Sub anchor',
metal: 'An anvil is the solid base that everything is struck on.',
problem:
'Every switch-up patch brings its own sub: a different level, a different phase. The weight of the drop jumps from note to note.',
points: [
'Replaces the band below Split with a clean mono sine that follows the bass pitch.',
'The same peak level and the same start phase on every note, so the kick lands the same.',
'Pitch from detection with 80 ms lookahead, or from MIDI.',
'Linkwitz–Riley crossover; the stereo above the split is kept.',
],
loadOn: 'Bass bus',
latency: '80 ms',
tracksNotes: true,
},
crucible: {
role: 'Resampler',
metal: 'A crucible melts material down so it can be cast again.',
problem:
'Resampling is the core loop of riddim sound design: bounce, chop, trim, declick, normalise, find the key, name, save. Every time.',
points: [
'Always records, so the take you liked is already there when you press Capture.',
'Captures the last 1–16 bars on the FL bar grid.',
'Slices on a grid or on transients, then trims, declicks and normalises.',
'Exports key-labelled WAVs, the full loop and an optional Serum/Vital wavetable.',
],
loadOn: 'End of the chain to resample',
latency: 'none',
tracksNotes: false,
},
lathe: {
role: 'Harmonic EQ',
metal: 'A lathe turns a piece so every cut lands the same all the way round.',
problem:
'A cut at 130 Hz takes out the 3rd harmonic of F1, the 2nd of C2 and the fundamental of C3. The same bass sounds different on every note.',
points: [
'One band on each of harmonics 1–32 of the played note.',
'"−12 dB on harmonic 3" stays on harmonic 3, whatever note plays.',
'Odd/even balance, tilt over harmonic number, and Focus between the harmonics.',
'Pitch from the audio, or from MIDI.',
],
loadOn: 'Bass, after the synth',
latency: '≈ 67 ms',
tracksNotes: true,
},
temper: {
role: 'Tracked distortion',
metal: 'Tempering sets how hard and how tough a metal ends up.',
problem:
'A 2 kHz lowpass after a distortion lets 45 harmonics of F1 through but only 22 of F2, and a quieter patch is driven less. Some notes fizz, others go dull.',
points: [
'Evens out each note’s level before the shaper, so drive means the same on every patch.',
'Tone filters set in multiples of the note: Floor, Push and Bright follow the pitch.',
'Soft, Hard, Fold and Tube curves with bias for even harmonics.',
'4× oversampled, with auto gain for level-matched comparison.',
],
loadOn: 'Bass',
latency: '≈ 67 ms + oversampling',
tracksNotes: true,
},
weld: {
role: 'Layer phase alignment',
metal: 'A weld fuses two pieces into one.',
problem:
'Layered bass patches partly cancel where the layers overlap in the mids. The sum gets thin and hollow, and it changes with every switch-up.',
points: [
'Hears the other layer through the sidechain input.',
'Fits polarity and up to four allpass sections: phase only, the layer sounds the same on its own.',
'Shows what the cancellation cost and how much Weld recovered.',
'No latency. Learn on a drop, then hold the fit.',
],
loadOn: 'One layer’s track, other layer as sidechain',
latency: 'none',
tracksNotes: false,
},
knurl: {
role: 'Cycle texture',
metal: 'Knurling presses a ridged grip pattern into a surface.',
problem:
'Texture makes a riddim patch, but it usually comes from long chains of distortion, comb, formant and resampling, and it changes from note to note.',
points: [
'A level pattern over 1–16 cycles: in-tune subharmonic growl, rattle and fry.',
'Random level and timing per cycle, the same on every note or free-running.',
'A noise burst inside every cycle, tuned to the note.',
'Counted in cycles, not Hz or tempo, so the grain is the same on F1 and on C2.',
],
loadOn: 'Bass',
latency: '≈ 71 ms',
tracksNotes: true,
},
};
export interface Plugin extends Copy {
slug: string;
name: string;
description: string;
clapId: string;
version: string;
tag: string;
zip: string;
download: string;
release: string;
guide: string;
changelog: string;
shot: ImageMetadata;
}
export const PLUGINS: Plugin[] = Object.entries(catalog).map(([slug, entry]) => {
const copy = COPY[slug];
if (!copy) throw new Error(`no site copy for ${slug}; add it to src/data/plugins.ts`);
const shot = shots[`../../../docs/${slug}/assets/editor.png`];
if (!shot) throw new Error(`no screenshot for ${slug}`);
const v = version(entry.manifest);
const tag = `${slug}-v${v}`;
const zip = `${slug}-windows-x86_64-${v}.zip`;
return {
...copy,
slug,
name: entry.name,
description: entry.description,
clapId: entry.clap_id,
version: v,
tag,
zip,
download: `${REPO}/releases/download/${tag}/${zip}`,
release: `${REPO}/releases/tag/${tag}`,
guide: `${REPO}/src/branch/main/${entry.documentation}/README.md`,
changelog: `${REPO}/src/branch/main/${entry.documentation}/CHANGELOG.md`,
shot,
};
});
/** Ideas from docs/ideas.md that are not built yet. */
export const IDEAS: { name: string; role: string; text: string; texture?: boolean }[] = [
{
name: 'Ballast',
role: 'Switch-up loudness leveller',
text: 'Measures perceived loudness per switch-up and sets gain only, so every patch lands at the same loudness without compression.',
},
{
name: 'Gauge',
role: 'Low-end mono check',
text: 'Correlation and mono-sum loss per band and per note in the bass range, with the notes where widening breaks the sum marked.',
},
{
name: 'Bellows',
role: 'Pitch-tracked resonator',
text: 'A comb resonator tuned to the played note: noise, distortion or drums turn into metallic tone that is in key on every note.',
},
{
name: 'Anneal',
role: 'Tone and grit split',
text: 'Splits a sound into harmonic tone, the grit between the harmonics, and transients. More grit, same body and sub.',
texture: true,
},
{
name: 'Swarf',
role: 'In-key granular shavings',
text: 'Sprays grains of the bass back as a layer, each a whole number of cycles and retuned to the current note.',
texture: true,
},
{
name: 'Etch',
role: 'Texture imprint',
text: 'Imprints the fine surface of a scrape, a vocal or a field recording on the bass, locked to its harmonics.',
texture: true,
},
{
name: 'Chase',
role: 'Talking formants',
text: 'A vowel pad with drawn paths. Snap moves each formant peak onto a harmonic, so no vowel vanishes between two harmonics.',
texture: true,
},
{
name: 'Damascus',
role: 'Texture step sequencer',
text: 'Four gain-matched texture slots and a step grid that switches between them: a switch-up inside one held note.',
texture: true,
},
{
name: 'Loupe',
role: 'Texture meter',
text: 'Roughness, grit, metal and brightness per note, with every switch-up of a drop placed on a texture map.',
texture: true,
},
];
+55
View File
@@ -0,0 +1,55 @@
---
import '../styles/global.css';
import Footer from '../components/Footer.astro';
import Header from '../components/Header.astro';
interface Props {
title: string;
description: string;
noindex?: boolean;
/** Header without section links, for pages outside the main flow (legal pages, 404). */
minimal?: boolean;
lang?: string;
}
const { title, description, noindex = false, minimal = false, lang = 'en' } = Astro.props;
const site = Astro.site!;
const canonical = new URL(Astro.url.pathname, site);
const ogImage = new URL('/og.png', site);
---
<!doctype html>
<html lang={lang} dir="ltr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<meta name="description" content={description} />
<meta name="theme-color" content="#0b0c0e" />
<meta name="color-scheme" content="dark" />
{noindex && <meta name="robots" content="noindex" />}
<link rel="canonical" href={canonical} />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="sitemap" href="/sitemap-index.xml" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Bass Foundry" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={ogImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:locale" content="en_US" />
<meta name="twitter:card" content="summary_large_image" />
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<Header minimal={minimal} />
<main id="main">
<slot />
</main>
<Footer />
</body>
</html>
+88
View File
@@ -0,0 +1,88 @@
---
import Base from './Base.astro';
interface Props {
title: string;
description: string;
}
const { title, description } = Astro.props;
---
<Base title={title} description={description} noindex minimal>
<article class="container legal">
<nav class="langs" aria-label="Sprache / Language">
<a href="#de" hreflang="de">Deutsch</a>
<span aria-hidden="true">·</span>
<a href="#en" hreflang="en">English</a>
</nav>
<slot />
</article>
</Base>
<style>
.legal {
max-width: 780px;
padding-block: 4rem 5rem;
color: #c3c7ce;
font-size: 1rem;
}
.langs {
display: flex;
gap: 0.6rem;
margin-bottom: 2rem;
color: var(--faint);
font-size: 0.925rem;
font-family: var(--display);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.legal :global(section + section) {
margin-top: 4.5rem;
padding-top: 3.5rem;
border-top: 1px solid var(--border);
}
.legal :global(h1) {
font-size: clamp(2rem, 1.6rem + 1.6vw, 2.75rem);
margin-bottom: 1.5rem;
color: var(--text);
text-transform: uppercase;
}
.legal :global(h2) {
font-size: 1.3rem;
margin: 2.25rem 0 0.75rem;
color: var(--text);
}
.legal :global(p),
.legal :global(ul),
.legal :global(address) {
margin: 0 0 0.9rem;
}
.legal :global(address) {
font-style: normal;
line-height: 1.7;
}
.legal :global(ul) {
padding-left: 1.25rem;
}
.legal :global(li + li) {
margin-top: 0.35rem;
}
.legal :global(.note) {
padding: 0.9rem 1.1rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--panel);
color: var(--muted);
font-size: 0.925rem;
}
</style>
+39
View File
@@ -0,0 +1,39 @@
---
import Base from '../layouts/Base.astro';
---
<Base title="Not found · Bass Foundry" description="This page does not exist." noindex minimal>
<section class="container nf">
<p class="code">404</p>
<h1>Nothing cast here</h1>
<p>This page does not exist, or it moved.</p>
<a class="btn btn-primary" href="/">Back to the foundry</a>
</section>
</Base>
<style>
.nf {
display: grid;
justify-items: center;
gap: 1rem;
padding-block: 7rem;
text-align: center;
}
.code {
color: var(--accent);
font-family: var(--display);
font-size: clamp(5rem, 3rem + 8vw, 9rem);
font-weight: 700;
line-height: 1;
}
h1 {
font-size: 2rem;
text-transform: uppercase;
}
.btn {
margin-top: 1rem;
}
</style>
+193
View File
@@ -0,0 +1,193 @@
---
// Keep this text in step with docker/nginx.conf: it promises no access logs, no cookies and
// no third-party requests. The %%…%% placeholders are filled in when the container starts.
import Legal from '../layouts/Legal.astro';
---
<Legal title="Datenschutzerklärung · Bass Foundry" description="Datenschutzerklärung / Privacy policy">
<section id="de" lang="de">
<h1>Datenschutzerklärung</h1>
<h2>1. Verantwortlicher</h2>
<p>Verantwortlich für die Datenverarbeitung auf dieser Website im Sinne der Datenschutz-Grundverordnung (DSGVO):</p>
<address>
%%IMPRESSUM_NAME%%<br />
%%IMPRESSUM_STREET%%<br />
%%IMPRESSUM_CITY%%<br />
%%IMPRESSUM_COUNTRY%%<br />
E-Mail: <a href="mailto:%%IMPRESSUM_EMAIL%%">%%IMPRESSUM_EMAIL%%</a>
</address>
<h2>2. Das Wichtigste in Kürze</h2>
<ul>
<li>Diese Website ist eine reine Informationsseite. Es gibt keine Registrierung, keine Formulare und keine Kommentare.</li>
<li>Es werden keine Cookies gesetzt und keine Daten im Browser gespeichert (kein Local Storage, kein Session Storage).</li>
<li>Es gibt kein Tracking, keine Reichweitenmessung, keine Werbung und keine Social-Media-Plugins.</li>
<li>
Alle Bestandteile der Seite (Schriften, Bilder, Skripte, Stylesheets) werden von diesem Server ausgeliefert.
Inhalte Dritter werden nicht eingebunden; Ihr Browser baut beim Aufruf keine Verbindung zu anderen Servern auf.
</li>
<li>Der Webserver schreibt keine Zugriffsprotokolle.</li>
</ul>
<h2>3. Bereitstellung der Website</h2>
<p>
Wenn Sie diese Website aufrufen, übermittelt Ihr Browser technisch bedingt Daten an den Server: Ihre IP-Adresse,
Datum und Uhrzeit der Anfrage, die angeforderte Adresse, die Browserkennung (User-Agent), gegebenenfalls die zuvor
besuchte Seite (Referrer) und Ihre bevorzugten Sprachen (Accept-Language).
</p>
<p>
Diese Daten werden ausschließlich verarbeitet, um die Seite auszuliefern, und nur für die Dauer der jeweiligen
Verbindung im Arbeitsspeicher gehalten. Zugriffsprotokolle werden nicht geführt. Protokolliert werden lediglich
schwerwiegende technische Fehler des Servers selbst; solche Einträge werden durch die automatische
Protokollrotation fortlaufend überschrieben.
</p>
<p>
Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Das berechtigte Interesse liegt in der sicheren und funktionsfähigen
Bereitstellung der Website. Die Verbindung ist per TLS verschlüsselt.
</p>
<p data-optional="hoster">
Die Website wird bei folgendem Anbieter betrieben, der die genannten Daten in meinem Auftrag verarbeitet (Art. 28
DSGVO): %%HOSTER%%
</p>
<h2>4. Cookies und Speicherung im Browser</h2>
<p>
Diese Website setzt keine Cookies und greift nicht auf Informationen in Ihrem Endgerät zu, die über das für die
Auslieferung technisch Notwendige hinausgehen. Eine Einwilligung nach § 25 TDDDG ist daher nicht erforderlich.
</p>
<h2>5. Links zu anderen Websites</h2>
<p>
Quellcode, Anleitungen und Downloads der Plugins liegen auf git.narl.io, einer Gitea-Instanz. Erst wenn Sie einen
solchen Link anklicken oder einen Download starten, baut Ihr Browser eine Verbindung zu diesem Server auf; ab dann
gelten die dortigen Datenschutzhinweise. Vorher werden keine Daten dorthin übertragen.
</p>
<h2>6. Kontakt per E-Mail</h2>
<p>
Wenn Sie mir eine E-Mail schreiben, verarbeite ich Ihre Adresse und den Inhalt der Nachricht, um Ihr Anliegen zu
bearbeiten (Art. 6 Abs. 1 lit. f DSGVO, bei vertragsbezogenen Anfragen lit. b). Die Daten werden gelöscht, sobald sie
dafür nicht mehr benötigt werden und keine gesetzlichen Aufbewahrungspflichten bestehen.
</p>
<h2>7. Ihre Rechte</h2>
<p>Sie haben gegenüber dem Verantwortlichen das Recht auf</p>
<ul>
<li>Auskunft über die zu Ihnen gespeicherten Daten (Art. 15 DSGVO),</li>
<li>Berichtigung unrichtiger Daten (Art. 16 DSGVO),</li>
<li>Löschung (Art. 17 DSGVO) und Einschränkung der Verarbeitung (Art. 18 DSGVO),</li>
<li>Datenübertragbarkeit (Art. 20 DSGVO),</li>
<li>Widerspruch gegen Verarbeitungen, die auf Art. 6 Abs. 1 lit. f DSGVO beruhen (Art. 21 DSGVO).</li>
</ul>
<p>
Außerdem können Sie sich bei einer Datenschutz-Aufsichtsbehörde beschweren (Art. 77 DSGVO), insbesondere in dem
Mitgliedstaat Ihres Aufenthaltsorts, Ihres Arbeitsplatzes oder des Orts des mutmaßlichen Verstoßes.
</p>
<h2>8. Keine automatisierte Entscheidungsfindung</h2>
<p>Eine automatisierte Entscheidungsfindung einschließlich Profiling (Art. 22 DSGVO) findet nicht statt.</p>
<h2>9. Die vorgestellte Software</h2>
<p>
Diese Erklärung betrifft nur diese Website. Die vorgestellten Plugins laufen ausschließlich auf Ihrem Rechner in
Ihrer Musiksoftware. Sie bauen keine Netzwerkverbindungen auf, benötigen kein Konto und senden keine
Nutzungsdaten. Dateien, die ein Plugin schreibt (etwa die Exporte von Crucible), bleiben auf Ihrem Rechner. Der
Entwickler erhält über die Plugins keine Daten.
</p>
<p class="note">Stand: November 2026</p>
</section>
<section id="en" lang="en">
<h1>Privacy policy</h1>
<p class="note">Courtesy translation. The German version above is the legally binding one.</p>
<h2>1. Controller</h2>
<p>The controller responsible for data processing on this website under the General Data Protection Regulation (GDPR) is:</p>
<address>
%%IMPRESSUM_NAME%%<br />
%%IMPRESSUM_STREET%%<br />
%%IMPRESSUM_CITY%%<br />
%%IMPRESSUM_COUNTRY%%<br />
Email: <a href="mailto:%%IMPRESSUM_EMAIL%%">%%IMPRESSUM_EMAIL%%</a>
</address>
<h2>2. In short</h2>
<ul>
<li>This website only provides information. There is no registration, no forms and no comments.</li>
<li>No cookies are set and nothing is stored in your browser (no local storage, no session storage).</li>
<li>There is no tracking, no analytics, no advertising and no social media plugins.</li>
<li>
Every part of the page (fonts, images, scripts, stylesheets) is served by this server. No third-party content is
embedded, and your browser connects to no other server while loading it.
</li>
<li>The web server writes no access logs.</li>
</ul>
<h2>3. Serving the website</h2>
<p>
When you open this website, your browser necessarily sends data to the server: your IP address, the date and time of
the request, the requested address, the browser identification (user agent), possibly the previously visited page
(referrer), and your preferred languages (Accept-Language).
</p>
<p>
This data is processed only to deliver the page and is held in memory only for the duration of the connection. No
access logs are kept. Only serious technical errors of the server itself are logged, and automatic log rotation
continuously overwrites such entries.
</p>
<p>
The legal basis is Art. 6 (1) (f) GDPR. The legitimate interest is serving the website securely and reliably. The
connection is encrypted with TLS.
</p>
<p data-optional="hoster">
The website is operated by the following provider, which processes this data on my behalf (Art. 28 GDPR): %%HOSTER%%
</p>
<h2>4. Cookies and browser storage</h2>
<p>
This website sets no cookies and accesses no information on your device beyond what is technically required to
deliver it. Consent under § 25 TDDDG is therefore not required.
</p>
<h2>5. Links to other websites</h2>
<p>
The plugins’ source code, guides and downloads are on git.narl.io, a Gitea instance. Your browser only connects
to that server when you click such a link or start a download; from then on, the privacy information there
applies. No data is sent there before that.
</p>
<h2>6. Contact by email</h2>
<p>
If you email me, I process your address and the content of your message to handle your request (Art. 6 (1) (f) GDPR,
or (b) for contract-related requests). The data is deleted as soon as it is no longer needed for that purpose and no
statutory retention obligations apply.
</p>
<h2>7. Your rights</h2>
<p>You have the right to</p>
<ul>
<li>access the data stored about you (Art. 15 GDPR),</li>
<li>rectification of inaccurate data (Art. 16 GDPR),</li>
<li>erasure (Art. 17 GDPR) and restriction of processing (Art. 18 GDPR),</li>
<li>data portability (Art. 20 GDPR),</li>
<li>object to processing based on Art. 6 (1) (f) GDPR (Art. 21 GDPR).</li>
</ul>
<p>
You may also lodge a complaint with a data protection supervisory authority (Art. 77 GDPR), in particular in the
member state of your residence, your place of work or the place of the alleged infringement.
</p>
<h2>8. No automated decision-making</h2>
<p>No automated decision-making, including profiling (Art. 22 GDPR), takes place.</p>
<h2>9. The software presented here</h2>
<p>
This policy covers only this website. The plugins presented here run only on your computer, inside your music
software. They make no network connections, need no account and send no usage data. Files a plugin writes (such
as Crucible’s exports) stay on your computer. The developer receives no data through the plugins.
</p>
<p class="note">Last updated: November 2026</p>
</section>
</Legal>
+140
View File
@@ -0,0 +1,140 @@
---
// The %%…%% placeholders are filled in when the container starts, from the IMPRESSUM_*
// environment variables (docker/40-legal-notice.sh). Elements marked data-optional are
// removed when their variable is empty.
import Legal from '../layouts/Legal.astro';
---
<Legal title="Impressum · Bass Foundry" description="Impressum / Legal notice">
<section id="de" lang="de">
<h1>Impressum</h1>
<h2>Angaben gemäß § 5 DDG</h2>
<address>
%%IMPRESSUM_NAME%%<br />
%%IMPRESSUM_STREET%%<br />
%%IMPRESSUM_CITY%%<br />
%%IMPRESSUM_COUNTRY%%
</address>
<h2>Kontakt</h2>
<p>E-Mail: <a href="mailto:%%IMPRESSUM_EMAIL%%">%%IMPRESSUM_EMAIL%%</a></p>
<p data-optional="phone">Telefon: %%IMPRESSUM_PHONE%%</p>
<h2>Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV</h2>
<address>
%%IMPRESSUM_NAME%%<br />
%%IMPRESSUM_STREET%%<br />
%%IMPRESSUM_CITY%%
</address>
<h2>Über dieses Angebot</h2>
<p>
Diese Website stellt „Bass Foundry“ vor, eine private, nicht kommerzielle Sammlung von Open-Source-Audio-Plugins
im CLAP-Format. Die Plugins werden kostenlos und ohne Gewähr bereitgestellt. Das Projekt steht in keiner
Verbindung zur Image-Line Software NV und wird von ihr weder unterstützt noch empfohlen. „FL Studio“ ist eine
Marke von Image-Line; der Name wird hier ausschließlich verwendet, um zu beschreiben, mit welcher Software die
Plugins zusammenarbeiten.
</p>
<h2>Verbraucherstreitbeilegung</h2>
<p>
Ich bin nicht bereit und nicht verpflichtet, an Streitbeilegungsverfahren vor einer
Verbraucherschlichtungsstelle teilzunehmen.
</p>
<h2>Haftung für Inhalte</h2>
<p>
Die Inhalte dieser Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und
Aktualität der Inhalte kann ich jedoch keine Gewähr übernehmen. Als Diensteanbieter bin ich gemäß § 7 Abs. 1
DDG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 DDG
bin ich jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder
nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. Verpflichtungen zur Entfernung
oder Sperrung der Nutzung von Informationen nach den allgemeinen Gesetzen bleiben hiervon unberührt. Eine
diesbezügliche Haftung ist erst ab dem Zeitpunkt der Kenntnis einer konkreten Rechtsverletzung möglich. Bei
Bekanntwerden entsprechender Rechtsverletzungen werde ich diese Inhalte umgehend entfernen.
</p>
<h2>Haftung für Links</h2>
<p>
Diese Website enthält Links zu externen Websites Dritter (zum Beispiel den Quellcode und die Downloads auf git.narl.io),
auf deren Inhalte ich keinen Einfluss habe. Für diese fremden Inhalte ist stets der jeweilige Anbieter oder
Betreiber der Seiten verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche
Rechtsverstöße überprüft; rechtswidrige Inhalte waren zu diesem Zeitpunkt nicht erkennbar. Eine permanente
inhaltliche Kontrolle der verlinkten Seiten ist ohne konkrete Anhaltspunkte einer Rechtsverletzung nicht
zumutbar. Bei Bekanntwerden von Rechtsverletzungen werde ich derartige Links umgehend entfernen.
</p>
<h2>Urheberrecht</h2>
<p>
Die Plugins von „Bass Foundry“ stehen unter der ISC-Lizenz. Die in die Plugins eingebettete Schrift Roboto
Condensed sowie die auf dieser Website verwendeten Schriften Roboto Condensed und Inter stehen unter der SIL Open
Font License 1.1. Texte, Grafiken und Screenshots dieser Website sind, soweit nicht anders angegeben, eigene Werke;
die Screenshots werden aus dem Code der Plugins erzeugt.
</p>
</section>
<section id="en" lang="en">
<h1>Legal notice</h1>
<p class="note">
Courtesy translation. The German version above is the legally binding one.
</p>
<h2>Information pursuant to § 5 DDG (German Digital Services Act)</h2>
<address>
%%IMPRESSUM_NAME%%<br />
%%IMPRESSUM_STREET%%<br />
%%IMPRESSUM_CITY%%<br />
%%IMPRESSUM_COUNTRY%%
</address>
<h2>Contact</h2>
<p>Email: <a href="mailto:%%IMPRESSUM_EMAIL%%">%%IMPRESSUM_EMAIL%%</a></p>
<p data-optional="phone">Phone: %%IMPRESSUM_PHONE%%</p>
<h2>Responsible for the content pursuant to § 18 (2) MStV</h2>
<address>
%%IMPRESSUM_NAME%%<br />
%%IMPRESSUM_STREET%%<br />
%%IMPRESSUM_CITY%%
</address>
<h2>About this website</h2>
<p>
This website presents “Bass Foundry”, a private, non-commercial collection of open-source audio plugins in the
CLAP format. The plugins are provided free of charge and without warranty. The project is not affiliated with,
endorsed by, or supported by Image-Line Software NV. “FL Studio” is a trademark of Image-Line, used here only to
describe the software the plugins work with.
</p>
<h2>Consumer dispute resolution</h2>
<p>I am neither willing nor obliged to take part in dispute resolution proceedings before a consumer arbitration board.</p>
<h2>Liability for content</h2>
<p>
The content of these pages was prepared with great care, but I cannot guarantee that it is correct, complete
or up to date. As a service provider I am responsible for my own content on these pages under general law
(§ 7 (1) DDG). Under §§ 8 to 10 DDG I am not obliged to monitor third-party information that is transmitted or
stored, or to investigate circumstances that indicate illegal activity. Obligations to remove or block the use
of information under general law remain unaffected. Liability in this respect is only possible from the time
a specific infringement becomes known. I will remove such content as soon as I learn of an infringement.
</p>
<h2>Liability for links</h2>
<p>
This website links to external third-party websites (for example the source code and downloads on git.narl.io) whose
content I cannot influence. The respective provider or operator is always responsible for the content of linked
pages. The linked pages were checked for possible legal violations when the link was created, and no illegal
content was apparent at that time. Permanent monitoring of linked pages is not reasonable without concrete
evidence of an infringement. I will remove such links as soon as I learn of an infringement.
</p>
<h2>Copyright</h2>
<p>
The “Bass Foundry” plugins are released under the ISC License. The Roboto Condensed font embedded in the plugins,
and the Roboto Condensed and Inter fonts used on this website, are under the SIL Open Font License 1.1. Unless
stated otherwise, the text, graphics and screenshots on this website are my own work; the screenshots are
rendered from the plugins’ code.
</p>
</section>
</Legal>
+23
View File
@@ -0,0 +1,23 @@
---
import Bench from '../components/Bench.astro';
import Foundry from '../components/Foundry.astro';
import Hero from '../components/Hero.astro';
import Idea from '../components/Idea.astro';
import Install from '../components/Install.astro';
import Plugins from '../components/Plugins.astro';
import Principles from '../components/Principles.astro';
import Base from '../layouts/Base.astro';
---
<Base
title="Bass Foundry · CLAP plugins for bass music in FL Studio"
description="Open-source CLAP plugins for riddim, dubstep and bass music in FL Studio: sub anchor, harmonic EQ, tracked distortion, layer phase alignment, cycle texture, resampler and more."
>
<Hero />
<Idea />
<Plugins />
<Install />
<Principles />
<Foundry />
<Bench />
</Base>
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more