Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,15 +113,18 @@ zig-out/bin/slab songs/paper_boulevard.slab # open a saved project
zig build test # run unit tests
```

Requires zig >= 0.16 (see `build.zig.zon`). macOS on Apple Silicon only
Requires zig 0.17 (see `build.zig.zon`). macOS on Apple Silicon only
— fy's JIT uses `MAP_JIT` + `pthread_jit_write_protect_np`.

## Coding conventions

### General Zig style

- Zig >= 0.16 idioms. Use `std.Build.Module` / `createModule` for
- Zig 0.17 idioms. Use `std.Build.Module` / `createModule` for
build targets (already done in `build.zig`).
- 0.17 has no `**` array repetition and no `@cImport`: fill arrays
with `@splat`, and include C headers from `src/c_*.h` (fy: `fy/src/c.h`),
which `build.zig` translates into modules.
- Avoid allocations in hot paths. Use bounded arrays where size is
known (`[256]ClipId` over `ArrayList(ClipId)`).
- Prefer `extern struct` for any layout that crosses the Zig↔fy
Expand Down
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,51 @@ GitHub are its section here (`tools/release.sh` copies them), so add to

## Unreleased

- **Building from source needs Zig 0.17.** 0.16 no longer builds Slab,
and 0.17 no longer fails on the build script (#3).

- **Rigs.** Rack and FX Rack sit under a RACKS heading in the machine
picker, with factory rigs in their preset menus: Fretwire Clean Rig and
Crunch Rig, Bass DI + Amp, and Parallel Squash (an FX Rack). A rig
opens folded to its named macro knobs; the fold button shows the lanes.
The old split and layer racks are rewritten as lanes.
- **Group into a rack.** Shift-click devices in the bay to select a run,
then ⌘G moves them into a new Rack (or FX Rack) with their settings and
automation; ⌘⇧G on a one-lane rack takes them back out. ⌘G still groups
tracks when no devices are selected.
- **Rack panel.** KEY, VEL and CHAIN tabs edit each lane's zones as bars,
inserts reorder by dragging their title strip, MAP outlines every
control it can map, and turning a mapped control by hand moves its
macro.
- **Automate inside a rack.** Controls of the machines in a rack's lanes
now get automation lanes, named with the machine (`AMP2 GAIN`), and
they keep working when lanes or inserts move. Files write them as
paths such as `fx1/2.1:amp-gain` (docs/22).
- **slabkit builds racks.** `rack()`, `.lane()`, `.macro()` and `.map()`
make racks in Python, `song.track("Bass", "rack", "bass-di-amp")` loads
a rig, and `slab --describe` lists Rack and FX Rack with their macros.
`songs/rack_rigs.py` is a demo.

- **Rack macros.** Racks and FX Racks get eight macro knobs (`rk-m1`…`rk-m8`)
and a CHAIN control that automate like any knob. MAP, then click a
control inside the open lane (or a lane's LV or PN), puts it on the
picked macro; each mapping has a MIN and MAX in the control's own units
and an optional formula such as `x^2` or `x * (1 - m4)`. A macro can
drive a rack nested inside. Mapped controls light their automation LED
and follow the macro smoothly, and macros save with the rack's presets.
- **Headless renders play Racks.** `slab --render` and `--stems` rendered
a track with a Rack on it as silence; they now load it as the app does.

- **Rack lanes take effects, and there's an FX Rack.** Each lane of a Rack
now holds a chain: its instrument and up to 16 inserts, shown beside the
lane list the way the bay shows a track's, each with its own preset
menu, bypass, replace and remove, and + to add one. The new FX Rack goes in an effect slot and runs its
lanes in parallel on the track's signal; an empty lane is the dry path.
Lanes get SOLO, racks fit inside racks three deep, and a lane with a
latent insert no longer smears against the others: the rack lines them
up and reports the latency. Old racks open and sound as before
(docs/42).

- **Hammerwork Grand, a physically modeled piano.** Felt hammers strike up
to three detuned strings per key, which ring into a soundboard through
a moving bridge. Velocity changes the tone the way a real hammer does,
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ in [fy](fy/), a small concatenative language that lives in this repo.

### Build from source

You need [Zig](https://ziglang.org) 0.16 or later and raylib
You need [Zig](https://ziglang.org) 0.17 and raylib
(`brew install raylib`).

```bash
Expand Down
28 changes: 18 additions & 10 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ pub fn build(b: *std.Build) void {
const optimize = b.standardOptimizeOption(.{});

// Pass no options through — fy's build.zig registers its own `target`
// and `optimize` via standard*Option, and zig 0.16 surfaces a warning
// and `optimize` via standard*Option, and zig surfaces a warning
// when we forward them. Our exe module takes the locally-resolved
// target/optimize anyway; fy just needs to compile to the same ABI,
// which matches since both default to the host.
Expand All @@ -29,23 +29,23 @@ pub fn build(b: *std.Build) void {
.root_module = exe_mod,
});

configureNativeDeps(b, exe_mod);
configureNativeDeps(b, exe_mod, target, optimize);

const kernel_probe_mod = b.createModule(.{
.root_source_file = b.path("src/kernel_probe.zig"),
.target = target,
.optimize = optimize,
});
kernel_probe_mod.addImport("fy", fy_mod);
configureNativeDeps(b, kernel_probe_mod);
configureNativeDeps(b, kernel_probe_mod, target, optimize);

const kernel_probe = b.addExecutable(.{
.name = "kernel-probe",
.root_module = kernel_probe_mod,
});

const kernel_probe_cmd = b.addRunArtifact(kernel_probe);
if (b.args) |args| kernel_probe_cmd.addArgs(args);
kernel_probe_cmd.addPassthruArgs();
const kernel_probe_step = b.step("kernel-probe", "Run a testable Fy DSP kernel fixture");
kernel_probe_step.dependOn(&kernel_probe_cmd.step);

Expand All @@ -56,13 +56,13 @@ pub fn build(b: *std.Build) void {
.optimize = optimize,
});
bench_mod.addImport("fy", fy_mod);
configureNativeDeps(b, bench_mod);
configureNativeDeps(b, bench_mod, target, optimize);
const bench = b.addExecutable(.{
.name = "bench",
.root_module = bench_mod,
});
const bench_cmd = b.addRunArtifact(bench);
if (b.args) |args| bench_cmd.addArgs(args);
bench_cmd.addPassthruArgs();
const bench_step = b.step("bench", "Render machines headless: sheets, reports, goldens");
bench_step.dependOn(&bench_cmd.step);

Expand All @@ -74,13 +74,13 @@ pub fn build(b: *std.Build) void {
const codesign = b.addSystemCommand(&[_][]const u8{
"codesign", "-s", "-", "--force", "--entitlements", "entitlements.plist", "--options", "runtime",
});
codesign.addArg(b.getInstallPath(.bin, exe.out_filename));
codesign.addFileArg(.{ .relative = .{ .base = .install_bin, .sub_path = exe.out_filename } });
codesign.step.dependOn(&install.step);
b.getInstallStep().dependOn(&codesign.step);

const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
if (b.args) |args| run_cmd.addArgs(args);
run_cmd.addPassthruArgs();

const run_step = b.step("run", "Run Slab");
run_step.dependOn(&run_cmd.step);
Expand All @@ -97,8 +97,16 @@ pub fn build(b: *std.Build) void {
test_step.dependOn(&b.addRunArtifact(bench_tests).step);
}

fn configureNativeDeps(b: *std.Build, mod: *std.Build.Module) void {
// raylib via homebrew — @cImport in src/c.zig picks up the header here.
fn configureNativeDeps(b: *std.Build, mod: *std.Build.Module, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) void {
// C headers become modules src/c.zig imports (zig 0.17 has no @cImport).
const rl_c = b.addTranslateC(.{ .root_source_file = b.path("src/c_raylib.h"), .target = target, .optimize = optimize });
rl_c.addIncludePath(.{ .cwd_relative = "/opt/homebrew/include" });
mod.addImport("raylib_c", rl_c.createModule());
const ma_c = b.addTranslateC(.{ .root_source_file = b.path("src/c_miniaudio.h"), .target = target, .optimize = optimize });
ma_c.addIncludePath(b.path("vendor"));
mod.addImport("miniaudio_c", ma_c.createModule());

// raylib via homebrew.
mod.addIncludePath(.{ .cwd_relative = "/opt/homebrew/include" });
mod.addLibraryPath(.{ .cwd_relative = "/opt/homebrew/lib" });
mod.linkSystemLibrary("raylib", .{});
Expand Down
2 changes: 1 addition & 1 deletion build.zig.zon
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
.{
.name = .slab,
.version = "0.0.14",
.minimum_zig_version = "0.16.0",
.minimum_zig_version = "0.17.0",
.paths = .{""},
.fingerprint = 0xc659b8b8e507d92d,
.dependencies = .{
Expand Down
47 changes: 28 additions & 19 deletions docs/19-project-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,29 +68,38 @@ copies of the files only the project has: into the home folder's
A track's `instrument.params` and an effect's `params` in a project use
exactly the same map.

A **Rack** preset (`machines/rack/presets/`) holds parts instead of
params, each part a machine with its settings in that machine's own
form:
A **Rack** or **FX Rack** preset (`machines/rack/presets/`,
`machines/fxrack/presets/`) holds the rack's state beside the header:
lanes, each a chain of machines in their own saved form, then macros
and mappings ([42-racks.md](42-racks.md) §Files):

```json
{"slab": "preset", "schema": 1, "machine": "rack", "note": "CMI tour / gabriel2 / FISHINET",
"parts": [{"machine": "unfairlight", "name": "VOC1", "lo": 0, "hi": 40,
"vlo": 1, "vhi": 127, "transpose": 12, "level": 0, "pan": 0,
"poly": 2, "mute": false,
"params": {"cmi-filter": 144, "cmi-atk": 0.47},
"assets": {"voice": "lib:cmi/tour/gabriel2/VOC1.vc"},
"zones": {}}]}
{"slab": "preset", "schema": 1, "machine": "rack", "note": "…",
"rack": 2, "kind": "instrument",
"lanes": [{"name": "BASS", "lo": 0, "hi": 127, "vlo": 1, "vhi": 127,
"clo": 0, "chi": 127, "transpose": 0, "level": 0, "pan": 0, "poly": 0,
"instrument": {"machine": "bass", "preset": "p-bass"},
"effects": [{"machine": "amp2", "preset": "bass-vintage-15"}]}],
"macros": [{"name": "GROWL", "value": 0.4}, …],
"chain": 0,
"maps": [{"macro": 1, "lane": 1, "insert": 1, "control": "amp-gain", "min": 0.15, "max": 0.85}],
"collapsed": true}
```

- A note goes to every part whose key range (`lo`–`hi`) and velocity
range (`vlo`–`vhi`, 1–127) hold it, `transpose` semitones away, so
splits and layers are ranges that don't or do overlap.
- `poly` caps a part's notes at once (0 = the machine's own voices): at
- A note goes to every lane whose key range (`lo`–`hi`), velocity
range (`vlo`–`vhi`, 1–127) and CHAIN zone (`clo`–`chi`, against
`rk-chain`) hold it, `transpose` semitones away, so splits and layers
are ranges that don't or do overlap. An FX Rack's lanes all get the
input and have no `instrument`.
- `poly` caps a lane's notes at once (0 = the machine's own voices): at
the cap its oldest note is released first.
- `level` is dB (−60..+12), `pan` −1..1 (a balance).
- `params`, `assets` and `zones` are what the part's machine would take
as a track's instrument. Parts naming an unknown machine are dropped;
racks don't nest.
- A lane machine is what a track's instrument or effect would hold:
`params`, `assets`, `zones`, `state`. With `preset` and no `params`
it loads that preset. Machines the registry lacks are dropped; racks
nest three deep.
- The first form, `{"parts": [{"machine": …, "lo": …, "params": …}]}`,
still loads, each part a lane with no inserts.

## Project

Expand Down Expand Up @@ -154,8 +163,8 @@ The document is JSON. Top level:
| `instrument.zones` | the sampler's per-zone edits, by zone name (the sample's file stem): `{"clap": {"level": -6, "tune": 0, "decay": 0, "tone": 0, "cut": 0}}`. level in dB, tune in semitones, decay in seconds to −60 dB (0 = off), tone in octaves of filter offset, cut the choke: 0 the pack's (`group`/`off_by`), 1 none, n+1 choke group n (the zone joins it and is cut by it). Zones sharing a name (an SFZ label, a sample's layers and round robins) take the same edits. Only edited zones are written; names that don't match the loaded keymap are ignored. `"reverse": true` plays the sound backwards (a reversed copy of its samples, loop mirrored). A **copy** is a sound of its own, `"snare 2": {"copy": "snare", "key": 39, …}`: every zone of the named one-key sound on `key`, pitched as the original, with its own edits (sampler and Unfairlight kits). |
| `instrument.preset` / effect `preset` | the preset the settings started from, by its full name (`slab/glass-lead`), for the panel's label. `params` stay authoritative: loading marks the preset current without applying it. Missing or unknown = no label ("init"). |
| `instrument.unison` | host unison (docs/08 §Unison), when on or the pool is resized: `{"count": 4, "detune": 50, "spread": 0.7, "blend": 0.75, "voices": 8}`. count is voices per note (1–8), detune is cents from lowest to highest voice, spread and blend are 0–1, voices is a poly machine's pool (1–16). Missing fields take their defaults. |
| `instrument.state` | settings a machine keeps beyond flat params: a Rack's `{"parts": […]}`, the same form as its presets. |
| `effects` | insert chain, run in order, stereo. `bypass: true` passes audio through untouched. `key` (an index into `tracks`) sidechains the effect's detector from that track's pre-fader signal ([23-routing.md](23-routing.md)). |
| `instrument.state` | settings a machine keeps beyond flat params: a Rack's lanes, `{"rack": 2, "kind": "instrument", "lanes": [{…, "instrument": {"machine": id, "params": {…}}, "effects": […]}]}`, the same form as its presets, with its `macros`, `chain` and `maps` ([42-racks.md](42-racks.md) §Files). The first form, `{"parts": […]}`, still reads, as lanes without inserts. |
| `effects` | insert chain, run in order, stereo. `bypass: true` passes audio through untouched. `key` (an index into `tracks`) sidechains the effect's detector from that track's pre-fader signal ([23-routing.md](23-routing.md)). An effect also writes `assets` and `state` the way an instrument does, when its machine has them; files without them load as before. |
| `kind` | `"bus"` for a bus (a group or a return: no instrument, no clips; its input is what's routed to it). Missing = an audio track. |
| `folded` | On a bus: `true` hides a group's members in the arrangement and mixer. UI state only; missing = unfolded. |
| `output` | index into `tracks` of the bus the post-fader signal goes to. Missing = the master. |
Expand Down
46 changes: 46 additions & 0 deletions docs/20-machine-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ project or preset stores (docs/19): real units for knobs, the option
| [`kit`](#kit) | Skinwork Kit | instrument | 12 | `sk-` |
| [`ms20`](#ms20) | SM-24 Mono | instrument | mono | `—` |
| [`profit5`](#profit5) | Profit-5 | instrument | 8 | `p5-` |
| [`rack`](#rack) | Rack | instrument | 0 | `rk-` |
| [`rhodes`](#rhodes) | Rhodes E-Piano | instrument | 8 | `rd-` |
| [`sampler`](#sampler) | Sampler | instrument | 8 | `smp-` |
| [`unfairlight`](#unfairlight) | Unfairlight TMI | instrument | 8 | `cmi-` |
Expand All @@ -34,6 +35,7 @@ project or preset stores (docs/19): real units for knobs, the option
| [`eq2`](#eq2) | EQ | effect | — | `eq-` |
| [`era`](#era) | Era | effect | — | `era-` |
| [`funk`](#funk) | Funk Overload | effect | — | `funk-` |
| [`fxrack`](#fxrack) | FX Rack | effect | — | `rk-` |
| [`gate2`](#gate2) | Gate | effect | — | `gate-` |
| [`geq8`](#geq8) | GEQ | effect | — | `geq-` |
| [`limiter2`](#limiter2) | Limiter | effect | — | `lim-` |
Expand Down Expand Up @@ -1076,6 +1078,30 @@ Presets:
- `afterimage/solar-brass` — Short Oberheim-mode brass with a soft attack and detuned oscillators; Afterimage Express.
- `afterimage/vector-arp` — Narrow-pulse pluck with two-voice unison; Afterimage Express. Try sixteenths above middle C; delay is not included.

## rack

**Rack**, instrument, 0 voices.

| param | section | label | range | default | notes |
|---|---|---|---|---|---|
| `rk-m1` | MACROS | MACRO 1 | 0 … 1 | 0 | linear |
| `rk-m2` | MACROS | MACRO 2 | 0 … 1 | 0 | linear |
| `rk-m3` | MACROS | MACRO 3 | 0 … 1 | 0 | linear |
| `rk-m4` | MACROS | MACRO 4 | 0 … 1 | 0 | linear |
| `rk-m5` | MACROS | MACRO 5 | 0 … 1 | 0 | linear |
| `rk-m6` | MACROS | MACRO 6 | 0 … 1 | 0 | linear |
| `rk-m7` | MACROS | MACRO 7 | 0 … 1 | 0 | linear |
| `rk-m8` | MACROS | MACRO 8 | 0 … 1 | 0 | linear |
| `rk-chain` | MACROS | CHAIN | 0 … 127 | 0 | integer |

Presets:

- `bass-di-amp` — Fretwire Bass split into a compressed DI and a 15-inch amp. BLEND crossfades them, GROWL drives the amp, PUNCH squeezes the DI.
- `bass-pad-split` — Juno bass under the split point, string pad over it
- `epiano-pad-layer` — FM e-piano layered over a quiet Juno pad
- `fretwire-clean-rig` — Fretwire into a clean amp, slapback and a small room. PICKUP picks neck, both or bridge; TONE opens the tone pot and the amp's treble together.
- `fretwire-crunch-rig` — Fretwire into a crunch amp. DRIVE turns the gain up and the amp's level down with it, so the rig holds its loudness.

## rhodes

**Rhodes E-Piano**, instrument, 8 voices.
Expand Down Expand Up @@ -1540,6 +1566,26 @@ Presets:
- `squelch-bass` — Tight squeezed bass wah, every note the same bite
- `touch-wah` — Gentle touch wah on guitar or clav: each note opens by how hard it hits

## fxrack

**FX Rack**, stereo insert effect.

| param | section | label | range | default | notes |
|---|---|---|---|---|---|
| `rk-m1` | MACROS | MACRO 1 | 0 … 1 | 0 | linear |
| `rk-m2` | MACROS | MACRO 2 | 0 … 1 | 0 | linear |
| `rk-m3` | MACROS | MACRO 3 | 0 … 1 | 0 | linear |
| `rk-m4` | MACROS | MACRO 4 | 0 … 1 | 0 | linear |
| `rk-m5` | MACROS | MACRO 5 | 0 … 1 | 0 | linear |
| `rk-m6` | MACROS | MACRO 6 | 0 … 1 | 0 | linear |
| `rk-m7` | MACROS | MACRO 7 | 0 … 1 | 0 | linear |
| `rk-m8` | MACROS | MACRO 8 | 0 … 1 | 0 | linear |
| `rk-chain` | MACROS | CHAIN | 0 … 127 | 0 | integer |

Presets:

- `parallel-squash` — Parallel compression: the dry signal beside a crushed, saturated copy. AMOUNT brings the copy up under it.

## gate2

**Gate**, stereo insert effect.
Expand Down
12 changes: 10 additions & 2 deletions docs/22-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,19 @@ A lane targets one value:
- `volume` or `pan` of the track.
- A control of the track's instrument or of one of its effects, by the
control's param id.
- A control inside a rack, by a path below the rack's target:
`fx1/2.1:amp-gain` (lane 2, first insert of the rack in effect 1),
`inst/1.0:jn-cutoff` (lane 1's instrument of the track's instrument
rack). Up to three steps; see [42-racks.md](42-racks.md) §Automation
inside a rack.

In memory, a target is (slot, control index), where a slot is the
instrument or a stable id for an effect instance. Reordering effects
therefore keeps each lane attached to its machine. Files write the
effect's position at save time (§Project format).

Not targets for now: the master bus, Rack parts' inner params, and
tempo (no tempo map yet, docs/19).
Not targets for now: the master bus and tempo (no tempo map yet,
docs/19).

## Precedence

Expand Down Expand Up @@ -501,6 +506,9 @@ option indices.
- `inst:<param>`
- `fx<N>:<param>`: `N` is the effect's 0-based position in the chain
at save time.
- `inst/<L>.<P>:<param>` or `fx<N>/<L>.<P>:<param>`, with up to three
`/<L>.<P>` steps: down a rack to lane `L` (from 1), place `P` (0 the
lane's instrument, n its n-th insert), as positions at save time.
- A point is `[beat, value]` or `[beat, value, shape, tension]`.
`shape` defaults to `linear` and `tension` to 0.
- Unknown targets and ids are dropped, matching the loader's leniency
Expand Down
2 changes: 1 addition & 1 deletion docs/33-composing.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ movement, articulation, register and silence can do the same job.

## Discover the instruments before programming

From a source checkout on an Apple Silicon Mac with Zig 0.16:
From a source checkout on an Apple Silicon Mac with Zig 0.17:

```sh
zig build -Doptimize=ReleaseFast
Expand Down
Loading
Loading