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
85 changed files with 8653 additions and 77 deletions

No files matched your search

+8 -5
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)
@@ -85,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
@@ -163,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.
+16 -13
View File
@@ -136,20 +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` |
| Knurl | (new) | `io.storyboard.cycle-texture` |
| 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.
+6
View File
@@ -1,5 +1,11 @@
# 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.
+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 -8
View File
@@ -7,14 +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` |
| Knurl | `knurl-v0.1.0` | `knurl-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 {
+8 -8
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"
},
@@ -121,7 +121,7 @@
"manifest": "plugins/knurl/plugin/Cargo.toml",
"library": "knurl",
"documentation": "docs/knurl",
"clap_id": "io.storyboard.cycle-texture",
"clap_id": "io.narl.bass-foundry.knurl",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
+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,
+1 -1
View File
@@ -861,7 +861,7 @@ dependencies = [
[[package]]
name = "knurl"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"clap-sys",
"crossbeam-queue",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "knurl"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
license = "ISC"
+1 -1
View File
@@ -180,7 +180,7 @@ impl Plugin for Knurl {
}
}
impl ClapPlugin for Knurl {
const CLAP_ID: &'static str = "io.storyboard.cycle-texture";
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;
+1 -1
View File
@@ -61,7 +61,7 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
assert_eq!(CStr::from_ptr((*descriptor).name), c"Knurl");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.storyboard.cycle-texture"
c"io.narl.bass-foundry.knurl"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
+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,
+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>
+297
View File
@@ -0,0 +1,297 @@
---
import { Picture } from 'astro:assets';
import Icon from '../../components/Icon.astro';
import { PLUGINS, type Plugin } from '../../data/plugins';
import Base from '../../layouts/Base.astro';
export function getStaticPaths() {
return PLUGINS.map((plugin, i) => ({
params: { slug: plugin.slug },
props: {
plugin,
prev: PLUGINS[(i + PLUGINS.length - 1) % PLUGINS.length],
next: PLUGINS[(i + 1) % PLUGINS.length],
},
}));
}
interface Props {
plugin: Plugin;
prev: Plugin;
next: Plugin;
}
const { plugin: p, prev, next } = Astro.props;
---
<Base title={`${p.name} · ${p.role} · Bass Foundry`} description={`${p.name}: ${p.description}. A free CLAP plugin for FL Studio.`}>
<article class="container page">
<a class="back" href="/#plugins"><Icon name="back" />All plugins</a>
<header class="head">
<p class="eyebrow">{p.role}</p>
<h1>{p.name}</h1>
<p class="lead">{p.description}.</p>
<div class="ctas">
<a class="btn btn-primary" href={p.download}><Icon name="download" />Download {p.version}</a>
<a class="btn btn-ghost" href={p.guide}><Icon name="book" />Full guide</a>
</div>
<p class="file">{p.zip} · Windows x64 · CLAP</p>
</header>
<figure class="shot plate">
<Picture
src={p.shot}
alt={`The ${p.name} editor`}
formats={['avif', 'webp']}
fallbackFormat="webp"
widths={[720, 1040, p.shot.width]}
sizes="(max-width: 1180px) 100vw, 1120px"
loading="eager"
/>
</figure>
<div class="cols">
<div class="text">
<section>
<h2>The problem</h2>
<p>{p.problem}</p>
</section>
<section>
<h2>What {p.name} does</h2>
<ul class="points" role="list">{p.points.map((point) => <li>{point}</li>)}</ul>
</section>
<p class="metal">{p.metal}</p>
</div>
<aside class="facts plate" aria-label={`${p.name} facts`}>
<dl>
<div><dt class="label">Version</dt><dd>{p.version}</dd></div>
<div><dt class="label">Load on</dt><dd>{p.loadOn}</dd></div>
<div><dt class="label">Latency</dt><dd>{p.latency}{p.latency !== 'none' && ', reported to the host'}</dd></div>
{p.tracksNotes && <div><dt class="label">Pitch</dt><dd>Follows the note, from audio or MIDI</dd></div>}
<div><dt class="label">Format</dt><dd>CLAP · Windows x64 · FL Studio 2024.1+</dd></div>
<div><dt class="label">CLAP ID</dt><dd><code>{p.clapId}</code></dd></div>
</dl>
<ul class="links" role="list">
<li><a href={p.release}><Icon name="tag" />Release {p.tag}</a></li>
<li><a href={p.changelog}><Icon name="list" />Changelog</a></li>
<li><a href={p.guide}><Icon name="book" />Guide</a></li>
</ul>
</aside>
</div>
<nav class="pager" aria-label="More plugins">
<a href={`/plugins/${prev.slug}/`}>
<span class="label">Previous</span>
<span class="name"><Icon name="back" />{prev.name}</span>
</a>
<a class="next" href={`/plugins/${next.slug}/`}>
<span class="label">Next</span>
<span class="name">{next.name}<Icon name="arrow" /></span>
</a>
</nav>
</article>
</Base>
<style>
.page {
padding-block: 2.5rem 5rem;
}
.back {
display: inline-flex;
align-items: center;
gap: 0.45rem;
color: var(--muted);
font-family: var(--display);
font-size: 0.9rem;
letter-spacing: 0.12em;
text-decoration: none;
text-transform: uppercase;
}
.back :global(svg),
.links :global(svg),
.name :global(svg) {
width: 1.1em;
height: 1.1em;
}
.head {
display: grid;
gap: 1.1rem;
max-width: 760px;
margin: 2.5rem 0 2.75rem;
}
h1 {
font-size: clamp(3.5rem, 2rem + 6vw, 6.5rem);
font-weight: 700;
line-height: 0.95;
text-transform: uppercase;
}
.lead {
color: #c3c7ce;
font-size: clamp(1.15rem, 1rem + 0.5vw, 1.4rem);
}
.ctas {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
}
.file {
color: var(--faint);
font-family: var(--mono);
font-size: 0.85rem;
}
.shot {
margin: 0;
padding: clamp(0.6rem, 2vw, 1.4rem);
}
.shot :global(img) {
width: 100%;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.cols {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: clamp(2rem, 5vw, 4rem);
margin-top: clamp(2.5rem, 5vw, 4rem);
align-items: start;
}
.text {
display: grid;
gap: 2.25rem;
}
h2 {
margin-bottom: 0.75rem;
font-size: 1.7rem;
text-transform: uppercase;
}
.points {
display: grid;
gap: 0.7rem;
margin: 0;
padding: 0;
list-style: none;
}
.points li {
position: relative;
padding-left: 1.6rem;
color: #c3c7ce;
}
.points li::before {
content: '';
position: absolute;
top: 0.62em;
left: 0;
width: 0.65rem;
height: 2px;
background: var(--accent);
}
.metal {
padding-left: 1rem;
border-left: 2px solid var(--accent);
color: var(--steel);
font-family: var(--display);
font-size: 1.15rem;
font-style: italic;
}
.facts {
position: sticky;
top: 6rem;
padding: 1.5rem 1.75rem;
}
dl {
display: grid;
gap: 0.9rem;
margin: 0;
}
dd {
margin: 0.1rem 0 0;
color: var(--text);
overflow-wrap: anywhere;
}
.links {
display: grid;
gap: 0.5rem;
margin: 1.4rem 0 0;
padding: 1.2rem 0 0;
border-top: 1px solid var(--line);
list-style: none;
}
.links a {
display: inline-flex;
align-items: center;
gap: 0.55rem;
text-decoration: none;
}
.pager {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-top: 4.5rem;
padding-top: 2rem;
border-top: 1px solid var(--line);
}
.pager a {
display: grid;
gap: 0.25rem;
padding: 1.1rem 1.4rem;
border: 1px solid var(--line);
border-radius: var(--radius);
text-decoration: none;
}
.pager a:hover {
border-color: var(--line-strong);
}
.pager .next {
justify-items: end;
text-align: right;
}
.name {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--text);
font-family: var(--display);
font-size: 1.5rem;
font-weight: 600;
text-transform: uppercase;
}
@media (max-width: 860px) {
.cols {
grid-template-columns: 1fr;
}
.facts {
position: static;
}
}
</style>
+6
View File
@@ -0,0 +1,6 @@
import type { APIRoute } from 'astro';
export const GET: APIRoute = ({ site }) =>
new Response(`User-agent: *\nAllow: /\n\nSitemap: ${new URL('/sitemap-index.xml', site)}\n`, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
+320
View File
@@ -0,0 +1,320 @@
@import '@fontsource-variable/inter';
@import '@fontsource-variable/roboto-condensed';
/* The palette of the plugin editors (plugins/common/ui), slightly deepened for a page. */
:root {
--bg: #0b0c0e;
--bg-2: #0e0f11;
--panel: #15171a;
--panel-2: #191b1f;
--line: rgb(184 192 204 / 0.12);
--line-strong: rgb(184 192 204 / 0.24);
--text: #e8e9ec;
--muted: #9aa0a9;
--faint: #6b717b;
--dim: #464a52;
--accent: #ff0160;
--accent-soft: rgb(255 1 96 / 0.14);
--steel: #b8c0cc;
--focus: #7ea7d8;
--display: 'Roboto Condensed Variable', 'Roboto Condensed', 'Arial Narrow', sans-serif;
--body: 'Inter Variable', Inter, system-ui, sans-serif;
--mono: ui-monospace, 'SFMono-Regular', 'JetBrains Mono', Menlo, Consolas, monospace;
--radius: 14px;
--radius-sm: 8px;
--max: 1180px;
color-scheme: dark;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
scroll-padding-top: 5rem;
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--muted);
font-family: var(--body);
font-size: 1.0625rem;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
h1,
h2,
h3,
h4 {
margin: 0;
color: var(--text);
font-family: var(--display);
font-weight: 600;
line-height: 1.08;
letter-spacing: -0.005em;
}
p {
margin: 0;
}
a {
color: var(--text);
text-decoration-color: var(--dim);
text-underline-offset: 0.2em;
transition: color 0.15s, text-decoration-color 0.15s;
}
a:hover {
color: var(--accent);
text-decoration-color: currentColor;
}
:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 3px;
border-radius: 4px;
}
img,
svg {
display: block;
max-width: 100%;
}
img {
height: auto;
}
code,
kbd {
font-family: var(--mono);
font-size: 0.88em;
}
code {
padding: 0.12em 0.4em;
border: 1px solid var(--line);
border-radius: 5px;
background: var(--panel-2);
color: var(--text);
}
::selection {
background: var(--accent);
color: #0b0c0e;
}
.container {
width: 100%;
max-width: var(--max);
margin-inline: auto;
padding-inline: clamp(1.1rem, 4vw, 2rem);
}
.section {
padding-block: clamp(4rem, 8vw, 6.5rem);
}
.section-head {
display: grid;
gap: 0.9rem;
max-width: 720px;
margin-bottom: clamp(2.5rem, 5vw, 3.75rem);
}
.section-head h2 {
font-size: clamp(2.1rem, 1.5rem + 2.4vw, 3.25rem);
text-transform: uppercase;
}
.section-head p {
font-size: 1.125rem;
}
/* Small uppercase label, the way the editors label their panels. */
.eyebrow {
display: inline-flex;
align-items: center;
gap: 0.6rem;
color: var(--accent);
font-family: var(--display);
font-size: 0.875rem;
font-weight: 600;
letter-spacing: 0.2em;
text-transform: uppercase;
}
.eyebrow::before {
content: '';
width: 1.6rem;
height: 2px;
background: currentColor;
}
.label {
color: var(--faint);
font-family: var(--display);
font-size: 0.8rem;
font-weight: 500;
letter-spacing: 0.16em;
text-transform: uppercase;
}
/* A steel plate with four rivets in the corners. */
.plate {
position: relative;
border: 1px solid var(--line);
border-radius: var(--radius);
background:
radial-gradient(circle at 12px 12px, var(--dim) 2.5px, transparent 3px),
radial-gradient(circle at calc(100% - 12px) 12px, var(--dim) 2.5px, transparent 3px),
radial-gradient(circle at 12px calc(100% - 12px), var(--dim) 2.5px, transparent 3px),
radial-gradient(circle at calc(100% - 12px) calc(100% - 12px), var(--dim) 2.5px, transparent 3px),
linear-gradient(180deg, var(--panel-2), var(--panel));
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.04),
0 20px 50px -30px rgb(0 0 0 / 0.8);
}
/* Knurled band: the crossed ridges of a knurled grip. */
.knurl {
height: 10px;
border-block: 1px solid var(--line);
background:
repeating-linear-gradient(45deg, rgb(184 192 204 / 0.16) 0 1px, transparent 1px 7px),
repeating-linear-gradient(-45deg, rgb(184 192 204 / 0.16) 0 1px, transparent 1px 7px),
var(--bg-2);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.55rem;
min-height: 2.9rem;
padding: 0.6rem 1.2rem;
border: 1px solid transparent;
border-radius: var(--radius-sm);
font-family: var(--display);
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.08em;
line-height: 1;
text-decoration: none;
text-transform: uppercase;
white-space: nowrap;
transition:
background 0.15s,
border-color 0.15s,
color 0.15s,
transform 0.15s,
box-shadow 0.15s;
}
.btn svg {
width: 1.1em;
height: 1.1em;
flex: none;
}
.btn-primary {
background: var(--accent);
color: #0b0c0e;
box-shadow: 0 8px 28px -10px rgb(255 1 96 / 0.7);
}
.btn-primary:hover {
background: #ff2b7a;
color: #0b0c0e;
transform: translateY(-1px);
box-shadow: 0 12px 34px -10px rgb(255 1 96 / 0.85);
}
.btn-ghost {
border-color: var(--line-strong);
background: rgb(255 255 255 / 0.02);
color: var(--text);
}
.btn-ghost:hover {
border-color: var(--steel);
background: rgb(255 255 255 / 0.05);
color: var(--text);
}
.btn-sm {
min-height: 2.35rem;
padding: 0.45rem 0.85rem;
font-size: 0.875rem;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.2rem 0.6rem;
border: 1px solid var(--line-strong);
border-radius: 999px;
color: var(--steel);
font-family: var(--display);
font-size: 0.8rem;
font-weight: 500;
letter-spacing: 0.06em;
white-space: nowrap;
}
.badge-accent {
border-color: rgb(255 1 96 / 0.45);
background: var(--accent-soft);
color: #ff6b9f;
}
.skip-link {
position: absolute;
top: -100px;
left: 1rem;
z-index: 100;
padding: 0.6rem 1rem;
border-radius: var(--radius-sm);
background: var(--accent);
color: #0b0c0e;
font-weight: 600;
}
.skip-link:focus {
top: 1rem;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}