From 110ba867d9ef2852a801901eba492fd24352e5de Mon Sep 17 00:00:00 2001 From: Marcin Gasperowicz Date: Fri, 16 Oct 2026 23:20:04 +0100 Subject: [PATCH 1/9] docs: racks design - chains in lanes, macros with formulas, rig presets (docs/42) Co-Authored-By: Claude Opus 5.5 --- docs/42-racks.md | 355 +++++++++++++++++++++++++++++++++++++++++++++++ docs/README.md | 2 + 2 files changed, 357 insertions(+) create mode 100644 docs/42-racks.md diff --git a/docs/42-racks.md b/docs/42-racks.md new file mode 100644 index 00000000..81c50786 --- /dev/null +++ b/docs/42-racks.md @@ -0,0 +1,355 @@ +# 42 — Racks: chains in lanes, macros, rigs + +**Status: proposal, phase 1 built.** `src/chain.zig` holds the chain +machinery tracks use, and effects save their `state` and `assets`. The +rest is still to build. The Rack that ships today +(`src/machines/rack.zig`) is described under "Where we start". + +A rack packages machines into one device. Each of its lanes holds a +chain, an instrument and its effects or just effects, and the lanes play +in parallel. Up to eight macro knobs on the rack's face drive any +controls inside it, each through a range and an optional formula. Saved +as a preset, a rack becomes a device you can drop on a track: a +"Fretwire Clean Rig" that loads Fretwire Guitar, Ampstack, a delay and a +room, with PICKUP, DRIVE, TONE and ROOM on its face. This is the shape +of Ableton Live's Instrument and Audio Effect Racks, with Slab's +hardware look. + +## Where we start + +The current Rack is an instrument rack only: + +- Up to 8 parts. Each is one instrument with a key range, a velocity + range, transpose, level, pan, a polyphony cap and mute. +- Parts render serially and are summed. +- Rack presets and projects store the parts as `{"parts":[…]}` + (`rack.zig:507-558`, `document.zig` instrument `state`). + +What it lacks: + +- Effects in a part. +- A use in an effect slot. +- Nesting (`rack.zig:292` skips natives). +- Automatable controls (docs/22 says so). +- Latency, tail and wake handling for its parts. +- Any form slabkit can see (`describe.zig` walks fy machines only). + +Racks extend this machine rather than adding a second container. Old +rack presets and projects load and sound as they do today. + +## Model + +``` +Rack + kind instrument | effect (from the slot it sits in) + macros [8] Macro name, value 0..1, color index + chain 0..127 the CHAIN selector, itself a control + lanes [≤8] Lane + name, on, solo, level dB, pan + zones key lo..hi, vel lo..hi, chain lo..hi (instrument rack: all three; + effect rack: chain only) + transpose, poly (instrument racks, as today) + chain instrument? + effects[≤16] the same Chain a track has + maps [≤64] Mapping + macro index, target (lane, slot uid, control id), min, max, formula? +``` + +**One machine, two kinds.** +- **Instrument rack:** in an instrument slot. It routes notes to the lanes + whose key, velocity and chain zones hold them, as parts do now. Each + lane renders its instrument, then its effects. +- **Effect rack:** in an effect slot. Every lane gets the rack's input. + A lane with no effects is a dry path, which is how a parallel-compression + rack or a DI-plus-amp bass rig is built. + +The kind comes from the slot. A rack preset records its kind so the +picker offers it only in the right slot. + +**Lanes reuse the track's chain.** The `Effect` slot type (uid, bypass, +sidechain key, PDC dry line, idle skip, wet crossfade) and its +UI→audio handoff live in `src/chain.zig` (`chain.State`). A track +and a rack lane share that one implementation: adopting by uid and +version, retiring replaced machines, latency, and the quiet state. It +also means everything that works on a track's effects works inside a +lane, sidechain keys included. + +**Nesting.** A rack can sit inside a lane, up to three levels deep. The +`rack.zig:292` guard becomes a depth check. A rack's latency, tail and +wake are its lanes', so a nested rack behaves like any machine. + +**Mixing and latency.** +- Lanes sum after their own level, pan, mute and solo. +- Lanes with different latency are aligned to the slowest one, with a + per-lane delay, before the sum. The rack reports that latency to the + track's PDC. +- An idle lane (silent instrument, every effect quiet) is skipped like + an idle track. +- Lanes render serially on their track's worker. Lanes on the render + pool are a later step, only if a heavy rig needs it. + +**Capacities** are bounded arrays: 8 lanes, 16 effects per lane, 8 +macros, 64 mappings in all, depth 3. A rack with every lane full is 136 +machines, so the retirement queue grows to match. + +## Macros + +A macro is a rack control like any other: `MACRO 1`…`MACRO 8` (ids +`rk-m1`…`rk-m8`), plus `CHAIN` (`rk-chain`). Because they're ordinary +controls they automate, take presets, show on the panel and slabkit +addresses them with no new code (`inst:rk-m2`, `fx1:rk-m4`). Each macro +has a name the panel shows in place of `MACRO n`. + +### Mappings + +A mapping ties one macro to one control inside the rack: + +- the target: lane, slot uid, control id. It can also be a lane's own + level or pan, or a nested rack's macro, which is how macros reach down + a level. +- `min` and `max`, in the target's real units, so a mapping reads + "AMP GAIN 2 → 7.5" or "DELAY MIX 0 % → 35 %". With `min > max` the + knob turns the target down. +- an optional **formula** that shapes the macro before the range. + +The macro's value `x` (0..1) passes through the formula, which gives `t` +(clamped to 0..1). The target then moves to `lerp(norm(min), norm(max), t)` +in its own knob space. A target with an exponential curve stays +exponential, so a cutoff mapped 200 Hz → 8 kHz sweeps the way its own +knob does. With no formula, `t = x`. + +**Formulas** are short expressions: + +``` +x the macro +m1 … m8 the other macros, for cross-coupling ("x * (1 - m3)") ++ - * / ^ and parentheses +min max clamp lerp abs exp log sqrt sin cos +step(x, n) snap to n steps (a 4-way PICKUP macro on a 3-way switch) +``` + +Examples: + +| formula | effect | +|---|---| +| `x^2` | slow start | +| `1 - x` | turns the target down as the macro goes up | +| `clamp(2*x - 1, 0, 1)` | does nothing for the first half of the turn | +| `x * (1 - m4)` | DRIVE fades out as macro 4 (CLEAN) comes up | + +The UI parses a formula into at most 32 postfix ops over fixed +registers. A typo stays in the field marked in red and the old formula +keeps running. The audio thread evaluates the program once per 32-sample +sub-block, the same rate automation uses. Evaluating it allocates +nothing and can't fail: division by zero gives 0, and NaN becomes 0. + +### Reaching the target + +Inner machines take macro values through a new audio-side **mod input** +on the host machine interface: `set_mod(control_index, norm)` and +`clear_mod(control_index)`. They are called only from the audio thread, +between sub-blocks. + +- `fy_raw_machine.zig` keeps a bit per control and a norm per control. +- After automation it applies mod values, so a macro wins over the + inner control's own automation and the knob. +- The usual 20 ms smoothing applies, so turning a macro is as smooth as + turning the knob. + +This is generic host plumbing, with no knowledge of any instrument. The +UI reads the same value back and draws a mapped knob at the macro's +position, with a mapped mark. Hand moves on a mapped knob pass through +to the macro. + +A switch target (Fretwire SELECT, Ampstack CAB) gets the norm snapped to +its option, so a stepped macro changes a switch the same way clicking it +would. + +## UI + +The rack draws in the bay like other devices, wider. It has three parts, +left to right: + +``` +┌ GUITAR RIG ───────────────────────────── [▾][MAP][ON] ┐ +│ ┌MACROS──────────────┐ ┌LANES──────────────┐ ┌LANE 1: CLEAN ─────── … ┐ +│ │ PICKUP DRIVE TONE │ │▣ CLEAN S ▮▮▮▯ C│ │ [FRETWIRE][AMPSTACK] │ +│ │ (o) (o) (o) │ │▣ DIRTY S ▮▮▯▯ C│ │ [DELAY] [VERB] │ +│ │ ROOM … │ │ + LANE │ │ (the chain's devices) │ +│ │ (o) │ ├ZONES KEY|VEL|CHN─┤ │ │ +│ └────────────────────┘ │ ▬▬▬▬▬▬ ▬▬▬▬ │ └────────────────────────┘ +└────────────────────────┴───────────────────┘ +``` + +1. **Macros:** eight knobs in two rows of four, at the panel's large + tier. Each knob carries its name and a count of its mappings. +2. **Lanes:** one row per lane, with ON, name, solo, a level bar and pan. + Under the list sit the zone editor's KEY, VEL and CHAIN tabs, with + one bar per lane, as in Live. +3. **The selected lane's chain:** its devices are drawn inline as nested + bay columns, under a bracket header that shows the depth. + +**Collapsed face.** The ▾ in the title strip collapses the rack to its +macro knobs. A rack loaded from a preset opens collapsed, showing only +its macros. That face is the pseudomachine: on the track it reads as one +device, GUITAR RIG, with four knobs. + +**MAP mode.** Pick a macro and arm MAP. Every mappable control in the +open lane gets an outline, and clicking one adds a mapping. Under the +macros, a table lists the picked macro's mappings: target, MIN, MAX and +the formula as a text field. Dragging MIN or MAX on a row sets the range +the same way dragging the target's knob does. + +The colors and widgets are the existing tokens: amber marks the armed +MAP and the selected lane and nothing else. No new widget kinds are +needed except the zone bars and the mapping table rows. + +**Group into a rack.** Select a run of devices on a track (the +instrument and some effects, or only effects) and press ⌘G. The run +moves into a new rack's first lane, with its settings and automation +intact (see "Automation inside a rack"). Ungroup does the reverse when +the rack has one lane and no mappings. + +## Presets: rigs + +A rack preset is the rack's whole state: kind, lanes and their chains, +each device's params and assets in its own preset form, zones, mixer +settings, macros and mappings. + +- Factory rack presets live in `machines/rack/presets/`, banked by + folder. +- The machine picker lists them under **RACKS**, filtered by kind. + Picking one inserts a rack and loads the preset. +- The bay's preset menu saves User and Project racks as it does for any + machine. + +Existing preset storage carries this with no new item kind (docs/25): +racks are a machine with presets. The one change is that a preset can +hold `state` beside `params`, which the rack already relies on. + +**Factory rigs to ship with phase 5:** + +- **Fretwire Clean Rig.** Guitar → amp2 (clean) → delay2 → verb2. + Macros: PICKUP (SELECT stepped), TONE (TONE pot + amp treble), ECHO, + ROOM. +- **Fretwire Crunch Rig.** As Clean with the crunch amp. DRIVE couples + amp gain and output level (`x` and `1 - 0.5*x`) so the level holds. +- **Bass DI + Amp.** Fretwire Bass, then an effect rack: lane 1 is DI + through comp2, lane 2 is amp2 (vintage 15). Macros: BLEND (the lane + levels, opposite ways), GROWL, PUNCH. +- **Parallel Squash** (effect rack). Lane 1 is dry, lane 2 is a heavy + comp2 with sat2. Macro AMOUNT. +- **Split and layer.** The current rack presets, rewritten in the new + form. + +## Automation inside a rack + +The macros cover most needs. Inner controls can also be automated +directly, so grouping loses nothing: + +- **The target path** extends `automation.Target` with a path of up to + three `(lane, slot uid)` steps under the rack's own target. Files + write slot positions, which load back to uids. +- **In files** it reads as the rack's target, then the path: + `fx1/2.1:amp-gain` is the rack in effect slot 1, lane 2, slot 1, control + `amp-gain`. +- **Resolving lanes.** `publishLanes` resolves the path to the inner + machine. `AutoView` gains the path, so `matches` stays a cheap compare. +- **Mapped controls.** A mapped control can't hold automation of its + own: the macro owns it. Grouping moves such lanes onto the macro when + the mapping is one-to-one, and otherwise keeps the lane and leaves the + control unmapped. + +## Files and compatibility + +**Rack state** gains `"rack": 2`: + +```json +{"rack": 2, "kind": "instrument", + "macros": [{"name": "DRIVE", "value": 0.4}, …], + "chain": 0, + "lanes": [{"name": "CLEAN", "on": true, "solo": false, "level": 0, "pan": 0, + "key": [0, 127], "vel": [1, 127], "select": [0, 127], + "transpose": 0, "poly": 0, + "instrument": {"machine": "guitar", "params": {…}, "preset": "twin-humbucker"}, + "effects": [{"machine": "amp2", "uid": 1, "params": {…}, "bypass": false}, …]}], + "maps": [{"macro": 1, "lane": 0, "slot": 1, "control": "amp-gain", + "min": 2.0, "max": 7.5, "formula": "x^2"}]} +``` + +- **Old rack state still loads.** State with `"parts"` and no `"rack"` + key reads as version 1. Each part becomes a lane holding its + instrument with its key, velocity, transpose, level, pan, poly and + mute, and no effects or macros. Projects with racks render + bit-identically, which the bench and a project render check. +- **Effects gain `state` and `assets`** in the project, so an effect slot + can hold a rack. Files name slots by chain position, as `fx` + lanes already do; uids stay in memory. Files without them load as + today. *(Built in phase 1.)* +- **Automation targets:** a target with no path is today's target. + Older Slab ignores a lane whose target it can't parse and keeps the + rest (docs/22). +- The project `SCHEMA` stays 1. These are additions that older files + never contain. + +## slabkit + +```python +from slabkit import Song, fx, rack, lane, macro + +rig = rack( + lanes=[lane("CLEAN", "guitar", "twin-humbucker", + fx=[fx("amp2", "guitar-clean"), fx("delay2", "slapback"), fx("verb2", "chamber")])], + macros=[macro("DRIVE", 0.3, maps=[("CLEAN.1:amp-gain", 2, 7.5, "x^2"), + ("CLEAN.1:amp-level", 0.8, 0.5)])]) +t = song.track("GTR", rig) # or song.track("GTR", "rack", "fretwire-clean-rig") +t.automate("rk-m1", {"chorus": (0.3, 0.8)}) +``` + +`describe.zig` lists the rack, its controls and its presets, so +`slab --describe` and the machine database see it. + +## Build phases + +Each phase lands on its own with tests, and existing goldens stay +bit-exact. + +1. **Chain extraction.** *Built.* + - `chain.State` in `src/chain.zig` holds the handoff, retirement, the + audio copy and the dry lines; the owner keeps the editable + instrument and effects. Tracks use it unchanged: songs render + byte-identical. + - Projects write effect `state` and `assets`. +2. **Lanes with chains.** + - Rack lanes hold an instrument plus effects; add the effect-rack + kind, nesting, per-lane latency alignment, tail and wake, and idle + skip. + - Rack state v2 with v1 reading. + - Tests: a v1 project renders bit-identically; a one-lane rack equals + its chain on a bare track. +3. **Macros.** + - Rack controls `rk-m1..8` and `rk-chain`; mappings, the formula + compiler and its VM; the `set_mod` host input in `fy_raw_machine`. + - Tests cover formula parsing and evaluation, and a macro sweep + matching the same sweep made with automation. +4. **UI.** The macro block, the lane list and zones, the inline lane + chain, collapse, MAP mode and the mapping table, and ⌘G group and + ungroup. +5. **Rigs.** + - Factory rack presets, the RACKS section of the picker, and the + slabkit `rack/lane/macro` builders with `describe`. + - Docs: this file moves from proposal to reference, plus docs/19, + docs/20, docs/22 and CHANGELOG. +6. **Inner automation.** Target paths, AutoView paths, and moving lanes + on group. + +## Open choices + +These are my defaults. Each is worth a word before phase 3. + +- **8 macros.** Live 12 allows up to 16. Eight fits two rows at the large + tier and has been enough for Live rigs for a decade. +- **Formulas stay this small.** There are no conditionals or state, so + there are no LFOs inside formulas. Modulation sources belong in their + own machines. +- **Lanes render serially.** Lanes on the render pool can come later, if + a rig needs it. diff --git a/docs/README.md b/docs/README.md index 25925976..a8ead1db 100644 --- a/docs/README.md +++ b/docs/README.md @@ -129,6 +129,8 @@ status and the source before assuming a feature is absent or complete. 42. [41-physical-models.md](41-physical-models.md) — Hammerwork Grand and Skinwork Kit: modal strings, membranes and plates, implicit hammer and stick contact, the `kernels/10-physical` building blocks +43. [42-racks.md](42-racks.md) — racks (proposal, phase 1 built): chains + in parallel lanes, macro knobs with ranges and formulas, rig presets ## Terminology crib sheet From 85c03fd3b2e1e3417674ea28ace62be0f5b588a2 Mon Sep 17 00:00:00 2001 From: Marcin Gasperowicz Date: Fri, 16 Oct 2026 23:20:05 +0100 Subject: [PATCH 2/9] =?UTF-8?q?audio:=20move=20chain=20publication=20into?= =?UTF-8?q?=20chain.zig;=20effects=20save=20state=20and=20assets=20(docs/4?= =?UTF-8?q?2=20=C2=A7Build=20phases=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chain.State holds the latest-value handoff, retirement, the audio copy and dry lines, so rack lanes can share what tracks use. Tracks keep the editable instrument and effects; songs render byte-identical. Co-Authored-By: Claude Opus 5.5 --- docs/19-project-format.md | 2 +- src/chain.zig | 280 ++++++++++++++++++++++++++++++++++++++ src/document.zig | 55 ++++++++ src/engine.zig | 30 ++-- src/track.zig | 247 ++++----------------------------- 5 files changed, 376 insertions(+), 238 deletions(-) create mode 100644 src/chain.zig diff --git a/docs/19-project-format.md b/docs/19-project-format.md index a38d1859..b2badd17 100644 --- a/docs/19-project-format.md +++ b/docs/19-project-format.md @@ -155,7 +155,7 @@ The document is JSON. Top level: | `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)). | +| `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. | diff --git a/src/chain.zig b/src/chain.zig new file mode 100644 index 00000000..7ab886a1 --- /dev/null +++ b/src/chain.zig @@ -0,0 +1,280 @@ +//! Chain: an instrument and a bounded insert chain, published from UI to +//! audio as one value (docs/42 §Model). The owner (a track, later a rack +//! lane) keeps the editable instrument and effect list; `State` holds what +//! crosses threads: the latest-value handoff, the retirement queue, the +//! audio thread's adopted copy and the dry lines bypassed latent effects +//! keep their latency in. +//! +//! UI prepares and publishes complete chains; audio owns its adopted chain +//! until the next adoption. Replaced machines retire on UI only after audio +//! acknowledges a chain without them. + +const std = @import("std"); +const machine = @import("machine.zig"); +const routing = @import("routing.zig"); +const handoff = @import("handoff.zig"); + +/// One slot in an insert chain: the effect machine, its registry index +/// (for persistence/forward-compat), and a per-effect bypass flag. UI +/// bypass edits publish with the complete chain; audio reads its adopted +/// copy. The editable ArrayList and its allocations are never traversed by +/// a render worker. Peak telemetry uses stable atomic storage shared by +/// both copies. +pub const Effect = struct { + mach: machine.Machine, + idx: ?u8 = null, + /// Stable per-chain id that automation lanes address (docs/22 §Targets): + /// it survives reordering the chain. + uid: u16 = 0, + version: u64 = 0, + bypass: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + /// Sidechain key (docs/23 §Sidechain keys): the track whose pre tap + /// drives this effect's detector, or routing.NONE. + key: u8 = routing.NONE, + /// Block peaks the engine writes after each render and the bay's I/O + /// meters read: in L, in R, out L, out R, as f32 bits. + shared_io: ?*[4]std.atomic.Value(u32) = null, + io_peak: [4]std.atomic.Value(u32) = [_]std.atomic.Value(u32){std.atomic.Value(u32).init(0)} ** 4, + /// Audio thread: samples its input and output have both been silent, + /// saturating; past the machine's hold it isn't rendered (docs/04 + /// §Idle skipping). + quiet: u32 = 0, + /// Audio thread (docs/07 §Declicking): the wet share the last chunk + /// ended on; bypass crossfades it to 0 and back over a chunk. + wet: f32 = 1, + /// Audio thread: the dry line holding this bypassed effect's latency + /// (-1: none). With one, the effect keeps rendering while bypassed and + /// its dry path is delayed by its latency, so bypass neither re-times + /// the PDC graph nor brings back stale buffers. + dry_line: i8 = -1, + + pub fn setIo(self: *Effect, in: [2]f32, out: [2]f32) void { + const v = [4]f32{ in[0], in[1], out[0], out[1] }; + for (self.shared_io orelse &self.io_peak, v) |*a, x| a.store(@bitCast(x), .monotonic); + } + + pub fn io(self: *const Effect) struct { in: [2]f32, out: [2]f32 } { + var v: [4]f32 = undefined; + for (&v, self.shared_io orelse &self.io_peak) |*x, *a| x.* = @bitCast(a.load(.monotonic)); + return .{ .in = .{ v[0], v[1] }, .out = .{ v[2], v[3] } }; + } +}; + +pub const MAX_EFFECTS: usize = 128; +pub const DRY_LINES = 4; +/// A dry line's length: the largest latency it can hold, a power of two. +pub const DRY_LEN = 8192; +pub const DryLine = struct { l: [DRY_LEN]f32, r: [DRY_LEN]f32, w: usize }; + +const Retired = struct { mach: machine.Machine, io: ?*[4]std.atomic.Value(u32) = null }; +const Published = struct { instrument: machine.Machine, effects: [MAX_EFFECTS]Effect = undefined, count: usize = 0, generation: u64 = 0, instrument_generation: u64 = 0 }; +const PublishedEffect = struct { uid: u16, version: u64, bypass: bool }; + +/// What crosses threads for one chain. Large (the effect arrays and the dry +/// lines), so the owner heap-allocates it with `create`. +pub const State = struct { + latest: handoff.Latest(Published), + retired: handoff.Retirement(Retired, 128) = .{}, + + // UI side. + generation: u64 = 0, + instrument_generation: u64 = 0, + published_instrument_generation: u64 = std.math.maxInt(u64), + published_count: usize = std.math.maxInt(usize), + published_effects: [MAX_EFFECTS]PublishedEffect = undefined, + next_uid: u16 = 1, + + // Audio side. + audio_machine: machine.Machine, + audio_instrument_generation: u64 = 0, + audio_effects: [MAX_EFFECTS]Effect = undefined, + audio_effect_count: usize = 0, + previous_effects: [MAX_EFFECTS]Effect = undefined, + /// Delay lines for bypassed latent effects (Effect.dry_line), touched + /// on UI at creation so audio never takes their first faults. + dry: [DRY_LINES]DryLine = undefined, + dry_used: u8 = 0, + + pub fn create(alloc: std.mem.Allocator, instrument: machine.Machine) !*State { + const s = try alloc.create(State); + s.* = .{ .latest = .init(.{ .instrument = instrument }), .audio_machine = instrument }; + @memset(std.mem.asBytes(&s.dry), 0); + return s; + } + + /// Frees the machines waiting to retire and the state itself; the + /// owner frees its live instrument and effects. + pub fn destroy(self: *State, alloc: std.mem.Allocator) void { + self.retired.drain(alloc, dispose); + alloc.destroy(self); + } + + fn dispose(alloc: std.mem.Allocator, old: Retired) void { + if (old.mach.deinit) |d| d(old.mach.state, alloc); + if (old.io) |io| alloc.destroy(io); + } + + // ---- UI thread ---- + + pub fn collect(self: *State, alloc: std.mem.Allocator) void { + self.retired.collect(alloc, dispose); + } + + fn reserve(self: *State, alloc: std.mem.Allocator) !void { + self.collect(alloc); + if (!self.retired.available()) return error.PublicationFull; + } + + /// Publish the chain if it differs from the last one published. + pub fn publish(self: *State, instrument: machine.Machine, effects: []Effect) void { + var changed = self.published_instrument_generation != self.instrument_generation or self.published_count != effects.len; + if (!changed) for (effects, 0..) |*fx, i| { + const old = self.published_effects[i]; + if (old.uid != fx.uid or old.version != fx.version or old.bypass != fx.bypass.load(.monotonic)) { + changed = true; + break; + } + }; + if (!changed) return; + self.published_instrument_generation = self.instrument_generation; + self.published_count = effects.len; + self.generation += 1; + const v = self.latest.write(); + v.instrument = instrument; + v.instrument_generation = self.instrument_generation; + v.generation = self.generation; + v.count = effects.len; + std.debug.assert(v.count <= MAX_EFFECTS); + for (effects, 0..) |*fx, i| { + const bypass = fx.bypass.load(.monotonic); + v.effects[i] = .{ .mach = fx.mach, .idx = fx.idx, .uid = fx.uid, .version = fx.version, .key = fx.key, .shared_io = fx.shared_io }; + v.effects[i].bypass.store(bypass, .monotonic); + self.published_effects[i] = .{ .uid = fx.uid, .version = fx.version, .bypass = bypass }; + } + self.latest.publish(); + } + + /// Swap the instrument; the old one retires once audio lets go of it. + pub fn replaceInstrument(self: *State, alloc: std.mem.Allocator, instrument: *machine.Machine, effects: []Effect, mach: machine.Machine) !void { + try self.reserve(alloc); + const old = instrument.*; + instrument.* = mach; + self.instrument_generation += 1; + self.publish(instrument.*, effects); + self.retired.append(self.generation, .{ .mach = old }); + } + + pub fn addEffect(self: *State, alloc: std.mem.Allocator, instrument: machine.Machine, effects: *std.ArrayList(Effect), mach: machine.Machine, idx: u8) !void { + if (effects.items.len >= MAX_EFFECTS) return error.TooManyEffects; + const io = try alloc.create([4]std.atomic.Value(u32)); + errdefer alloc.destroy(io); + io.* = @splat(.init(0)); + try effects.append(alloc, .{ .mach = mach, .idx = idx, .uid = self.next_uid, .shared_io = io }); + self.next_uid +%= 1; + self.publish(instrument, effects.items); + } + + pub fn removeEffect(self: *State, alloc: std.mem.Allocator, instrument: machine.Machine, effects: *std.ArrayList(Effect), i: usize) !void { + if (i >= effects.items.len) return; + try self.reserve(alloc); + const old = effects.orderedRemove(i); + self.publish(instrument, effects.items); + self.retired.append(self.generation, .{ .mach = old.mach, .io = old.shared_io }); + } + + pub fn moveEffect(self: *State, instrument: machine.Machine, effects: *std.ArrayList(Effect), from: usize, to: usize) void { + const n = effects.items.len; + if (from >= n or to >= n or from == to) return; + const e = effects.orderedRemove(from); + effects.insertAssumeCapacity(@min(to, effects.items.len), e); + self.publish(instrument, effects.items); + } + + pub fn replaceEffect(self: *State, alloc: std.mem.Allocator, instrument: machine.Machine, effects: []Effect, i: usize, mach: machine.Machine, idx: u8) !void { + if (i >= effects.len) return error.InvalidEffect; + try self.reserve(alloc); + const slot = &effects[i]; + const old = slot.mach; + slot.mach = mach; + slot.idx = idx; + slot.version += 1; + self.publish(instrument, effects); + self.retired.append(self.generation, .{ .mach = old }); + } + + // ---- audio thread ---- + + /// Take the newest published chain, if there is one; `apply` makes it + /// the one that renders. + pub fn adopt(self: *State) bool { + if (!self.latest.adopt()) return false; + machine.codeBarrier(); + return true; + } + + /// The adopted version swaps the instrument or the inserts, not only + /// bypass states. + pub fn changes(self: *State) bool { + const v = self.latest.current(); + if (v.instrument_generation != self.audio_instrument_generation or v.count != self.audio_effect_count) return true; + for (v.effects[0..v.count], self.audioEffects()) |a, b| { + if (a.uid != b.uid or a.version != b.version) return true; + } + return false; + } + + /// Render the adopted chain from now on, carrying each surviving + /// effect's audio state over by uid and version. Returns whether the + /// instrument changed. + pub fn apply(self: *State) bool { + const v = self.latest.current(); + const previous = self.previous_effects[0..self.audio_effect_count]; + @memcpy(previous, self.audioEffects()); + for (v.effects[0..v.count], 0..) |fx, i| { + const e = &self.audio_effects[i]; + e.* = fx; + const bypass = fx.bypass.load(.monotonic); + // New here: it starts where it stands, no fade. + e.wet = if (bypass) 0 else 1; + e.dry_line = -1; + for (previous) |old| if (old.uid == fx.uid and old.version == fx.version) { + e.quiet = old.quiet; + e.wet = old.wet; + e.dry_line = old.dry_line; + const was = old.bypass.load(.monotonic); + if (bypass and !was and e.dry_line < 0 and e.mach.latencySamples() > 0) { + // Keep its latency through the bypass (a free line, else + // the old way: the graph re-times). + if (@ctz(~self.dry_used) < DRY_LINES) { + const k: u3 = @intCast(@ctz(~self.dry_used)); + e.dry_line = k; + self.dry_used |= @as(u8, 1) << k; + @memset(&self.dry[k].l, 0); + @memset(&self.dry[k].r, 0); + self.dry[k].w = 0; + } + } + // Back from a bypass it was not rendered through: its + // buffers hold audio from before; start it clean. + if (!bypass and was and e.dry_line < 0) e.mach.reset(e.mach.state); + break; + }; + } + // Lines held by effects that are gone are free again. + var used: u8 = 0; + for (self.audio_effects[0..v.count]) |e| if (e.dry_line >= 0) { + used |= @as(u8, 1) << @intCast(e.dry_line); + }; + self.dry_used = used; + const new_instrument = self.audio_instrument_generation != v.instrument_generation; + self.audio_instrument_generation = v.instrument_generation; + self.audio_machine = v.instrument; + self.audio_effect_count = v.count; + self.retired.acknowledge(v.generation); + return new_instrument; + } + + pub fn audioEffects(self: *State) []Effect { + return self.audio_effects[0..self.audio_effect_count]; + } +}; diff --git a/src/document.zig b/src/document.zig index 3e8d36a3..c94b087f 100644 --- a/src/document.zig +++ b/src/document.zig @@ -557,6 +557,11 @@ fn appendEffects(alloc: std.mem.Allocator, out: *std.ArrayList(u8), t: *const tr try appendFmt(alloc, out, ",\"bypass\":{s},\"params\":", .{boolStr(t.effectBypassed(ei))}); try appendParams(alloc, out, fx.mach); try appendPreset(alloc, out, fx.mach); + try appendAssets(alloc, out, fx.mach); + if (fx.mach.write_state_json) |f| { + try out.appendSlice(alloc, ",\"state\":"); + try f(fx.mach.state, out, alloc); + } if (fx.key != routing.NONE) try appendFmt(alloc, out, ",\"key\":{d}", .{fx.key}); try out.append(alloc, '}'); } @@ -1138,6 +1143,8 @@ fn applyEffects(alloc: std.mem.Allocator, reg: *registry_mod.Registry, t: *track fxmach.reset(fxmach.state); try t.addEffect(alloc, fxmach, @intCast(idx)); if (objGet(fo, "params")) |pv| applyParams(fxmach, pv); + if (objGet(fo, "assets")) |av| applyAssets(fxmach, av); + if (objGet(fo, "state")) |sv| if (fxmach.apply_state_json) |f| f(fxmach.state, sv); markPreset(fxmach, objGet(fo, "preset")); if (objGet(fo, "bypass")) |bv| if (asBool(bv)) t.toggleEffectBypass(t.effects.items.len - 1); if (objGet(fo, "key")) |kv| t.effects.items[t.effects.items.len - 1].key = asTrackRef(kv); @@ -1693,6 +1700,54 @@ test "JSON project round-trips a sampler's loaded keymap path" { try std.testing.expectEqualStrings("{\"smp\":\"factory:machines/sampler/assets\"}", got.items); } +test "JSON project round-trips an effect's loaded assets" { + // Any machine in an effect slot keeps its files (docs/42 §Files): the + // sampler stands in for an effect that loads one. + const alloc = std.testing.allocator; + var reg = registry_mod.Registry.init(alloc); + defer reg.deinit(); + try reg.loadFyMachine("machines/sampler/sampler.fy"); + setRegistry(®); + defer active_reg = null; + var pool = audio_pool_mod.AudioPool.init(alloc); + defer pool.deinit(); + setPool(&pool); + defer active_pool = null; + var master = try track_mod.Track.init(alloc, "Master", .{ .r = 0, .g = 0, .b = 0, .a = 255 }, test_machine); + master.kind = .master; + defer master.deinit(alloc); + setMaster(&master); + defer active_master = null; + var transport: transport_mod.Transport = .{}; + transport.sample_rate = 48_000; + + const idx = reg.findById("sampler").?; + const fx = try reg.instantiate(idx); + try std.testing.expect(fx.load_asset.?(fx.state, "smp", "machines/sampler/assets")); + var tracks = [_]track_mod.Track{ + try track_mod.Track.init(alloc, "Fx", .{ .r = 1, .g = 2, .b = 3, .a = 255 }, test_machine), + }; + defer for (&tracks) |*t| t.deinit(alloc); + try tracks[0].addEffect(alloc, fx, @intCast(idx)); + + const bytes = try serialize(alloc, tracks[0..], &transport); + defer alloc.free(bytes); + try std.testing.expect(std.mem.indexOf(u8, bytes, "\"assets\":{\"smp\":\"factory:machines/sampler/assets\"}") != null); + + var loaded_buf: [1]track_mod.Track = undefined; + var loaded_count: usize = 0; + var lt: transport_mod.Transport = .{}; + lt.sample_rate = 48_000; + try apply(alloc, bytes, ®, loaded_buf[0..], &loaded_count, <, test_machine); + defer for (loaded_buf[0..loaded_count]) |*t| t.deinit(alloc); + try std.testing.expectEqual(@as(usize, 1), loaded_buf[0].effects.items.len); + const m = loaded_buf[0].effects.items[0].mach; + var got: std.ArrayList(u8) = .empty; + defer got.deinit(alloc); + try m.write_assets_json.?(m.state, &got, alloc); + try std.testing.expectEqualStrings("{\"smp\":\"factory:machines/sampler/assets\"}", got.items); +} + test "JSON project round-trips instrument-by-id, settings, and effect chain" { const alloc = std.testing.allocator; var reg = registry_mod.Registry.init(alloc); diff --git a/src/engine.zig b/src/engine.zig index 389eae16..f2c8a37b 100644 --- a/src/engine.zig +++ b/src/engine.zig @@ -596,7 +596,7 @@ pub const Engine = struct { fn adoptTrack(t: *Track, fade: bool) void { _ = t.currentSnapshot(); t.adoptMachines(fade); - if (t.audio_machine.adopt_updates) |f| f(t.audio_machine.state); + if (t.chain.audio_machine.adopt_updates) |f| f(t.chain.audio_machine.state); for (t.audioEffects()) |*fx| if (fx.mach.adopt_updates) |f| f(fx.mach.state); } @@ -875,7 +875,7 @@ pub const Engine = struct { fn resetAllMachines(self: *Engine) void { self.adoptMachines(); for (self.tracks) |*t| { - t.audio_machine.reset(t.audio_machine.state); + t.chain.audio_machine.reset(t.chain.audio_machine.state); for (t.audioEffects()) |*fx| fx.mach.reset(fx.mach.state); } if (self.master) |mb| { @@ -1084,7 +1084,7 @@ pub const Engine = struct { break; }; t.noteSounding(events[0..event_count]); - t.audio_machine.render(t.audio_machine.state, &ctx, l, r); + t.chain.audio_machine.render(t.chain.audio_machine.state, &ctx, l, r); const rendered = renderEffects(t, ctx, l, r, fx_l_buf[0..n], fx_r_buf[0..n], .{}); const final_l = rendered.l; const final_r = rendered.r; @@ -1445,13 +1445,13 @@ pub const Engine = struct { const inst_back = !node.is_bus and t.audio_inst_parked and t.isEnabled(); if (inst_back) { t.audio_inst_parked = false; - t.audio_machine.reset(t.audio_machine.state); + t.chain.audio_machine.reset(t.chain.audio_machine.state); t.inst_quiet = 0; } const unparked = t.audio_parked and (node.is_bus or t.isEnabled()); if (unparked) { t.audio_parked = false; - t.audio_machine.reset(t.audio_machine.state); + t.chain.audio_machine.reset(t.chain.audio_machine.state); for (t.audioEffects()) |*fx| fx.mach.reset(fx.mach.state); t.inst_quiet = 0; } @@ -1525,18 +1525,18 @@ pub const Engine = struct { if (!en and !was_on) t.audio_inst_parked = true; if (en or was_on) { if (!self.idle_skip) { - t.audio_machine.render(t.audio_machine.state, &ctx, l, r); + t.chain.audio_machine.render(t.chain.audio_machine.state, &ctx, l, r); } else { const ahead = IDLE_WAKE_AHEAD_S * bpm / 60.0; // A control edit wakes it too, so its params and // displays catch up (the edit may be all there is). - const edited = t.audio_machine.takeWake(); + const edited = t.chain.audio_machine.takeWake(); wake = n_events > 0 or held_here or (!b.ring_out and notesNear(snap, beat_start, beat_end + ahead)); if (edited) t.inst_quiet = 0; - const hold = t.audio_machine.idleHold(@floatFromInt(sr), idleHoldSamples(sr)); + const hold = t.chain.audio_machine.idleHold(@floatFromInt(sr), idleHoldSamples(sr)); // Asleep: `l`/`r` stay silent. if (wake or hold == machine.TAIL_FOREVER or t.inst_quiet < hold) { - t.audio_machine.render(t.audio_machine.state, &ctx, l, r); + t.chain.audio_machine.render(t.chain.audio_machine.state, &ctx, l, r); const loud = @max(blockPeak(l), blockPeak(r)) > IDLE_FLOOR; t.inst_quiet = if (wake or loud) 0 else t.inst_quiet +| frames; } @@ -1649,7 +1649,7 @@ pub const Engine = struct { @as(f64, @floatFromInt(fx_ns)) / 1_000_000.0, @as(f64, @floatFromInt(total_ns)) / 1_000_000.0, @as(f64, @floatFromInt(budget_ns)) / 1_000_000.0, - t.audio_effect_count, + t.chain.audio_effect_count, }, ); } @@ -1789,7 +1789,7 @@ pub const Engine = struct { self.master_subsonic.process(l, r, @floatFromInt(self.transport.sample_rate)) else self.master_subsonic.reset(); - if (mb.audio_effect_count > 0) { + if (mb.chain.audio_effect_count > 0) { const base = machine.MachineCtx{ .sample_rate = @floatFromInt(self.transport.sample_rate), .block_size = frames, @@ -2230,7 +2230,7 @@ fn localTime(snap: *const snap_mod.TrackSnapshot, beat: f64, bpm: f64, song_bar: } fn instLatency(t: *Track, is_bus: bool) u32 { - return if (!is_bus and t.isEnabled() and !t.frozen.load(.acquire)) t.audio_machine.latencySamples() else 0; + return if (!is_bus and t.isEnabled() and !t.frozen.load(.acquire)) t.chain.audio_machine.latencySamples() else 0; } /// A frozen track's audio for the block at `block_start` (it starts at @@ -2339,7 +2339,7 @@ fn renderEffectsKeyed( const n_fx = cur_l.len; const mixing = wet_from != 1 or wet_to != 1; if (fx.dry_line >= 0) { - const line = &t.chain_state.dry[@intCast(fx.dry_line)]; + const line = &t.chain.dry[@intCast(fx.dry_line)]; const d: usize = @min(fx_lat, track_mod.DRY_LEN - MAX_BLOCK); const mask = track_mod.DRY_LEN - 1; for (0..n_fx) |k| { @@ -2393,7 +2393,7 @@ fn renderEffectsKeyed( } // Fully back in: its line is free again. if (!bypassed and wet_from == 1 and fx.dry_line >= 0) { - t.chain_state.dry_used &= ~(@as(u8, 1) << @intCast(fx.dry_line)); + t.chain.dry_used &= ~(@as(u8, 1) << @intCast(fx.dry_line)); fx.dry_line = -1; } const out_peak = [2]f32{ blockPeak(next_l), blockPeak(next_r) }; @@ -2474,7 +2474,7 @@ fn contain(t: *Track, l: []f32, r: []f32) void { if (allFinite(l) and allFinite(r)) return; @memset(l, 0); @memset(r, 0); - t.audio_machine.reset(t.audio_machine.state); + t.chain.audio_machine.reset(t.chain.audio_machine.state); for (t.audioEffects()) |*fx| fx.mach.reset(fx.mach.state); _ = t.faults.fetchAdd(1, .monotonic); } diff --git a/src/track.zig b/src/track.zig index 9a30cc8a..63897bf1 100644 --- a/src/track.zig +++ b/src/track.zig @@ -15,79 +15,18 @@ const automation = @import("automation.zig"); const recorder_mod = @import("recorder.zig"); const routing = @import("routing.zig"); const handoff = @import("handoff.zig"); +const chain_mod = @import("chain.zig"); pub const MAX_NAME = 32; -/// One slot in a track's insert chain: the effect machine, its registry -/// index (for persistence/forward-compat), and a per-effect bypass flag. -/// UI bypass edits publish with the complete chain; audio reads its adopted -/// copy. The editable ArrayList and its allocations are never traversed by -/// a render worker. Peak telemetry uses stable atomic storage shared by -/// both copies. -pub const Effect = struct { - mach: machine.Machine, - idx: ?u8 = null, - /// Stable per-track id that automation lanes address (docs/22 §Targets): - /// it survives reordering the chain. - uid: u16 = 0, - version: u64 = 0, - bypass: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), - /// Sidechain key (docs/23 §Sidechain keys): the track whose pre tap - /// drives this effect's detector, or routing.NONE. - key: u8 = routing.NONE, - /// Block peaks the engine writes after each render and the bay's I/O - /// meters read: in L, in R, out L, out R, as f32 bits. - shared_io: ?*[4]std.atomic.Value(u32) = null, - io_peak: [4]std.atomic.Value(u32) = [_]std.atomic.Value(u32){std.atomic.Value(u32).init(0)} ** 4, - /// Audio thread: samples its input and output have both been silent, - /// saturating; past the machine's hold it isn't rendered (docs/04 - /// §Idle skipping). - quiet: u32 = 0, - /// Audio thread (docs/07 §Declicking): the wet share the last chunk - /// ended on; bypass crossfades it to 0 and back over a chunk. - wet: f32 = 1, - /// Audio thread: the track's dry line holding this bypassed effect's - /// latency (-1: none). With one, the effect keeps rendering while - /// bypassed and its dry path is delayed by its latency, so bypass - /// neither re-times the PDC graph nor brings back stale buffers. - dry_line: i8 = -1, - - pub fn setIo(self: *Effect, in: [2]f32, out: [2]f32) void { - const v = [4]f32{ in[0], in[1], out[0], out[1] }; - for (self.shared_io orelse &self.io_peak, v) |*a, x| a.store(@bitCast(x), .monotonic); - } - - pub fn io(self: *const Effect) struct { in: [2]f32, out: [2]f32 } { - var v: [4]f32 = undefined; - for (&v, self.shared_io orelse &self.io_peak) |*x, *a| x.* = @bitCast(a.load(.monotonic)); - return .{ .in = .{ v[0], v[1] }, .out = .{ v[2], v[3] } }; - } -}; - -pub const MAX_EFFECTS: usize = 128; +pub const Effect = chain_mod.Effect; +pub const MAX_EFFECTS = chain_mod.MAX_EFFECTS; +pub const DRY_LINES = chain_mod.DRY_LINES; +pub const DRY_LEN = chain_mod.DRY_LEN; +pub const DryLine = chain_mod.DryLine; /// A chain switch while playing: the old chain renders one chunk fading /// out (`out`, then `out_done` once rendered), the new one one fading in. pub const Swap = enum { none, out, out_done, in }; -const RetiredMachine = struct { mach: machine.Machine, io: ?*[4]std.atomic.Value(u32) = null }; -const Chain = struct { instrument: machine.Machine, effects: [MAX_EFFECTS]Effect = undefined, count: usize = 0, generation: u64 = 0, instrument_generation: u64 = 0 }; - -const PublishedEffect = struct { uid: u16, version: u64, bypass: bool }; -pub const DRY_LINES = 4; -/// A dry line's length: the largest latency it can hold, a power of two. -pub const DRY_LEN = 8192; -pub const DryLine = struct { l: [DRY_LEN]f32, r: [DRY_LEN]f32, w: usize }; - -const ChainState = struct { - chain: handoff.Latest(Chain), - retired: handoff.Retirement(RetiredMachine, 128) = .{}, - audio_effects: [MAX_EFFECTS]Effect = undefined, - previous_effects: [MAX_EFFECTS]Effect = undefined, - published_effects: [MAX_EFFECTS]PublishedEffect = undefined, - /// Audio thread: delay lines for bypassed latent effects (Effect.dry_line), - /// touched on UI at creation so audio never takes their first faults. - dry: [DRY_LINES]DryLine = undefined, - dry_used: u8 = 0, -}; /// What role a Track plays in the signal graph (docs/23). Audio tracks /// have an instrument + clips. A `bus` has neither: its input is the sum of @@ -148,18 +87,8 @@ pub const Track = struct { send_count: u8 = 0, color: c.rl.Color, machine: machine.Machine, - chain_state: *ChainState, - chain: *handoff.Latest(Chain), - retired_machines: *handoff.Retirement(RetiredMachine, 128), - chain_generation: u64 = 0, - instrument_generation: u64 = 0, - audio_instrument_generation: u64 = 0, - published_instrument_generation: u64 = std.math.maxInt(u64), - published_effect_count: usize = std.math.maxInt(usize), - published_effects: *[MAX_EFFECTS]PublishedEffect, - audio_machine: machine.Machine, - audio_effects: *[MAX_EFFECTS]Effect, - audio_effect_count: usize = 0, + /// The chain's publication, retirement and audio copy (chain.zig). + chain: *chain_mod.State, /// Registry index for persistence. Null means the silent placeholder. machine_idx: ?u8 = null, @@ -207,7 +136,6 @@ pub const Track = struct { /// Its stretchers (docs/29 §On the audio thread), made when a warped /// clip first needs them, freed with the track. stretch: ?*stretch_mod.Bank = null, - next_fx_uid: u16 = 1, /// Audio-thread-owned per-lane segment cursors (automation.evalCursor). auto_cursors: [snap_mod.MAX_LANES_PER_TRACK]u32 = [_]u32{0} ** snap_mod.MAX_LANES_PER_TRACK, /// Manual overrides of automated volume/pan (docs/22 §Manual changes): @@ -280,9 +208,7 @@ pub const Track = struct { errdefer alloc.destroy(s1); const s2 = try alloc.create(snap_mod.TrackSnapshot); errdefer alloc.destroy(s2); - const chain_state = try alloc.create(ChainState); - chain_state.* = .{ .chain = .init(.{ .instrument = mach }) }; - @memset(std.mem.asBytes(&chain_state.dry), 0); + const chain = try chain_mod.State.create(alloc, mach); s0.* = .{}; s1.* = .{}; s2.* = .{}; @@ -290,12 +216,7 @@ pub const Track = struct { var t = Track{ .color = color, .machine = mach, - .audio_machine = mach, - .chain_state = chain_state, - .chain = &chain_state.chain, - .retired_machines = &chain_state.retired, - .audio_effects = &chain_state.audio_effects, - .published_effects = &chain_state.published_effects, + .chain = chain, .snap = .{ s0, s1, s2 }, }; const n = @min(track_name.len, MAX_NAME); @@ -358,7 +279,6 @@ pub const Track = struct { } pub fn deinit(self: *Track, alloc: std.mem.Allocator) void { - self.retired_machines.drain(alloc, disposeMachine); if (self.machine.deinit) |deinit_fn| { deinit_fn(self.machine.state, alloc); } @@ -372,53 +292,19 @@ pub const Track = struct { for (self.lanes.items) |*lane| lane.deinit(alloc); self.lanes.deinit(alloc); for (self.snap) |s| alloc.destroy(s); - alloc.destroy(self.chain_state); + self.chain.destroy(alloc); if (self.stretch) |b| { b.deinit(); std.heap.page_allocator.destroy(b); } } - fn disposeMachine(alloc: std.mem.Allocator, old: RetiredMachine) void { - if (old.mach.deinit) |d| d(old.mach.state, alloc); - if (old.io) |io| alloc.destroy(io); - } - pub fn collectMachines(self: *Track, alloc: std.mem.Allocator) void { - self.retired_machines.collect(alloc, disposeMachine); - } - - fn reserveRetirement(self: *Track, alloc: std.mem.Allocator) !void { - self.collectMachines(alloc); - if (!self.retired_machines.available()) return error.PublicationFull; + self.chain.collect(alloc); } pub fn publishMachines(self: *Track) void { - var changed = self.published_instrument_generation != self.instrument_generation or self.published_effect_count != self.effects.items.len; - if (!changed) for (self.effects.items, 0..) |*fx, i| { - const old = self.published_effects[i]; - if (old.uid != fx.uid or old.version != fx.version or old.bypass != fx.bypass.load(.monotonic)) { - changed = true; - break; - } - }; - if (!changed) return; - self.published_instrument_generation = self.instrument_generation; - self.published_effect_count = self.effects.items.len; - self.chain_generation += 1; - const v = self.chain.write(); - v.instrument = self.machine; - v.instrument_generation = self.instrument_generation; - v.generation = self.chain_generation; - v.count = self.effects.items.len; - std.debug.assert(v.count <= MAX_EFFECTS); - for (self.effects.items, 0..) |*fx, i| { - const bypass = fx.bypass.load(.monotonic); - v.effects[i] = .{ .mach = fx.mach, .idx = fx.idx, .uid = fx.uid, .version = fx.version, .key = fx.key, .shared_io = fx.shared_io }; - v.effects[i].bypass.store(bypass, .monotonic); - self.published_effects[i] = .{ .uid = fx.uid, .version = fx.version, .bypass = bypass }; - } - self.chain.publish(); + self.chain.publish(self.machine, self.effects.items); } /// Audio only, before latency calculation or any render worker runs. @@ -446,102 +332,35 @@ pub const Track = struct { .none => {}, } if (!self.chain.adopt()) return; - machine.codeBarrier(); - if (fade and self.chainChanges(self.chain.current())) { + if (fade and self.chain.changes()) { self.swap = .out; return; } self.applyChain(); } - /// The adopted version swaps the instrument or the inserts, not only - /// bypass states. - fn chainChanges(self: *Track, v: *const Chain) bool { - if (v.instrument_generation != self.audio_instrument_generation or v.count != self.audio_effect_count) return true; - for (v.effects[0..v.count], self.audioEffects()) |a, b| { - if (a.uid != b.uid or a.version != b.version) return true; - } - return false; - } - fn applyChain(self: *Track) void { - const v = self.chain.current(); - const previous_count = self.audio_effect_count; - const previous = self.chain_state.previous_effects[0..previous_count]; - @memcpy(previous, self.audioEffects()); - const cs = self.chain_state; - for (v.effects[0..v.count], 0..) |fx, i| { - const e = &self.audio_effects[i]; - e.* = fx; - const bypass = fx.bypass.load(.monotonic); - // New here: it starts where it stands, no fade. - e.wet = if (bypass) 0 else 1; - e.dry_line = -1; - for (previous) |old| if (old.uid == fx.uid and old.version == fx.version) { - e.quiet = old.quiet; - e.wet = old.wet; - e.dry_line = old.dry_line; - const was = old.bypass.load(.monotonic); - if (bypass and !was and e.dry_line < 0 and e.mach.latencySamples() > 0) { - // Keep its latency through the bypass (a free line, else - // the old way: the graph re-times). - if (@ctz(~cs.dry_used) < DRY_LINES) { - const k: u3 = @intCast(@ctz(~cs.dry_used)); - e.dry_line = k; - cs.dry_used |= @as(u8, 1) << k; - @memset(&cs.dry[k].l, 0); - @memset(&cs.dry[k].r, 0); - cs.dry[k].w = 0; - } - } - // Back from a bypass it was not rendered through: its - // buffers hold audio from before; start it clean. - if (!bypass and was and e.dry_line < 0) e.mach.reset(e.mach.state); - break; - }; - } - // Lines held by effects that are gone are free again. - var used: u8 = 0; - for (self.audio_effects[0..v.count]) |e| if (e.dry_line >= 0) { - used |= @as(u8, 1) << @intCast(e.dry_line); - }; - cs.dry_used = used; - if (self.audio_instrument_generation != v.instrument_generation) { + if (self.chain.apply()) { self.inst_quiet = 0; self.clearSounding(); // A new instrument hears none of the notes already held. self.audio_inst_new = true; } - self.audio_instrument_generation = v.instrument_generation; - self.audio_machine = v.instrument; - self.audio_effect_count = v.count; - self.retired_machines.acknowledge(v.generation); } pub fn audioEffects(self: *Track) []Effect { - return self.audio_effects[0..self.audio_effect_count]; + return self.chain.audioEffects(); } pub fn audioEffectBypassed(self: *const Track, i: usize) bool { - return self.audio_effects[i].bypass.load(.monotonic); + return self.chain.audio_effects[i].bypass.load(.monotonic); } pub fn replaceMachine(self: *Track, alloc: std.mem.Allocator, mach: machine.Machine) !void { - try self.reserveRetirement(alloc); - const old = self.machine; - self.machine = mach; - self.instrument_generation += 1; - self.publishMachines(); - self.retired_machines.append(self.chain_generation, .{ .mach = old }); + try self.chain.replaceInstrument(alloc, &self.machine, self.effects.items, mach); } pub fn addEffect(self: *Track, alloc: std.mem.Allocator, mach: machine.Machine, idx: u8) !void { - if (self.effects.items.len >= MAX_EFFECTS) return error.TooManyEffects; - const io = try alloc.create([4]std.atomic.Value(u32)); - errdefer alloc.destroy(io); - io.* = @splat(.init(0)); - try self.effects.append(alloc, .{ .mach = mach, .idx = idx, .uid = self.next_fx_uid, .shared_io = io }); - self.next_fx_uid +%= 1; - self.publishMachines(); + try self.chain.addEffect(alloc, self.machine, &self.effects, mach, idx); } pub fn effectByUid(self: *const Track, uid: u16) ?*Effect { @@ -579,31 +398,15 @@ pub const Track = struct { /// UI edits the chain; audio keeps the previous version until adoption. pub fn removeEffect(self: *Track, alloc: std.mem.Allocator, i: usize) !void { - if (i >= self.effects.items.len) return; - try self.reserveRetirement(alloc); - const old = self.effects.orderedRemove(i); - self.publishMachines(); - self.retired_machines.append(self.chain_generation, .{ .mach = old.mach, .io = old.shared_io }); + try self.chain.removeEffect(alloc, self.machine, &self.effects, i); } pub fn moveEffect(self: *Track, from: usize, to: usize) void { - const n = self.effects.items.len; - if (from >= n or to >= n or from == to) return; - const e = self.effects.orderedRemove(from); - self.effects.insertAssumeCapacity(@min(to, self.effects.items.len), e); - self.publishMachines(); + self.chain.moveEffect(self.machine, &self.effects, from, to); } pub fn replaceEffect(self: *Track, alloc: std.mem.Allocator, i: usize, mach: machine.Machine, idx: u8) !void { - if (i >= self.effects.items.len) return error.InvalidEffect; - try self.reserveRetirement(alloc); - const slot = &self.effects.items[i]; - const old = slot.mach; - slot.mach = mach; - slot.idx = idx; - slot.version += 1; - self.publishMachines(); - self.retired_machines.append(self.chain_generation, .{ .mach = old }); + try self.chain.replaceEffect(alloc, self.machine, self.effects.items, i, mach, idx); } pub fn isBus(self: *const Track) bool { @@ -1418,12 +1221,12 @@ test "realtime: instrument and effect retirement happens only after audio adopti t.collectMachines(a); try std.testing.expectEqual(@as(usize, 0), old.count); try std.testing.expectEqual(@as(usize, 0), fx.count); - try std.testing.expect(@intFromPtr(t.audio_machine.state) == @intFromPtr(&old)); + try std.testing.expect(@intFromPtr(t.chain.audio_machine.state) == @intFromPtr(&old)); t.audioEffects()[0].setIo(.{ 0.2, 0.3 }, .{ 0.4, 0.5 }); try std.testing.expectEqual(@as(u32, @bitCast(@as(f32, 0.2))), io[0].load(.monotonic)); t.adoptMachines(false); - try std.testing.expect(@intFromPtr(t.audio_machine.state) == @intFromPtr(&new)); - try std.testing.expectEqual(@as(usize, 0), t.audio_effect_count); + try std.testing.expect(@intFromPtr(t.chain.audio_machine.state) == @intFromPtr(&new)); + try std.testing.expectEqual(@as(usize, 0), t.chain.audio_effect_count); t.collectMachines(a); try std.testing.expectEqual(@as(usize, 1), old.count); try std.testing.expectEqual(@as(usize, 1), fx.count); From 2411a339e3866f5e4a73d76ffbcca9a4a2c23044 Mon Sep 17 00:00:00 2001 From: Marcin Gasperowicz Date: Fri, 16 Oct 2026 23:41:28 +0100 Subject: [PATCH 3/9] =?UTF-8?q?machine:=20rack=20lanes=20hold=20chains;=20?= =?UTF-8?q?FX=20Rack,=20nesting,=20latency=20alignment,=20rack=20state=20v?= =?UTF-8?q?2=20(docs/42=20=C2=A7Build=20phases=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each lane owns a chain.State: an instrument and up to 16 inserts in a Rack, inserts alone in the new FX Rack (an effect, its lanes in parallel on the input). Lanes align to the slowest one and the rack reports that latency; tails, wakes and idle skip reach the inserts; racks nest three deep. State is {"rack":2,...,"lanes":[...]}; the first form's parts read as lanes, and a project with one renders byte-identical. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 + docs/19-project-format.md | 2 +- docs/42-racks.md | 50 +- src/document.zig | 49 ++ src/engine.zig | 28 +- src/machine_registry.zig | 17 +- src/machines/rack.zig | 1143 +++++++++++++++++++++++++++---------- src/ui/machine_bay.zig | 9 +- 8 files changed, 976 insertions(+), 331 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5674b5c0..9b67e647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ GitHub are its section here (`tools/release.sh` copies them), so add to ## Unreleased +- **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 (+ FX under the + lane's settings). 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, diff --git a/docs/19-project-format.md b/docs/19-project-format.md index b2badd17..5074f6e5 100644 --- a/docs/19-project-format.md +++ b/docs/19-project-format.md @@ -154,7 +154,7 @@ 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. | +| `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 ([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. | diff --git a/docs/42-racks.md b/docs/42-racks.md index 81c50786..afe6d4af 100644 --- a/docs/42-racks.md +++ b/docs/42-racks.md @@ -1,8 +1,9 @@ # 42 — Racks: chains in lanes, macros, rigs -**Status: proposal, phase 1 built.** `src/chain.zig` holds the chain -machinery tracks use, and effects save their `state` and `assets`. The -rest is still to build. The Rack that ships today +**Status: phases 1 and 2 built.** Rack lanes hold chains, the FX Rack +plays lanes of inserts, racks nest and align their lanes' latency, and +rack state is in its second form. Macros, the new panel, rigs and inner +automation are still to build. The Rack that ships today (`src/machines/rack.zig`) is described under "Where we start". A rack packages machines into one device. Each of its lanes holds a @@ -54,24 +55,30 @@ Rack macro index, target (lane, slot uid, control id), min, max, formula? ``` -**One machine, two kinds.** -- **Instrument rack:** in an instrument slot. It routes notes to the lanes - whose key, velocity and chain zones hold them, as parts do now. Each - lane renders its instrument, then its effects. -- **Effect rack:** in an effect slot. Every lane gets the rack's input. - A lane with no effects is a dry path, which is how a parallel-compression +**One machine, two registry entries.** +- **Rack** (`rack`): an instrument. It routes notes to the lanes whose + key and velocity ranges hold them, as parts did. Each lane renders its + instrument, then its inserts. +- **FX Rack** (`fxrack`): an effect. Every lane gets the rack's input. + A lane with no inserts is a dry path, which is how a parallel-compression rack or a DI-plus-amp bass rig is built. -The kind comes from the slot. A rack preset records its kind so the -picker offers it only in the right slot. +Both are `src/machines/rack.zig` with a `Kind`; being two entries, the +picker offers each in its own slot with no new rules, and each has its +own presets folder. + +**MUTE and SOLO close a lane's input.** A muted lane, or one another +lane's SOLO leaves out, takes no new notes (an effect lane, no input); +what it already holds rings out and is heard, as a muted part did. **Lanes reuse the track's chain.** The `Effect` slot type (uid, bypass, sidechain key, PDC dry line, idle skip, wet crossfade) and its UI→audio handoff live in `src/chain.zig` (`chain.State`). A track and a rack lane share that one implementation: adopting by uid and version, retiring replaced machines, latency, and the quiet state. It -also means everything that works on a track's effects works inside a -lane, sidechain keys included. +also means what works on a track's effects works inside a lane, but two +things: sidechain keys don't reach lane inserts yet, and a lane's chain +changes at once, without the track's one-chunk crossfade. **Nesting.** A rack can sit inside a lane, up to three levels deep. The `rack.zig:292` guard becomes a depth check. A rack's latency, tail and @@ -319,13 +326,16 @@ bit-exact. instrument and effects. Tracks use it unchanged: songs render byte-identical. - Projects write effect `state` and `assets`. -2. **Lanes with chains.** - - Rack lanes hold an instrument plus effects; add the effect-rack - kind, nesting, per-lane latency alignment, tail and wake, and idle - skip. - - Rack state v2 with v1 reading. - - Tests: a v1 project renders bit-identically; a one-lane rack equals - its chain on a bare track. +2. **Lanes with chains.** *Built.* + - Rack lanes hold an instrument plus inserts; the FX Rack; nesting + three deep; per-lane latency alignment; tail, wake and idle skip of + lane inserts. Lane machines get no automation until phase 6. + - Rack state v2, with the first form read as lanes. + - The panel lists a lane's inserts (ON, remove, + FX) and shows the + picked machine's panel, until phase 4's. + - Tests: a first-form project renders byte-identical; a one-lane FX + Rack equals its insert sample for sample; a dry lane is held to a + latent one. 3. **Macros.** - Rack controls `rk-m1..8` and `rk-chain`; mappings, the formula compiler and its VM; the `set_mod` host input in `fy_raw_machine`. diff --git a/src/document.zig b/src/document.zig index c94b087f..bc1966eb 100644 --- a/src/document.zig +++ b/src/document.zig @@ -13,6 +13,7 @@ const clip_mod = @import("clip.zig"); const warp_mod = @import("warp.zig"); const tune_mod = @import("tune.zig"); const registry_mod = @import("machine_registry.zig"); +const rack_mod = @import("machines/rack.zig"); const transport_mod = @import("transport.zig"); const machine_mod = @import("machine.zig"); const audio_pool_mod = @import("audio_pool.zig"); @@ -1748,6 +1749,54 @@ test "JSON project round-trips an effect's loaded assets" { try std.testing.expectEqualStrings("{\"smp\":\"factory:machines/sampler/assets\"}", got.items); } +test "JSON project round-trips an FX Rack in an effect slot, lanes and inserts" { + const alloc = std.testing.allocator; + var reg = registry_mod.Registry.init(alloc); + defer reg.deinit(); + try reg.loadFyMachine("machines/sat2/sat2.fy"); + try reg.loadNative(); + setRegistry(®); + defer active_reg = null; + var pool = audio_pool_mod.AudioPool.init(alloc); + defer pool.deinit(); + setPool(&pool); + defer active_pool = null; + var master = try track_mod.Track.init(alloc, "Master", .{ .r = 0, .g = 0, .b = 0, .a = 255 }, test_machine); + master.kind = .master; + defer master.deinit(alloc); + setMaster(&master); + defer active_master = null; + var transport: transport_mod.Transport = .{}; + transport.sample_rate = 48_000; + + const idx = reg.findById("fxrack").?; + const fx = try reg.instantiate(idx); + const rk: *rack_mod.Rack = @ptrCast(@alignCast(fx.state)); + _ = try rk.addLane(null); + _ = try rk.addLane(reg.findById("sat2").?); + rk.lanes[1].level_db = -6; + var tracks = [_]track_mod.Track{ + try track_mod.Track.init(alloc, "Fx", .{ .r = 1, .g = 2, .b = 3, .a = 255 }, test_machine), + }; + defer for (&tracks) |*t| t.deinit(alloc); + try tracks[0].addEffect(alloc, fx, @intCast(idx)); + + const bytes = try serialize(alloc, tracks[0..], &transport); + defer alloc.free(bytes); + try std.testing.expect(std.mem.indexOf(u8, bytes, "\"machine\":\"fxrack\"") != null); + + var loaded_buf: [1]track_mod.Track = undefined; + var loaded_count: usize = 0; + var lt: transport_mod.Transport = .{}; + lt.sample_rate = 48_000; + try apply(alloc, bytes, ®, loaded_buf[0..], &loaded_count, <, test_machine); + defer for (loaded_buf[0..loaded_count]) |*t| t.deinit(alloc); + const got: *rack_mod.Rack = @ptrCast(@alignCast(loaded_buf[0].effects.items[0].mach.state)); + try std.testing.expectEqual(@as(usize, 2), got.count); + try std.testing.expectEqual(@as(usize, 1), got.lanes[1].effects.items.len); + try std.testing.expectEqual(@as(f32, -6), got.lanes[1].level_db); +} + test "JSON project round-trips instrument-by-id, settings, and effect chain" { const alloc = std.testing.allocator; var reg = registry_mod.Registry.init(alloc); diff --git a/src/engine.zig b/src/engine.zig index f2c8a37b..3ebcc74b 100644 --- a/src/engine.zig +++ b/src/engine.zig @@ -8,6 +8,7 @@ const audio = @import("audio.zig"); const machine = @import("machine.zig"); const Transport = @import("transport.zig").Transport; const track_mod = @import("track.zig"); +const chain_mod = @import("chain.zig"); const Track = track_mod.Track; const snap_mod = @import("snapshot.zig"); const meter = @import("meter.zig"); @@ -1085,7 +1086,7 @@ pub const Engine = struct { }; t.noteSounding(events[0..event_count]); t.chain.audio_machine.render(t.chain.audio_machine.state, &ctx, l, r); - const rendered = renderEffects(t, ctx, l, r, fx_l_buf[0..n], fx_r_buf[0..n], .{}); + const rendered = renderEffects(t.chain, ctx, l, r, fx_l_buf[0..n], fx_r_buf[0..n], .{}); const final_l = rendered.l; const final_r = rendered.r; const g = faderGains(t, snap, playhead); @@ -1577,7 +1578,7 @@ pub const Engine = struct { .lat = self.lat_in[ti] + instLatency(t, node.is_bus), } else null; if (frozen == null) { - const rendered = renderEffectsKeyed(t, ctx, l, r, scratch.fx_l[0..frames], scratch.fx_r[0..frames], keys, .{ .on = self.idle_skip, .wake = wake }); + const rendered = renderEffectsKeyed(t.chain, ctx, l, r, scratch.fx_l[0..frames], scratch.fx_r[0..frames], keys, .{ .on = self.idle_skip, .wake = wake }); // The chain may end in the scratch pair; the pre tap is `l`/`r`. if (rendered.l.ptr != l.ptr) { @memcpy(l, rendered.l); @@ -1798,7 +1799,7 @@ pub const Engine = struct { .ppq_position = beat, .transport_state = if (self.transport.isPlaying()) .playing else .stopped, }; - const rendered = renderEffects(mb, base, l, r, self.master_fx_l[0..n], self.master_fx_r[0..n], .{ .on = self.idle_skip }); + const rendered = renderEffects(mb.chain, base, l, r, self.master_fx_l[0..n], self.master_fx_r[0..n], .{ .on = self.idle_skip }); l = rendered.l; r = rendered.r; } @@ -2098,7 +2099,7 @@ fn signalStatsPlanar(l: []const f32, r: []const f32, gain: f32) SignalStats { return stats; } -const RenderedPair = struct { +pub const RenderedPair = struct { l: []f32, r: []f32, }; @@ -2255,7 +2256,7 @@ fn playFrozen(fz: snap_mod.FrozenSnap, block_start: u64, frames: u32, sample_rat } /// Idle skipping for one chain (docs/04 §Idle skipping). -const Idle = struct { +pub const Idle = struct { on: bool = false, /// The track's instrument has a note sounding or close: every effect /// renders, whatever its input. @@ -2266,8 +2267,9 @@ fn idleHoldSamples(sample_rate: u32) u32 { return @intFromFloat(IDLE_HOLD_S * @as(f32, @floatFromInt(sample_rate))); } -fn renderEffects( - t: *Track, +/// A chain's inserts over `src` (a track's, or a rack lane's, rack.zig). +pub fn renderEffects( + ch: *chain_mod.State, base_ctx: machine.MachineCtx, src_l: []f32, src_r: []f32, @@ -2275,11 +2277,11 @@ fn renderEffects( scratch_r: []f32, idle: Idle, ) RenderedPair { - return renderEffectsKeyed(t, base_ctx, src_l, src_r, scratch_l, scratch_r, null, idle); + return renderEffectsKeyed(ch, base_ctx, src_l, src_r, scratch_l, scratch_r, null, idle); } fn renderEffectsKeyed( - t: *Track, + ch: *chain_mod.State, base_ctx: machine.MachineCtx, src_l: []f32, src_r: []f32, @@ -2300,9 +2302,9 @@ fn renderEffectsKeyed( // Each effect's input peak is the previous one's output peak, scanned once. var peak = [2]f32{ blockPeak(cur_l), blockPeak(cur_r) }; - for (t.audioEffects(), 0..) |*fx, i| { + for (ch.audioEffects(), 0..) |*fx, i| { const in_peak = peak; - const bypassed = t.audioEffectBypassed(i); + const bypassed = ch.audio_effects[i].bypass.load(.monotonic); const wet_to: f32 = if (bypassed) 0 else 1; const wet_from = fx.wet; fx.wet = wet_to; @@ -2339,7 +2341,7 @@ fn renderEffectsKeyed( const n_fx = cur_l.len; const mixing = wet_from != 1 or wet_to != 1; if (fx.dry_line >= 0) { - const line = &t.chain.dry[@intCast(fx.dry_line)]; + const line = &ch.dry[@intCast(fx.dry_line)]; const d: usize = @min(fx_lat, track_mod.DRY_LEN - MAX_BLOCK); const mask = track_mod.DRY_LEN - 1; for (0..n_fx) |k| { @@ -2393,7 +2395,7 @@ fn renderEffectsKeyed( } // Fully back in: its line is free again. if (!bypassed and wet_from == 1 and fx.dry_line >= 0) { - t.chain.dry_used &= ~(@as(u8, 1) << @intCast(fx.dry_line)); + ch.dry_used &= ~(@as(u8, 1) << @intCast(fx.dry_line)); fx.dry_line = -1; } const out_peak = [2]f32{ blockPeak(next_l), blockPeak(next_r) }; diff --git a/src/machine_registry.zig b/src/machine_registry.zig index 62d823ba..c895649e 100644 --- a/src/machine_registry.zig +++ b/src/machine_registry.zig @@ -205,7 +205,7 @@ test "user machines: /.fy folders, sorted, builtins and strays skipped" } /// Machines built in Zig rather than fy. -pub const Native = enum(u8) { none, rack }; +pub const Native = enum(u8) { none, rack, fxrack }; pub const Entry = struct { native: Native = .none, @@ -272,7 +272,8 @@ pub const Registry = struct { if (idx >= self.count) return error.InvalidMachineIndex; const e = &self.entries[idx]; switch (e.native) { - .rack => return (try rack_mod.Rack.create(self.alloc, self)).machineInterface(), + .rack => return (try rack_mod.Rack.create(self.alloc, self, .instrument)).machineInterface(), + .fxrack => return (try rack_mod.Rack.create(self.alloc, self, .effect)).machineInterface(), .none => {}, } const raw = try fy_raw_machine_mod.FyRawMachine.create(self.alloc, e.pathSlice()); @@ -283,7 +284,11 @@ pub const Registry = struct { pub fn instantiatePrepared(self: *Registry, idx: usize) !machine.Machine { if (idx >= self.count) return error.InvalidMachineIndex; const e = &self.entries[idx]; - if (e.native == .rack) return (try rack_mod.Rack.create(self.alloc, self)).machineInterface(); + switch (e.native) { + .rack => return (try rack_mod.Rack.create(self.alloc, self, .instrument)).machineInterface(), + .fxrack => return (try rack_mod.Rack.create(self.alloc, self, .effect)).machineInterface(), + .none => {}, + } return (try fy_raw_machine_mod.FyRawMachine.createIsolated(self.alloc, e.pathSlice())).machineInterface(); } @@ -310,6 +315,12 @@ pub const Registry = struct { try copyEntryString16(e.path[0..], &e.path_len, rack_mod.PATH); self.entries[self.count] = e; self.count += 1; + try self.ensureRoom(); + var fx = Entry{ .native = .fxrack, .in_audio = true, .out_audio = true, .panel_w = rack_mod.SIDE_W + 200 }; + try copyEntryString(fx.name[0..], &fx.name_len, "FX Rack"); + try copyEntryString16(fx.path[0..], &fx.path_len, rack_mod.FX_PATH); + self.entries[self.count] = fx; + self.count += 1; } /// Register a manifest-driven raw machine: compile its .fy file in a diff --git a/src/machines/rack.zig b/src/machines/rack.zig index a3730373..601dc762 100644 --- a/src/machines/rack.zig +++ b/src/machines/rack.zig @@ -1,21 +1,28 @@ -//! Rack: splits and layers of any instrument machines, as one instrument. +//! Rack: lanes of chains played as one device (docs/42). //! -//! Up to MAX_PARTS parts, each a machine from the registry with its own -//! settings, a key range, a velocity range, a transpose, level, pan and a -//! polyphony cap. A note goes to every part whose ranges hold it (split -//! and layer are the same thing: ranges that don't or do overlap); a part -//! at its polyphony cap releases its oldest note first, as the Fairlight's -//! registers do. Everything else (CCs, resets) goes to every part. +//! Up to MAX_LANES lanes, each a chain: an instrument and its inserts in +//! an instrument rack (`rack`), inserts alone in an effect rack (`fxrack`). +//! An instrument rack sends a note to every lane whose key and velocity +//! ranges hold it (split and layer are the same thing: ranges that don't +//! or do overlap), transposed, with a polyphony cap that releases the +//! oldest note first, as the Fairlight's registers do; everything else +//! (CCs, resets) goes to every lane. An effect rack gives every lane its +//! input. Lanes sum after their level, pan, mute and solo, aligned to the +//! slowest lane's latency, which the rack reports. A rack can sit in a +//! lane, MAX_DEPTH deep. //! //! A rack preset (machines/rack/presets/*.preset) and a project store the -//! parts as {"parts":[{"machine":id,"lo":…,"params":{…},"assets":{…}},…]}, -//! each part's settings in its machine's own form, so any machine that -//! persists works inside a rack; tools (tools/library/cmi.py for CMI -//! instruments) write the same form. +//! lanes as {"rack":2,"kind":…,"lanes":[{…,"instrument":{"machine":id, +//! "params":{…}},"effects":[…]}]}, each machine's settings in its own form, +//! so any machine that persists works inside a rack. The first form, +//! {"parts":[{"machine":id,"lo":…,"params":{…}},…]}, reads as lanes with an +//! instrument and no effects; tools (tools/library/cmi.py for CMI +//! instruments) write it. //! -//! UI owns the editable parts; audio owns its adopted parts and held notes. -//! Prepared configurations use a latest-value handoff. Replaced machines -//! retire on UI only after audio acknowledges a configuration without them. +//! UI owns the editable lanes; audio owns its adopted lanes and held notes. +//! The lane list publishes through a latest-value handoff, each lane's +//! chain through its own chain.State (the track's machinery). A removed +//! lane retires on UI only after audio acknowledges a list without it. const std = @import("std"); const storage = @import("../storage.zig"); @@ -23,6 +30,8 @@ const machine = @import("../machine.zig"); const registry_mod = @import("../machine_registry.zig"); const presets_mod = @import("../presets.zig"); const handoff = @import("../handoff.zig"); +const chain_mod = @import("../chain.zig"); +const engine = @import("../engine.zig"); const ui_core = @import("../ui/core.zig"); const ctl = @import("../ui/controls.zig"); const ui_style = @import("../ui/style.zig"); @@ -34,24 +43,48 @@ const Ui = ui_core.Ui; const Rect = ui_core.Rect; const Machine = machine.Machine; const NoteEvent = machine.NoteEvent; - -pub const MAX_PARTS = 8; -const MAX_BLOCK = 4096; +const Effect = chain_mod.Effect; + +pub const MAX_LANES = 8; +/// Effects one lane holds (docs/42 §Capacities). +pub const MAX_LANE_EFFECTS = 16; +/// Racks nest this deep: a top rack is level 0. +pub const MAX_DEPTH = 3; +const MAX_BLOCK = engine.MAX_BLOCK; const MAX_EVENTS = 512; const MAX_HELD = 32; const MAX_LABEL = 31; const PRESET_FILE = 256 * 1024; pub const SIDE_W: i32 = 232; const PICK_MENU_KEY: u64 = 0x5241434b; // "RACK" +/// The longest latency lanes are aligned over: what a lane's line holds. +const MAX_ALIGN = chain_mod.DRY_LEN - MAX_BLOCK; -/// The machine's registry path: a directory with presets/ and no .fy. +/// The registry paths: a directory with presets/ and no .fy. pub const PATH = "machines/rack/rack"; +pub const FX_PATH = "machines/fxrack/fxrack"; + +pub const Kind = enum { instrument, effect }; const Held = struct { channel: u8, id: i32, pitch: f32 }; -pub const Part = struct { - mach: Machine, - reg_idx: u8, +/// An effect rack's lanes have no instrument: this one, which plays nothing. +var silent_state: u8 = 0; +fn silentRender(_: *anyopaque, _: *const machine.MachineCtx, _: []f32, _: []f32) void {} +fn silentReset(_: *anyopaque) void {} +fn silentPanel(_: *anyopaque, _: *Ui, _: Rect) void {} +const silent = Machine{ .name = "none", .state = &silent_state, .render = silentRender, .reset = silentReset, .draw_panel = silentPanel }; + +pub const Lane = struct { + /// The chain's publication; `instrument` and `effects` are its + /// editable side (UI only). + chain: *chain_mod.State, + instrument: Machine = silent, + /// Registry index of the instrument; null in an effect rack. + reg_idx: ?u8 = null, + effects: std.ArrayList(Effect) = .empty, + /// The line that holds this lane back to the slowest lane (audio). + line: *chain_mod.DryLine, label: [MAX_LABEL + 1]u8 = [_]u8{0} ** (MAX_LABEL + 1), label_len: u8 = 0, // Settings are copied from the UI configuration at block boundaries. @@ -59,70 +92,103 @@ pub const Part = struct { hi: u8 = 127, vlo: u8 = 1, vhi: u8 = 127, - /// Semitones, whole: a part's fine tuning is its machine's. + /// Semitones, whole: a lane's fine tuning is its instrument's. transpose: i8 = 0, level_db: f32 = 0, pan: f32 = 0, - /// Notes at once, 0 = the machine's own voices. + /// Notes at once, 0 = the instrument's own voices. poly: u8 = 0, mute: bool = false, - // Audio thread: the notes this part is holding, oldest first. + solo: bool = false, + // Audio thread: the notes this lane is holding, oldest first. held: [MAX_HELD]Held = undefined, held_n: u8 = 0, + /// Audio thread: the delay its line holds now. + delay: u32 = 0, - pub fn labelSlice(self: *const Part) []const u8 { + pub fn labelSlice(self: *const Lane) []const u8 { return self.label[0..self.label_len]; } - fn setLabel(self: *Part, s: []const u8) void { + fn setLabel(self: *Lane, s: []const u8) void { const n = @min(s.len, MAX_LABEL); @memcpy(self.label[0..n], s[0..n]); self.label[n] = 0; self.label_len = @intCast(n); } - fn takes(self: *const Part, pitch: f32, vel: f32) bool { - if (self.mute) return false; + fn takes(self: *const Lane, pitch: f32, vel: f32) bool { const k: i32 = @intFromFloat(@round(pitch)); const v: i32 = @intFromFloat(@round(std.math.clamp(vel, 0, 1) * 127)); return k >= self.lo and k <= self.hi and v >= self.vlo and v <= self.vhi; } + + /// Audio: samples its output is late, instrument and active inserts. + fn latency(self: *Lane) u32 { + var n = self.chain.audio_machine.latencySamples(); + for (self.chain.audioEffects()) |*fx| { + if (!fx.bypass.load(.monotonic) or fx.dry_line >= 0) n += fx.mach.latencySamples(); + } + return n; + } + + /// Audio: how long it rings on after its input stops (docs/04 §Idle + /// skipping), its instrument's tail and every insert's in turn. + fn tail(self: *Lane, sample_rate: f64) u32 { + var n = self.chain.audio_machine.idleHold(sample_rate, 0); + if (n == machine.TAIL_FOREVER) return n; + for (self.chain.audioEffects()) |*fx| { + const h = fx.mach.idleHold(sample_rate, 0); + if (h == machine.TAIL_FOREVER) return h; + n +|= h; + } + return n; + } }; -const PartVersion = struct { parts: [MAX_PARTS]Part = undefined, count: usize = 0, generation: u64 = 0 }; +const LaneVersion = struct { lanes: [MAX_LANES]Lane = undefined, count: usize = 0, generation: u64 = 0 }; +const RetiredLane = struct { chain: *chain_mod.State, instrument: Machine, effects: std.ArrayList(Effect), line: *chain_mod.DryLine }; pub const Rack = struct { alloc: std.mem.Allocator, reg: *registry_mod.Registry, - parts: [MAX_PARTS]Part = undefined, + kind: Kind, + /// How deep it sits in other racks: 0 on a track. + level: u8 = 0, + lanes: [MAX_LANES]Lane = undefined, count: usize = 0, - versions: handoff.Latest(PartVersion) = .init(.{}), - retired: handoff.Retirement(Machine, 128) = .{}, + versions: handoff.Latest(LaneVersion) = .init(.{}), + retired: handoff.Retirement(RetiredLane, 128) = .{}, generation: u64 = 0, // UI-owned publication_depth: usize = 0, - audio_parts: [MAX_PARTS]Part = undefined, + audio_lanes: [MAX_LANES]Lane = undefined, audio_count: usize = 0, // UI selected: usize = 0, + /// The selected lane's machine on the panel: -1 its instrument, else + /// an insert. + selected_fx: i32 = -1, presets: presets_mod.List = .{}, current_preset: i32 = -1, // audio scratch sl: [MAX_BLOCK]f32 = undefined, sr: [MAX_BLOCK]f32 = undefined, + fl: [MAX_BLOCK]f32 = undefined, + fr: [MAX_BLOCK]f32 = undefined, events: [MAX_EVENTS]NoteEvent = undefined, - pub fn create(alloc: std.mem.Allocator, reg: *registry_mod.Registry) !*Rack { + pub fn create(alloc: std.mem.Allocator, reg: *registry_mod.Registry, kind: Kind) !*Rack { const self = try alloc.create(Rack); - self.* = .{ .alloc = alloc, .reg = reg }; + self.* = .{ .alloc = alloc, .reg = reg, .kind = kind }; self.rescan(); return self; } pub fn machineInterface(self: *Rack) Machine { return .{ - .name = "Rack", + .name = if (self.kind == .effect) "FX Rack" else "Rack", .state = self, .render = renderImpl, .draw_panel = drawPanelImpl, @@ -138,109 +204,212 @@ pub const Rack = struct { .mark_preset = markPresetImpl, .write_state_json = writeStateJsonImpl, .apply_state_json = applyStateJsonImpl, + .take_edited = takeEditedImpl, + .latency = latencyImpl, + .tail = tailImpl, + .take_wake = takeWakeImpl, .panel_w_fn = panelWImpl, .host_titlebar = true, - // Parts get the notes, bends included; most of them play it. - .takes_expression = true, + // Lanes get the notes, bends included; most of them play it. + .takes_expression = self.kind == .instrument, .panel_w = SIDE_W + 200, }; } - fn disposePart(self: *Rack, m: Machine) void { + fn disposeMachine(self: *Rack, m: Machine) void { if (m.deinit) |d| d(m.state, self.alloc); } + fn disposeLane(self: *Rack, l: RetiredLane) void { + self.disposeMachine(l.instrument); + var fx = l.effects; + for (fx.items) |*e| { + self.disposeMachine(e.mach); + if (e.shared_io) |io| self.alloc.destroy(io); + } + fx.deinit(self.alloc); + l.chain.destroy(self.alloc); + self.alloc.destroy(l.line); + } + fn collect(self: *Rack) void { - self.retired.collect(self, disposePart); + self.retired.collect(self, disposeLane); + for (self.lanes[0..self.count]) |*l| l.chain.collect(self.alloc); } - fn publishParts(self: *Rack) void { + fn publishLanes(self: *Rack) void { if (self.publication_depth != 0) return; self.generation += 1; const v = self.versions.write(); v.count = self.count; v.generation = self.generation; - @memcpy(v.parts[0..self.count], self.parts[0..self.count]); + @memcpy(v.lanes[0..self.count], self.lanes[0..self.count]); self.versions.publish(); } - fn adoptParts(self: *Rack) void { - if (!self.versions.adopt()) return; - machine.codeBarrier(); - const v = self.versions.current(); - const previous = self.audio_parts; - const n = self.audio_count; - for (v.parts[0..v.count], 0..) |p, i| { - self.audio_parts[i] = p; - for (previous[0..n]) |old| if (old.mach.state == p.mach.state) { - self.audio_parts[i].held = old.held; - self.audio_parts[i].held_n = old.held_n; - break; - }; + /// Audio: the newest lane list, and each lane's newest chain. + fn adoptLanes(self: *Rack) void { + if (self.versions.adopt()) { + machine.codeBarrier(); + const v = self.versions.current(); + const previous = self.audio_lanes; + const n = self.audio_count; + for (v.lanes[0..v.count], 0..) |p, i| { + self.audio_lanes[i] = p; + self.audio_lanes[i].held_n = 0; + self.audio_lanes[i].delay = 0; + for (previous[0..n]) |old| if (old.chain == p.chain) { + self.audio_lanes[i].held = old.held; + self.audio_lanes[i].held_n = old.held_n; + self.audio_lanes[i].delay = old.delay; + break; + }; + } + self.audio_count = v.count; + self.retired.acknowledge(v.generation); + } + for (self.audio_lanes[0..self.audio_count]) |*l| { + // A new instrument hears none of the notes already held. + if (l.chain.adopt() and l.chain.apply()) l.held_n = 0; } - self.audio_count = v.count; - self.retired.acknowledge(v.generation); } fn rescan(self: *Rack) void { - self.presets = presets_mod.scanMachine(presetDir(), "rack"); + self.presets = presets_mod.scanMachine(self.presetDir(), self.id()); } - fn presetDir() []const u8 { - return "machines/rack/presets"; + pub fn id(self: *const Rack) []const u8 { + return if (self.kind == .effect) "fxrack" else "rack"; } - // ── parts ──────────────────────────────────────────────────────── + fn presetDir(self: *const Rack) []const u8 { + return if (self.kind == .effect) "machines/fxrack/presets" else "machines/rack/presets"; + } + + // ── lanes ──────────────────────────────────────────────────────── - /// UI thread: prepare a part in a private compiler/image and reset it - /// before publication. Audio keeps using its adopted configuration. + /// UI thread: prepare a machine in a private compiler/image and reset + /// it before publication. Audio keeps using its adopted configuration. + /// A rack goes one level deeper than this one, MAX_DEPTH at most. fn instantiate(self: *Rack, reg_idx: usize) !Machine { + const nested = self.reg.entries[reg_idx].native != .none; + if (nested and self.level + 1 >= MAX_DEPTH) return error.RackTooDeep; const m = try self.reg.instantiatePrepared(reg_idx); + if (nested) { + const inner: *Rack = @ptrCast(@alignCast(m.state)); + inner.level = self.level + 1; + } m.reset(m.state); return m; } - /// Append a part playing registry machine `reg_idx` over all keys. - pub fn addPart(self: *Rack, reg_idx: usize) !*Part { - if (self.count >= MAX_PARTS) return error.RackFull; - const m = try self.instantiate(reg_idx); - var p = Part{ .mach = m, .reg_idx = @intCast(reg_idx) }; - p.setLabel(self.reg.entries[reg_idx].nameSlice()); - self.parts[self.count] = p; + fn newLane(self: *Rack, inst: Machine, reg_idx: ?u8) !*Lane { + if (self.count >= MAX_LANES) return error.RackFull; + const ch = try chain_mod.State.create(self.alloc, inst); + errdefer ch.destroy(self.alloc); + const line = try self.alloc.create(chain_mod.DryLine); + @memset(std.mem.asBytes(line), 0); + self.lanes[self.count] = .{ .chain = ch, .instrument = inst, .reg_idx = reg_idx, .line = line }; self.count += 1; - self.publishParts(); - return &self.parts[self.count - 1]; + self.publishLanes(); + return &self.lanes[self.count - 1]; } - /// Swap part `i`'s machine for registry machine `reg_idx`, keeping its - /// ranges and mix. - pub fn replacePart(self: *Rack, i: usize, reg_idx: usize) !void { - if (i >= self.count) return; + /// Append a lane: in an instrument rack, one playing registry machine + /// `reg_idx` over all keys; in an effect rack, an empty one (a dry + /// path) that `reg_idx`, when given, becomes the first insert of. + pub fn addLane(self: *Rack, reg_idx: ?usize) !*Lane { + if (self.count >= MAX_LANES) return error.RackFull; + if (self.kind == .instrument) { + const idx = reg_idx orelse return error.NoInstrument; + const m = try self.instantiate(idx); + errdefer self.disposeMachine(m); + const l = try self.newLane(m, @intCast(idx)); + l.setLabel(self.reg.entries[idx].nameSlice()); + return l; + } + const l = try self.newLane(silent, null); + if (reg_idx) |idx| { + try self.addEffect(self.count - 1, idx); + l.setLabel(self.reg.entries[idx].nameSlice()); + } else l.setLabel("DRY"); + return l; + } + + /// Swap lane `i`'s instrument for registry machine `reg_idx`, keeping + /// its ranges, mix and inserts. + pub fn replaceInstrument(self: *Rack, i: usize, reg_idx: usize) !void { + if (i >= self.count or self.kind != .instrument) return; self.collect(); - if (!self.retired.available()) return error.PublicationFull; const m = try self.instantiate(reg_idx); - const next_generation = self.generation + 1; - const old = self.parts[i].mach; - self.parts[i].mach = m; - self.parts[i].reg_idx = @intCast(reg_idx); - self.parts[i].held_n = 0; - self.parts[i].setLabel(self.reg.entries[reg_idx].nameSlice()); - self.publishParts(); - self.retired.append(next_generation, old); + const l = &self.lanes[i]; + l.chain.replaceInstrument(self.alloc, &l.instrument, l.effects.items, m) catch |e| { + self.disposeMachine(m); + return e; + }; + l.reg_idx = @intCast(reg_idx); + l.setLabel(self.reg.entries[reg_idx].nameSlice()); + } + + /// Append registry machine `reg_idx` to lane `i`'s inserts. + pub fn addEffect(self: *Rack, i: usize, reg_idx: usize) !void { + if (i >= self.count) return error.InvalidLane; + const l = &self.lanes[i]; + if (l.effects.items.len >= MAX_LANE_EFFECTS) return error.TooManyEffects; + const m = try self.instantiate(reg_idx); + l.chain.addEffect(self.alloc, l.instrument, &l.effects, m, @intCast(reg_idx)) catch |e| { + self.disposeMachine(m); + return e; + }; + } + + pub fn removeEffect(self: *Rack, i: usize, j: usize) void { + if (i >= self.count) return; + const l = &self.lanes[i]; + l.chain.removeEffect(self.alloc, l.instrument, &l.effects, j) catch {}; + } + + pub fn replaceEffect(self: *Rack, i: usize, j: usize, reg_idx: usize) !void { + if (i >= self.count) return error.InvalidLane; + const l = &self.lanes[i]; + const m = try self.instantiate(reg_idx); + l.chain.replaceEffect(self.alloc, l.instrument, l.effects.items, j, m, @intCast(reg_idx)) catch |e| { + self.disposeMachine(m); + return e; + }; + } + + pub fn moveEffect(self: *Rack, i: usize, from: usize, to: usize) void { + if (i >= self.count) return; + const l = &self.lanes[i]; + l.chain.moveEffect(l.instrument, &l.effects, from, to); + } + + pub fn setEffectBypass(self: *Rack, i: usize, j: usize, on: bool) void { + if (i >= self.count) return; + const l = &self.lanes[i]; + if (j >= l.effects.items.len) return; + l.effects.items[j].bypass.store(on, .monotonic); + l.chain.publish(l.instrument, l.effects.items); + } + + fn retireLane(self: *Rack, generation: u64, l: *Lane) void { + self.retired.append(generation, .{ .chain = l.chain, .instrument = l.instrument, .effects = l.effects, .line = l.line }); } - pub fn removePart(self: *Rack, i: usize) void { + pub fn removeLane(self: *Rack, i: usize) void { if (i >= self.count) return; self.collect(); if (!self.retired.available()) return; const next_generation = self.generation + 1; - const old = self.parts[i].mach; + var old = self.lanes[i]; var j = i; - while (j + 1 < self.count) : (j += 1) self.parts[j] = self.parts[j + 1]; + while (j + 1 < self.count) : (j += 1) self.lanes[j] = self.lanes[j + 1]; self.count -= 1; - self.publishParts(); - self.retired.append(next_generation, old); + self.publishLanes(); + self.retireLane(next_generation, &old); if (self.selected >= self.count and self.selected > 0) self.selected -= 1; + self.selected_fx = -1; } pub fn clear(self: *Rack) !void { @@ -248,16 +417,17 @@ pub const Rack = struct { if (self.retired.len + self.count > 128) return error.PublicationFull; const next_generation = self.generation + 1; const n = self.count; - var old: [MAX_PARTS]Machine = undefined; - for (self.parts[0..n], 0..) |*p, i| old[i] = p.mach; + var old: [MAX_LANES]Lane = undefined; + @memcpy(old[0..n], self.lanes[0..n]); self.count = 0; - self.publishParts(); - for (old[0..n]) |m| self.retired.append(next_generation, m); + self.publishLanes(); + for (old[0..n]) |*l| self.retireLane(next_generation, l); self.selected = 0; + self.selected_fx = -1; } - /// Apply one part's saved machine settings (params, then files, then - /// zone edits), as a project load does for a track's instrument. + /// Apply one machine's saved settings (params, then files, then zone + /// edits, then its own state), as a project load does for a track's. fn applyMachineJson(m: Machine, po: std.json.ObjectMap) void { if (po.get("params")) |pv| if (pv == .object) if (m.set_param) |set| { var it = pv.object.iterator(); @@ -270,89 +440,139 @@ pub const Rack = struct { }; }; if (po.get("zones")) |zv| if (m.apply_zones_json) |f| f(m.state, zv); + if (po.get("state")) |sv| if (m.apply_state_json) |f| f(m.state, sv); + } + + fn machineOf(self: *Rack, v: ?std.json.Value) ?usize { + const o = v orelse return null; + if (o != .object) return null; + const mid = if (o.object.get("machine")) |x| (if (x == .string) x.string else return null) else return null; + return self.reg.findById(mid); } - /// Replace every part with those in `v` ({"parts":[…]}). Parts naming a - /// machine the registry doesn't have are skipped. + /// A lane's ranges, transpose and mix from `po`. + fn applyLaneSettings(l: *Lane, po: std.json.ObjectMap) void { + if (po.get("name")) |x| if (x == .string) l.setLabel(x.string); + if (po.get("lo")) |x| l.lo = jsonU7(x); + if (po.get("hi")) |x| l.hi = jsonU7(x); + if (po.get("vlo")) |x| l.vlo = jsonU7(x); + if (po.get("vhi")) |x| l.vhi = jsonU7(x); + if (po.get("transpose")) |x| l.transpose = @intFromFloat(std.math.clamp(@round(jsonF64(x)), -48, 48)); + if (po.get("level")) |x| l.level_db = @floatCast(std.math.clamp(jsonF64(x), -60, 12)); + if (po.get("pan")) |x| l.pan = @floatCast(std.math.clamp(jsonF64(x), -1, 1)); + if (po.get("poly")) |x| l.poly = @intFromFloat(std.math.clamp(@round(jsonF64(x)), 0, 64)); + if (po.get("mute")) |x| l.mute = x == .bool and x.bool; + if (po.get("solo")) |x| l.solo = x == .bool and x.bool; + } + + /// Replace every lane with those in `v`: {"rack":2,"lanes":[…]}, or the + /// first form {"parts":[…]}. Machines the registry doesn't have are + /// skipped, and so are racks past MAX_DEPTH. pub fn applyJson(self: *Rack, v: std.json.Value) void { if (v != .object) return; - const pv = v.object.get("parts") orelse return; - if (pv != .array) return; self.publication_depth += 1; defer { self.publication_depth -= 1; - self.publishParts(); - } - self.clear() catch return; - for (pv.array.items) |item| { - if (item != .object) continue; - const po = item.object; - const mid = if (po.get("machine")) |x| (if (x == .string) x.string else continue) else continue; - const idx = self.reg.findById(mid) orelse continue; - if (self.reg.entries[idx].native != .none) continue; - const p = self.addPart(idx) catch continue; - applyMachineJson(p.mach, po); - if (po.get("name")) |x| if (x == .string) p.setLabel(x.string); - if (po.get("lo")) |x| p.lo = jsonU7(x); - if (po.get("hi")) |x| p.hi = jsonU7(x); - if (po.get("vlo")) |x| p.vlo = jsonU7(x); - if (po.get("vhi")) |x| p.vhi = jsonU7(x); - if (po.get("transpose")) |x| p.transpose = @intFromFloat(std.math.clamp(@round(jsonF64(x)), -48, 48)); - if (po.get("level")) |x| p.level_db = @floatCast(std.math.clamp(jsonF64(x), -60, 12)); - if (po.get("pan")) |x| p.pan = @floatCast(std.math.clamp(jsonF64(x), -1, 1)); - if (po.get("poly")) |x| p.poly = @intFromFloat(std.math.clamp(@round(jsonF64(x)), 0, 64)); - if (po.get("mute")) |x| p.mute = x == .bool and x.bool; - self.publishParts(); + self.publishLanes(); } + if (v.object.get("lanes")) |lv| { + if (lv != .array) return; + self.clear() catch return; + for (lv.array.items) |item| { + if (item != .object) continue; + const po = item.object; + const l = if (self.kind == .instrument) blk: { + const idx = self.machineOf(po.get("instrument")) orelse continue; + const lane = self.addLane(idx) catch continue; + applyMachineJson(lane.instrument, po.get("instrument").?.object); + break :blk lane; + } else self.addLane(null) catch continue; + const li = self.count - 1; + if (po.get("effects")) |ev| if (ev == .array) for (ev.array.items) |fv| { + const idx = self.machineOf(fv) orelse continue; + self.addEffect(li, idx) catch continue; + const fx = &l.effects.items[l.effects.items.len - 1]; + applyMachineJson(fx.mach, fv.object); + if (fv.object.get("bypass")) |b| if (b == .bool and b.bool) fx.bypass.store(true, .monotonic); + }; + applyLaneSettings(l, po); + l.chain.publish(l.instrument, l.effects.items); + } + } else if (v.object.get("parts")) |pv| { + // The first form: instrument lanes, no inserts. + if (pv != .array or self.kind != .instrument) return; + self.clear() catch return; + for (pv.array.items) |item| { + if (item != .object) continue; + const idx = self.machineOf(item) orelse continue; + const l = self.addLane(idx) catch continue; + applyMachineJson(l.instrument, item.object); + applyLaneSettings(l, item.object); + } + } else return; self.selected = 0; + self.selected_fx = -1; } - pub fn writeJson(self: *Rack, out: *std.ArrayList(u8), alloc: std.mem.Allocator) !void { - try out.appendSlice(alloc, "{\"parts\":["); - for (self.parts[0..self.count], 0..) |*p, i| { - if (i > 0) try out.append(alloc, ','); - try out.appendSlice(alloc, "{\"machine\":"); - try jsonString(out, alloc, self.reg.entries[p.reg_idx].idSlice()); - try out.appendSlice(alloc, ",\"name\":"); - try jsonString(out, alloc, p.labelSlice()); - try out.print(alloc, ",\"lo\":{d},\"hi\":{d},\"vlo\":{d},\"vhi\":{d},\"transpose\":{d},\"level\":{d},\"pan\":{d},\"poly\":{d},\"mute\":{s}", .{ - p.lo, p.hi, p.vlo, p.vhi, p.transpose, p.level_db, p.pan, p.poly, if (p.mute) "true" else "false", - }); - if (p.mach.write_params_json) |f| { - try out.appendSlice(alloc, ",\"params\":"); - try f(p.mach.state, out, alloc); - } - if (p.mach.write_assets_json) |f| { + fn writeMachineJson(self: *Rack, out: *std.ArrayList(u8), alloc: std.mem.Allocator, m: Machine, reg_idx: u8) !void { + try out.appendSlice(alloc, "{\"machine\":"); + try jsonString(out, alloc, self.reg.entries[reg_idx].idSlice()); + if (m.write_params_json) |f| { + try out.appendSlice(alloc, ",\"params\":"); + try f(m.state, out, alloc); + } + inline for (.{ .{ "assets", "write_assets_json" }, .{ "zones", "write_zones_json" } }) |kv| { + if (@field(m, kv[1])) |f| { const at = out.items.len; - try out.appendSlice(alloc, ",\"assets\":"); + try out.appendSlice(alloc, ",\"" ++ kv[0] ++ "\":"); const before = out.items.len; - try f(p.mach.state, out, alloc); + try f(m.state, out, alloc); if (out.items.len == before) out.shrinkRetainingCapacity(at); } - if (p.mach.write_zones_json) |f| { - const at = out.items.len; - try out.appendSlice(alloc, ",\"zones\":"); - const before = out.items.len; - try f(p.mach.state, out, alloc); - if (out.items.len == before) out.shrinkRetainingCapacity(at); + } + if (m.write_state_json) |f| { + try out.appendSlice(alloc, ",\"state\":"); + try f(m.state, out, alloc); + } + } + + pub fn writeJson(self: *Rack, out: *std.ArrayList(u8), alloc: std.mem.Allocator) !void { + try out.print(alloc, "{{\"rack\":2,\"kind\":\"{s}\",\"lanes\":[", .{@tagName(self.kind)}); + for (self.lanes[0..self.count], 0..) |*l, i| { + if (i > 0) try out.append(alloc, ','); + try out.appendSlice(alloc, "{\"name\":"); + try jsonString(out, alloc, l.labelSlice()); + try out.print(alloc, ",\"lo\":{d},\"hi\":{d},\"vlo\":{d},\"vhi\":{d},\"transpose\":{d},\"level\":{d},\"pan\":{d},\"poly\":{d},\"mute\":{s},\"solo\":{s}", .{ + l.lo, l.hi, l.vlo, l.vhi, l.transpose, l.level_db, l.pan, l.poly, if (l.mute) "true" else "false", if (l.solo) "true" else "false", + }); + if (l.reg_idx) |ri| { + try out.appendSlice(alloc, ",\"instrument\":"); + try self.writeMachineJson(out, alloc, l.instrument, ri); + try out.append(alloc, '}'); } - try out.append(alloc, '}'); + try out.appendSlice(alloc, ",\"effects\":["); + for (l.effects.items, 0..) |*fx, j| { + if (j > 0) try out.append(alloc, ','); + try self.writeMachineJson(out, alloc, fx.mach, fx.idx orelse 0); + try out.print(alloc, ",\"bypass\":{s}}}", .{if (fx.bypass.load(.monotonic)) "true" else "false"}); + } + try out.appendSlice(alloc, "]}"); } try out.appendSlice(alloc, "]}"); } // ── audio ──────────────────────────────────────────────────────── - /// Events for part `p` out of the block's `in`: its notes transposed, + /// Events for lane `p` out of the block's `in`: its notes transposed, /// the oldest released first when it is at its cap, and everything /// that isn't a note. Returns how many. - fn route(p: *Part, in: []const NoteEvent, out: []NoteEvent) usize { + fn route(p: *Lane, in: []const NoteEvent, out: []NoteEvent, open: bool) usize { var n: usize = 0; for (in) |ev| { if (n + 2 > out.len) break; switch (ev.kind) { .note_on => if (ev.velocity > 0) { - if (!p.takes(ev.pitch, ev.velocity)) continue; + if (!open or !p.takes(ev.pitch, ev.velocity)) continue; const cap: usize = if (p.poly > 0) @min(p.poly, MAX_HELD) else MAX_HELD; if (p.held_n >= cap) { var off = ev; @@ -406,21 +626,21 @@ pub const Rack = struct { return n; } - fn findHeld(p: *const Part, ev: NoteEvent) ?usize { + fn findHeld(p: *const Lane, ev: NoteEvent) ?usize { for (p.held[0..p.held_n], 0..) |h, i| { if (h.channel == ev.channel and h.id == ev.note_id) return i; } return null; } - fn dropHeld(p: *Part, i: usize) void { + fn dropHeld(p: *Lane, i: usize) void { var j = i; while (j + 1 < p.held_n) : (j += 1) p.held[j] = p.held[j + 1]; p.held_n -= 1; } - /// The note-off for a note this part holds, at the pitch it played. - fn releaseHeld(p: *Part, ev: NoteEvent) ?NoteEvent { + /// The note-off for a note this lane holds, at the pitch it played. + fn releaseHeld(p: *Lane, ev: NoteEvent) ?NoteEvent { const i = findHeld(p, ev) orelse return null; var off = ev; off.kind = .note_off; @@ -428,56 +648,184 @@ pub const Rack = struct { dropHeld(p, i); return off; } + + /// Audio: the slowest lane's latency, which every lane is held to. + fn latency(self: *Rack) u32 { + var n: u32 = 0; + for (self.audio_lanes[0..self.audio_count]) |*l| n = @max(n, l.latency()); + return @min(n, MAX_ALIGN); + } }; fn adoptImpl(state: *anyopaque) void { const self: *Rack = @ptrCast(@alignCast(state)); - self.adoptParts(); - for (self.audio_parts[0..self.audio_count]) |*p| if (p.mach.adopt_updates) |f| f(p.mach.state); + self.adoptLanes(); + for (self.audio_lanes[0..self.audio_count]) |*l| { + const m = &l.chain.audio_machine; + if (m.adopt_updates) |f| f(m.state); + for (l.chain.audioEffects()) |*fx| if (fx.mach.adopt_updates) |f| f(fx.mach.state); + } } fn renderImpl(state: *anyopaque, ctx: *const machine.MachineCtx, l: []f32, r: []f32) void { const self: *Rack = @ptrCast(@alignCast(state)); const frames = @min(@as(usize, ctx.block_size), @min(l.len, @min(r.len, MAX_BLOCK))); + // An effect rack's input, read before the output (they may alias). + var in_l: [MAX_BLOCK]f32 = undefined; + var in_r: [MAX_BLOCK]f32 = undefined; + if (self.kind == .effect) { + if (ctx.audio_in) |ports| if (ctx.audio_in_count >= 2) { + @memcpy(in_l[0..frames], ports[0][0..frames]); + @memcpy(in_r[0..frames], ports[1][0..frames]); + } else { + @memset(in_l[0..frames], 0); + @memset(in_r[0..frames], 0); + } else { + @memset(in_l[0..frames], 0); + @memset(in_r[0..frames], 0); + } + } @memset(l[0..frames], 0); @memset(r[0..frames], 0); - self.adoptParts(); + self.adoptLanes(); const in: []const NoteEvent = if (ctx.note_in) |p| p[0..ctx.note_in_count] else &.{}; - for (self.audio_parts[0..self.audio_count]) |*p| { - const n = Rack.route(p, in, &self.events); + const total = self.latency(); + var solo = false; + for (self.audio_lanes[0..self.audio_count]) |*p| solo = solo or p.solo; + for (self.audio_lanes[0..self.audio_count]) |*p| { + // The lanes' machines aren't the track's: no automation reaches them. var sub = ctx.*; - sub.note_in = if (n > 0) &self.events else null; - sub.note_in_count = @intCast(n); + sub.automation = null; sub.note_out = null; sub.note_out_cap = 0; sub.note_out_count = null; - @memset(self.sl[0..frames], 0); - @memset(self.sr[0..frames], 0); - p.mach.render(p.mach.state, &sub, self.sl[0..frames], self.sr[0..frames]); + sub.audio_in = null; + sub.audio_in_count = 0; + // MUTE, or another lane's SOLO, closes the lane's input: no new + // notes, no audio in. What it holds rings out and is heard. + const open = !p.mute and (!solo or p.solo); + if (self.kind == .instrument) { + const n = Rack.route(p, in, &self.events, open); + sub.note_in = if (n > 0) &self.events else null; + sub.note_in_count = @intCast(n); + @memset(self.sl[0..frames], 0); + @memset(self.sr[0..frames], 0); + const m = &p.chain.audio_machine; + m.render(m.state, &sub, self.sl[0..frames], self.sr[0..frames]); + } else { + sub.note_in = null; + sub.note_in_count = 0; + if (open) { + @memcpy(self.sl[0..frames], in_l[0..frames]); + @memcpy(self.sr[0..frames], in_r[0..frames]); + } else { + @memset(self.sl[0..frames], 0); + @memset(self.sr[0..frames], 0); + } + } + var out = engine.RenderedPair{ .l = self.sl[0..frames], .r = self.sr[0..frames] }; + if (p.chain.audio_effect_count > 0) { + out = engine.renderEffects(p.chain, sub, self.sl[0..frames], self.sr[0..frames], self.fl[0..frames], self.fr[0..frames], .{ .on = true, .wake = p.held_n > 0 }); + } + // Held back to the slowest lane. + const d = total -| p.latency(); + if (d > 0 or p.delay > 0) { + const line = p.line; + const mask = chain_mod.DRY_LEN - 1; + if (d != p.delay) { + // A new alignment: what the line held was for another delay. + @memset(&line.l, 0); + @memset(&line.r, 0); + line.w = 0; + p.delay = d; + } + for (0..frames) |k| { + const w = (line.w + k) & mask; + line.l[w] = out.l[k]; + line.r[w] = out.r[k]; + const rd = (line.w + k + chain_mod.DRY_LEN - d) & mask; + out.l[k] = line.l[rd]; + out.r[k] = line.r[rd]; + } + line.w = (line.w + frames) & mask; + } const g = std.math.pow(f32, 10, p.level_db / 20); const gl = g * @min(1, 1 - p.pan); const gr = g * @min(1, 1 + p.pan); - for (l[0..frames], r[0..frames], self.sl[0..frames], self.sr[0..frames]) |*ol, *or_, a, b| { + for (l[0..frames], r[0..frames], out.l, out.r) |*ol, *or_, a, b| { ol.* += a * gl; or_.* += b * gr; } } } +fn latencyImpl(state: *anyopaque) u32 { + const self: *Rack = @ptrCast(@alignCast(state)); + self.adoptLanes(); + return self.latency(); +} + +fn tailImpl(state: *anyopaque, sample_rate: f64) u32 { + const self: *Rack = @ptrCast(@alignCast(state)); + var n: u32 = 0; + for (self.audio_lanes[0..self.audio_count]) |*l| { + const t = l.tail(sample_rate); + if (t == machine.TAIL_FOREVER) return t; + n = @max(n, t); + } + return n; +} + +/// A control edit on any machine inside wakes the rack, and the insert it +/// landed on renders again however long it has been quiet. +fn takeWakeImpl(state: *anyopaque) bool { + const self: *Rack = @ptrCast(@alignCast(state)); + var any = false; + for (self.audio_lanes[0..self.audio_count]) |*l| { + if (l.chain.audio_machine.takeWake()) any = true; + for (l.chain.audioEffects()) |*fx| if (fx.mach.takeWake()) { + fx.quiet = 0; + any = true; + }; + } + return any; +} + +fn takeEditedImpl(state: *anyopaque) bool { + const self: *Rack = @ptrCast(@alignCast(state)); + var any = false; + for (self.lanes[0..self.count]) |*l| { + if (l.instrument.takeEdited()) any = true; + for (l.effects.items) |*fx| if (fx.mach.takeEdited()) { + any = true; + }; + } + return any; +} + fn resetImpl(state: *anyopaque) void { const self: *Rack = @ptrCast(@alignCast(state)); - self.adoptParts(); - for (self.audio_parts[0..self.audio_count]) |*p| { + self.adoptLanes(); + for (self.audio_lanes[0..self.audio_count]) |*p| { p.held_n = 0; - p.mach.reset(p.mach.state); + const m = &p.chain.audio_machine; + m.reset(m.state); + for (p.chain.audioEffects()) |*fx| { + fx.mach.reset(fx.mach.state); + fx.quiet = 0; + } + if (p.delay > 0) { + @memset(&p.line.l, 0); + @memset(&p.line.r, 0); + } } } fn deinitImpl(state: *anyopaque, alloc: std.mem.Allocator) void { const self: *Rack = @ptrCast(@alignCast(state)); // Device/consumer stopped before destruction. - for (self.parts[0..self.count]) |p| self.disposePart(p.mach); - self.retired.drain(self, Rack.disposePart); + for (self.lanes[0..self.count]) |*l| self.disposeLane(.{ .chain = l.chain, .instrument = l.instrument, .effects = l.effects, .line = l.line }); + self.retired.drain(self, Rack.disposeLane); alloc.destroy(self); } @@ -509,7 +857,7 @@ fn applyPresetImpl(state: *anyopaque, index: machine.PresetIndex) void { if (index >= self.presets.count) return; const buf = self.alloc.alloc(u8, PRESET_FILE) catch return; defer self.alloc.free(buf); - const data = presets_mod.readPreset(buf, Rack.presetDir(), "rack", self.presets.names[index].slice()) orelse return; + const data = presets_mod.readPreset(buf, self.presetDir(), self.id(), self.presets.names[index].slice()) orelse return; var parsed = std.json.parseFromSlice(std.json.Value, self.alloc, data, .{}) catch return; defer parsed.deinit(); self.applyJson(parsed.value); @@ -536,17 +884,17 @@ fn savePresetIn(state: *anyopaque, name_z: [*:0]const u8, bank: presets_mod.Bank if (name.len > presets_mod.MAX_NAME) return null; var out: std.ArrayList(u8) = .empty; defer out.deinit(self.alloc); - out.appendSlice(self.alloc, "{\"slab\":\"preset\",\"schema\":1,\"machine\":\"rack\",") catch return null; + out.print(self.alloc, "{{\"slab\":\"preset\",\"schema\":1,\"machine\":\"{s}\",", .{self.id()}) catch return null; const at = out.items.len; if (bank == .project) storage.beginProjectSave(); const wrote = self.writeJson(&out, self.alloc); if (bank == .project) storage.endProjectSave(); wrote catch return null; - // splice the parts object's body into the preset object + // splice the lanes object's body into the preset object std.mem.copyForwards(u8, out.items[at..], out.items[at + 1 ..]); out.shrinkRetainingCapacity(out.items.len - 1); out.append(self.alloc, '\n') catch return null; - if (!presets_mod.writePreset(Rack.presetDir(), "rack", name, out.items)) return null; + if (!presets_mod.writePreset(self.presetDir(), self.id(), name, out.items)) return null; self.rescan(); for (self.presets.names[0..self.presets.count], 0..) |*pn, i| { if (std.mem.eql(u8, pn.slice(), name)) { @@ -571,22 +919,30 @@ fn applyStateJsonImpl(state: *anyopaque, v: std.json.Value) void { // ── panel ──────────────────────────────────────────────────────────── // -// ┌ PARTS ───────────┐┌ the selected part's own panel ┐ -// │ 1 ALTO1 ● ││ │ -// │ 2 ALTO2 ││ │ -// │ [key map lanes] ││ │ -// │ LO HI VLO VHI ││ │ -// │ TRN POLY LVL PAN ││ │ -// │ [MACHINE] [+][−] ││ │ -// └──────────────────┘└───────────────────────────────┘ +// ┌ LANES ───────────┐┌ the selected machine's own panel ┐ +// │ 1 ALTO1 ● ││ │ +// │ 2 ALTO2 ││ │ +// │ [key map lanes] ││ │ +// │ LO HI VLO VHI ││ (instrument racks) │ +// │ TRN POLY LVL PAN ││ │ +// │ [MACHINE] [+][−] ││ │ +// │ FX: inserts [−] ││ │ +// │ [+ FX] [+ LANE] ││ │ +// └──────────────────┘└──────────────────────────────────┘ + +/// The machine the panel shows: the selected lane's instrument or insert. +fn shown(self: *Rack) ?*Machine { + if (self.selected >= self.count) return null; + const l = &self.lanes[self.selected]; + if (self.selected_fx >= 0 and self.selected_fx < l.effects.items.len) return &l.effects.items[@intCast(self.selected_fx)].mach; + if (l.reg_idx != null) return &l.instrument; + return null; +} fn panelWImpl(state: *anyopaque) f32 { const self: *Rack = @ptrCast(@alignCast(state)); var w: f32 = 200; - if (self.selected < self.count) { - const m = &self.parts[self.selected].mach; - w = if (m.panel_w_fn) |f| f(m.state) else if (m.panel_w > 0) m.panel_w else 200; - } + if (shown(self)) |m| w = if (m.panel_w_fn) |f| f(m.state) else if (m.panel_w > 0) m.panel_w else 200; return @as(f32, @floatFromInt(SIDE_W)) + w; } @@ -616,79 +972,98 @@ fn step7(v: *u8, d: i32, lo: i32, hi: i32) void { v.* = @intCast(std.math.clamp(@as(i32, v.*) + d, lo, hi)); } -const PART_COLORS = [_]ui_style.Color{ +const LANE_COLORS = [_]ui_style.Color{ ui_style.led_green, ui_style.vfd, ui_style.accent, ui_style.led_green.mix(ui_style.vfd, 0.5), ui_style.vfd.mix(ui_style.text, 0.4), ui_style.accent.mix(ui_style.text, 0.4), ui_style.led_green.mix(ui_style.text, 0.4), ui_style.text_dim, }; +/// The lane's label from a picked preset's name. +fn labelFromPreset(l: *Lane, m: Machine, pi: machine.PresetIndex) void { + var buf: [64]u8 = undefined; + if (m.preset_name) |pn| l.setLabel(std.fmt.bufPrint(&buf, "{s}", .{lastSegment(std.mem.span(pn(m.state, pi)))}) catch ""); +} + fn drawPanelImpl(state: *anyopaque, ui: *Ui, rect: Rect) void { const self: *Rack = @ptrCast(@alignCast(state)); self.collect(); - defer self.publishParts(); + defer self.publishLanes(); var body = rect; var side = body.cutLeft(SIDE_W); ui.rect(side, ui_style.chassis); - side = ctl.strip(ui, side.inset(4), "PARTS"); + side = ctl.strip(ui, side.inset(4), "LANES"); + const inst = self.kind == .instrument; - // part list + // lane list var i: usize = 0; - while (i < MAX_PARTS) : (i += 1) { + while (i < MAX_LANES) : (i += 1) { const row = side.cutTop(ROW_H); if (i >= self.count) { ui.rect(row.insetXY(0, 1), ui_style.well.alpha(120)); continue; } - const p = &self.parts[i]; + const p = &self.lanes[i]; ui.pushId(i); defer ui.popId(); var on = i == self.selected; var lb: [48]u8 = undefined; var a: [4]u8 = undefined; var b: [4]u8 = undefined; - const text = std.fmt.bufPrint(&lb, "{d} {s} {s}-{s}", .{ i + 1, p.labelSlice(), noteName(&a, p.lo), noteName(&b, p.hi) }) catch "?"; - if (ctl.button(ui, row, "sel", &on, .{ .kind = .latch, .label = text, .flush = true, .lit = PART_COLORS[i] })) self.selected = i; + const text = if (inst) + std.fmt.bufPrint(&lb, "{d} {s} {s}-{s}", .{ i + 1, p.labelSlice(), noteName(&a, p.lo), noteName(&b, p.hi) }) catch "?" + else + std.fmt.bufPrint(&lb, "{d} {s} {d} FX", .{ i + 1, p.labelSlice(), p.effects.items.len }) catch "?"; + if (ctl.button(ui, row, "sel", &on, .{ .kind = .latch, .label = text, .flush = true, .lit = LANE_COLORS[i] })) { + if (self.selected != i) self.selected_fx = -1; + self.selected = i; + } } _ = side.cutTop(4); - // key map: a lane per part over the 128 keys - const map = side.cutTop(@intCast(4 * MAX_PARTS / 2 + 4)); - ui.rect(map, ui_style.well); - const lane_h: i32 = 2; - for (self.parts[0..self.count], 0..) |*p, pi| { - const x0 = map.x + 2 + @divFloor((map.w - 4) * @as(i32, p.lo), 128); - const x1 = map.x + 2 + @divFloor((map.w - 4) * (@as(i32, p.hi) + 1), 128); - const col = if (p.mute) ui_style.text_mute else PART_COLORS[pi]; - ui.rect(Rect.xywh(x0, map.y + 2 + @as(i32, @intCast(pi)) * lane_h, @max(1, x1 - x0), lane_h), if (pi == self.selected) col else col.alpha(140)); - } - // octave ticks at the Cs - var k: i32 = 0; - while (k < 128) : (k += 12) ui.rect(Rect.xywh(map.x + 2 + @divFloor((map.w - 4) * k, 128), map.bottom() - 3, 1, 3), ui_style.text_mute); - _ = side.cutTop(4); + if (inst) { + // key map: a lane per lane over the 128 keys + const map = side.cutTop(@intCast(4 * MAX_LANES / 2 + 4)); + ui.rect(map, ui_style.well); + const lane_h: i32 = 2; + for (self.lanes[0..self.count], 0..) |*p, pi| { + const x0 = map.x + 2 + @divFloor((map.w - 4) * @as(i32, p.lo), 128); + const x1 = map.x + 2 + @divFloor((map.w - 4) * (@as(i32, p.hi) + 1), 128); + const col = if (p.mute) ui_style.text_mute else LANE_COLORS[pi]; + ui.rect(Rect.xywh(x0, map.y + 2 + @as(i32, @intCast(pi)) * lane_h, @max(1, x1 - x0), lane_h), if (pi == self.selected) col else col.alpha(140)); + } + // octave ticks at the Cs + var k: i32 = 0; + while (k < 128) : (k += 12) ui.rect(Rect.xywh(map.x + 2 + @divFloor((map.w - 4) * k, 128), map.bottom() - 3, 1, 3), ui_style.text_mute); + _ = side.cutTop(4); + } if (self.selected < self.count) { - const p = &self.parts[self.selected]; + const p = &self.lanes[self.selected]; var s = p.*; // edit a copy, publish at the block boundary var buf: [16]u8 = undefined; - var half = side.cutTop(FIELD_H); - var left = half.cutLeft(@divFloor(half.w, 2) - 2); - _ = half.cutLeft(4); - step7(&s.lo, field(ui, left, "lo", "LO", noteName(&buf, s.lo), 12), 0, s.hi); - step7(&s.hi, field(ui, half, "hi", "HI", noteName(&buf, s.hi), 12), s.lo, 127); - _ = side.cutTop(2); - half = side.cutTop(FIELD_H); - left = half.cutLeft(@divFloor(half.w, 2) - 2); - _ = half.cutLeft(4); - step7(&s.vlo, field(ui, left, "vlo", "VLO", std.fmt.bufPrint(&buf, "{d}", .{s.vlo}) catch "?", 16), 1, s.vhi); - step7(&s.vhi, field(ui, half, "vhi", "VHI", std.fmt.bufPrint(&buf, "{d}", .{s.vhi}) catch "?", 16), s.vlo, 127); - _ = side.cutTop(2); - half = side.cutTop(FIELD_H); - left = half.cutLeft(@divFloor(half.w, 2) - 2); - _ = half.cutLeft(4); - const dt = field(ui, left, "trn", "TRN", std.fmt.bufPrint(&buf, "{s}{d}", .{ if (s.transpose > 0) "+" else "", s.transpose }) catch "?", 12); - s.transpose = @intCast(std.math.clamp(@as(i32, s.transpose) + dt, -48, 48)); - const dp = field(ui, half, "poly", "POLY", if (s.poly == 0) "ALL" else std.fmt.bufPrint(&buf, "{d}", .{s.poly}) catch "?", 4); - s.poly = @intCast(std.math.clamp(@as(i32, s.poly) + dp, 0, 32)); - _ = side.cutTop(2); + var half: Rect = undefined; + var left: Rect = undefined; + if (inst) { + half = side.cutTop(FIELD_H); + left = half.cutLeft(@divFloor(half.w, 2) - 2); + _ = half.cutLeft(4); + step7(&s.lo, field(ui, left, "lo", "LO", noteName(&buf, s.lo), 12), 0, s.hi); + step7(&s.hi, field(ui, half, "hi", "HI", noteName(&buf, s.hi), 12), s.lo, 127); + _ = side.cutTop(2); + half = side.cutTop(FIELD_H); + left = half.cutLeft(@divFloor(half.w, 2) - 2); + _ = half.cutLeft(4); + step7(&s.vlo, field(ui, left, "vlo", "VLO", std.fmt.bufPrint(&buf, "{d}", .{s.vlo}) catch "?", 16), 1, s.vhi); + step7(&s.vhi, field(ui, half, "vhi", "VHI", std.fmt.bufPrint(&buf, "{d}", .{s.vhi}) catch "?", 16), s.vlo, 127); + _ = side.cutTop(2); + half = side.cutTop(FIELD_H); + left = half.cutLeft(@divFloor(half.w, 2) - 2); + _ = half.cutLeft(4); + const dt = field(ui, left, "trn", "TRN", std.fmt.bufPrint(&buf, "{s}{d}", .{ if (s.transpose > 0) "+" else "", s.transpose }) catch "?", 12); + s.transpose = @intCast(std.math.clamp(@as(i32, s.transpose) + dt, -48, 48)); + const dp = field(ui, half, "poly", "POLY", if (s.poly == 0) "ALL" else std.fmt.bufPrint(&buf, "{d}", .{s.poly}) catch "?", 4); + s.poly = @intCast(std.math.clamp(@as(i32, s.poly) + dp, 0, 32)); + _ = side.cutTop(2); + } half = side.cutTop(FIELD_H); left = half.cutLeft(@divFloor(half.w, 2) - 2); _ = half.cutLeft(4); @@ -700,10 +1075,13 @@ fn drawPanelImpl(state: *anyopaque, ui: *Ui, rect: Rect) void { _ = side.cutTop(4); var btns = side.cutTop(FIELD_H); - const mute_r = btns.cutRight(44); + const mute_r = btns.cutRight(36); _ = ctl.button(ui, mute_r, "mute", &s.mute, .{ .kind = .latch, .label = "MUTE", .led = ui_style.accent }); + _ = btns.cutRight(2); + const solo_r = btns.cutRight(36); + _ = ctl.button(ui, solo_r, "solo", &s.solo, .{ .kind = .latch, .label = "SOLO", .led = ui_style.accent }); if (s.lo != p.lo or s.hi != p.hi or s.vlo != p.vlo or s.vhi != p.vhi or s.transpose != p.transpose or - s.poly != p.poly or s.level_db != p.level_db or s.pan != p.pan or s.mute != p.mute) + s.poly != p.poly or s.level_db != p.level_db or s.pan != p.pan or s.mute != p.mute or s.solo != p.solo) { p.lo = s.lo; p.hi = s.hi; @@ -714,63 +1092,121 @@ fn drawPanelImpl(state: *anyopaque, ui: *Ui, rect: Rect) void { p.level_db = s.level_db; p.pan = s.pan; p.mute = s.mute; - self.publishParts(); + p.solo = s.solo; + self.publishLanes(); } _ = btns.cutRight(4); const del_r = btns.cutRight(20); if (ctl.button(ui, del_r, "del", null, .{ .label = "\u{2212}" })) { - self.removePart(self.selected); - } else { - menu.tip(ui, del_r, "Remove part"); - _ = btns.cutRight(4); + self.removeLane(self.selected); + return; + } + menu.tip(ui, del_r, "Remove lane"); + _ = btns.cutRight(4); + if (inst) { const mkey = pane.keyFromIds(PICK_MENU_KEY, @intFromPtr(self), 1); - if (ctl.button(ui, btns, "machine", null, .{ .label = self.reg.entries[p.reg_idx].nameSlice() }) and !menu.isOpen(mkey)) { - bay.scanRegistryPresets(self.reg); - menu.openBelow(mkey, btns); + var sel_inst = self.selected_fx < 0; + if (ctl.button(ui, btns, "machine", &sel_inst, .{ .kind = .latch, .label = self.reg.entries[p.reg_idx orelse 0].nameSlice(), .flush = true })) { + if (self.selected_fx < 0 and !menu.isOpen(mkey)) { + bay.scanRegistryPresets(self.reg); + menu.openBelow(mkey, btns); + } + self.selected_fx = -1; } - menu.tip(ui, btns, "Replace this part's machine"); + menu.tip(ui, btns, "This lane's instrument: click again to replace it"); if (bay.machinePickerMenuFor(mkey, self.reg, .instruments)) |pick| { - self.replacePart(self.selected, pick.reg_idx) catch {}; - const np = &self.parts[self.selected]; - if (pick.preset) |pi| if (np.mach.apply_preset) |ap| { - ap(np.mach.state, pi); - if (np.mach.preset_name) |pn| np.setLabel(std.fmt.bufPrint(&buf, "{s}", .{lastSegment(std.mem.span(pn(np.mach.state, pi)))}) catch ""); + self.replaceInstrument(self.selected, pick.reg_idx) catch {}; + const np = &self.lanes[self.selected]; + if (pick.preset) |pi| if (np.instrument.apply_preset) |ap| { + ap(np.instrument.state, pi); + labelFromPreset(np, np.instrument, pi); }; } } _ = side.cutTop(4); + + // the lane's inserts + var j: usize = 0; + while (j < p.effects.items.len) : (j += 1) { + var row = side.cutTop(ROW_H); + if (row.h < ROW_H) break; + const fx = &p.effects.items[j]; + ui.pushId(@as(usize, 0x100) + j); + defer ui.popId(); + const rm = row.cutRight(20); + _ = row.cutRight(2); + const byp = row.cutRight(28); + _ = row.cutRight(2); + var lb: [48]u8 = undefined; + var on = self.selected_fx == @as(i32, @intCast(j)); + const name = if (fx.idx) |fi| self.reg.entries[fi].nameSlice() else fx.mach.name; + if (ctl.button(ui, row, "fx", &on, .{ .kind = .latch, .label = std.fmt.bufPrint(&lb, "FX{d} {s}", .{ j + 1, name }) catch "?", .flush = true })) self.selected_fx = @intCast(j); + var active = !fx.bypass.load(.monotonic); + if (ctl.button(ui, byp, "on", &active, .{ .kind = .latch, .label = "ON", .led = ui_style.accent })) self.setEffectBypass(self.selected, j, !active); + if (ctl.button(ui, rm, "rm", null, .{ .label = "\u{2212}" })) { + self.removeEffect(self.selected, j); + self.selected_fx = -1; + break; + } + } + if (p.effects.items.len < MAX_LANE_EFFECTS) { + const add = side.cutTop(FIELD_H); + const fkey = pane.keyFromIds(PICK_MENU_KEY, @intFromPtr(self), 3); + if (ctl.button(ui, add, "addfx", null, .{ .label = "+ FX" }) and !menu.isOpen(fkey)) { + bay.scanRegistryPresets(self.reg); + menu.openBelow(fkey, add); + } + menu.tip(ui, add, "Add an insert to this lane"); + if (bay.machinePickerMenuFor(fkey, self.reg, .effects)) |pick| { + if (self.addEffect(self.selected, pick.reg_idx)) { + const fx = &self.lanes[self.selected].effects.items[self.lanes[self.selected].effects.items.len - 1]; + if (pick.preset) |pi| if (fx.mach.apply_preset) |ap| ap(fx.mach.state, pi); + self.selected_fx = @intCast(self.lanes[self.selected].effects.items.len - 1); + } else |_| {} + } + _ = side.cutTop(4); + } } - // add a part - if (self.count < MAX_PARTS) { + // add a lane + if (self.count < MAX_LANES) { const add = side.cutTop(FIELD_H); const akey = pane.keyFromIds(PICK_MENU_KEY, @intFromPtr(self), 2); - if (ctl.button(ui, add, "add", null, .{ .label = "+ PART" }) and !menu.isOpen(akey)) { - bay.scanRegistryPresets(self.reg); - menu.openBelow(akey, add); - } - menu.tip(ui, add, "Add a part: another machine layered or split"); - if (bay.machinePickerMenuFor(akey, self.reg, .instruments)) |pick| { - if (self.addPart(pick.reg_idx)) |np| { - self.selected = self.count - 1; - if (pick.preset) |pi| if (np.mach.apply_preset) |ap| { - ap(np.mach.state, pi); - var buf: [64]u8 = undefined; - if (np.mach.preset_name) |pn| np.setLabel(std.fmt.bufPrint(&buf, "{s}", .{lastSegment(std.mem.span(pn(np.mach.state, pi)))}) catch ""); - }; - } else |_| {} + if (inst) { + if (ctl.button(ui, add, "add", null, .{ .label = "+ LANE" }) and !menu.isOpen(akey)) { + bay.scanRegistryPresets(self.reg); + menu.openBelow(akey, add); + } + menu.tip(ui, add, "Add a lane: another instrument layered or split"); + if (bay.machinePickerMenuFor(akey, self.reg, .instruments)) |pick| { + if (self.addLane(pick.reg_idx)) |np| { + self.selected = self.count - 1; + self.selected_fx = -1; + if (pick.preset) |pi| if (np.instrument.apply_preset) |ap| { + ap(np.instrument.state, pi); + labelFromPreset(np, np.instrument, pi); + }; + } else |_| {} + } + } else { + if (ctl.button(ui, add, "add", null, .{ .label = "+ LANE" })) { + if (self.addLane(null)) |_| { + self.selected = self.count - 1; + self.selected_fx = -1; + } else |_| {} + } + menu.tip(ui, add, "Add a lane: a dry path to put inserts on"); } } - // the selected part's own panel - if (self.selected < self.count) { - const m = &self.parts[self.selected].mach; + // the selected machine's own panel + if (shown(self)) |m| { ui.pushId(m.state); defer ui.popId(); m.draw_panel(m.state, ui, body); } else { ui.rect(body, ui_style.well); - ui.textIn(&ui.fonts.body, body, "Add a part", ui_style.text_mute, .center, true); + ui.textIn(&ui.fonts.body, body, if (self.count == 0) "Add a lane" else "Dry: add an insert", ui_style.text_mute, .center, true); } } @@ -837,11 +1273,25 @@ fn play(m: Machine, evs: []const NoteEvent, out: []f32) void { var first = true; while (at + 512 <= out.len) : (at += 512) { const ctx = testCtx(if (first) evs else &.{}, 512); + if (m.adopt_updates) |f| f(m.state); m.render(m.state, &ctx, out[at..][0..512], &r); first = false; } } +/// Blocks of 256 of `in` (mono, both channels) through an effect `m`. +fn process(m: Machine, in: []const f32, out_l: []f32, out_r: []f32) void { + var at: usize = 0; + while (at + 256 <= in.len) : (at += 256) { + var ctx = testCtx(&.{}, 256); + const ports = [_][*]const f32{ in[at..].ptr, in[at..].ptr }; + ctx.audio_in = &ports; + ctx.audio_in_count = 2; + if (m.adopt_updates) |f| f(m.state); + m.render(m.state, &ctx, out_l[at..][0..256], out_r[at..][0..256]); + } +} + /// Pitch by autocorrelation: the lag (50..800 Hz) where the signal best /// matches itself, refined by a parabola. fn acHz(x: []const f32) f64 { @@ -883,72 +1333,75 @@ fn rms(x: []const f32) f64 { return @sqrt(s / @as(f64, @floatFromInt(x.len))); } -test "rack: a split sends each key to its part, transposed; POLY releases the oldest; parts round-trip as JSON" { +test "rack: a split sends each key to its lane, transposed; POLY releases the oldest; lanes round-trip as JSON" { const a = testing.allocator; var reg = registry_mod.Registry.init(a); defer reg.deinit(); try reg.loadFyMachine("machines/unfairlight/unfairlight.fy"); try reg.loadNative(); - const rack = try Rack.create(a, ®); + const rack = try Rack.create(a, ®, .instrument); const m = rack.machineInterface(); defer m.deinit.?(m.state, a); // lower half plays as keyed, upper half an octave down; both dark // enough that zero crossings count the fundamental - const lo = try rack.addPart(0); + const lo = try rack.addLane(0); lo.hi = 59; - const hi = try rack.addPart(0); + const hi = try rack.addLane(0); hi.lo = 60; hi.transpose = -12; - for (rack.parts[0..2]) |*p| { - p.mach.set_param.?(p.mach.state, "cmi-filter", 60); - p.mach.set_param.?(p.mach.state, "cmi-loop", 1); + for (rack.lanes[0..2]) |*p| { + p.instrument.set_param.?(p.instrument.state, "cmi-filter", 60); + p.instrument.set_param.?(p.instrument.state, "cmi-loop", 1); } var out = [_]f32{0} ** (512 * 24); - rack.publishParts(); - // A2 on the lower part: the bundled pluck (A3) an octave down + rack.publishLanes(); + // A2 on the lower lane: the bundled pluck (A3) an octave down play(m, &.{noteOn(45, 1)}, &out); try testing.expectApproxEqRel(@as(f64, 110), acHz(out[512 * 4 ..]), 0.01); - try testing.expectEqual(@as(u8, 1), rack.audio_parts[0].held_n); - try testing.expectEqual(@as(u8, 0), rack.audio_parts[1].held_n); + try testing.expectEqual(@as(u8, 1), rack.audio_lanes[0].held_n); + try testing.expectEqual(@as(u8, 0), rack.audio_lanes[1].held_n); play(m, &.{noteOff(45, 1)}, &out); - try testing.expectEqual(@as(u8, 0), rack.audio_parts[0].held_n); - // A4 on the upper part, down an octave: A3 + try testing.expectEqual(@as(u8, 0), rack.audio_lanes[0].held_n); + // A4 on the upper lane, down an octave: A3 m.reset(m.state); play(m, &.{noteOn(69, 2)}, &out); try testing.expectApproxEqRel(@as(f64, 220), acHz(out[512 * 4 ..]), 0.01); - try testing.expectEqual(@as(u8, 1), rack.audio_parts[1].held_n); - try testing.expectEqual(@as(f32, 57), rack.audio_parts[1].held[0].pitch); + try testing.expectEqual(@as(u8, 1), rack.audio_lanes[1].held_n); + try testing.expectEqual(@as(f32, 57), rack.audio_lanes[1].held[0].pitch); m.reset(m.state); // POLY 1: a second note releases the first - rack.parts[1].poly = 1; + rack.lanes[1].poly = 1; var evs: [8]NoteEvent = undefined; const in = [_]NoteEvent{ noteOn(72, 3), noteOn(74, 4) }; - const n = Rack.route(&rack.parts[1], &in, &evs); + const n = Rack.route(&rack.lanes[1], &in, &evs, true); try testing.expectEqual(@as(usize, 3), n); try testing.expectEqual(machine.NoteKind.note_off, evs[1].kind); try testing.expectEqual(@as(i32, 3), evs[1].note_id); - try testing.expectEqual(@as(u8, 1), rack.parts[1].held_n); - try testing.expectEqual(@as(i32, 4), rack.parts[1].held[0].id); + try testing.expectEqual(@as(u8, 1), rack.lanes[1].held_n); + try testing.expectEqual(@as(i32, 4), rack.lanes[1].held[0].id); + // a closed lane (MUTE, or another's SOLO) takes no new notes + try testing.expectEqual(@as(usize, 0), Rack.route(&rack.lanes[0], &.{noteOn(40, 5)}, &evs, false)); m.reset(m.state); - // JSON: the parts and their machines' settings come back + // JSON: the lanes and their machines' settings come back var js: std.ArrayList(u8) = .empty; defer js.deinit(a); try m.write_state_json.?(m.state, &js, a); - const rack2 = try Rack.create(a, ®); + try testing.expect(std.mem.startsWith(u8, js.items, "{\"rack\":2,\"kind\":\"instrument\",\"lanes\":[")); + const rack2 = try Rack.create(a, ®, .instrument); const m2 = rack2.machineInterface(); defer m2.deinit.?(m2.state, a); var parsed = try std.json.parseFromSlice(std.json.Value, a, js.items, .{}); defer parsed.deinit(); m2.apply_state_json.?(m2.state, parsed.value); try testing.expectEqual(@as(usize, 2), rack2.count); - try testing.expectEqual(@as(u8, 59), rack2.parts[0].hi); - try testing.expectEqual(@as(u8, 60), rack2.parts[1].lo); - try testing.expectEqual(@as(i8, -12), rack2.parts[1].transpose); - try testing.expectEqual(@as(u8, 1), rack2.parts[1].poly); + try testing.expectEqual(@as(u8, 59), rack2.lanes[0].hi); + try testing.expectEqual(@as(u8, 60), rack2.lanes[1].lo); + try testing.expectEqual(@as(i8, -12), rack2.lanes[1].transpose); + try testing.expectEqual(@as(u8, 1), rack2.lanes[1].poly); var js2: std.ArrayList(u8) = .empty; defer js2.deinit(a); try m2.write_state_json.?(m2.state, &js2, a); @@ -959,3 +1412,113 @@ test "rack: a split sends each key to its part, transposed; POLY releases the ol play(m2, &.{noteOn(45, 5)}, &out); try testing.expect(rms(out[512 * 2 ..]) > 0.01); } + +test "rack: the first form's parts load as lanes and play as they did" { + const a = testing.allocator; + var reg = registry_mod.Registry.init(a); + defer reg.deinit(); + try reg.loadFyMachine("machines/unfairlight/unfairlight.fy"); + try reg.loadNative(); + const v1 = + \\{"parts":[{"machine":"unfairlight","name":"LOW","lo":0,"hi":59,"vlo":1,"vhi":127,"transpose":0,"level":-3,"pan":-0.5,"poly":2,"mute":false,"params":{"cmi-filter":60}}, + \\ {"machine":"unfairlight","name":"HIGH","lo":60,"hi":127,"transpose":-12,"level":0,"pan":0,"poly":0,"mute":true}]} + ; + var parsed = try std.json.parseFromSlice(std.json.Value, a, v1, .{}); + defer parsed.deinit(); + const rack = try Rack.create(a, ®, .instrument); + const m = rack.machineInterface(); + defer m.deinit.?(m.state, a); + m.apply_state_json.?(m.state, parsed.value); + try testing.expectEqual(@as(usize, 2), rack.count); + try testing.expectEqualStrings("LOW", rack.lanes[0].labelSlice()); + try testing.expectEqual(@as(f32, -3), rack.lanes[0].level_db); + try testing.expectEqual(@as(u8, 2), rack.lanes[0].poly); + try testing.expect(rack.lanes[1].mute); + try testing.expectEqual(@as(usize, 0), rack.lanes[0].effects.items.len); + var out = [_]f32{0} ** (512 * 8); + play(m, &.{noteOn(45, 1)}, &out); + try testing.expect(rms(out[512 * 2 ..]) > 0.01); + // the muted upper lane takes nothing + m.reset(m.state); + @memset(&out, 0); + play(m, &.{noteOn(72, 2)}, &out); + try testing.expectEqual(@as(f64, 0), rms(&out)); +} + +test "fxrack: one lane with an insert is that insert, sample for sample" { + const a = testing.allocator; + var reg = registry_mod.Registry.init(a); + defer reg.deinit(); + try reg.loadFyMachine("machines/sat2/sat2.fy"); + try reg.loadNative(); + const sat = reg.findById("sat2").?; + const alone = try reg.instantiate(sat); + defer alone.deinit.?(alone.state, a); + alone.reset(alone.state); + const rack = try Rack.create(a, ®, .effect); + const m = rack.machineInterface(); + defer m.deinit.?(m.state, a); + _ = try rack.addLane(sat); + for ([_]Machine{ alone, rack.lanes[0].effects.items[0].mach }) |x| x.set_param.?(x.state, "sat-drive", 12); + + var in: [256 * 16]f32 = undefined; + for (&in, 0..) |*x, i| x.* = 0.5 * @sin(@as(f32, @floatFromInt(i)) * 0.031); + var al: [in.len]f32 = undefined; + var ar: [in.len]f32 = undefined; + var bl: [in.len]f32 = undefined; + var br: [in.len]f32 = undefined; + process(alone, &in, &al, &ar); + process(m, &in, &bl, &br); + try testing.expect(rms(&al) > 0.05); + try testing.expectEqualSlices(f32, &al, &bl); + try testing.expectEqualSlices(f32, &ar, &br); +} + +test "fxrack: a dry lane is held back to a latent lane, and the rack reports that latency" { + const a = testing.allocator; + var reg = registry_mod.Registry.init(a); + defer reg.deinit(); + try reg.loadFyMachine("machines/limiter2/limiter2.fy"); + try reg.loadNative(); + const lim = reg.findById("limiter2").?; + const rack = try Rack.create(a, ®, .effect); + const m = rack.machineInterface(); + defer m.deinit.?(m.state, a); + _ = try rack.addLane(null); // dry + const wet = try rack.addLane(lim); + wet.mute = true; // only the dry path is heard + rack.publishLanes(); + + var in = [_]f32{0} ** (256 * 8); + in[300] = 1; + var ol: [in.len]f32 = undefined; + var or_: [in.len]f32 = undefined; + process(m, &in, &ol, &or_); + const lat = m.latencySamples(); + try testing.expect(lat > 0 and lat < 1000); + try testing.expectEqual(@as(f32, 1), ol[300 + lat]); + try testing.expectEqual(@as(f32, 0), ol[300]); +} + +test "rack: racks nest MAX_DEPTH deep, no deeper" { + const a = testing.allocator; + var reg = registry_mod.Registry.init(a); + defer reg.deinit(); + try reg.loadNative(); + const fx = reg.findById("fxrack").?; + const top = try Rack.create(a, ®, .effect); + const m = top.machineInterface(); + defer m.deinit.?(m.state, a); + _ = try top.addLane(fx); + const mid: *Rack = @ptrCast(@alignCast(top.lanes[0].effects.items[0].mach.state)); + try testing.expectEqual(@as(u8, 1), mid.level); + _ = try mid.addLane(fx); + const low: *Rack = @ptrCast(@alignCast(mid.lanes[0].effects.items[0].mach.state)); + try testing.expectEqual(@as(u8, 2), low.level); + try testing.expectError(error.RackTooDeep, low.addLane(fx)); + // and the nesting round-trips + var js: std.ArrayList(u8) = .empty; + defer js.deinit(a); + try top.writeJson(&js, a); + try testing.expect(std.mem.indexOf(u8, js.items, "\"machine\":\"fxrack\",\"state\":{\"rack\":2,\"kind\":\"effect\"") != null); +} diff --git a/src/ui/machine_bay.zig b/src/ui/machine_bay.zig index 2c659279..aadb049c 100644 --- a/src/ui/machine_bay.zig +++ b/src/ui/machine_bay.zig @@ -543,9 +543,9 @@ fn machinePickerMenu(menu_key: u64, reg: *const Registry) ?AddPick { return machinePickerMenuFor(menu_key, reg, .all); } -/// Which machines a picker offers: all, or the fy instruments a rack's -/// parts can be. -pub const PickerFilter = enum { all, instruments }; +/// Which machines a picker offers: all, the instruments a rack lane can +/// play, or the inserts it can hold (docs/42). +pub const PickerFilter = enum { all, instruments, effects }; pub fn machinePickerMenuFor(menu_key: u64, reg: *const Registry, filter: PickerFilter) ?AddPick { if (!menu.isOpen(menu_key)) return null; @@ -553,7 +553,8 @@ pub fn machinePickerMenuFor(menu_key: u64, reg: *const Registry, filter: PickerF var items: [registry_mod.MAX_MACHINES]menu.Item = undefined; var n: usize = 0; for (reg.entries[0..menu_count], 0..) |*e, i| { - if (filter == .instruments and (e.native != .none or !e.in_notes)) continue; + if (filter == .instruments and !e.in_notes) continue; + if (filter == .effects and (!e.in_audio or e.in_notes)) continue; items[n] = .{ .label = std.mem.span(e.nameZ()), .id = @intCast(i), .submenu = add_scan_cache[i].count > 0 }; n += 1; } From d219dcb38c884bbc17e55ecbde03d389aecd14d6 Mon Sep 17 00:00:00 2001 From: Marcin Gasperowicz Date: Fri, 16 Oct 2026 23:47:29 +0100 Subject: [PATCH 4/9] cli: headless render loads the native machines, so Racks play in --render and --stems Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ src/main.zig | 2 ++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b67e647..f5d8ee2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ GitHub are its section here (`tools/release.sh` copies them), so add to ## Unreleased +- **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 (+ FX under the lane's settings). The new FX Rack goes in an effect slot and runs its diff --git a/src/main.zig b/src/main.zig index a8bc90dd..66f34f5b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -6133,6 +6133,8 @@ fn renderHeadless(alloc: std.mem.Allocator, project: []const u8, cli: Cli) !void defer reg.deinit(); for (registry_mod.builtin_machines) |path| try reg.loadFyMachine(path); reg.loadUserMachines(); + // Racks, as the app has them: a project with one renders it. + try reg.loadNative(); var pool = audio_pool_mod.AudioPool.init(alloc); defer pool.deinit(); document_mod.setPool(&pool); From 54acf260a1ae63d188276fdd5b617fce970dcdc3 Mon Sep 17 00:00:00 2001 From: Marcin Gasperowicz Date: Fri, 16 Oct 2026 23:59:28 +0100 Subject: [PATCH 5/9] ui: rack panel packs its lane column and nests each lane machine's title strip (docs/42) The selected lane's chain draws as the bay draws a track's: preset menu, ON/BYP, replace and remove on every machine, I/O meters on inserts, + to add one. Preset actions go through main as the bay's do; lane and insert edits are undo steps; lane machines keep their preset names in the state. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +- docs/42-racks.md | 16 +- src/machines/rack.zig | 491 ++++++++++++++++++++++------------------- src/main.zig | 60 ++++- src/ui/machine_bay.zig | 34 ++- 5 files changed, 361 insertions(+), 245 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5d8ee2d..6174b456 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,9 @@ GitHub are its section here (`tools/release.sh` copies them), so add to 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 (+ FX under the - lane's settings). The new FX Rack goes in an effect slot and runs its + 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 diff --git a/docs/42-racks.md b/docs/42-racks.md index afe6d4af..8b7290c5 100644 --- a/docs/42-racks.md +++ b/docs/42-racks.md @@ -331,8 +331,13 @@ bit-exact. three deep; per-lane latency alignment; tail, wake and idle skip of lane inserts. Lane machines get no automation until phase 6. - Rack state v2, with the first form read as lanes. - - The panel lists a lane's inserts (ON, remove, + FX) and shows the - picked machine's panel, until phase 4's. + - The panel: a packed lane column (lanes with M and S, + and − for + lanes, the key map, zone and mix fields four to a row) beside the + selected lane's chain, drawn as the bay draws a track's: each + machine its own title strip (preset menu, ON/BYP, click the name to + replace it, × to remove it), inserts with I/O meters, + to add one. + Preset actions inside go through the bay's (`NestedPreset`), and + adding, removing or replacing is an undo step. - Tests: a first-form project renders byte-identical; a one-lane FX Rack equals its insert sample for sample; a dry lane is held to a latent one. @@ -341,9 +346,10 @@ bit-exact. compiler and its VM; the `set_mod` host input in `fy_raw_machine`. - Tests cover formula parsing and evaluation, and a macro sweep matching the same sweep made with automation. -4. **UI.** The macro block, the lane list and zones, the inline lane - chain, collapse, MAP mode and the mapping table, and ⌘G group and - ungroup. +4. **UI.** The macro block, CHAIN zones, collapse, MAP mode and the + mapping table, dragging inserts to reorder, and ⌘G group and + ungroup. (The lane column and the inline lane chain came with + phase 2.) 5. **Rigs.** - Factory rack presets, the RACKS section of the picker, and the slabkit `rack/lane/macro` builders with `describe`. diff --git a/src/machines/rack.zig b/src/machines/rack.zig index 601dc762..8274014d 100644 --- a/src/machines/rack.zig +++ b/src/machines/rack.zig @@ -55,7 +55,7 @@ const MAX_EVENTS = 512; const MAX_HELD = 32; const MAX_LABEL = 31; const PRESET_FILE = 256 * 1024; -pub const SIDE_W: i32 = 232; +pub const SIDE_W: i32 = 200; const PICK_MENU_KEY: u64 = 0x5241434b; // "RACK" /// The longest latency lanes are aligned over: what a lane's line holds. const MAX_ALIGN = chain_mod.DRY_LEN - MAX_BLOCK; @@ -166,11 +166,10 @@ pub const Rack = struct { // UI selected: usize = 0, - /// The selected lane's machine on the panel: -1 its instrument, else - /// an insert. - selected_fx: i32 = -1, presets: presets_mod.List = .{}, current_preset: i32 = -1, + /// A lane or an insert came or went since the project last asked. + edited: bool = false, // audio scratch sl: [MAX_BLOCK]f32 = undefined, @@ -409,7 +408,6 @@ pub const Rack = struct { self.publishLanes(); self.retireLane(next_generation, &old); if (self.selected >= self.count and self.selected > 0) self.selected -= 1; - self.selected_fx = -1; } pub fn clear(self: *Rack) !void { @@ -423,7 +421,6 @@ pub const Rack = struct { self.publishLanes(); for (old[0..n]) |*l| self.retireLane(next_generation, l); self.selected = 0; - self.selected_fx = -1; } /// Apply one machine's saved settings (params, then files, then zone @@ -441,6 +438,12 @@ pub const Rack = struct { }; if (po.get("zones")) |zv| if (m.apply_zones_json) |f| f(m.state, zv); if (po.get("state")) |sv| if (m.apply_state_json) |f| f(m.state, sv); + // The preset it was set from, for its title strip (the label only). + if (po.get("preset")) |nv| if (nv == .string) if (m.mark_preset) |mark| if (m.preset_count) |count| if (m.preset_name) |name_of| { + for (0..count(m.state)) |i| { + if (std.mem.eql(u8, std.mem.span(name_of(m.state, @intCast(i))), nv.string)) mark(m.state, @intCast(i)); + } + }; } fn machineOf(self: *Rack, v: ?std.json.Value) ?usize { @@ -511,7 +514,6 @@ pub const Rack = struct { } } else return; self.selected = 0; - self.selected_fx = -1; } fn writeMachineJson(self: *Rack, out: *std.ArrayList(u8), alloc: std.mem.Allocator, m: Machine, reg_idx: u8) !void { @@ -534,6 +536,13 @@ pub const Rack = struct { try out.appendSlice(alloc, ",\"state\":"); try f(m.state, out, alloc); } + if (m.current_preset) |cf| if (m.preset_name) |name_of| { + const cur = cf(m.state); + if (cur >= 0) { + try out.appendSlice(alloc, ",\"preset\":"); + try jsonString(out, alloc, std.mem.span(name_of(m.state, @intCast(cur)))); + } + }; } pub fn writeJson(self: *Rack, out: *std.ArrayList(u8), alloc: std.mem.Allocator) !void { @@ -793,7 +802,8 @@ fn takeWakeImpl(state: *anyopaque) bool { fn takeEditedImpl(state: *anyopaque) bool { const self: *Rack = @ptrCast(@alignCast(state)); - var any = false; + var any = self.edited; + self.edited = false; for (self.lanes[0..self.count]) |*l| { if (l.instrument.takeEdited()) any = true; for (l.effects.items) |*fx| if (fx.mach.takeEdited()) { @@ -919,55 +929,68 @@ fn applyStateJsonImpl(state: *anyopaque, v: std.json.Value) void { // ── panel ──────────────────────────────────────────────────────────── // -// ┌ LANES ───────────┐┌ the selected machine's own panel ┐ -// │ 1 ALTO1 ● ││ │ -// │ 2 ALTO2 ││ │ -// │ [key map lanes] ││ │ -// │ LO HI VLO VHI ││ (instrument racks) │ -// │ TRN POLY LVL PAN ││ │ -// │ [MACHINE] [+][−] ││ │ -// │ FX: inserts [−] ││ │ -// │ [+ FX] [+ LANE] ││ │ -// └──────────────────┘└──────────────────────────────────┘ - -/// The machine the panel shows: the selected lane's instrument or insert. -fn shown(self: *Rack) ?*Machine { - if (self.selected >= self.count) return null; +// ┌ LANES [+][−]┐┌[×][INSTRUMENT][preset▾][ON]┐┌[×][INSERT][preset▾][BYP]┐┌+┐ +// │ 1 BASS [M][S]││ its panel ││ its panel │IO││ │ +// │ 2 CLEAN [M][S]││ ││ │ ││ │ +// │ ▬▬▬▬ key map ││ ││ │ ││ │ +// │ LO HI VLO VHI ││ ││ │ ││ │ +// │ TRN PLY LVL PAN ││ ││ │ ││ │ +// └─────────────────┘└─────────────────────────────┘└──────────────────────┘└──┘ +// +// The selected lane's chain draws as the bay draws a track's (nested +// device columns with their own title strips: preset, ON/BYP, replace, +// remove); the + tile adds an insert. + +const ROW_H: i32 = 16; +const FIELD_H: i32 = 18; + +fn chainW(self: *Rack) i32 { + if (self.selected >= self.count) return bay.TITLE_H * 6; const l = &self.lanes[self.selected]; - if (self.selected_fx >= 0 and self.selected_fx < l.effects.items.len) return &l.effects.items[@intCast(self.selected_fx)].mach; - if (l.reg_idx != null) return &l.instrument; - return null; + var w: i32 = bay.TITLE_H; + if (l.reg_idx != null) w += bay.panelW(&l.instrument); + for (l.effects.items) |*fx| w += bay.effectW(fx); + if (l.reg_idx == null and l.effects.items.len == 0) w += 160; + return w; } fn panelWImpl(state: *anyopaque) f32 { const self: *Rack = @ptrCast(@alignCast(state)); - var w: f32 = 200; - if (shown(self)) |m| w = if (m.panel_w_fn) |f| f(m.state) else if (m.panel_w > 0) m.panel_w else 200; - return @as(f32, @floatFromInt(SIDE_W)) + w; + return @floatFromInt(SIDE_W + chainW(self)); } -const ROW_H: i32 = 16; -const FIELD_H: i32 = 20; - fn noteName(buf: []u8, k: u8) []const u8 { const names = [_][]const u8{ "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B" }; const oct = @as(i32, k / 12) - 1; return std.fmt.bufPrint(buf, "{s}{d}", .{ names[k % 12], oct }) catch "?"; } -/// A labelled readout with ▴▾ steps in its glass; returns the step (+/-1, -/// x `big` with Shift). +/// A packed readout: a short legend, then the value in glass with ▴▾ +/// steps; returns the step (+/-1, x `big` with Shift). fn field(ui: *Ui, r: Rect, key: []const u8, label: []const u8, value: []const u8, big: i32) i32 { ui.pushId(key); defer ui.popId(); var row = r; - const lab = row.cutLeft(28); + const lab = row.cutLeft(ui.fonts.legend.measure(label) + 3); ui.textIn(&ui.fonts.legend, lab, label, ui_style.text_dim, .left, true); ctl.display(ui, row, value, .{ .flush = true }); const d = ctl.glassSteps(ui, row, "step"); return if (ui.in.shift) d * big else d; } +/// `n` equal cells across `r`, 2 px apart. +fn cells(r: Rect, comptime n: usize) [n]Rect { + var out: [n]Rect = undefined; + var row = r; + const ni: i32 = @intCast(n); + const w = @divFloor(r.w - 2 * (ni - 1), ni); + for (&out, 0..) |*c, i| { + c.* = if (i + 1 == n) row else row.cutLeft(w); + if (i + 1 < n) _ = row.cutLeft(2); + } + return out; +} + fn step7(v: *u8, d: i32, lo: i32, hi: i32) void { v.* = @intCast(std.math.clamp(@as(i32, v.*) + d, lo, hi)); } @@ -983,27 +1006,78 @@ fn labelFromPreset(l: *Lane, m: Machine, pi: machine.PresetIndex) void { if (m.preset_name) |pn| l.setLabel(std.fmt.bufPrint(&buf, "{s}", .{lastSegment(std.mem.span(pn(m.state, pi)))}) catch ""); } +/// Before the panel changes what the rack holds: an undo step, and the +/// project is edited. +fn beforeEdit(self: *Rack) void { + bay.beforeNestedEdit(); + self.edited = true; +} + fn drawPanelImpl(state: *anyopaque, ui: *Ui, rect: Rect) void { const self: *Rack = @ptrCast(@alignCast(state)); self.collect(); defer self.publishLanes(); var body = rect; - var side = body.cutLeft(SIDE_W); - ui.rect(side, ui_style.chassis); - side = ctl.strip(ui, side.inset(4), "LANES"); - const inst = self.kind == .instrument; + const side = body.cutLeft(SIDE_W); + drawLanes(self, ui, side); + drawChain(self, ui, body); +} - // lane list - var i: usize = 0; - while (i < MAX_LANES) : (i += 1) { - const row = side.cutTop(ROW_H); - if (i >= self.count) { - ui.rect(row.insetXY(0, 1), ui_style.well.alpha(120)); - continue; +fn drawLanes(self: *Rack, ui: *Ui, r: Rect) void { + const inst = self.kind == .instrument; + var side = ui.plate(r, .{}).inset(3); + + // header: LANES, + and − + var head = side.cutTop(ROW_H); + _ = side.cutTop(2); + const rm = head.cutRight(ROW_H); + _ = head.cutRight(2); + const add = head.cutRight(ROW_H); + _ = ui.engraved(&ui.fonts.legend, head.x + 2, head.y + 3, "LANES", ui_style.text_dim); + const akey = pane.keyFromIds(PICK_MENU_KEY, @intFromPtr(self), 2); + if (self.count < MAX_LANES) { + if (ctl.button(ui, add, "add", null, .{ .label = "+", .flush = true })) { + if (inst) { + if (!menu.isOpen(akey)) { + bay.scanRegistryPresets(self.reg); + menu.openBelow(akey, add); + } + } else if (blk: { + beforeEdit(self); + break :blk self.addLane(null); + }) |_| { + self.selected = self.count - 1; + } else |_| {} + } + menu.tip(ui, add, if (inst) "Add a lane: another instrument layered or split" else "Add a lane: a dry path to put inserts on"); + } + if (inst) if (bay.machinePickerMenuFor(akey, self.reg, .instruments)) |pick| { + beforeEdit(self); + if (self.addLane(pick.reg_idx)) |np| { + self.selected = self.count - 1; + if (pick.preset) |pi| if (np.instrument.apply_preset) |ap| { + ap(np.instrument.state, pi); + labelFromPreset(np, np.instrument, pi); + }; + } else |_| {} + }; + if (self.count > 0) { + if (ctl.button(ui, rm, "rm", null, .{ .label = "\u{2212}", .flush = true })) { + beforeEdit(self); + self.removeLane(self.selected); + return; } - const p = &self.lanes[i]; + menu.tip(ui, rm, "Remove the selected lane"); + } + + // one row a lane: select, MUTE, SOLO + for (self.lanes[0..self.count], 0..) |*p, i| { + var row = side.cutTop(ROW_H); ui.pushId(i); defer ui.popId(); + const so = row.cutRight(ROW_H); + const mu = row.cutRight(ROW_H); + _ = row.cutRight(2); var on = i == self.selected; var lb: [48]u8 = undefined; var a: [4]u8 = undefined; @@ -1011,202 +1085,169 @@ fn drawPanelImpl(state: *anyopaque, ui: *Ui, rect: Rect) void { const text = if (inst) std.fmt.bufPrint(&lb, "{d} {s} {s}-{s}", .{ i + 1, p.labelSlice(), noteName(&a, p.lo), noteName(&b, p.hi) }) catch "?" else - std.fmt.bufPrint(&lb, "{d} {s} {d} FX", .{ i + 1, p.labelSlice(), p.effects.items.len }) catch "?"; - if (ctl.button(ui, row, "sel", &on, .{ .kind = .latch, .label = text, .flush = true, .lit = LANE_COLORS[i] })) { - if (self.selected != i) self.selected_fx = -1; - self.selected = i; - } + std.fmt.bufPrint(&lb, "{d} {s}", .{ i + 1, p.labelSlice() }) catch "?"; + if (ctl.button(ui, row, "sel", &on, .{ .kind = .latch, .label = text, .flush = true, .lit = LANE_COLORS[i] })) self.selected = i; + if (ctl.button(ui, mu, "mute", &p.mute, .{ .kind = .latch, .label = "M", .flush = true, .lit = ui_style.accent })) self.publishLanes(); + menu.tip(ui, mu, "Mute: the lane takes no new notes or input"); + if (ctl.button(ui, so, "solo", &p.solo, .{ .kind = .latch, .label = "S", .flush = true, .lit = ui_style.accent })) self.publishLanes(); + menu.tip(ui, so, "Solo: only soloed lanes take notes or input"); } - _ = side.cutTop(4); + _ = side.cutTop(3); - if (inst) { - // key map: a lane per lane over the 128 keys - const map = side.cutTop(@intCast(4 * MAX_LANES / 2 + 4)); + if (inst and self.count > 0) { + // key map: a stripe per lane over the 128 keys + const map = side.cutTop(@intCast(2 * self.count + 6)); ui.rect(map, ui_style.well); - const lane_h: i32 = 2; for (self.lanes[0..self.count], 0..) |*p, pi| { const x0 = map.x + 2 + @divFloor((map.w - 4) * @as(i32, p.lo), 128); const x1 = map.x + 2 + @divFloor((map.w - 4) * (@as(i32, p.hi) + 1), 128); const col = if (p.mute) ui_style.text_mute else LANE_COLORS[pi]; - ui.rect(Rect.xywh(x0, map.y + 2 + @as(i32, @intCast(pi)) * lane_h, @max(1, x1 - x0), lane_h), if (pi == self.selected) col else col.alpha(140)); + ui.rect(Rect.xywh(x0, map.y + 2 + @as(i32, @intCast(pi)) * 2, @max(1, x1 - x0), 2), if (pi == self.selected) col else col.alpha(140)); } - // octave ticks at the Cs var k: i32 = 0; - while (k < 128) : (k += 12) ui.rect(Rect.xywh(map.x + 2 + @divFloor((map.w - 4) * k, 128), map.bottom() - 3, 1, 3), ui_style.text_mute); - _ = side.cutTop(4); - } - - if (self.selected < self.count) { - const p = &self.lanes[self.selected]; - var s = p.*; // edit a copy, publish at the block boundary - var buf: [16]u8 = undefined; - var half: Rect = undefined; - var left: Rect = undefined; - if (inst) { - half = side.cutTop(FIELD_H); - left = half.cutLeft(@divFloor(half.w, 2) - 2); - _ = half.cutLeft(4); - step7(&s.lo, field(ui, left, "lo", "LO", noteName(&buf, s.lo), 12), 0, s.hi); - step7(&s.hi, field(ui, half, "hi", "HI", noteName(&buf, s.hi), 12), s.lo, 127); - _ = side.cutTop(2); - half = side.cutTop(FIELD_H); - left = half.cutLeft(@divFloor(half.w, 2) - 2); - _ = half.cutLeft(4); - step7(&s.vlo, field(ui, left, "vlo", "VLO", std.fmt.bufPrint(&buf, "{d}", .{s.vlo}) catch "?", 16), 1, s.vhi); - step7(&s.vhi, field(ui, half, "vhi", "VHI", std.fmt.bufPrint(&buf, "{d}", .{s.vhi}) catch "?", 16), s.vlo, 127); - _ = side.cutTop(2); - half = side.cutTop(FIELD_H); - left = half.cutLeft(@divFloor(half.w, 2) - 2); - _ = half.cutLeft(4); - const dt = field(ui, left, "trn", "TRN", std.fmt.bufPrint(&buf, "{s}{d}", .{ if (s.transpose > 0) "+" else "", s.transpose }) catch "?", 12); - s.transpose = @intCast(std.math.clamp(@as(i32, s.transpose) + dt, -48, 48)); - const dp = field(ui, half, "poly", "POLY", if (s.poly == 0) "ALL" else std.fmt.bufPrint(&buf, "{d}", .{s.poly}) catch "?", 4); - s.poly = @intCast(std.math.clamp(@as(i32, s.poly) + dp, 0, 32)); - _ = side.cutTop(2); - } - half = side.cutTop(FIELD_H); - left = half.cutLeft(@divFloor(half.w, 2) - 2); - _ = half.cutLeft(4); - const dl = field(ui, left, "lvl", "LVL", std.fmt.bufPrint(&buf, "{d:.0}dB", .{s.level_db}) catch "?", 6); - s.level_db = std.math.clamp(s.level_db + @as(f32, @floatFromInt(dl)), -60, 12); - const dpan = field(ui, half, "pan", "PAN", std.fmt.bufPrint(&buf, "{d:.1}", .{s.pan}) catch "?", 5); - s.pan = std.math.clamp(s.pan + 0.1 * @as(f32, @floatFromInt(dpan)), -1, 1); - if (@abs(s.pan) < 0.05) s.pan = 0; - _ = side.cutTop(4); - - var btns = side.cutTop(FIELD_H); - const mute_r = btns.cutRight(36); - _ = ctl.button(ui, mute_r, "mute", &s.mute, .{ .kind = .latch, .label = "MUTE", .led = ui_style.accent }); - _ = btns.cutRight(2); - const solo_r = btns.cutRight(36); - _ = ctl.button(ui, solo_r, "solo", &s.solo, .{ .kind = .latch, .label = "SOLO", .led = ui_style.accent }); - if (s.lo != p.lo or s.hi != p.hi or s.vlo != p.vlo or s.vhi != p.vhi or s.transpose != p.transpose or - s.poly != p.poly or s.level_db != p.level_db or s.pan != p.pan or s.mute != p.mute or s.solo != p.solo) - { - p.lo = s.lo; - p.hi = s.hi; - p.vlo = s.vlo; - p.vhi = s.vhi; - p.transpose = s.transpose; - p.poly = s.poly; - p.level_db = s.level_db; - p.pan = s.pan; - p.mute = s.mute; - p.solo = s.solo; + while (k < 128) : (k += 12) ui.rect(Rect.xywh(map.x + 2 + @divFloor((map.w - 4) * k, 128), map.bottom() - 2, 1, 2), ui_style.text_mute); + _ = side.cutTop(3); + } + + if (self.selected >= self.count) return; + const p = &self.lanes[self.selected]; + var s = p.*; // edit a copy, publish at the block boundary + var buf: [16]u8 = undefined; + if (inst) { + const c1 = cells(side.cutTop(FIELD_H), 4); + step7(&s.lo, field(ui, c1[0], "lo", "LO", noteName(&buf, s.lo), 12), 0, s.hi); + step7(&s.hi, field(ui, c1[1], "hi", "HI", noteName(&buf, s.hi), 12), s.lo, 127); + step7(&s.vlo, field(ui, c1[2], "vlo", "V", std.fmt.bufPrint(&buf, "{d}", .{s.vlo}) catch "?", 16), 1, s.vhi); + step7(&s.vhi, field(ui, c1[3], "vhi", "-", std.fmt.bufPrint(&buf, "{d}", .{s.vhi}) catch "?", 16), s.vlo, 127); + _ = side.cutTop(2); + } + const c2 = cells(side.cutTop(FIELD_H), 4); + var at: usize = 0; + if (inst) { + const dt = field(ui, c2[0], "trn", "TR", std.fmt.bufPrint(&buf, "{s}{d}", .{ if (s.transpose > 0) "+" else "", s.transpose }) catch "?", 12); + s.transpose = @intCast(std.math.clamp(@as(i32, s.transpose) + dt, -48, 48)); + const dp = field(ui, c2[1], "poly", "PL", if (s.poly == 0) "ALL" else std.fmt.bufPrint(&buf, "{d}", .{s.poly}) catch "?", 4); + s.poly = @intCast(std.math.clamp(@as(i32, s.poly) + dp, 0, 32)); + at = 2; + } + const dl = field(ui, c2[at], "lvl", "LV", std.fmt.bufPrint(&buf, "{d:.0}", .{s.level_db}) catch "?", 6); + s.level_db = std.math.clamp(s.level_db + @as(f32, @floatFromInt(dl)), -60, 12); + const dpan = field(ui, c2[at + 1], "pan", "PN", std.fmt.bufPrint(&buf, "{d:.1}", .{s.pan}) catch "?", 5); + s.pan = std.math.clamp(s.pan + 0.1 * @as(f32, @floatFromInt(dpan)), -1, 1); + if (@abs(s.pan) < 0.05) s.pan = 0; + if (s.lo != p.lo or s.hi != p.hi or s.vlo != p.vlo or s.vhi != p.vhi or s.transpose != p.transpose or + s.poly != p.poly or s.level_db != p.level_db or s.pan != p.pan) + { + p.lo = s.lo; + p.hi = s.hi; + p.vlo = s.vlo; + p.vhi = s.vhi; + p.transpose = s.transpose; + p.poly = s.poly; + p.level_db = s.level_db; + p.pan = s.pan; + self.publishLanes(); + } +} + +/// The selected lane's chain as nested bay devices, then the + tile. +fn drawChain(self: *Rack, ui: *Ui, r: Rect) void { + if (self.selected >= self.count) { + ui.rect(r, ui_style.well); + ui.textIn(&ui.fonts.legend, r, "ADD A LANE", ui_style.text_mute, .center, true); + return; + } + const li = self.selected; + const l = &self.lanes[li]; + ui.pushId(@intFromPtr(l.chain)); + defer ui.popId(); + var res = bay.Result{}; + var x = r.x; + if (l.reg_idx != null) { + const w = bay.panelW(&l.instrument); + const out = bay.drawDevice(ui, Rect.xywh(x, r.y, w, r.h), &l.instrument, .instrument, null, !l.mute, null, self.reg, &res); + if (out.toggle) { + l.mute = !l.mute; self.publishLanes(); } - _ = btns.cutRight(4); - const del_r = btns.cutRight(20); - if (ctl.button(ui, del_r, "del", null, .{ .label = "\u{2212}" })) { - self.removeLane(self.selected); - return; - } - menu.tip(ui, del_r, "Remove lane"); - _ = btns.cutRight(4); - if (inst) { - const mkey = pane.keyFromIds(PICK_MENU_KEY, @intFromPtr(self), 1); - var sel_inst = self.selected_fx < 0; - if (ctl.button(ui, btns, "machine", &sel_inst, .{ .kind = .latch, .label = self.reg.entries[p.reg_idx orelse 0].nameSlice(), .flush = true })) { - if (self.selected_fx < 0 and !menu.isOpen(mkey)) { - bay.scanRegistryPresets(self.reg); - menu.openBelow(mkey, btns); - } - self.selected_fx = -1; - } - menu.tip(ui, btns, "This lane's instrument: click again to replace it"); - if (bay.machinePickerMenuFor(mkey, self.reg, .instruments)) |pick| { - self.replaceInstrument(self.selected, pick.reg_idx) catch {}; - const np = &self.lanes[self.selected]; - if (pick.preset) |pi| if (np.instrument.apply_preset) |ap| { - ap(np.instrument.state, pi); - labelFromPreset(np, np.instrument, pi); - }; - } - } - _ = side.cutTop(4); - - // the lane's inserts - var j: usize = 0; - while (j < p.effects.items.len) : (j += 1) { - var row = side.cutTop(ROW_H); - if (row.h < ROW_H) break; - const fx = &p.effects.items[j]; - ui.pushId(@as(usize, 0x100) + j); - defer ui.popId(); - const rm = row.cutRight(20); - _ = row.cutRight(2); - const byp = row.cutRight(28); - _ = row.cutRight(2); - var lb: [48]u8 = undefined; - var on = self.selected_fx == @as(i32, @intCast(j)); - const name = if (fx.idx) |fi| self.reg.entries[fi].nameSlice() else fx.mach.name; - if (ctl.button(ui, row, "fx", &on, .{ .kind = .latch, .label = std.fmt.bufPrint(&lb, "FX{d} {s}", .{ j + 1, name }) catch "?", .flush = true })) self.selected_fx = @intCast(j); - var active = !fx.bypass.load(.monotonic); - if (ctl.button(ui, byp, "on", &active, .{ .kind = .latch, .label = "ON", .led = ui_style.accent })) self.setEffectBypass(self.selected, j, !active); - if (ctl.button(ui, rm, "rm", null, .{ .label = "\u{2212}" })) { - self.removeEffect(self.selected, j); - self.selected_fx = -1; - break; - } - } - if (p.effects.items.len < MAX_LANE_EFFECTS) { - const add = side.cutTop(FIELD_H); - const fkey = pane.keyFromIds(PICK_MENU_KEY, @intFromPtr(self), 3); - if (ctl.button(ui, add, "addfx", null, .{ .label = "+ FX" }) and !menu.isOpen(fkey)) { - bay.scanRegistryPresets(self.reg); - menu.openBelow(fkey, add); - } - menu.tip(ui, add, "Add an insert to this lane"); - if (bay.machinePickerMenuFor(fkey, self.reg, .effects)) |pick| { - if (self.addEffect(self.selected, pick.reg_idx)) { - const fx = &self.lanes[self.selected].effects.items[self.lanes[self.selected].effects.items.len - 1]; - if (pick.preset) |pi| if (fx.mach.apply_preset) |ap| ap(fx.mach.state, pi); - self.selected_fx = @intCast(self.lanes[self.selected].effects.items.len - 1); - } else |_| {} - } - _ = side.cutTop(4); - } + x += w; } - - // add a lane - if (self.count < MAX_LANES) { - const add = side.cutTop(FIELD_H); - const akey = pane.keyFromIds(PICK_MENU_KEY, @intFromPtr(self), 2); - if (inst) { - if (ctl.button(ui, add, "add", null, .{ .label = "+ LANE" }) and !menu.isOpen(akey)) { + for (l.effects.items, 0..) |*fx, j| { + const w = bay.effectW(fx); + const out = bay.drawDevice(ui, Rect.xywh(x, r.y, w, r.h), &fx.mach, .{ .effect = j }, fx, !fx.bypass.load(.monotonic), null, self.reg, &res); + if (out.toggle) self.setEffectBypass(li, j, !fx.bypass.load(.monotonic)); + // A click on an insert's name opens its replace menu, as the bay's. + if (out.name_released) { + const key = pane.keyFromIds(bay.REPLACE_MENU_KEY, @intFromPtr(fx.mach.state), 0); + if (!menu.isOpen(key)) { bay.scanRegistryPresets(self.reg); - menu.openBelow(akey, add); + menu.openBelow(key, out.name_rect); } - menu.tip(ui, add, "Add a lane: another instrument layered or split"); - if (bay.machinePickerMenuFor(akey, self.reg, .instruments)) |pick| { - if (self.addLane(pick.reg_idx)) |np| { - self.selected = self.count - 1; - self.selected_fx = -1; - if (pick.preset) |pi| if (np.instrument.apply_preset) |ap| { - ap(np.instrument.state, pi); - labelFromPreset(np, np.instrument, pi); - }; - } else |_| {} - } - } else { - if (ctl.button(ui, add, "add", null, .{ .label = "+ LANE" })) { - if (self.addLane(null)) |_| { - self.selected = self.count - 1; - self.selected_fx = -1; - } else |_| {} - } - menu.tip(ui, add, "Add a lane: a dry path to put inserts on"); } + x += w; + } + if (l.reg_idx == null and l.effects.items.len == 0) { + const hint = Rect.xywh(x, r.y, 160, r.h); + ui.rect(hint, ui_style.well); + ui.textIn(&ui.fonts.legend, hint, "DRY: + ADDS AN INSERT", ui_style.text_mute, .center, true); + x += 160; + } + // + : an insert at the end of the lane + const plus = Rect.xywh(x, r.y, bay.TITLE_H, bay.TITLE_H); + const fkey = pane.keyFromIds(PICK_MENU_KEY, @intFromPtr(self), 3); + var open = menu.isOpen(fkey); + if (ctl.button(ui, plus, "addfx", &open, .{ .label = "+", .flush = true }) and !menu.isOpen(fkey)) { + bay.scanRegistryPresets(self.reg); + menu.openBelow(fkey, plus); + } + menu.tip(ui, plus, "Add an insert to this lane"); + if (x + bay.TITLE_H <= r.right()) _ = ui.plate(Rect.xywh(x, r.y + bay.TITLE_H, bay.TITLE_H, r.h - bay.TITLE_H), .{}); + if (bay.machinePickerMenuFor(fkey, self.reg, .effects)) |pick| { + beforeEdit(self); + if (self.addEffect(li, pick.reg_idx)) { + const fx = &l.effects.items[l.effects.items.len - 1]; + if (pick.preset) |pi| if (fx.mach.apply_preset) |ap| ap(fx.mach.state, pi); + } else |_| {} } - // the selected machine's own panel - if (shown(self)) |m| { - ui.pushId(m.state); - defer ui.popId(); - m.draw_panel(m.state, ui, body); - } else { - ui.rect(body, ui_style.well); - ui.textIn(&ui.fonts.body, body, if (self.count == 0) "Add a lane" else "Dry: add an insert", ui_style.text_mute, .center, true); + // What the title strips asked for. + if (res.remove_ref != null or res.replace_ref != null) beforeEdit(self); + if (res.remove_ref) |ref| switch (ref) { + // An instrument lane is its instrument: removing it removes the lane. + .instrument => { + self.removeLane(li); + return; + }, + .effect => |j| self.removeEffect(li, j), + }; + if (res.replace_ref) |ref| if (res.replace_machine) |idx| { + switch (ref) { + .instrument => self.replaceInstrument(li, idx) catch {}, + .effect => |j| self.replaceEffect(li, j, idx) catch {}, + } + const m: ?Machine = switch (ref) { + .instrument => l.instrument, + .effect => |j| if (j < l.effects.items.len) l.effects.items[j].mach else null, + }; + if (res.replace_preset) |pi| if (m) |mm| if (mm.apply_preset) |ap| { + ap(mm.state, pi); + if (ref == .instrument) labelFromPreset(l, mm, pi); + }; + }; + const target: ?bay.DeviceRef = res.preset_apply_ref orelse res.preset_save_ref orelse res.preset_rename_ref; + if (target) |ref| { + const m: ?Machine = switch (ref) { + .instrument => l.instrument, + .effect => |j| if (j < l.effects.items.len) l.effects.items[j].mach else null, + }; + if (m) |mm| bay.nested_preset = .{ + .mach = mm, + .apply = res.preset_apply, + .save = res.preset_save_ref != null and !res.preset_save_library, + .save_library = res.preset_save_ref != null and res.preset_save_library, + .rename = if (res.preset_rename_ref != null) res.preset_rename_index else null, + .anchor = res.preset_anchor, + }; } } diff --git a/src/main.zig b/src/main.zig index 66f34f5b..9176b05a 100644 --- a/src/main.zig +++ b/src/main.zig @@ -426,6 +426,8 @@ const RenameState = struct { /// instrument, else the effect index. device_track: ?*track_mod.Track = null, device_effect: ?usize = null, + /// Or the machine itself, when it sits inside another (a rack lane's). + device_mach: ?@import("machine.zig").Machine = null, /// For preset_rename: the preset index being renamed. preset_index: u16 = 0, tb: text_field.TextBuf = .{ .limit = track_mod.MAX_NAME }, @@ -1768,6 +1770,10 @@ pub fn main(init: std.process.Init) !void { } machine_bay.sample_rate = transport.sample_rate; + // A rack's panel edits what it holds: an undo step first (docs/42). + var nested_undo = NestedUndo{ .alloc = alloc, .history = &history, .tracks = tracks, .transport = &transport }; + machine_bay.before_nested_edit = .{ .ctx = &nested_undo, .f = NestedUndo.push }; + defer machine_bay.before_nested_edit = null; const mbres = machine_bay.draw(ui, rects.machine_bay, bay_dev, bay_idx, bay_is_bus, layout.machine_bay_collapsed, ®, tracks); if (mbres.key_menu_fx) |uid| if (bay_idx) |ti| @import("ui/route_menu.zig").openKey(ti, uid, mbres.key_menu_at[0], mbres.key_menu_at[1]); if (mbres.unison_at) |at| if (bay_idx) |ti| if (!bay_is_bus) { @@ -1913,6 +1919,31 @@ pub fn main(init: std.process.Init) !void { if (mbres.preset_save_ref) |ref| if (bay_dev) |dev| { beginPresetSave(&rename, dev, refEffect(ref), mbres.preset_anchor, mbres.preset_save_library); }; + // A machine inside a rack lane asked for a preset action (docs/42). + if (machine_bay.nested_preset) |np| { + machine_bay.nested_preset = null; + const mach = np.mach; + if (np.apply) |preset| if (mach.apply_preset) |apply| { + if (!bay_is_bus) pushHistorySnapshot(alloc, &history, tracks, &transport); + audio.stop(); + defer audio.start() catch |err| std.log.err("audio restart failed: {s}", .{@errorName(err)}); + apply(mach.state, preset); + dirty = true; + status.set("Preset {s}", .{if (mach.preset_name) |name| name(mach.state, preset) else ""}); + }; + if (np.save or np.save_library) { + rename = .{ .kind = if (np.save_library) .preset_save_library else .preset_save, .device_mach = mach }; + anchorRenameRect(&rename, np.anchor); + } + if (np.rename) |idx| { + var cur: []const u8 = ""; + if (mach.preset_name) |nf| cur = std.mem.span(nf(mach.state, idx)); + if (std.mem.lastIndexOfScalar(u8, cur, '/')) |slash| cur = cur[slash + 1 ..]; + rename = .{ .kind = .preset_rename, .device_mach = mach, .preset_index = idx }; + renameSetText(&rename, cur); + anchorRenameRect(&rename, np.anchor); + } + } if (mbres.preset_rename_ref) |ref| if (mbres.preset_rename_index) |idx| if (bay_dev) |dev| { const eff = refEffect(ref); if (deviceMachineOf(dev, eff)) |mach| { @@ -6541,6 +6572,18 @@ fn serviceMachineAutoRequest( } } +const NestedUndo = struct { + alloc: std.mem.Allocator, + history: *history_mod.History, + tracks: []track_mod.Track, + transport: *transport_mod.Transport, + + fn push(ctx: *anyopaque) void { + const self: *NestedUndo = @ptrCast(@alignCast(ctx)); + pushHistorySnapshot(self.alloc, self.history, self.tracks, self.transport); + } +}; + fn pushHistorySnapshot(alloc: std.mem.Allocator, history: *history_mod.History, tracks: []track_mod.Track, transport: *transport_mod.Transport) void { const snapshot = document_mod.serialize(alloc, tracks, transport) catch |err| { std.log.err("history snapshot failed: {s}", .{@errorName(err)}); @@ -6679,13 +6722,16 @@ fn commitRename( // live next to the machine, so there's no document snapshot/undo here. fn commitPreset(rename: *RenameState, tracks: []track_mod.Track, dirty: *bool, status: *StatusMessage) void { _ = tracks; - const t = rename.device_track orelse { - status.set("Preset target gone", .{}); - return; - }; - const mach = deviceMachineOf(t, rename.device_effect) orelse { - status.set("Preset target gone", .{}); - return; + var nested = rename.device_mach; + const mach = if (nested) |*m| m else blk: { + const t = rename.device_track orelse { + status.set("Preset target gone", .{}); + return; + }; + break :blk deviceMachineOf(t, rename.device_effect) orelse { + status.set("Preset target gone", .{}); + return; + }; }; const name = rename.tb.text(); if (name.len == 0) { diff --git a/src/ui/machine_bay.zig b/src/ui/machine_bay.zig index aadb049c..96e9a095 100644 --- a/src/ui/machine_bay.zig +++ b/src/ui/machine_bay.zig @@ -74,6 +74,28 @@ pub const Result = struct { unison_at: ?[2]i32 = null, }; +/// A preset action on a machine drawn inside another's panel (a rack +/// lane's, docs/42): main runs it as it does the bay's own, on `mach`. +pub const NestedPreset = struct { + mach: Machine, + apply: ?u16 = null, + save: bool = false, + save_library: bool = false, + rename: ?u16 = null, + anchor: c.rl.Rectangle = .{ .x = 0, .y = 0, .width = 0, .height = 0 }, +}; +pub var nested_preset: ?NestedPreset = null; + +/// Called by a machine's panel just before it changes what it holds (a +/// rack adding or removing a lane or an insert), so the edit is an undo +/// step like the bay's own. Main sets it before drawing the bay. +pub const BeforeEdit = struct { ctx: *anyopaque, f: *const fn (ctx: *anyopaque) void }; +pub var before_nested_edit: ?BeforeEdit = null; + +pub fn beforeNestedEdit() void { + if (before_nested_edit) |b| b.f(b.ctx); +} + // The tracks and the edited track's index, for the KEY latch's label. var bay_tracks: []const Track = &.{}; /// For the latency readouts in ms; main sets it from the transport. @@ -88,7 +110,7 @@ const DEFAULT_PANEL_W: i32 = 200; const DRAG_THRESHOLD: f32 = 6; const ADD_MENU_KEY: u64 = 0x4d414444; // "MADD" -const REPLACE_MENU_KEY: u64 = 0x5245504c; // "REPL" +pub const REPLACE_MENU_KEY: u64 = 0x5245504c; // "REPL" const PRESET_MENU_KEY: u64 = 0x50524553; // "PRES" const CONFIRM_MENU_KEY: u64 = 0x434f4e46; // "CONF" const SAVE_ITEM_ID: u32 = 9001; @@ -120,18 +142,18 @@ var fx_drag: ReorderDrag = .{}; var led_seen: [16]u32 = [_]u32{0} ** 16; var led_glow: [16]f32 = [_]f32{0} ** 16; -fn panelW(mach: *const Machine) i32 { +pub fn panelW(mach: *const Machine) i32 { if (mach.panel_w_fn) |f| return @intFromFloat(@round(f(mach.state))); return if (mach.panel_w > 0) @intFromFloat(@round(mach.panel_w)) else DEFAULT_PANEL_W; } /// An effect card: its panel plus the I/O meter column. -fn effectW(fx: *const Effect) i32 { +pub fn effectW(fx: *const Effect) i32 { return panelW(&fx.mach) + IO_W; } /// IN | scale | OUT: two stereo bars sharing one graduated scale. -const IO_W: i32 = 48; +pub const IO_W: i32 = 48; const IO_BAR_W: i32 = 10; fn ioMeters(ui: *Ui, r: Rect, fx: *const Effect) void { @@ -333,7 +355,7 @@ fn drawMinimap(ui: *Ui, r: Rect, view_w: i32, content_w: i32, max_scroll: i32, i } } -const DeviceOut = struct { +pub const DeviceOut = struct { toggle: bool = false, // mute / bypass clicked name_pressed: bool = false, // name tile pressed (effects arm a drag) name_released: bool = false, @@ -350,7 +372,7 @@ fn isInstrument(ref: DeviceRef) bool { // Draw one device column: the title strip, its menus, and the machine's // panel body. Folds the name (replace), preset and delete-confirm outcomes // into `result`, scoped to `ref`. -fn drawDevice(ui: *Ui, card: Rect, mach: *Machine, ref: DeviceRef, fx: ?*const Effect, active: bool, glow: ?f32, reg: *const Registry, result: *Result) DeviceOut { +pub fn drawDevice(ui: *Ui, card: Rect, mach: *Machine, ref: DeviceRef, fx: ?*const Effect, active: bool, glow: ?f32, reg: *const Registry, result: *Result) DeviceOut { var out = DeviceOut{}; ui.pushId(mach.state); defer ui.popId(); From b42f0d17559aa9f1b978aea86b32dfa852990a9c Mon Sep 17 00:00:00 2001 From: Marcin Gasperowicz Date: Sat, 17 Oct 2026 00:51:58 +0100 Subject: [PATCH 6/9] =?UTF-8?q?machine:=20rack=20macros=20with=20mappings,?= =?UTF-8?q?=20formulas=20and=20a=20set=5Fmod=20host=20input;=20macro=20blo?= =?UTF-8?q?ck=20and=20MAP=20mode=20(docs/42=20=C2=A7Build=20phases=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Racks get rk-m1..8 and rk-chain controls that automate, save and nest. A mapping drives a lane machine's control, or the lane's level or pan, over a range in the target's knob space through an optional formula (src/formula.zig: postfix ops, a fixed stack, no failure at run time). Machines take the value through set_mod, which wins over automation and the knob and follows as a lane does; while a macro moves the rack renders its lanes in 32-sample chunks, so a macro sweep equals the same sweep automated on the control. Lane CHAIN zones close lanes outside them. The panel gets the macro block: eight knobs, NAME, MAP to map a clicked control or LV/PN, and the picked macro's mappings with MIN, MAX and f(x). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 + docs/19-project-format.md | 2 +- docs/42-racks.md | 71 +- src/formula.zig | 282 ++++++++ src/machine.zig | 5 + src/machines/fy_raw_machine.zig | 59 +- src/machines/rack.zig | 1087 ++++++++++++++++++++++++++++++- src/main.zig | 1 + 8 files changed, 1453 insertions(+), 61 deletions(-) create mode 100644 src/formula.zig diff --git a/CHANGELOG.md b/CHANGELOG.md index 6174b456..691c14fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ GitHub are its section here (`tools/release.sh` copies them), so add to ## Unreleased +- **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. diff --git a/docs/19-project-format.md b/docs/19-project-format.md index 5074f6e5..7ba9154e 100644 --- a/docs/19-project-format.md +++ b/docs/19-project-format.md @@ -154,7 +154,7 @@ 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 lanes, `{"rack": 2, "kind": "instrument", "lanes": [{…, "instrument": {"machine": id, "params": {…}}, "effects": […]}]}`, the same form as its presets ([42-racks.md](42-racks.md) §Files). The first form, `{"parts": […]}`, still reads, as lanes without inserts. | +| `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. | diff --git a/docs/42-racks.md b/docs/42-racks.md index 8b7290c5..78c4c2fa 100644 --- a/docs/42-racks.md +++ b/docs/42-racks.md @@ -1,9 +1,12 @@ # 42 — Racks: chains in lanes, macros, rigs -**Status: phases 1 and 2 built.** Rack lanes hold chains, the FX Rack -plays lanes of inserts, racks nest and align their lanes' latency, and -rack state is in its second form. Macros, the new panel, rigs and inner -automation are still to build. The Rack that ships today +**Status: phases 1–3 built, and part of phase 4.** Rack lanes hold +chains, the FX Rack plays lanes of inserts, racks nest and align their +lanes' latency, and rack state is in its second form. Macros map to +controls inside through ranges and formulas, with the macro block and +MAP mode on the panel. CHAIN zones on the panel, the collapsed face, +insert reordering, grouping, rigs and inner automation are still to +build. The Rack that ships today (`src/machines/rack.zig`) is described under "Where we start". A rack packages machines into one device. Each of its lanes holds a @@ -272,17 +275,27 @@ directly, so grouping loses nothing: ```json {"rack": 2, "kind": "instrument", + "lanes": [{"name": "CLEAN", "lo": 0, "hi": 127, "vlo": 1, "vhi": 127, + "clo": 0, "chi": 127, "transpose": 0, "level": 0, "pan": 0, + "poly": 0, "mute": false, "solo": false, + "instrument": {"machine": "guitar", "params": {…}, "preset": "twin-humbucker"}, + "effects": [{"machine": "amp2", "params": {…}, "bypass": false}, …]}], "macros": [{"name": "DRIVE", "value": 0.4}, …], "chain": 0, - "lanes": [{"name": "CLEAN", "on": true, "solo": false, "level": 0, "pan": 0, - "key": [0, 127], "vel": [1, 127], "select": [0, 127], - "transpose": 0, "poly": 0, - "instrument": {"machine": "guitar", "params": {…}, "preset": "twin-humbucker"}, - "effects": [{"machine": "amp2", "uid": 1, "params": {…}, "bypass": false}, …]}], - "maps": [{"macro": 1, "lane": 0, "slot": 1, "control": "amp-gain", - "min": 2.0, "max": 7.5, "formula": "x^2"}]} + "maps": [{"macro": 1, "lane": 1, "insert": 1, "control": "amp-gain", + "min": 0.2, "max": 0.95, "formula": "x^2"}, + {"macro": 2, "lane": 2, "target": "level", "min": -40, "max": -3}]} ``` +- Macros, lanes and inserts count from 1 in `maps`. A mapping with no + `insert` reaches the lane's instrument; `target` is `level` or `pan` + for the lane's own mix, absent for a control. `min` and `max` are in + the target's real units (dB for level). +- `clo`–`chi` is the lane's CHAIN zone: it plays while `chain` is inside. +- The rack's controls also write as `params` (`rk-m1`…`rk-m8`, + `rk-chain`), which is how slabkit and automation set them; `macros` + then sets the same values. + - **Old rack state still loads.** State with `"parts"` and no `"rack"` key reads as version 1. Each part becomes a lane holding its instrument with its key, velocity, transpose, level, pan, poly and @@ -341,15 +354,33 @@ bit-exact. - Tests: a first-form project renders byte-identical; a one-lane FX Rack equals its insert sample for sample; a dry lane is held to a latent one. -3. **Macros.** - - Rack controls `rk-m1..8` and `rk-chain`; mappings, the formula - compiler and its VM; the `set_mod` host input in `fy_raw_machine`. - - Tests cover formula parsing and evaluation, and a macro sweep - matching the same sweep made with automation. -4. **UI.** The macro block, CHAIN zones, collapse, MAP mode and the - mapping table, dragging inserts to reorder, and ⌘G group and - ungroup. (The lane column and the inline lane chain came with - phase 2.) +3. **Macros.** *Built.* + - Rack controls `rk-m1..8` and `rk-chain`, automatable; mappings to a + lane machine's control or the lane's level or pan; the formula + compiler and its VM (`src/formula.zig`); the `set_mod` host input, + in `fy_raw_machine` and in the rack itself (a nested rack's macro is + a target). + - While a macro moves (a lane, or the hand's 20 ms glide) the rack + renders its lanes in 32-sample chunks and sets the mapped controls + between them, which then follow exactly as automation does. Steady + macros render whole blocks: projects without mappings are + byte-identical. + - A mapped control shows the macro's value with its automation LED + lit; turning it by hand moves its own value, which the macro + overrides. + - Lane CHAIN zones (`clo`–`chi`) close a lane outside them. + - Tests: formula parsing, precedence and run-time safety; mappings and + macros round-trip; an automated macro sweep equals automating the + control itself, sample for sample; a formula, a stepped target, + unmapping; CHAIN zones; a macro reaching a nested rack's macro. +4. **UI.** *The macro block is built:* eight knobs, NAME, MAP (click a + control in the open lane, or LV/PN, to map it to the picked macro over + its whole range), and the picked macro's mappings with × , MIN and + MAX steps in the target's units and the f(x) field, red until it + parses. Still to build: CHAIN zone editing, the collapsed face, + outlines on mappable controls in MAP mode, hand moves on a mapped + knob reaching the macro, dragging inserts to reorder, and ⌘G group + and ungroup. 5. **Rigs.** - Factory rack presets, the RACKS section of the picker, and the slabkit `rack/lane/macro` builders with `describe`. diff --git a/src/formula.zig b/src/formula.zig new file mode 100644 index 00000000..c8167714 --- /dev/null +++ b/src/formula.zig @@ -0,0 +1,282 @@ +//! Macro formulas (docs/42 §Mappings): a short expression over the macro +//! `x` and the rack's other macros `m1`…`m8`, compiled on the UI thread to +//! at most MAX_OPS postfix ops and evaluated on the audio thread with a +//! fixed stack. Evaluation allocates nothing and can't fail: division by +//! zero gives 0 and NaN becomes 0. +//! +//! x m1 … m8 numbers + - * / ^ unary - ( ) +//! min(a,b) max(a,b) clamp(v,lo,hi) lerp(a,b,t) step(v,n) +//! abs exp log sqrt sin cos + +const std = @import("std"); + +pub const MAX_OPS = 32; +pub const MAX_TEXT = 63; +const MAX_STACK = 16; + +pub const Op = union(enum) { + num: f32, + /// 0 = x, 1..8 = m1..m8. + reg: u8, + neg, + add, + sub, + mul, + div, + pow, + min, + max, + clamp, + lerp, + step, + abs, + exp, + log, + sqrt, + sin, + cos, + + fn arity(op: Op) u8 { + return switch (op) { + .num, .reg => 0, + .neg, .abs, .exp, .log, .sqrt, .sin, .cos => 1, + .add, .sub, .mul, .div, .pow, .min, .max, .step => 2, + .clamp, .lerp => 3, + }; + } +}; + +/// A compiled formula. The empty program is the identity, `t = x`. +pub const Program = struct { + ops: [MAX_OPS]Op = undefined, + len: u8 = 0, + + /// `x` the macro, `m[i]` macro i+1, all 0..1. The result is 0..1. + pub fn eval(self: *const Program, x: f32, m: *const [8]f32) f32 { + if (self.len == 0) return std.math.clamp(x, 0, 1); + var st: [MAX_STACK]f32 = undefined; + var sp: usize = 0; + for (self.ops[0..self.len]) |op| { + const k = op.arity(); + // compile() proved the depth; the guards keep a corrupt program harmless + if (sp < k or (k == 0 and sp >= MAX_STACK)) return 0; + const a = if (k >= 1) st[sp - k] else 0; + const b = if (k >= 2) st[sp - k + 1] else 0; + const c = if (k >= 3) st[sp - k + 2] else 0; + const v: f32 = switch (op) { + .num => |n| n, + .reg => |r| if (r == 0) x else m[r - 1], + .neg => -a, + .add => a + b, + .sub => a - b, + .mul => a * b, + .div => if (b == 0) 0 else a / b, + .pow => std.math.pow(f32, a, b), + .min => @min(a, b), + .max => @max(a, b), + .clamp => std.math.clamp(a, @min(b, c), @max(b, c)), + .lerp => a + (b - a) * c, + // n positions over 0..1: step(x, 3) gives 0, 0.5, 1 + .step => blk: { + const n = @round(b); + if (!(n >= 2)) break :blk 0; + break :blk @round(std.math.clamp(a, 0, 1) * (n - 1)) / (n - 1); + }, + .abs => @abs(a), + .exp => @exp(a), + .log => if (a > 0) @log(a) else 0, + .sqrt => if (a > 0) @sqrt(a) else 0, + .sin => @sin(a), + .cos => @cos(a), + }; + sp -= k; + st[sp] = if (std.math.isFinite(v)) v else 0; + sp += 1; + } + if (sp != 1) return 0; + return std.math.clamp(st[0], 0, 1); + } +}; + +pub const Error = error{ Syntax, TooLong }; + +/// Compile `src`; blank text is the identity. +pub fn compile(src: []const u8) Error!Program { + var p = Parser{ .s = src }; + var prog = Program{}; + p.skip(); + if (p.i == src.len) return prog; + var depth: usize = 0; + try p.expr(&prog, &depth); + p.skip(); + if (p.i != src.len or depth != 1) return error.Syntax; + return prog; +} + +const Parser = struct { + s: []const u8, + i: usize = 0, + + fn skip(p: *Parser) void { + while (p.i < p.s.len and (p.s[p.i] == ' ' or p.s[p.i] == '\t')) p.i += 1; + } + + fn peek(p: *Parser) u8 { + p.skip(); + return if (p.i < p.s.len) p.s[p.i] else 0; + } + + fn emit(prog: *Program, depth: *usize, op: Op) Error!void { + if (prog.len >= MAX_OPS) return error.TooLong; + const k = op.arity(); + if (depth.* < k) return error.Syntax; + depth.* = depth.* - k + 1; + if (depth.* > MAX_STACK) return error.TooLong; + prog.ops[prog.len] = op; + prog.len += 1; + } + + // expr := term (('+'|'-') term)* + fn expr(p: *Parser, prog: *Program, d: *usize) Error!void { + try p.term(prog, d); + while (true) { + const c = p.peek(); + if (c != '+' and c != '-') return; + p.i += 1; + try p.term(prog, d); + try emit(prog, d, if (c == '+') .add else .sub); + } + } + + // term := unary (('*'|'/') unary)* + fn term(p: *Parser, prog: *Program, d: *usize) Error!void { + try p.unary(prog, d); + while (true) { + const c = p.peek(); + if (c != '*' and c != '/') return; + p.i += 1; + try p.unary(prog, d); + try emit(prog, d, if (c == '*') .mul else .div); + } + } + + // unary := '-' unary | power + fn unary(p: *Parser, prog: *Program, d: *usize) Error!void { + if (p.peek() == '-') { + p.i += 1; + try p.unary(prog, d); + return emit(prog, d, .neg); + } + return p.power(prog, d); + } + + // power := atom ('^' unary)? (right-associative, binds tighter than unary minus on its left) + fn power(p: *Parser, prog: *Program, d: *usize) Error!void { + try p.atom(prog, d); + if (p.peek() == '^') { + p.i += 1; + try p.unary(prog, d); + try emit(prog, d, .pow); + } + } + + fn atom(p: *Parser, prog: *Program, d: *usize) Error!void { + const c = p.peek(); + if (c == '(') { + p.i += 1; + try p.expr(prog, d); + if (p.peek() != ')') return error.Syntax; + p.i += 1; + return; + } + if ((c >= '0' and c <= '9') or c == '.') { + const start = p.i; + while (p.i < p.s.len and ((p.s[p.i] >= '0' and p.s[p.i] <= '9') or p.s[p.i] == '.')) p.i += 1; + const v = std.fmt.parseFloat(f32, p.s[start..p.i]) catch return error.Syntax; + return emit(prog, d, .{ .num = v }); + } + if (!std.ascii.isAlphabetic(c)) return error.Syntax; + const start = p.i; + while (p.i < p.s.len and std.ascii.isAlphanumeric(p.s[p.i])) p.i += 1; + const name = p.s[start..p.i]; + if (std.mem.eql(u8, name, "x")) return emit(prog, d, .{ .reg = 0 }); + if (name.len == 2 and name[0] == 'm' and name[1] >= '1' and name[1] <= '8') + return emit(prog, d, .{ .reg = name[1] - '0' }); + const funcs = .{ + .{ "min", Op.min }, .{ "max", Op.max }, .{ "clamp", Op.clamp }, .{ "lerp", Op.lerp }, + .{ "step", Op.step }, .{ "abs", Op.abs }, .{ "exp", Op.exp }, .{ "log", Op.log }, + .{ "sqrt", Op.sqrt }, .{ "sin", Op.sin }, .{ "cos", Op.cos }, + }; + inline for (funcs) |f| { + if (std.mem.eql(u8, name, f[0])) { + const op: Op = f[1]; + if (p.peek() != '(') return error.Syntax; + p.i += 1; + var n: u8 = 0; + while (true) { + try p.expr(prog, d); + n += 1; + const sep = p.peek(); + p.i += 1; + if (sep == ')') break; + if (sep != ',') return error.Syntax; + } + if (n != op.arity()) return error.Syntax; + return emit(prog, d, op); + } + } + if (std.mem.eql(u8, name, "pi")) return emit(prog, d, .{ .num = std.math.pi }); + return error.Syntax; + } +}; + +const testing = std.testing; + +fn run(src: []const u8, x: f32, m: [8]f32) !f32 { + const p = try compile(src); + return p.eval(x, &m); +} + +test "formula: parses the docs' examples and evaluates them" { + const z: [8]f32 = @splat(0); + try testing.expectEqual(@as(f32, 0.25), try run("x^2", 0.5, z)); + try testing.expectEqual(@as(f32, 0.75), try run("1 - x", 0.25, z)); + try testing.expectEqual(@as(f32, 0), try run("clamp(2*x - 1, 0, 1)", 0.4, z)); + try testing.expectApproxEqAbs(@as(f32, 0.6), try run("clamp(2*x - 1, 0, 1)", 0.8, z), 1e-6); + var m = z; + m[3] = 0.25; + try testing.expectApproxEqAbs(@as(f32, 0.6), try run("x * (1 - m4)", 0.8, m), 1e-6); + // blank is the identity, clamped + try testing.expectEqual(@as(f32, 0.3), try run(" ", 0.3, z)); + try testing.expectEqual(@as(f32, 1), try run("", 1.7, z)); +} + +test "formula: precedence, unary minus, functions, step" { + const z: [8]f32 = @splat(0); + try testing.expectApproxEqAbs(@as(f32, 0.7), try run("0.1 + 0.2 * 3", 0, z), 1e-6); + try testing.expectApproxEqAbs(@as(f32, 0.9), try run("(0.1 + 0.2) * 3", 0, z), 1e-6); + // -x^2 is -(x^2); 2^-1 parses + try testing.expectEqual(@as(f32, 0), try run("-x^2", 0.5, z)); + try testing.expectEqual(@as(f32, 0.5), try run("2^-1", 0, z)); + try testing.expectEqual(@as(f32, 0.5), try run("-x^2 + 0.75", 0.5, z)); + try testing.expectEqual(@as(f32, 0.5), try run("step(x, 3)", 0.6, z)); + try testing.expectEqual(@as(f32, 1), try run("step(x, 3)", 0.8, z)); + try testing.expectEqual(@as(f32, 0.5), try run("lerp(0.2, 0.8, x)", 0.5, z)); + try testing.expectEqual(@as(f32, 0.2), try run("min(x, 0.2)", 0.5, z)); + try testing.expectApproxEqAbs(@as(f32, 0.5), try run("abs(x - 1)", 0.5, z), 1e-6); + try testing.expectApproxEqAbs(@as(f32, 0.5), try run("sqrt(x)", 0.25, z), 1e-6); +} + +test "formula: nothing fails at run time" { + const z: [8]f32 = @splat(0); + try testing.expectEqual(@as(f32, 0), try run("1 / x", 0, z)); + try testing.expectEqual(@as(f32, 0), try run("log(x)", 0, z)); + try testing.expectEqual(@as(f32, 0), try run("sqrt(0 - x)", 0.5, z)); + try testing.expectEqual(@as(f32, 0), try run("(0-1)^0.5", 0, z)); +} + +test "formula: typos and oversized programs are refused" { + const bad = [_][]const u8{ "x +", "x x", "foo(x)", "min(x)", "clamp(x,1)", "(x", "x)", "m9", "y", "2 ** x", "step x" }; + for (bad) |s| try testing.expectError(error.Syntax, compile(s)); + try testing.expectError(error.TooLong, compile("x+x+x+x+x+x+x+x+x+x+x+x+x+x+x+x+x")); +} diff --git a/src/machine.zig b/src/machine.zig index f000e4dc..ee13ab15 100644 --- a/src/machine.zig +++ b/src/machine.zig @@ -279,6 +279,10 @@ pub const TakeAutoRequestFn = *const fn (state: *anyopaque) ?AutoRequest; /// space), for automation recording. Taking it clears it. pub const Touch = struct { control: u16, knob: f32 }; pub const TakeTouchFn = *const fn (state: *anyopaque) ?Touch; +/// Audio thread, between renders: a rack's macro drives control `i` at +/// `knob` (knob space), over its automation and its hand-set value, until +/// it is set to null (docs/42 §Reaching the target). +pub const SetModFn = *const fn (state: *anyopaque, i: usize, knob: ?f32) void; /// Audio thread, each block: how many samples later the output is than the /// input (docs/07 §PDC). pub const LatencyFn = *const fn (state: *anyopaque) u32; @@ -498,6 +502,7 @@ pub const Machine = struct { clear_overrides: ?ClearOverridesFn = null, take_auto_request: ?TakeAutoRequestFn = null, take_touch: ?TakeTouchFn = null, + set_mod: ?SetModFn = null, /// Delay compensation (docs/07 §PDC); none = no latency. latency: ?LatencyFn = null, /// Idle skipping (docs/04 §Idle skipping); none = no stored sound diff --git a/src/machines/fy_raw_machine.zig b/src/machines/fy_raw_machine.zig index 9ac48d77..4aba575d 100644 --- a/src/machines/fy_raw_machine.zig +++ b/src/machines/fy_raw_machine.zig @@ -303,13 +303,15 @@ const IO_STRIDE: u12 = @sizeOf(IoFrame); /// glides to its target with this time constant, so exp-curve knobs sweep /// perceptually evenly. While any knob moves, the block renders in /// SMOOTH_CHUNK-sample sub-blocks with params re-synced between them. -const SMOOTH_TAU_S: f64 = 0.02; -const SMOOTH_CHUNK: usize = 32; -const SMOOTH_EPS: f32 = 1e-5; +pub const SMOOTH_TAU_S: f64 = 0.02; +pub const SMOOTH_CHUNK: usize = 32; +pub const SMOOTH_EPS: f32 = 1e-5; /// An automated knob within this of its curve stops gliding and follows it; /// a lane jump bigger than AUTO_JUMP in one chunk glides instead. const AUTO_CATCH: f32 = 1e-3; const AUTO_JUMP: f32 = 0.05; +/// mod_shown when no macro sets the control: a NaN no value can be. +const MOD_NONE: u32 = 0x7fc0_0bad; /// -120 dBFS: a released voice whose whole-block contribution stays below /// this stops rendering until its next note-on. @@ -439,6 +441,12 @@ pub const FyRawMachine = struct { auto_on: [MAX_CONTROLS]bool = [_]bool{false} ** MAX_CONTROLS, auto_val: [MAX_CONTROLS]f32 = [_]f32{0} ** MAX_CONTROLS, auto_locked: [MAX_CONTROLS]bool = [_]bool{false} ** MAX_CONTROLS, + // A rack's macro (docs/42 §Reaching the target), audio-owned: it wins + // over the lane and the hand, and follows like a lane does. mod_shown + // is the value for the panel (NaN bits: none). + mod_on: [MAX_CONTROLS]bool = [_]bool{false} ** MAX_CONTROLS, + mod_val: [MAX_CONTROLS]f32 = [_]f32{0} ** MAX_CONTROLS, + mod_shown: [MAX_CONTROLS]std.atomic.Value(u32) = [_]std.atomic.Value(u32){std.atomic.Value(u32).init(MOD_NONE)} ** MAX_CONTROLS, // UI writes, audio reads: a manual override of an automated control, // 0 none, 1 held (touch), 2 sticky until the transport starts. auto_override: [MAX_CONTROLS]std.atomic.Value(u8) = [_]std.atomic.Value(u8){std.atomic.Value(u8).init(0)} ** MAX_CONTROLS, @@ -1225,6 +1233,7 @@ pub const FyRawMachine = struct { .clear_overrides = clearOverridesImpl, .take_auto_request = takeAutoRequestImpl, .take_touch = takeTouchImpl, + .set_mod = setModImpl, }; } @@ -1456,8 +1465,8 @@ pub const FyRawMachine = struct { var moving = false; for (self.desc.controls[0..self.desc.control_count], 0..) |*ctl, i| { if (ctl.kind != .direct_f64) continue; - const auto = self.automated(i); - const target = if (auto) self.auto_val[i] else self.audioNorm(i); + const auto = self.driven(i); + const target = if (self.mod_on[i]) self.mod_val[i] else if (auto) self.auto_val[i] else self.audioNorm(i); if (self.snap_req[i]) { self.smooth_norm[i] = target; self.snap_req[i] = false; @@ -1509,8 +1518,8 @@ pub const FyRawMachine = struct { for (self.desc.controls[0..self.desc.control_count], 0..) |*ctl, i| { if (ctl.kind != .direct_f64) continue; if (self.snap_req[i]) continue; - if (self.auto_on[i]) continue; // chunked anyway while automated - if (self.smooth_norm[i] != self.audioNorm(i)) return true; + if (self.auto_on[i] and !self.mod_on[i]) continue; // chunked anyway while automated + if (self.smooth_norm[i] != (if (self.mod_on[i]) self.mod_val[i] else self.audioNorm(i))) return true; } return false; } @@ -1521,8 +1530,14 @@ pub const FyRawMachine = struct { return self.auto_on[i] and self.auto_override[i].load(.monotonic) == 0; } + /// Audio thread: a macro or a lane sets control `i` this chunk. + fn driven(self: *const FyRawMachine, i: usize) bool { + return self.mod_on[i] or self.automated(i); + } + /// Audio thread: the raw value a stepped control plays this chunk. fn effRaw(self: *const FyRawMachine, i: usize) f32 { + if (self.mod_on[i]) return self.mod_val[i]; return if (self.automated(i)) self.auto_val[i] else self.audioNorm(i); } @@ -1544,10 +1559,17 @@ pub const FyRawMachine = struct { /// UI thread: the value a control shows — its automated value unless /// the hand overrides it. fn shownNorm(self: *const FyRawMachine, i: usize) f32 { + if (self.modShown(i)) |v| return v; if (self.ui_auto[i]) |v| if (self.auto_override[i].load(.monotonic) == 0) return v; return self.controlNorm(i); } + /// UI thread: the value a macro sets control `i` to, if one does. + fn modShown(self: *const FyRawMachine, i: usize) ?f32 { + const b = self.mod_shown[i].load(.monotonic); + return if (b == MOD_NONE) null else @bitCast(b); + } + pub fn setControlNorm(self: *FyRawMachine, idx: usize, value: f32) void { self.raw_control_bits[idx].store(@bitCast(std.math.clamp(value, 0.0, 1.0)), .monotonic); self.publishControls(); @@ -2272,6 +2294,22 @@ fn setAutoUiImpl(state: *anyopaque, i: usize, knob: ?f32) void { if (knob == null) self.auto_override[i].store(0, .monotonic); } +fn setModImpl(state: *anyopaque, i: usize, knob: ?f32) void { + const self: *FyRawMachine = @ptrCast(@alignCast(state)); + if (i >= self.desc.control_count) return; + if (knob) |k| { + const v = if (self.desc.controls[i].kind == .direct_f64) std.math.clamp(k, 0, 1) else k; + if (!self.mod_on[i] or self.mod_val[i] != v) self.wake_req.store(true, .release); + self.mod_on[i] = true; + self.mod_val[i] = v; + self.mod_shown[i].store(@bitCast(v), .monotonic); + } else { + if (self.mod_on[i]) self.wake_req.store(true, .release); + self.mod_on[i] = false; + self.mod_shown[i].store(MOD_NONE, .monotonic); + } +} + fn clearOverridesImpl(state: *anyopaque) void { const self: *FyRawMachine = @ptrCast(@alignCast(state)); for (&self.auto_override) |*o| o.store(0, .monotonic); @@ -3885,6 +3923,13 @@ fn drawAutomatable(self: *FyRawMachine, ui: *Ui, kr: Rect, gi: usize, ctl: *cons self.ctx_control = gi; ui_menu.openAt(autoMenuKey(self), ui.in.ix(), ui.in.iy()); } + if (self.modShown(gi) != null) { + // A macro sets it: the lit LED marks it mapped. + if (ui.isHot(wid) and ui.touch.time == ui.in.time) ui.touch.automated = true; + const at = ui_ctl.autoLedPos(ui, kr, ctl.label[0..ctl.label_len]); + _ = ui_ctl.autoLed(ui, at[0], at[1], .{ "auto", gi }, false); + return; + } if (self.ui_auto[gi] == null) return; // The title display's value channel says the value isn't all yours. if (ui.isHot(wid) and ui.touch.time == ui.in.time) ui.touch.automated = true; diff --git a/src/machines/rack.zig b/src/machines/rack.zig index 8274014d..3a3add23 100644 --- a/src/machines/rack.zig +++ b/src/machines/rack.zig @@ -11,6 +11,14 @@ //! slowest lane's latency, which the rack reports. A rack can sit in a //! lane, MAX_DEPTH deep. //! +//! Macros (docs/42 §Macros) are the rack's controls `rk-m1`…`rk-m8`, with +//! `rk-chain` beside them: they automate, take presets and nest like any +//! control. A mapping ties a macro to a control inside (or a lane's level +//! or pan) over a range in the target's knob space, through an optional +//! formula. The rack sets mapped controls with `set_mod` between +//! SMOOTH_CHUNK-sample sub-blocks while a macro moves, so a macro sweep is +//! the same as automating the control itself. +//! //! A rack preset (machines/rack/presets/*.preset) and a project store the //! lanes as {"rack":2,"kind":…,"lanes":[{…,"instrument":{"machine":id, //! "params":{…}},"effects":[…]}]}, each machine's settings in its own form, @@ -38,6 +46,11 @@ const ui_style = @import("../ui/style.zig"); const menu = @import("../ui/menu.zig"); const pane = @import("../ui/pane_input.zig"); const bay = @import("../ui/machine_bay.zig"); +const text_field = @import("../ui/text_field.zig"); +const formula = @import("../formula.zig"); +const snapshot = @import("../snapshot.zig"); +const automation = @import("../automation.zig"); +const fy_raw = @import("fy_raw_machine.zig"); const Ui = ui_core.Ui; const Rect = ui_core.Rect; @@ -66,6 +79,72 @@ pub const FX_PATH = "machines/fxrack/fxrack"; pub const Kind = enum { instrument, effect }; +pub const MAX_MACROS = 8; +pub const MAX_MAPS = 64; +/// Control index of CHAIN, after the macros. +pub const CHAIN = MAX_MACROS; +pub const CONTROLS = MAX_MACROS + 1; +const CONTROL_IDS = [CONTROLS][]const u8{ "rk-m1", "rk-m2", "rk-m3", "rk-m4", "rk-m5", "rk-m6", "rk-m7", "rk-m8", "rk-chain" }; +const MACRO_LABELS = [MAX_MACROS][]const u8{ "MACRO 1", "MACRO 2", "MACRO 3", "MACRO 4", "MACRO 5", "MACRO 6", "MACRO 7", "MACRO 8" }; + +pub const Name = struct { + buf: [MAX_LABEL + 1]u8 = [_]u8{0} ** (MAX_LABEL + 1), + len: u8 = 0, + + pub fn slice(self: *const Name) []const u8 { + return self.buf[0..self.len]; + } + pub fn set(self: *Name, s: []const u8) void { + const n = @min(s.len, MAX_LABEL); + @memcpy(self.buf[0..n], s[0..n]); + self.buf[n] = 0; + self.len = @intCast(n); + } +}; + +/// What a mapping moves: a machine's control in the lane, or the lane's +/// own level (dB) or pan (-1..1). +pub const TargetKind = enum(u8) { control, level, pan }; + +/// One macro → one target (docs/42 §Mappings). `min` and `max` are in the +/// target's knob space (a continuous control's norm, a stepped one's raw +/// value; dB for level), so a target with a curve keeps it. +pub const Mapping = struct { + macro: u8 = 0, + lane: u8 = 0, + target: TargetKind = .control, + /// The insert's chain uid; null = the lane's instrument. + slot: ?u16 = null, + control: u16 = 0, + stepped: bool = false, + min: f32 = 0, + max: f32 = 1, + program: formula.Program = .{}, + text: [formula.MAX_TEXT + 1]u8 = [_]u8{0} ** (formula.MAX_TEXT + 1), + text_len: u8 = 0, + /// The text doesn't compile; `program` is the last one that did. + bad: bool = false, + + pub fn textSlice(self: *const Mapping) []const u8 { + return self.text[0..self.text_len]; + } + + /// The target's value for macro `x`, the macros being `m`. + pub fn value(self: *const Mapping, x: f32, m: *const [MAX_MACROS]f32) f32 { + const t = self.program.eval(x, m); + const v = self.min + (self.max - self.min) * t; + return if (self.stepped) @round(v) else v; + } + + pub fn sameTarget(a: *const Mapping, b: *const Mapping) bool { + return a.lane == b.lane and a.target == b.target and + (a.target != .control or (a.slot == b.slot and a.control == b.control)); + } +}; + +/// Audio: a control the rack set last chunk. +const Applied = struct { state: *anyopaque, set: machine.SetModFn, control: u16, value: f32 }; + const Held = struct { channel: u8, id: i32, pitch: f32 }; /// An effect rack's lanes have no instrument: this one, which plays nothing. @@ -92,6 +171,9 @@ pub const Lane = struct { hi: u8 = 127, vlo: u8 = 1, vhi: u8 = 127, + /// The CHAIN values it plays at (docs/42 §UI zones). + clo: u8 = 0, + chi: u8 = 127, /// Semitones, whole: a lane's fine tuning is its instrument's. transpose: i8 = 0, level_db: f32 = 0, @@ -146,7 +228,13 @@ pub const Lane = struct { } }; -const LaneVersion = struct { lanes: [MAX_LANES]Lane = undefined, count: usize = 0, generation: u64 = 0 }; +const LaneVersion = struct { + lanes: [MAX_LANES]Lane = undefined, + count: usize = 0, + generation: u64 = 0, + maps: [MAX_MAPS]Mapping = undefined, + map_count: usize = 0, +}; const RetiredLane = struct { chain: *chain_mod.State, instrument: Machine, effects: std.ArrayList(Effect), line: *chain_mod.DryLine }; pub const Rack = struct { @@ -170,6 +258,43 @@ pub const Rack = struct { current_preset: i32 = -1, /// A lane or an insert came or went since the project last asked. edited: bool = false, + macro_names: [MAX_MACROS]Name = [_]Name{.{}} ** MAX_MACROS, + maps: [MAX_MAPS]Mapping = undefined, + map_count: usize = 0, + /// The automated value each control shows (null: no lane), set by the + /// host every frame. + ui_auto: [CONTROLS]?f32 = [_]?f32{null} ** CONTROLS, + touch: ?machine.Touch = null, + // The macro block (UI): the picked macro and mapping, MAP armed, the + // control a MAP click already took, and the name and formula fields. + sel_macro: usize = 0, + sel_map: ?usize = null, + map_armed: bool = false, + map_held: ?struct { state: *anyopaque, control: u16 } = null, + name_tb: text_field.TextBuf = .{}, + name_for: ?usize = null, + formula_tb: text_field.TextBuf = .{}, + formula_for: ?usize = null, + + // Macros, UI writes and audio reads: the hand-set value in knob space + // (CHAIN's raw 0..127), and a count a jump (preset, project) bumps. + hand: [CONTROLS]std.atomic.Value(u32) = [_]std.atomic.Value(u32){std.atomic.Value(u32).init(0)} ** CONTROLS, + snaps: [CONTROLS]std.atomic.Value(u32) = [_]std.atomic.Value(u32){std.atomic.Value(u32).init(0)} ** CONTROLS, + wake: std.atomic.Value(bool) = .init(false), + /// Audio: what each macro plays now, the smoothed hand value unless a + /// lane or an outer rack's macro drives it. + cur: [CONTROLS]f32 = @splat(0), + eff: [CONTROLS]f32 = @splat(0), + seen_snaps: [CONTROLS]u32 = @splat(0), + primed: bool = false, + auto_on: [CONTROLS]bool = @splat(false), + auto_val: [CONTROLS]f32 = @splat(0), + mod_on: [CONTROLS]bool = @splat(false), + mod_val: [CONTROLS]f32 = @splat(0), + applied: [MAX_MAPS]Applied = undefined, + applied_n: usize = 0, + lane_level: [MAX_LANES]?f32 = @splat(null), + lane_pan: [MAX_LANES]?f32 = @splat(null), // audio scratch sl: [MAX_BLOCK]f32 = undefined, @@ -177,6 +302,7 @@ pub const Rack = struct { fl: [MAX_BLOCK]f32 = undefined, fr: [MAX_BLOCK]f32 = undefined, events: [MAX_EVENTS]NoteEvent = undefined, + chunk_events: [MAX_EVENTS]NoteEvent = undefined, pub fn create(alloc: std.mem.Allocator, reg: *registry_mod.Registry, kind: Kind) !*Rack { const self = try alloc.create(Rack); @@ -207,11 +333,22 @@ pub const Rack = struct { .latency = latencyImpl, .tail = tailImpl, .take_wake = takeWakeImpl, + .control_count = controlCountImpl, + .control_info = controlInfoImpl, + .control_value = controlValueImpl, + .control_knob = controlKnobImpl, + .control_base = controlBaseImpl, + .format_control = formatControlImpl, + .set_auto_ui = setAutoUiImpl, + .take_touch = takeTouchImpl, + .set_mod = setModImpl, + .write_params_json = writeParamsJsonImpl, + .set_param = setParamImpl, .panel_w_fn = panelWImpl, .host_titlebar = true, // Lanes get the notes, bends included; most of them play it. .takes_expression = self.kind == .instrument, - .panel_w = SIDE_W + 200, + .panel_w = MACRO_W + SIDE_W + 200, }; } @@ -243,6 +380,8 @@ pub const Rack = struct { v.count = self.count; v.generation = self.generation; @memcpy(v.lanes[0..self.count], self.lanes[0..self.count]); + v.map_count = self.map_count; + @memcpy(v.maps[0..self.map_count], self.maps[0..self.map_count]); self.versions.publish(); } @@ -348,6 +487,7 @@ pub const Rack = struct { }; l.reg_idx = @intCast(reg_idx); l.setLabel(self.reg.entries[reg_idx].nameSlice()); + self.dropMaps(i, .instrument); } /// Append registry machine `reg_idx` to lane `i`'s inserts. @@ -365,6 +505,7 @@ pub const Rack = struct { pub fn removeEffect(self: *Rack, i: usize, j: usize) void { if (i >= self.count) return; const l = &self.lanes[i]; + if (j < l.effects.items.len) self.dropMaps(i, .{ .insert = l.effects.items[j].uid }); l.chain.removeEffect(self.alloc, l.instrument, &l.effects, j) catch {}; } @@ -372,6 +513,7 @@ pub const Rack = struct { if (i >= self.count) return error.InvalidLane; const l = &self.lanes[i]; const m = try self.instantiate(reg_idx); + if (j < l.effects.items.len) self.dropMaps(i, .{ .insert = l.effects.items[j].uid }); l.chain.replaceEffect(self.alloc, l.instrument, l.effects.items, j, m, @intCast(reg_idx)) catch |e| { self.disposeMachine(m); return e; @@ -392,6 +534,170 @@ pub const Rack = struct { l.chain.publish(l.instrument, l.effects.items); } + // ── macros and mappings ────────────────────────────────────────── + + /// UI: set control `i` (a macro's 0..1, CHAIN's 0..127) by hand; + /// `jump` skips the glide (presets, projects). + pub fn setMacro(self: *Rack, i: usize, knob: f32, jump: bool) void { + if (i >= CONTROLS) return; + const v = if (i == CHAIN) std.math.clamp(@round(knob), 0, 127) else std.math.clamp(knob, 0, 1); + self.hand[i].store(@bitCast(v), .monotonic); + if (jump) _ = self.snaps[i].fetchAdd(1, .monotonic); + self.wake.store(true, .release); + } + + pub fn macroBase(self: *const Rack, i: usize) f32 { + return @bitCast(self.hand[i].load(.monotonic)); + } + + pub fn macroLabel(self: *const Rack, i: usize) []const u8 { + if (i == CHAIN) return "CHAIN"; + const n = self.macro_names[i].slice(); + return if (n.len > 0) n else MACRO_LABELS[i]; + } + + /// The machine a mapping's target sits on, UI side. + pub fn targetMachine(self: *Rack, m: *const Mapping) ?Machine { + if (m.target != .control or m.lane >= self.count) return null; + const l = &self.lanes[m.lane]; + const uid = m.slot orelse return if (l.reg_idx != null) l.instrument else null; + for (l.effects.items) |*fx| if (fx.uid == uid) return fx.mach; + return null; + } + + /// Add mapping `m`, replacing one on the same macro and target. Its + /// steppedness comes from the target. + pub fn addMapping(self: *Rack, m: Mapping) !*Mapping { + var mm = m; + if (mm.macro >= MAX_MACROS or mm.lane >= self.count) return error.InvalidTarget; + if (mm.target == .control) { + const t = self.targetMachine(&mm) orelse return error.InvalidTarget; + if (mm.control >= t.controlCount()) return error.InvalidTarget; + mm.stepped = t.control_info.?(t.state, mm.control).stepped; + } else mm.stepped = false; + for (self.maps[0..self.map_count]) |*x| if (x.macro == mm.macro and x.sameTarget(&mm)) { + x.* = mm; + return x; + }; + if (self.map_count >= MAX_MAPS) return error.TooManyMappings; + self.maps[self.map_count] = mm; + self.map_count += 1; + self.publishLanes(); + return &self.maps[self.map_count - 1]; + } + + pub fn removeMapping(self: *Rack, k: usize) void { + if (k >= self.map_count) return; + var j = k; + while (j + 1 < self.map_count) : (j += 1) self.maps[j] = self.maps[j + 1]; + self.map_count -= 1; + self.publishLanes(); + } + + /// Set a mapping's formula text. Text that doesn't compile is kept, + /// marked bad, and the last good program keeps running. + pub fn setFormula(m: *Mapping, text: []const u8) void { + const n = @min(text.len, formula.MAX_TEXT); + @memcpy(m.text[0..n], text[0..n]); + m.text[n] = 0; + m.text_len = @intCast(n); + if (formula.compile(m.text[0..n])) |prog| { + m.program = prog; + m.bad = false; + } else |_| m.bad = true; + } + + /// The target's control name, or LEVEL / PAN. + pub fn targetLabel(self: *Rack, m: *const Mapping) []const u8 { + return switch (m.target) { + .level => "LEVEL", + .pan => "PAN", + .control => if (self.targetMachine(m)) |t| t.control_info.?(t.state, m.control).label else "?", + }; + } + + fn formatTarget(self: *Rack, m: *const Mapping, knob: f32, buf: []u8) []const u8 { + switch (m.target) { + .level => return std.fmt.bufPrint(buf, "{d:.0}", .{knob}) catch "?", + .pan => return std.fmt.bufPrint(buf, "{d:.1}", .{knob}) catch "?", + .control => { + const t = self.targetMachine(m) orelse return "?"; + const f = t.format_control orelse return std.fmt.bufPrint(buf, "{d:.2}", .{knob}) catch "?"; + return f(t.state, m.control, knob, buf); + }, + } + } + + /// One MIN/MAX step (`d` steps, Shift ×10): a hundredth of a knob's + /// travel, a stepped control's next value, a dB, a tenth of pan. + fn stepTarget(self: *Rack, m: *const Mapping, knob: f32, d: i32) f32 { + const df: f32 = @floatFromInt(d); + return switch (m.target) { + .level => std.math.clamp(knob + df, -60, 12), + .pan => std.math.clamp(knob + 0.1 * df, -1, 1), + .control => blk: { + if (!m.stepped) break :blk std.math.clamp(knob + 0.01 * df, 0, 1); + const t = self.targetMachine(m) orelse break :blk knob; + const info = t.control_info.?(t.state, m.control); + break :blk std.math.clamp(knob + std.math.sign(df), info.lo, info.hi); + }, + }; + } + + /// MAP: put control `control` of `t` (lane `li`, insert `slot`) on the + /// picked macro over its whole range, once per press. + fn mapTouched(self: *Rack, li: usize, slot: ?u16, t: Machine, control: u16) void { + if (!self.map_armed) return; + if (self.map_held) |h| if (h.state == t.state and h.control == control) return; + self.map_held = .{ .state = t.state, .control = control }; + const info = t.control_info orelse return; + const ci = info(t.state, control); + beforeEdit(self); + const m = self.addMapping(.{ + .macro = @intCast(self.sel_macro), + .lane = @intCast(li), + .slot = slot, + .control = control, + .min = if (ci.stepped) ci.lo else 0, + .max = if (ci.stepped) ci.hi else 1, + }) catch return; + self.sel_map = (@intFromPtr(m) - @intFromPtr(&self.maps[0])) / @sizeOf(Mapping); + self.formula_for = null; + } + + /// MAP: lane `li`'s level or pan on the picked macro, from off (or + /// hard left) up to where it is now (or hard right). + fn mapLane(self: *Rack, li: usize, what: TargetKind) void { + const l = &self.lanes[li]; + beforeEdit(self); + const m = self.addMapping(.{ + .macro = @intCast(self.sel_macro), + .lane = @intCast(li), + .target = what, + .min = if (what == .level) -60 else -1, + .max = if (what == .level) l.level_db else 1, + }) catch return; + self.sel_map = (@intFromPtr(m) - @intFromPtr(&self.maps[0])) / @sizeOf(Mapping); + self.formula_for = null; + } + + const Drop = union(enum) { lane, instrument, insert: u16 }; + + /// Drop the mappings onto lane `i` (all of it, its instrument, or one + /// insert by uid). + fn dropMaps(self: *Rack, i: usize, what: Drop) void { + var k: usize = 0; + while (k < self.map_count) { + const m = &self.maps[k]; + const hit = m.lane == i and switch (what) { + .lane => true, + .instrument => m.target == .control and m.slot == null, + .insert => |uid| m.target == .control and m.slot != null and m.slot.? == uid, + }; + if (hit) self.removeMapping(k) else k += 1; + } + } + fn retireLane(self: *Rack, generation: u64, l: *Lane) void { self.retired.append(generation, .{ .chain = l.chain, .instrument = l.instrument, .effects = l.effects, .line = l.line }); } @@ -405,6 +711,10 @@ pub const Rack = struct { var j = i; while (j + 1 < self.count) : (j += 1) self.lanes[j] = self.lanes[j + 1]; self.count -= 1; + self.dropMaps(i, .lane); + for (self.maps[0..self.map_count]) |*m| if (m.lane > i) { + m.lane -= 1; + }; self.publishLanes(); self.retireLane(next_generation, &old); if (self.selected >= self.count and self.selected > 0) self.selected -= 1; @@ -418,6 +728,7 @@ pub const Rack = struct { var old: [MAX_LANES]Lane = undefined; @memcpy(old[0..n], self.lanes[0..n]); self.count = 0; + self.map_count = 0; self.publishLanes(); for (old[0..n]) |*l| self.retireLane(next_generation, l); self.selected = 0; @@ -460,6 +771,8 @@ pub const Rack = struct { if (po.get("hi")) |x| l.hi = jsonU7(x); if (po.get("vlo")) |x| l.vlo = jsonU7(x); if (po.get("vhi")) |x| l.vhi = jsonU7(x); + if (po.get("clo")) |x| l.clo = jsonU7(x); + if (po.get("chi")) |x| l.chi = jsonU7(x); if (po.get("transpose")) |x| l.transpose = @intFromFloat(std.math.clamp(@round(jsonF64(x)), -48, 48)); if (po.get("level")) |x| l.level_db = @floatCast(std.math.clamp(jsonF64(x), -60, 12)); if (po.get("pan")) |x| l.pan = @floatCast(std.math.clamp(jsonF64(x), -1, 1)); @@ -501,6 +814,7 @@ pub const Rack = struct { applyLaneSettings(l, po); l.chain.publish(l.instrument, l.effects.items); } + self.applyMacrosJson(v.object); } else if (v.object.get("parts")) |pv| { // The first form: instrument lanes, no inserts. if (pv != .array or self.kind != .instrument) return; @@ -516,6 +830,92 @@ pub const Rack = struct { self.selected = 0; } + /// Macro names and values, CHAIN, and the mappings, once the lanes + /// they reach are in. + fn applyMacrosJson(self: *Rack, o: std.json.ObjectMap) void { + for (&self.macro_names, 0..) |*n, i| { + n.* = .{}; + self.setMacro(i, 0, true); + } + self.setMacro(CHAIN, 0, true); + if (o.get("macros")) |mv| if (mv == .array) for (mv.array.items[0..@min(mv.array.items.len, MAX_MACROS)], 0..) |item, i| { + if (item != .object) continue; + if (item.object.get("name")) |x| if (x == .string) self.macro_names[i].set(x.string); + if (item.object.get("value")) |x| self.setMacro(i, @floatCast(jsonF64(x)), true); + }; + if (o.get("chain")) |x| self.setMacro(CHAIN, @floatCast(jsonF64(x)), true); + const mv = o.get("maps") orelse return; + if (mv != .array) return; + for (mv.array.items) |item| { + if (item != .object) continue; + const mo = item.object; + var m = Mapping{}; + const macro = if (mo.get("macro")) |x| jsonF64(x) else continue; + const lane = if (mo.get("lane")) |x| jsonF64(x) else continue; + if (macro < 1 or macro > MAX_MACROS or lane < 1 or lane > @as(f64, @floatFromInt(self.count))) continue; + m.macro = @intFromFloat(macro - 1); + m.lane = @intFromFloat(lane - 1); + const l = &self.lanes[m.lane]; + if (mo.get("target")) |x| if (x == .string) { + m.target = std.meta.stringToEnum(TargetKind, x.string) orelse continue; + }; + var tm: ?Machine = null; + if (m.target == .control) { + if (mo.get("insert")) |x| { + const n = jsonF64(x); + if (n < 1 or n > @as(f64, @floatFromInt(l.effects.items.len))) continue; + m.slot = l.effects.items[@intFromFloat(n - 1)].uid; + } + tm = self.targetMachine(&m) orelse continue; + const cid = if (mo.get("control")) |x| (if (x == .string) x.string else continue) else continue; + m.control = @intCast(tm.?.controlIndex(cid) orelse continue); + } + inline for (.{ "min", "max" }) |k| if (mo.get(k)) |x| { + const real = jsonF64(x); + @field(m, k) = if (tm) |t| (if (t.control_knob) |f| f(t.state, m.control, real) else @floatCast(real)) else @floatCast(real); + }; + if (mo.get("formula")) |x| if (x == .string) setFormula(&m, x.string); + _ = self.addMapping(m) catch continue; + } + } + + fn writeMacrosJson(self: *Rack, out: *std.ArrayList(u8), alloc: std.mem.Allocator) !void { + try out.appendSlice(alloc, ",\"macros\":["); + for (0..MAX_MACROS) |i| { + if (i > 0) try out.append(alloc, ','); + try out.appendSlice(alloc, "{\"name\":"); + try jsonString(out, alloc, self.macro_names[i].slice()); + try out.print(alloc, ",\"value\":{d}}}", .{self.macroBase(i)}); + } + try out.print(alloc, "],\"chain\":{d},\"maps\":[", .{self.macroBase(CHAIN)}); + for (self.maps[0..self.map_count], 0..) |*m, k| { + if (k > 0) try out.append(alloc, ','); + try out.print(alloc, "{{\"macro\":{d},\"lane\":{d}", .{ m.macro + 1, m.lane + 1 }); + var lo: f64 = m.min; + var hi: f64 = m.max; + if (m.target != .control) { + try out.print(alloc, ",\"target\":\"{s}\"", .{@tagName(m.target)}); + } else if (self.targetMachine(m)) |t| { + if (m.slot) |uid| for (self.lanes[m.lane].effects.items, 0..) |*fx, j| if (fx.uid == uid) { + try out.print(alloc, ",\"insert\":{d}", .{j + 1}); + }; + try out.appendSlice(alloc, ",\"control\":"); + try jsonString(out, alloc, t.control_info.?(t.state, m.control).id); + if (t.control_value) |f| { + lo = f(t.state, m.control, m.min); + hi = f(t.state, m.control, m.max); + } + } + try out.print(alloc, ",\"min\":{d},\"max\":{d}", .{ lo, hi }); + if (m.text_len > 0) { + try out.appendSlice(alloc, ",\"formula\":"); + try jsonString(out, alloc, m.textSlice()); + } + try out.append(alloc, '}'); + } + try out.append(alloc, ']'); + } + fn writeMachineJson(self: *Rack, out: *std.ArrayList(u8), alloc: std.mem.Allocator, m: Machine, reg_idx: u8) !void { try out.appendSlice(alloc, "{\"machine\":"); try jsonString(out, alloc, self.reg.entries[reg_idx].idSlice()); @@ -551,8 +951,8 @@ pub const Rack = struct { if (i > 0) try out.append(alloc, ','); try out.appendSlice(alloc, "{\"name\":"); try jsonString(out, alloc, l.labelSlice()); - try out.print(alloc, ",\"lo\":{d},\"hi\":{d},\"vlo\":{d},\"vhi\":{d},\"transpose\":{d},\"level\":{d},\"pan\":{d},\"poly\":{d},\"mute\":{s},\"solo\":{s}", .{ - l.lo, l.hi, l.vlo, l.vhi, l.transpose, l.level_db, l.pan, l.poly, if (l.mute) "true" else "false", if (l.solo) "true" else "false", + try out.print(alloc, ",\"lo\":{d},\"hi\":{d},\"vlo\":{d},\"vhi\":{d},\"clo\":{d},\"chi\":{d},\"transpose\":{d},\"level\":{d},\"pan\":{d},\"poly\":{d},\"mute\":{s},\"solo\":{s}", .{ + l.lo, l.hi, l.vlo, l.vhi, l.clo, l.chi, l.transpose, l.level_db, l.pan, l.poly, if (l.mute) "true" else "false", if (l.solo) "true" else "false", }); if (l.reg_idx) |ri| { try out.appendSlice(alloc, ",\"instrument\":"); @@ -567,7 +967,9 @@ pub const Rack = struct { } try out.appendSlice(alloc, "]}"); } - try out.appendSlice(alloc, "]}"); + try out.append(alloc, ']'); + try self.writeMacrosJson(out, alloc); + try out.append(alloc, '}'); } // ── audio ──────────────────────────────────────────────────────── @@ -697,44 +1099,89 @@ fn renderImpl(state: *anyopaque, ctx: *const machine.MachineCtx, l: []f32, r: [] @memset(l[0..frames], 0); @memset(r[0..frames], 0); self.adoptLanes(); - const in: []const NoteEvent = if (ctx.note_in) |p| p[0..ctx.note_in_count] else &.{}; + if (frames == 0) return; + + // Macros that move reach their targets every SMOOTH_CHUNK samples, as + // automation reaches a machine's own knobs; steady ones once a block. + const view: ?*const snapshot.AutoView = if (ctx.automation) |p| @ptrCast(@alignCast(p)) else null; + const auto = if (view) |v| v.any() else false; + if (!auto) @memset(self.auto_on[0..], false); + const mapped = self.versions.current().map_count > 0; + const chunk: usize = if (mapped and (auto or macrosGliding(self))) fy_raw.SMOOTH_CHUNK else frames; + const beats_per_sample = ctx.tempo_bpm / (60.0 * ctx.sample_rate); + var pos: usize = 0; + while (pos < frames) : (pos += chunk) { + const n = @min(chunk, frames - pos); + const beat = ctx.ppq_position + @as(f64, @floatFromInt(pos)) * beats_per_sample; + if (auto) evalAutomation(self, view.?, beat); + advanceMacros(self, n, ctx.sample_rate); + if (mapped or self.applied_n > 0) applyMaps(self); + renderLanes(self, ctx, pos, n, beat, in_l[pos..], in_r[pos..], l[pos..][0..n], r[pos..][0..n]); + } +} + +/// Frames [pos, pos+n) of the block through every lane, summed into +/// `l`/`r`. +fn renderLanes(self: *Rack, ctx: *const machine.MachineCtx, pos: usize, n: usize, beat: f64, in_l: []const f32, in_r: []const f32, l: []f32, r: []f32) void { + const whole = pos == 0 and n == ctx.block_size; + const all: []const NoteEvent = if (ctx.note_in) |p| p[0..ctx.note_in_count] else &.{}; + // This window's events, re-based; late offsets land in the last one. + var in: []const NoteEvent = all; + if (!whole) { + var c: usize = 0; + const last = pos + n >= ctx.block_size; + for (all) |e| { + const at: usize = e.sample_offset; + if ((at >= pos and at < pos + n) or (last and at >= pos + n)) { + if (c == self.chunk_events.len) break; + self.chunk_events[c] = e; + self.chunk_events[c].sample_offset = @intCast(@min(at, pos + n - 1) - pos); + c += 1; + } + } + in = self.chunk_events[0..c]; + } const total = self.latency(); + const chain_at: u8 = @intFromFloat(std.math.clamp(self.eff[CHAIN], 0, 127)); var solo = false; for (self.audio_lanes[0..self.audio_count]) |*p| solo = solo or p.solo; - for (self.audio_lanes[0..self.audio_count]) |*p| { + for (self.audio_lanes[0..self.audio_count], 0..) |*p, li| { // The lanes' machines aren't the track's: no automation reaches them. var sub = ctx.*; + sub.block_size = @intCast(n); + sub.ppq_position = beat; sub.automation = null; sub.note_out = null; sub.note_out_cap = 0; sub.note_out_count = null; sub.audio_in = null; sub.audio_in_count = 0; - // MUTE, or another lane's SOLO, closes the lane's input: no new - // notes, no audio in. What it holds rings out and is heard. - const open = !p.mute and (!solo or p.solo); + // MUTE, another lane's SOLO, or a CHAIN outside its zone closes the + // lane's input: no new notes, no audio in. What it holds rings out + // and is heard. + const open = !p.mute and (!solo or p.solo) and chain_at >= p.clo and chain_at <= p.chi; if (self.kind == .instrument) { - const n = Rack.route(p, in, &self.events, open); - sub.note_in = if (n > 0) &self.events else null; - sub.note_in_count = @intCast(n); - @memset(self.sl[0..frames], 0); - @memset(self.sr[0..frames], 0); + const k = Rack.route(p, in, &self.events, open); + sub.note_in = if (k > 0) &self.events else null; + sub.note_in_count = @intCast(k); + @memset(self.sl[0..n], 0); + @memset(self.sr[0..n], 0); const m = &p.chain.audio_machine; - m.render(m.state, &sub, self.sl[0..frames], self.sr[0..frames]); + m.render(m.state, &sub, self.sl[0..n], self.sr[0..n]); } else { sub.note_in = null; sub.note_in_count = 0; if (open) { - @memcpy(self.sl[0..frames], in_l[0..frames]); - @memcpy(self.sr[0..frames], in_r[0..frames]); + @memcpy(self.sl[0..n], in_l[0..n]); + @memcpy(self.sr[0..n], in_r[0..n]); } else { - @memset(self.sl[0..frames], 0); - @memset(self.sr[0..frames], 0); + @memset(self.sl[0..n], 0); + @memset(self.sr[0..n], 0); } } - var out = engine.RenderedPair{ .l = self.sl[0..frames], .r = self.sr[0..frames] }; + var out = engine.RenderedPair{ .l = self.sl[0..n], .r = self.sr[0..n] }; if (p.chain.audio_effect_count > 0) { - out = engine.renderEffects(p.chain, sub, self.sl[0..frames], self.sr[0..frames], self.fl[0..frames], self.fr[0..frames], .{ .on = true, .wake = p.held_n > 0 }); + out = engine.renderEffects(p.chain, sub, self.sl[0..n], self.sr[0..n], self.fl[0..n], self.fr[0..n], .{ .on = true, .wake = p.held_n > 0 }); } // Held back to the slowest lane. const d = total -| p.latency(); @@ -748,7 +1195,7 @@ fn renderImpl(state: *anyopaque, ctx: *const machine.MachineCtx, l: []f32, r: [] line.w = 0; p.delay = d; } - for (0..frames) |k| { + for (0..n) |k| { const w = (line.w + k) & mask; line.l[w] = out.l[k]; line.r[w] = out.r[k]; @@ -756,18 +1203,201 @@ fn renderImpl(state: *anyopaque, ctx: *const machine.MachineCtx, l: []f32, r: [] out.l[k] = line.l[rd]; out.r[k] = line.r[rd]; } - line.w = (line.w + frames) & mask; + line.w = (line.w + n) & mask; } - const g = std.math.pow(f32, 10, p.level_db / 20); - const gl = g * @min(1, 1 - p.pan); - const gr = g * @min(1, 1 + p.pan); - for (l[0..frames], r[0..frames], out.l, out.r) |*ol, *or_, a, b| { + const level = self.lane_level[li] orelse p.level_db; + const pan = self.lane_pan[li] orelse p.pan; + const g = std.math.pow(f32, 10, level / 20); + const gl = g * @min(1, 1 - pan); + const gr = g * @min(1, 1 + pan); + for (l, r, out.l, out.r) |*ol, *or_, a, b| { ol.* += a * gl; or_.* += b * gr; } } } +// ── macros, audio side ─────────────────────────────────────────────── + +fn handAudio(self: *const Rack, i: usize) f32 { + return @bitCast(self.hand[i].load(.monotonic)); +} + +/// Audio: a hand-set macro still gliding to where the hand left it. +fn macrosGliding(self: *const Rack) bool { + if (!self.primed) return false; + for (0..MAX_MACROS) |i| { + if (self.mod_on[i] or self.auto_on[i]) continue; + if (self.snaps[i].load(.monotonic) != self.seen_snaps[i]) continue; + if (self.cur[i] != handAudio(self, i)) return true; + } + return false; +} + +/// Audio: evaluate the rack's own lanes (its macros and CHAIN) at `beat`. +fn evalAutomation(self: *Rack, view: *const snapshot.AutoView, machine_beat: f64) void { + const beat = view.hold_beat orelse machine_beat; + @memset(self.auto_on[0..], false); + const snap = view.snap; + for (snap.lanes[0..snap.lane_count], 0..) |lane, li| { + if (!view.matches(lane) or lane.control >= CONTROLS) continue; + const lb = lane.localBeat(beat) orelse continue; + self.auto_on[lane.control] = true; + self.auto_val[lane.control] = automation.evalCursor(view.points(lane), lb, &view.cursors[li]); + } +} + +/// Audio: what each control plays over the next `n` samples. An outer +/// rack's macro wins, then a lane; a hand-set value glides as a knob does. +fn advanceMacros(self: *Rack, n: usize, sample_rate: f64) void { + const a: f32 = @floatCast(1.0 - @exp(-@as(f64, @floatFromInt(n)) / (fy_raw.SMOOTH_TAU_S * sample_rate))); + for (0..CONTROLS) |i| { + const target = handAudio(self, i); + const sn = self.snaps[i].load(.monotonic); + if (!self.primed or sn != self.seen_snaps[i] or i == CHAIN) { + self.cur[i] = target; + self.seen_snaps[i] = sn; + } else if (self.cur[i] != target) { + var c = self.cur[i] + (target - self.cur[i]) * a; + if (@abs(target - c) < fy_raw.SMOOTH_EPS) c = target; + self.cur[i] = c; + } + self.eff[i] = if (self.mod_on[i]) self.mod_val[i] else if (self.auto_on[i]) self.auto_val[i] else self.cur[i]; + } + self.primed = true; +} + +fn liveMachine(self: *Rack, st: *anyopaque) bool { + for (self.audio_lanes[0..self.audio_count]) |*p| { + if (p.chain.audio_machine.state == st) return true; + for (p.chain.audioEffects()) |*fx| if (fx.mach.state == st) return true; + } + return false; +} + +/// Audio: every mapping's target to its macro's value; controls no +/// mapping sets any more go back to their own. +fn applyMaps(self: *Rack) void { + const v = self.versions.current(); + @memset(self.lane_level[0..], null); + @memset(self.lane_pan[0..], null); + var now: [MAX_MAPS]Applied = undefined; + var nn: usize = 0; + const macros: *const [MAX_MACROS]f32 = self.eff[0..MAX_MACROS]; + for (v.maps[0..v.map_count]) |*m| { + if (m.lane >= self.audio_count) continue; + const val = m.value(macros[m.macro], macros); + const p = &self.audio_lanes[m.lane]; + switch (m.target) { + .level => self.lane_level[m.lane] = val, + .pan => self.lane_pan[m.lane] = val, + .control => { + var fx: ?*Effect = null; + const mach: *Machine = if (m.slot) |uid| blk: { + for (p.chain.audioEffects()) |*e| if (e.uid == uid) { + fx = e; + break :blk &e.mach; + }; + continue; + } else &p.chain.audio_machine; + const set = mach.set_mod orelse continue; + const was: ?f32 = for (self.applied[0..self.applied_n]) |x| { + if (x.state == mach.state and x.control == m.control) break x.value; + } else null; + // A quiet insert wakes for a new value. + if (fx) |e| if (was == null or was.? != val) { + e.quiet = 0; + }; + set(mach.state, m.control, val); + const dup = for (now[0..nn]) |*x| { + if (x.state == mach.state and x.control == m.control) break x; + } else null; + if (dup) |x| x.value = val else { + now[nn] = .{ .state = mach.state, .set = set, .control = m.control, .value = val }; + nn += 1; + } + }, + } + } + for (self.applied[0..self.applied_n]) |x| { + const kept = for (now[0..nn]) |y| { + if (y.state == x.state and y.control == x.control) break true; + } else false; + if (!kept and liveMachine(self, x.state)) x.set(x.state, x.control, null); + } + @memcpy(self.applied[0..nn], now[0..nn]); + self.applied_n = nn; +} + +// ── controls (docs/22) ─────────────────────────────────────────────── + +fn controlCountImpl(_: *anyopaque) usize { + return CONTROLS; +} + +fn controlInfoImpl(state: *anyopaque, i: usize) machine.ControlInfo { + const self: *Rack = @ptrCast(@alignCast(state)); + if (i == CHAIN) return .{ .id = CONTROL_IDS[i], .label = "CHAIN", .module = "MACROS", .stepped = true, .lo = 0, .hi = 127 }; + return .{ .id = CONTROL_IDS[@min(i, CHAIN)], .label = self.macroLabel(@min(i, CHAIN)), .module = "MACROS" }; +} + +/// Real units are knob space: a macro's 0..1, CHAIN's 0..127. +fn controlValueImpl(_: *anyopaque, _: usize, knob: f32) f64 { + return knob; +} + +fn controlKnobImpl(_: *anyopaque, i: usize, value: f64) f32 { + const v: f32 = @floatCast(value); + return if (i == CHAIN) std.math.clamp(@round(v), 0, 127) else std.math.clamp(v, 0, 1); +} + +fn controlBaseImpl(state: *anyopaque, i: usize) f32 { + const self: *Rack = @ptrCast(@alignCast(state)); + return if (i < CONTROLS) self.macroBase(i) else 0; +} + +fn formatControlImpl(_: *anyopaque, i: usize, knob: f32, buf: []u8) []const u8 { + if (i == CHAIN) return std.fmt.bufPrint(buf, "{d}", .{@as(i32, @intFromFloat(@round(knob)))}) catch "?"; + return std.fmt.bufPrint(buf, "{d}%", .{@as(i32, @intFromFloat(@round(knob * 100)))}) catch "?"; +} + +fn setAutoUiImpl(state: *anyopaque, i: usize, knob: ?f32) void { + const self: *Rack = @ptrCast(@alignCast(state)); + if (i < CONTROLS) self.ui_auto[i] = knob; +} + +fn takeTouchImpl(state: *anyopaque) ?machine.Touch { + const self: *Rack = @ptrCast(@alignCast(state)); + const t = self.touch; + self.touch = null; + return t; +} + +/// A rack in a lane: the outer rack's macro drives this one's. +fn setModImpl(state: *anyopaque, i: usize, knob: ?f32) void { + const self: *Rack = @ptrCast(@alignCast(state)); + if (i >= CONTROLS) return; + if (knob) |k| { + self.mod_on[i] = true; + self.mod_val[i] = if (i == CHAIN) std.math.clamp(@round(k), 0, 127) else std.math.clamp(k, 0, 1); + } else self.mod_on[i] = false; +} + +fn writeParamsJsonImpl(state: *anyopaque, out: *std.ArrayList(u8), alloc: std.mem.Allocator) anyerror!void { + const self: *Rack = @ptrCast(@alignCast(state)); + try out.append(alloc, '{'); + for (CONTROL_IDS, 0..) |cid, i| { + if (i > 0) try out.append(alloc, ','); + try out.print(alloc, "\"{s}\":{d}", .{ cid, self.macroBase(i) }); + } + try out.append(alloc, '}'); +} + +fn setParamImpl(state: *anyopaque, pid: []const u8, value: f64) void { + const self: *Rack = @ptrCast(@alignCast(state)); + for (CONTROL_IDS, 0..) |cid, i| if (std.mem.eql(u8, cid, pid)) self.setMacro(i, controlKnobImpl(state, i, value), true); +} + fn latencyImpl(state: *anyopaque) u32 { const self: *Rack = @ptrCast(@alignCast(state)); self.adoptLanes(); @@ -789,7 +1419,7 @@ fn tailImpl(state: *anyopaque, sample_rate: f64) u32 { /// landed on renders again however long it has been quiet. fn takeWakeImpl(state: *anyopaque) bool { const self: *Rack = @ptrCast(@alignCast(state)); - var any = false; + var any = self.wake.swap(false, .acquire); for (self.audio_lanes[0..self.audio_count]) |*l| { if (l.chain.audio_machine.takeWake()) any = true; for (l.chain.audioEffects()) |*fx| if (fx.mach.takeWake()) { @@ -956,7 +1586,7 @@ fn chainW(self: *Rack) i32 { fn panelWImpl(state: *anyopaque) f32 { const self: *Rack = @ptrCast(@alignCast(state)); - return @floatFromInt(SIDE_W + chainW(self)); + return @floatFromInt(MACRO_W + SIDE_W + chainW(self)); } fn noteName(buf: []u8, k: u8) []const u8 { @@ -1018,11 +1648,177 @@ fn drawPanelImpl(state: *anyopaque, ui: *Ui, rect: Rect) void { self.collect(); defer self.publishLanes(); var body = rect; + drawMacros(self, ui, body.cutLeft(MACRO_W)); const side = body.cutLeft(SIDE_W); drawLanes(self, ui, side); drawChain(self, ui, body); + pollMapTouch(self); } +// ── macro block ────────────────────────────────────────────────────── +// +// ┌ MACROS [MAP]┐ four knobs a row, two rows; the picked one is +// │ (o) (o) (o) (o) │ underlined. NAME names it. A row a mapping: +// │ (o) (o) (o) (o) │ × removes it, the label picks it, MIN and MAX +// │ NAME [DRIVE ] │ step its range in the target's units. f(x) +// │ × 1·DRIVE 2 30 │ edits the picked mapping's formula; red until +// │ × 1·LEVEL 0 -6 │ it parses (the old one keeps running). +// │ f(x) [x^2 ] │ +// └───────────────────┘ + +const MACRO_W: i32 = 184; +const RANGE_W: i32 = 44; +const KNOB_LABELS = [MAX_MACROS][]const u8{ "M1", "M2", "M3", "M4", "M5", "M6", "M7", "M8" }; + +fn drawMacros(self: *Rack, ui: *Ui, r: Rect) void { + var col = ui.plate(r, .{}).inset(3); + var head = col.cutTop(ROW_H); + _ = col.cutTop(2); + const mapb = head.cutRight(34); + _ = ui.engraved(&ui.fonts.legend, head.x + 2, head.y + 3, "MACROS", ui_style.text_dim); + if (ctl.button(ui, mapb, "map", &self.map_armed, .{ .kind = .latch, .label = "MAP", .flush = true, .lit = ui_style.accent })) self.map_held = null; + menu.tip(ui, mapb, "Map: click a control in the lane, or LV or PN, to put it on the picked macro"); + + const cell = ctl.knobCell(.m, true); + for (0..2) |ri| { + var row = col.cutTop(cell[1]); + const cw = @divFloor(row.w, 4); + for (0..4) |ci| { + const i = ri * 4 + ci; + const kr = if (ci == 3) row else row.cutLeft(cw); + ui.pushId(i); + defer ui.popId(); + var v = self.ui_auto[i] orelse self.macroBase(i); + const before = v; + var vb: [8]u8 = undefined; + const readout = std.fmt.bufPrint(&vb, "{d}", .{@as(i32, @intFromFloat(@round(v * 100)))}) catch "?"; + _ = ctl.knob(ui, kr, "knob", &v, .{ .size = .m, .label = if (self.macro_names[i].len > 0) self.macro_names[i].slice() else KNOB_LABELS[i], .readout = readout }); + if (v != before) self.setMacro(i, v, false); + if (ui.active == ui.id("knob")) { + self.touch = .{ .control = @intCast(i), .knob = v }; + if (self.sel_macro != i) self.sel_map = null; + self.sel_macro = i; + } + if (i == self.sel_macro) ui.rect(Rect.xywh(kr.x + 6, kr.bottom() - 1, kr.w - 12, 1), if (self.map_armed) ui_style.accent else ui_style.text_dim); + } + } + _ = col.cutTop(3); + const sel = self.sel_macro; + + // NAME + var nrow = col.cutTop(ROW_H); + _ = col.cutTop(2); + const nl = nrow.cutLeft(ui.fonts.legend.measure("NAME") + 4); + ui.textIn(&ui.fonts.legend, nl, "NAME", ui_style.text_dim, .left, true); + if (self.name_for != sel) { + self.name_tb.set(self.macro_names[sel].slice()); + self.name_for = sel; + } + switch (text_field.field(ui, nrow, "name", &self.name_tb, .{ .commit_on_blur = true })) { + .commit => if (!std.mem.eql(u8, self.name_tb.text(), self.macro_names[sel].slice())) { + beforeEdit(self); + self.macro_names[sel].set(std.mem.trim(u8, self.name_tb.text(), " ")); + self.name_for = null; + }, + .cancel => self.name_for = null, + else => {}, + } + + // f(x), at the bottom + var frow = col.cutBottom(ROW_H); + _ = col.cutBottom(2); + + // a row a mapping of the picked macro + var shown: usize = 0; + var k: usize = 0; + while (k < self.map_count) : (k += 1) { + const m = &self.maps[k]; + if (m.macro != sel) continue; + if (col.h < ROW_H) break; + shown += 1; + var row = col.cutTop(ROW_H); + _ = col.cutTop(1); + ui.pushId(.{ "map", k }); + defer ui.popId(); + const x = row.cutLeft(ROW_H); + _ = row.cutLeft(2); + if (ctl.button(ui, x, "rm", null, .{ .label = "\u{00d7}", .flush = true })) { + beforeEdit(self); + self.removeMapping(k); + self.sel_map = null; + self.formula_for = null; + break; + } + const hi_r = row.cutRight(RANGE_W); + _ = row.cutRight(2); + const lo_r = row.cutRight(RANGE_W); + _ = row.cutRight(2); + var lb: [40]u8 = undefined; + var on = self.sel_map == k; + const text = std.fmt.bufPrint(&lb, "{d}\u{00b7}{s}", .{ m.lane + 1, self.targetLabel(m) }) catch "?"; + if (ctl.button(ui, row, "pick", &on, .{ .kind = .latch, .label = text, .flush = true, .lit = if (m.bad) ui_style.led_red else ui_style.text })) { + self.sel_map = k; + self.formula_for = null; + } + inline for (.{ .{ "min", lo_r }, .{ "max", hi_r } }) |pr| { + var vb: [24]u8 = undefined; + const cur = @field(m, pr[0]); + const d = field(ui, pr[1], pr[0], "", self.formatTarget(m, cur, &vb), 10); + if (d != 0) { + beforeEdit(self); + @field(m, pr[0]) = self.stepTarget(m, cur, d); + } + } + } + if (shown == 0) { + const hint = if (self.map_armed) "CLICK A CONTROL TO MAP" else "NO MAPPINGS"; + ui.textIn(&ui.fonts.legend, col.cutTop(ROW_H), hint, ui_style.text_mute, .center, true); + } + + // the picked mapping's formula + const fm: ?*Mapping = if (self.sel_map) |i| (if (i < self.map_count and self.maps[i].macro == sel) &self.maps[i] else null) else null; + const fl = frow.cutLeft(ui.fonts.legend.measure("f(x)") + 4); + ui.textIn(&ui.fonts.legend, fl, "f(x)", if (fm != null and fm.?.bad) ui_style.led_red else ui_style.text_dim, .left, true); + const m = fm orelse { + ui.rect(frow, ui_style.well); + return; + }; + const mi = self.sel_map.?; + if (self.formula_for != mi) { + self.formula_tb.set(m.textSlice()); + self.formula_for = mi; + } + switch (text_field.field(ui, frow, .{ "formula", mi }, &self.formula_tb, .{ .commit_on_blur = true })) { + .commit => if (!std.mem.eql(u8, self.formula_tb.text(), m.textSlice())) { + beforeEdit(self); + Rack.setFormula(m, std.mem.trim(u8, self.formula_tb.text(), " ")); + self.formula_for = null; + }, + .cancel => self.formula_for = null, + else => {}, + } + menu.tip(ui, frow, "Shapes the macro x (0..1) before the range: x^2, 1 - x, x * (1 - m4), step(x, 3)"); +} + +/// MAP armed: the first frame a hand takes a control in the open lane, +/// that control goes on the picked macro over its whole range. +fn pollMapTouch(self: *Rack) void { + if (self.selected >= self.count) return; + const li = self.selected; + const l = &self.lanes[li]; + var held = false; + if (l.reg_idx != null) if (l.instrument.take_touch) |tt| if (tt(l.instrument.state)) |t| { + held = true; + self.mapTouched(li, null, l.instrument, t.control); + }; + for (l.effects.items) |*fx| if (fx.mach.take_touch) |tt| if (tt(fx.mach.state)) |t| { + held = true; + self.mapTouched(li, fx.uid, fx.mach, t.control); + }; + if (!held) self.map_held = null; +} + + fn drawLanes(self: *Rack, ui: *Ui, r: Rect) void { const inst = self.kind == .instrument; var side = ui.plate(r, .{}).inset(3); @@ -1131,9 +1927,15 @@ fn drawLanes(self: *Rack, ui: *Ui, r: Rect) void { at = 2; } const dl = field(ui, c2[at], "lvl", "LV", std.fmt.bufPrint(&buf, "{d:.0}", .{s.level_db}) catch "?", 6); - s.level_db = std.math.clamp(s.level_db + @as(f32, @floatFromInt(dl)), -60, 12); const dpan = field(ui, c2[at + 1], "pan", "PN", std.fmt.bufPrint(&buf, "{d:.1}", .{s.pan}) catch "?", 5); - s.pan = std.math.clamp(s.pan + 0.1 * @as(f32, @floatFromInt(dpan)), -1, 1); + if (self.map_armed) { + // MAP takes the click: the lane's level or pan goes on the macro. + if (dl != 0) self.mapLane(self.selected, .level); + if (dpan != 0) self.mapLane(self.selected, .pan); + } else { + s.level_db = std.math.clamp(s.level_db + @as(f32, @floatFromInt(dl)), -60, 12); + s.pan = std.math.clamp(s.pan + 0.1 * @as(f32, @floatFromInt(dpan)), -1, 1); + } if (@abs(s.pan) < 0.05) s.pan = 0; if (s.lo != p.lo or s.hi != p.hi or s.vlo != p.vlo or s.vhi != p.vhi or s.transpose != p.transpose or s.poly != p.poly or s.level_db != p.level_db or s.pan != p.pan) @@ -1561,5 +2363,224 @@ test "rack: racks nest MAX_DEPTH deep, no deeper" { var js: std.ArrayList(u8) = .empty; defer js.deinit(a); try top.writeJson(&js, a); - try testing.expect(std.mem.indexOf(u8, js.items, "\"machine\":\"fxrack\",\"state\":{\"rack\":2,\"kind\":\"effect\"") != null); + try testing.expect(std.mem.indexOf(u8, js.items, "\"machine\":\"fxrack\",\"params\":{\"rk-m1\":0,") != null); + try testing.expect(std.mem.indexOf(u8, js.items, "\"state\":{\"rack\":2,\"kind\":\"effect\"") != null); +} + +fn fyOf(m: Machine) *fy_raw.FyRawMachine { + return @ptrCast(@alignCast(m.state)); +} + +/// A sat2 FX Rack lane plus its registry, for the macro tests. +const SatRack = struct { + reg: registry_mod.Registry, + rack: *Rack, + m: Machine, + + fn init(self: *SatRack, a: std.mem.Allocator) !void { + self.reg = registry_mod.Registry.init(a); + try self.reg.loadFyMachine("machines/sat2/sat2.fy"); + try self.reg.loadNative(); + self.rack = try Rack.create(a, &self.reg, .effect); + self.m = self.rack.machineInterface(); + _ = try self.rack.addLane(self.reg.findById("sat2").?); + } + fn deinit(self: *SatRack, a: std.mem.Allocator) void { + self.m.deinit.?(self.m.state, a); + self.reg.deinit(); + } + fn sat(self: *SatRack) Machine { + return self.rack.lanes[0].effects.items[0].mach; + } +}; + +test "rack: macros, names and mappings with formulas round-trip as JSON" { + const a = testing.allocator; + var t: SatRack = undefined; + try t.init(a); + defer t.deinit(a); + const rack = t.rack; + const sat = t.sat(); + rack.macro_names[1].set("DRIVE"); + rack.setMacro(1, 0.25, true); + rack.setMacro(CHAIN, 3, true); + const drive = sat.controlIndex("sat-drive").?; + const m = try rack.addMapping(.{ .macro = 1, .lane = 0, .slot = rack.lanes[0].effects.items[0].uid, .control = @intCast(drive), .min = sat.control_knob.?(sat.state, drive, 0), .max = sat.control_knob.?(sat.state, drive, 24) }); + Rack.setFormula(m, "x^2"); + _ = try rack.addMapping(.{ .macro = 1, .lane = 0, .target = .level, .min = 0, .max = -12 }); + try testing.expectEqual(@as(usize, 2), rack.map_count); + // the same macro and target again replaces the mapping + _ = try rack.addMapping(.{ .macro = 1, .lane = 0, .target = .level, .min = 0, .max = -6 }); + try testing.expectEqual(@as(usize, 2), rack.map_count); + try testing.expectEqualStrings("DRIVE", controlInfoImpl(rack, 1).label); + try testing.expectEqualStrings("MACRO 3", controlInfoImpl(rack, 2).label); + + var js: std.ArrayList(u8) = .empty; + defer js.deinit(a); + try rack.writeJson(&js, a); + try testing.expect(std.mem.indexOf(u8, js.items, "\"maps\":[{\"macro\":2,\"lane\":1,\"insert\":1,\"control\":\"sat-drive\",\"min\":0,\"max\":24,\"formula\":\"x^2\"},{\"macro\":2,\"lane\":1,\"target\":\"level\",\"min\":0,\"max\":-6}]") != null); + try testing.expect(std.mem.indexOf(u8, js.items, "\"chain\":3") != null); + + const back = try Rack.create(a, &t.reg, .effect); + const bm = back.machineInterface(); + defer bm.deinit.?(bm.state, a); + var parsed = try std.json.parseFromSlice(std.json.Value, a, js.items, .{}); + defer parsed.deinit(); + back.applyJson(parsed.value); + try testing.expectEqual(@as(usize, 2), back.map_count); + try testing.expectEqualStrings("x^2", back.maps[0].textSlice()); + try testing.expectEqual(@as(f32, 0.25), back.macroBase(1)); + try testing.expectEqual(@as(f32, 3), back.macroBase(CHAIN)); + var js2: std.ArrayList(u8) = .empty; + defer js2.deinit(a); + try back.writeJson(&js2, a); + try testing.expectEqualStrings(js.items, js2.items); + + // removing the insert drops its mapping, the lane's level one stays + rack.removeEffect(0, 0); + try testing.expectEqual(@as(usize, 1), rack.map_count); + try testing.expectEqual(TargetKind.level, rack.maps[0].target); +} + +/// Blocks of 256 of `in` through effect `m`, the beat running at 120 BPM +/// from 0, with `view` as its automation. +fn processAuto(m: Machine, in: []const f32, out_l: []f32, out_r: []f32, view: ?*const snapshot.AutoView) void { + var at: usize = 0; + const bps = 120.0 / (60.0 * 48000.0); + while (at + 256 <= in.len) : (at += 256) { + var ctx = testCtx(&.{}, 256); + ctx.ppq_position = @as(f64, @floatFromInt(at)) * bps; + const ports = [_][*]const f32{ in[at..].ptr, in[at..].ptr }; + ctx.audio_in = &ports; + ctx.audio_in_count = 2; + ctx.automation = view; + if (m.adopt_updates) |f| f(m.state); + m.render(m.state, &ctx, out_l[at..][0..256], out_r[at..][0..256]); + } +} + +test "rack: an automated macro sweeps its target as automating the target does" { + const a = testing.allocator; + var t: SatRack = undefined; + try t.init(a); + defer t.deinit(a); + const alone = try t.reg.instantiate(t.reg.findById("sat2").?); + defer alone.deinit.?(alone.state, a); + alone.reset(alone.state); + const drive = alone.controlIndex("sat-drive").?; + _ = try t.rack.addMapping(.{ .macro = 0, .lane = 0, .slot = t.rack.lanes[0].effects.items[0].uid, .control = @intCast(drive), .min = 0, .max = 1 }); + + // One lane 0 → 1 over a beat, on sat-drive alone and on MACRO 1. + var snap = std.mem.zeroes(snapshot.TrackSnapshot); + snap.auto_points[0] = .{ .beat = 0, .value = 0 }; + snap.auto_points[1] = .{ .beat = 1, .value = 1 }; + snap.auto_point_count = 2; + snap.lanes[0] = .{ .kind = .inst, .control = @intCast(drive), .points_start = 0, .points_count = 2 }; + snap.lane_count = 1; + var cursors = [_]u32{0} ** snapshot.MAX_LANES_PER_TRACK; + const view = snapshot.AutoView{ .snap = &snap, .cursors = &cursors, .kind = .inst }; + var snap2 = snap; + snap2.lanes[0].control = 0; + var cursors2 = [_]u32{0} ** snapshot.MAX_LANES_PER_TRACK; + const view2 = snapshot.AutoView{ .snap = &snap2, .cursors = &cursors2, .kind = .inst }; + + var in: [256 * 120]f32 = undefined; + for (&in, 0..) |*x, i| x.* = 0.3 * @sin(@as(f32, @floatFromInt(i)) * 0.031); + const al = try a.alloc(f32, in.len); + defer a.free(al); + const ar = try a.alloc(f32, in.len); + defer a.free(ar); + const bl = try a.alloc(f32, in.len); + defer a.free(bl); + const br = try a.alloc(f32, in.len); + defer a.free(br); + processAuto(alone, &in, al, ar, &view); + processAuto(t.m, &in, bl, br, &view2); + // it did sweep: the end is driven harder than the start + try testing.expect(rms(al[al.len - 4096 ..]) > 1.5 * rms(al[0..4096])); + try testing.expectEqualSlices(f32, al, bl); + try testing.expectEqualSlices(f32, ar, br); +} + +test "rack: a formula shapes the macro, a stepped target snaps, unmapping lets go" { + const a = testing.allocator; + var t: SatRack = undefined; + try t.init(a); + defer t.deinit(a); + const rack = t.rack; + const sat = t.sat(); + const f = fyOf(sat); + const uid = rack.lanes[0].effects.items[0].uid; + const mix = sat.controlIndex("sat-mix").?; + const mode = sat.controlIndex("sat-mode").?; + const m = try rack.addMapping(.{ .macro = 0, .lane = 0, .slot = uid, .control = @intCast(mix), .min = 0.2, .max = 0.6 }); + Rack.setFormula(m, "1 - x"); + const sw = try rack.addMapping(.{ .macro = 0, .lane = 0, .slot = uid, .control = @intCast(mode), .min = 0, .max = 3 }); + try testing.expect(sw.stepped); + rack.setMacro(0, 0.25, true); + rack.publishLanes(); + var in: [256 * 4]f32 = @splat(0.1); + var ol: [in.len]f32 = undefined; + var or_: [in.len]f32 = undefined; + process(t.m, &in, &ol, &or_); + try testing.expect(f.mod_on[mix]); + try testing.expectApproxEqAbs(@as(f32, 0.2 + 0.4 * 0.75), f.mod_val[mix], 1e-6); + try testing.expectEqual(@as(f32, 1), f.mod_val[mode]); // round(0.75) + try testing.expectApproxEqAbs(@as(f32, 0.5), f.smooth_norm[mix], 1e-6); + // A typo keeps the old program running. + Rack.setFormula(&rack.maps[0], "1 - "); + try testing.expect(rack.maps[0].bad); + try testing.expectApproxEqAbs(@as(f32, 0.5), rack.maps[0].value(0.25, &@as([MAX_MACROS]f32, @splat(0))), 1e-6); + rack.removeMapping(0); + rack.publishLanes(); + process(t.m, &in, &ol, &or_); + try testing.expect(!f.mod_on[mix]); + try testing.expect(f.mod_on[mode]); +} + +test "rack: CHAIN closes the lanes whose zone it leaves" { + const a = testing.allocator; + var t: SatRack = undefined; + try t.init(a); + defer t.deinit(a); + t.rack.lanes[0].clo = 10; + t.rack.lanes[0].chi = 20; + t.rack.publishLanes(); + var in: [256 * 8]f32 = undefined; + for (&in, 0..) |*x, i| x.* = 0.3 * @sin(@as(f32, @floatFromInt(i)) * 0.031); + var ol: [in.len]f32 = undefined; + var or_: [in.len]f32 = undefined; + process(t.m, &in, &ol, &or_); + try testing.expect(rms(ol[1024..]) < 1e-6); + t.rack.setMacro(CHAIN, 15, false); + process(t.m, &in, &ol, &or_); + try testing.expect(rms(ol[1024..]) > 0.05); +} + +test "rack: a macro reaches through a nested rack's macro" { + const a = testing.allocator; + var reg = registry_mod.Registry.init(a); + defer reg.deinit(); + try reg.loadFyMachine("machines/sat2/sat2.fy"); + try reg.loadNative(); + const top = try Rack.create(a, ®, .effect); + const m = top.machineInterface(); + defer m.deinit.?(m.state, a); + _ = try top.addLane(reg.findById("fxrack").?); + const inner_m = top.lanes[0].effects.items[0].mach; + const inner: *Rack = @ptrCast(@alignCast(inner_m.state)); + _ = try inner.addLane(reg.findById("sat2").?); + const sat = inner.lanes[0].effects.items[0].mach; + const mix = sat.controlIndex("sat-mix").?; + _ = try inner.addMapping(.{ .macro = 2, .lane = 0, .slot = inner.lanes[0].effects.items[0].uid, .control = @intCast(mix), .min = 0, .max = 1 }); + _ = try top.addMapping(.{ .macro = 0, .lane = 0, .slot = top.lanes[0].effects.items[0].uid, .control = 2, .min = 0, .max = 0.5 }); + inner.publishLanes(); + top.publishLanes(); + top.setMacro(0, 1, true); + var in: [256 * 4]f32 = @splat(0.1); + var ol: [in.len]f32 = undefined; + var or_: [in.len]f32 = undefined; + process(m, &in, &ol, &or_); + try testing.expectEqual(@as(f32, 0.5), inner.eff[2]); + try testing.expectEqual(@as(f32, 0.5), fyOf(sat).mod_val[mix]); } diff --git a/src/main.zig b/src/main.zig index 9176b05a..1024efa0 100644 --- a/src/main.zig +++ b/src/main.zig @@ -104,6 +104,7 @@ test { _ = @import("audio.zig"); _ = @import("machines/fy_raw_machine.zig"); _ = @import("machines/rack.zig"); + _ = @import("formula.zig"); _ = @import("ui/sprites.zig"); _ = @import("ui/core.zig"); _ = @import("ui/controls.zig"); From 3d69e988c846e012b3d3eae941f021d1ce9e88ed Mon Sep 17 00:00:00 2001 From: Marcin Gasperowicz Date: Wed, 21 Oct 2026 19:57:50 +0100 Subject: [PATCH 7/9] build: move to Zig 0.17 0.17 drops `b.args`, `getInstallPath`, `**` array repetition, `@cImport`, `bufPrintZ`/`dupeZ`, `std.meta.fields`, `std.enums.values` and extern struct `@bitCast`, and reshapes `@typeInfo` and `builtin.mode`. Arrays fill with `@splat`; C headers are translated by the build (src/c_*.h, fy/src/c.h); `@hasDecl` sees only pub decls, so the tune test's context makes `tuneAt` pub. Tests (523 + fy's 82) pass, and every bench render hashes the same under 0.16 and 0.17. Fixes #3. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 7 +- CHANGELOG.md | 3 + README.md | 2 +- build.zig | 28 ++- build.zig.zon | 2 +- docs/33-composing.md | 2 +- fy/build.zig | 13 +- fy/build.zig.zon | 2 +- fy/src/c.h | 13 ++ fy/src/compat.zig | 9 +- fy/src/dsp.zig | 4 +- fy/src/dsp2.zig | 8 +- fy/src/main.zig | 27 +-- fy/src/tests.zig | 28 +-- src/audio.zig | 2 +- src/audio_pool.zig | 2 +- src/automation.zig | 2 +- src/bench/plot.zig | 2 +- src/bench_main.zig | 8 +- src/c.zig | 10 +- src/c_miniaudio.h | 2 + src/c_raylib.h | 2 + src/chain.zig | 2 +- src/chords.zig | 13 +- src/clip.zig | 4 +- src/document.zig | 16 +- src/drums.zig | 15 +- src/dx7_algorithms.zig | 2 +- src/dx7_voice_render_test.zig | 16 +- src/dx7_voice_test.zig | 8 +- src/engine.zig | 42 ++-- src/export.zig | 4 +- src/export_settings.zig | 20 +- src/exporter.zig | 2 +- src/extract.zig | 2 +- src/fft.zig | 2 +- src/fy_host.zig | 12 +- src/kernel_probe.zig | 6 +- src/keymap.zig | 20 +- src/machine.zig | 2 +- src/machine_desc.zig | 58 ++--- src/machine_registry.zig | 16 +- src/machines/fy_raw_machine.zig | 360 ++++++++++++++++---------------- src/machines/rack.zig | 24 +-- src/main.zig | 28 +-- src/meter.zig | 4 +- src/ml.zig | 2 +- src/moog_ladder_test.zig | 12 +- src/native_app.zig | 4 +- src/native_dialog.zig | 14 +- src/oversample_test.zig | 6 +- src/package.zig | 6 +- src/packs.zig | 2 +- src/pitch.zig | 2 +- src/presets.zig | 12 +- src/preview.zig | 2 +- src/recorder.zig | 16 +- src/storage.zig | 14 +- src/stretch.zig | 42 ++-- src/track.zig | 4 +- src/transients.zig | 2 +- src/tune.zig | 2 +- src/ui/audio_clip_editor.zig | 2 +- src/ui/browser.zig | 10 +- src/ui/clip_editor.zig | 2 +- src/ui/core.zig | 10 +- src/ui/draw.zig | 10 +- src/ui/export_dialog.zig | 11 +- src/ui/font.zig | 2 +- src/ui/gallery_browser.zig | 12 +- src/ui/gallery_concoction.zig | 4 +- src/ui/machine_bay.zig | 4 +- src/ui/menu.zig | 2 +- src/ui/preset_browser.zig | 2 +- src/ui/sprites.zig | 4 +- src/ui/transport_bar.zig | 6 +- src/warp.zig | 6 +- src/wav.zig | 4 +- src/waveform.zig | 4 +- src/wavetable.zig | 2 +- src/wavetable_cache.zig | 2 +- src/wavetable_edit.zig | 8 +- src/wavetable_file.zig | 6 +- 83 files changed, 577 insertions(+), 534 deletions(-) create mode 100644 fy/src/c.h create mode 100644 src/c_miniaudio.h create mode 100644 src/c_raylib.h diff --git a/AGENTS.md b/AGENTS.md index 155d7e44..7dfdd88f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 691c14fe..c6e2bbc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ 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). + - **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 diff --git a/README.md b/README.md index b6c39827..be243690 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/build.zig b/build.zig index 23324146..c95bde8e 100644 --- a/build.zig +++ b/build.zig @@ -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. @@ -29,7 +29,7 @@ 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"), @@ -37,7 +37,7 @@ pub fn build(b: *std.Build) void { .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", @@ -45,7 +45,7 @@ pub fn build(b: *std.Build) void { }); 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); @@ -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); @@ -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); @@ -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", .{}); diff --git a/build.zig.zon b/build.zig.zon index c551b4b6..3a5dfcfa 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -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 = .{ diff --git a/docs/33-composing.md b/docs/33-composing.md index e038880b..9c3da297 100644 --- a/docs/33-composing.md +++ b/docs/33-composing.md @@ -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 diff --git a/fy/build.zig b/fy/build.zig index 686950ee..95718ef7 100644 --- a/fy/build.zig +++ b/fy/build.zig @@ -22,6 +22,14 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); + // C headers (zig 0.17 has no @cImport): src/c.h becomes module "c". + const c_mod = b.addTranslateC(.{ + .root_source_file = b.path("src/c.h"), + .target = target, + .optimize = optimize, + }).createModule(); + fy_mod.addImport("c", c_mod); + // Static library artifact — for C consumers of the C-ABI. const lib = b.addLibrary(.{ .name = "fy", @@ -66,7 +74,7 @@ pub fn build(b: *std.Build) void { .root_module = bench_mod, }); const run_bench = b.addRunArtifact(bench_exe); - if (b.args) |args| run_bench.addArgs(args); + run_bench.addPassthruArgs(); if (builtin.os.tag == .macos) { const sign_bench_enabled = b.option(bool, "codesign-bench", "Codesign benchmark executable for macOS JIT (default: false)") orelse false; @@ -94,6 +102,7 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, }); + exe_mod.addImport("c", c_mod); const exe = b.addExecutable(.{ .name = "fy", .root_module = exe_mod, @@ -102,7 +111,7 @@ pub fn build(b: *std.Build) void { 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 fy CLI"); run_step.dependOn(&run_cmd.step); diff --git a/fy/build.zig.zon b/fy/build.zig.zon index 6a9229b5..3808c326 100644 --- a/fy/build.zig.zon +++ b/fy/build.zig.zon @@ -2,7 +2,7 @@ .name = .fy, .version = "0.0.1", .fingerprint = 0x5ba71746e1bb417b, - .minimum_zig_version = "0.16.0", + .minimum_zig_version = "0.17.0", .paths = .{ "src/", "build.zig", "build.zig.zon" }, // zigline was used by the REPL (src/main.zig `repl` fn). It hasn't been // ported to zig 0.16 yet. When the CLI is revived, re-add it here and diff --git a/fy/src/c.h b/fy/src/c.h new file mode 100644 index 00000000..30737c40 --- /dev/null +++ b/fy/src/c.h @@ -0,0 +1,13 @@ +// C headers fy calls into, translated by the build (zig 0.17 dropped @cImport). +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef __APPLE__ +#include +#include +#endif diff --git a/fy/src/compat.zig b/fy/src/compat.zig index 0a572daa..4967bd63 100644 --- a/fy/src/compat.zig +++ b/fy/src/compat.zig @@ -125,14 +125,7 @@ pub fn stdinReader() FdReader { // synchronous blocking; we call libc directly. // ------------------------------------------------------------------ -const c = @cImport({ - @cInclude("fcntl.h"); - @cInclude("unistd.h"); - @cInclude("sys/stat.h"); - @cInclude("dirent.h"); - @cInclude("stdlib.h"); - @cInclude("errno.h"); -}); +const c = @import("c"); // src/c.h, translated by build.zig pub const FileError = error{ OpenFailed, diff --git a/fy/src/dsp.zig b/fy/src/dsp.zig index dea8d858..bb924ebd 100644 --- a/fy/src/dsp.zig +++ b/fy/src/dsp.zig @@ -243,9 +243,9 @@ fn writeF64ScalarLiteralReturn(code: *compat.ArrayList(u32), value: f64) bool { } fn optimizeF64LiteralOps(code: *compat.ArrayList(u32)) bool { - var x_known = [_]bool{false} ** 32; + var x_known: [32]bool = @splat(false); var x_value: [32]u64 = undefined; - var d_known = [_]bool{false} ** 32; + var d_known: [32]bool = @splat(false); var d_value: [32]f64 = undefined; var saw_float_op = false; diff --git a/fy/src/dsp2.zig b/fy/src/dsp2.zig index 3a30d40b..a0db3b11 100644 --- a/fy/src/dsp2.zig +++ b/fy/src/dsp2.zig @@ -1894,10 +1894,10 @@ const Codegen = struct { // Spilling state. owner_*: the value living in a register, if any (only // pool registers; raw arg registers are never owned). Pins protect a // register from eviction while an instruction still needs it. - owner_d: [32]?usize = [_]?usize{null} ** 32, - owner_x: [32]?usize = [_]?usize{null} ** 32, - pin_d: [32]u16 = [_]u16{0} ** 32, - pin_x: [32]u16 = [_]u16{0} ** 32, + owner_d: [32]?usize = @splat(null), + owner_x: [32]?usize = @splat(null), + pin_d: [32]u16 = @splat(0), + pin_x: [32]u16 = @splat(0), pin_log: [1024]u8 = undefined, pin_len: usize = 0, trace: ?*compat.ArrayList(usize) = null, diff --git a/fy/src/main.zig b/fy/src/main.zig index 594e1ac8..40d3bd4a 100644 --- a/fy/src/main.zig +++ b/fy/src/main.zig @@ -1,17 +1,10 @@ const std = @import("std"); const builtin = @import("builtin"); const darwin = builtin.os.tag == .macos; -const darwin_c = if (darwin) @cImport({ - @cInclude("sys/mman.h"); - @cInclude("pthread.h"); -}) else struct {}; -const posix_dl = @cImport({ - @cInclude("dlfcn.h"); -}); -const c_std = @cImport({ - @cInclude("stdlib.h"); - @cInclude("stdio.h"); -}); +const c_headers = @import("c"); // src/c.h, translated by build.zig +const darwin_c = if (darwin) c_headers else struct {}; +const posix_dl = c_headers; +const c_std = c_headers; // NOTE: zigline dependency removed pending 0.16 port — the REPL is stubbed // out below. The fy library target (src/lib.zig) does not need it. const Asm = @import("asm.zig"); @@ -759,10 +752,10 @@ pub const Fy = struct { @setEvalBranchQuota(4000); const T = @TypeOf(fun); const typeinfo = @typeInfo(T).@"fn"; - const paramCount = typeinfo.params.len; + const paramCount = typeinfo.param_types.len; var returnCount = 0; for (0..paramCount) |i| { - if (typeinfo.params[i].type.? != Value) { + if (typeinfo.param_types[i].? != Value) { @compileError("fnToWord: param {} must be Value"); } } @@ -778,7 +771,7 @@ pub const Fy = struct { // 1 for call slot, 1 for each param, 1 for each return value const codeLen: usize = 1 + paramCount + returnCount; - var code = [1]u32{undefined} ** codeLen; + var code: [codeLen]u32 = @splat(undefined); // pop all params for (0..paramCount) |i| { @@ -1276,7 +1269,7 @@ pub const Fy = struct { pub const Dsp2RawRepeatedSlots = extern struct { iterations: u64 = 0, - arg_bits: [Dsp2.RAW_X_ARG_REGS.len]u64 = [_]u64{0} ** Dsp2.RAW_X_ARG_REGS.len, + arg_bits: [Dsp2.RAW_X_ARG_REGS.len]u64 = @splat(0), }; pub const Dsp2RawRepeatedCaller = struct { @@ -2248,7 +2241,7 @@ pub const Fy = struct { stride: u12, ) !Dsp2RawRepeatedCaller { if (lane_args.len == 0 or lane_args[0] != .pair) return error.LaneUnsupported; - var used = [_]bool{false} ** Dsp2.RAW_X_ARG_REGS.len; + var used: [Dsp2.RAW_X_ARG_REGS.len]bool = @splat(false); for (lane_args) |la| switch (la) { .uniform => |r| { if (r >= used.len) return error.RegisterExhausted; @@ -5456,7 +5449,7 @@ pub const Fy = struct { } fn fileExists(alloc: std.mem.Allocator, path: []const u8) bool { - const z = alloc.dupeZ(u8, path) catch return false; + const z = alloc.dupeSentinel(u8, path, 0) catch return false; defer alloc.free(z); return std.c.access(z, 0) == 0; } diff --git a/fy/src/tests.zig b/fy/src/tests.zig index 124ca8f5..6e997486 100644 --- a/fy/src/tests.zig +++ b/fy/src/tests.zig @@ -969,7 +969,7 @@ test "dsp: raw repeated wrapper can advance output and input streams" { \\; ); - var out = [_]f64{0} ** 4; + var out: [4]f64 = @splat(0); var input = [_]f64{ 0.25, -0.5, 0.75, -1.0 }; var state: f64 = 0; var params: f64 = 0; @@ -1013,7 +1013,7 @@ test "dsp: cached raw repeated caller reuses wrapper with new slots" { var state: f64 = 0; var params: f64 = 0; - var out_a = [_]f64{0} ** 2; + var out_a: [2]f64 = @splat(0); var in_a = [_]f64{ 0.5, -0.25 }; const args_a = [_]Fy.Dsp2RawArg{ .{ .ptr = @intFromPtr(&out_a[0]) }, @@ -1025,7 +1025,7 @@ test "dsp: cached raw repeated caller reuses wrapper with new slots" { try std.testing.expectApproxEqAbs(1.0, out_a[0], 0.000000000001); try std.testing.expectApproxEqAbs(-0.5, out_a[1], 0.000000000001); - var out_b = [_]f64{0} ** 3; + var out_b: [3]f64 = @splat(0); var in_b = [_]f64{ 1.0, 1.25, -1.5 }; const args_b = [_]Fy.Dsp2RawArg{ .{ .ptr = @intFromPtr(&out_b[0]) }, @@ -1402,7 +1402,7 @@ test "dsp: composition repeated caller loops with auto-advanced output" { var slots: Fy.Dsp2RawRepeatedSlots = .{}; var caller = try fy.compileDsp2CompositionCaller("t-rep", &slots, 8, false); - var out = [_]f64{0} ** 4; + var out: [4]f64 = @splat(0); const args = [_]Fy.Dsp2RawArg{.{ .ptr = @intFromPtr(&out[0]) }}; _ = try caller.call(4, &args); for (out) |v| try std.testing.expectApproxEqAbs(1.5, v, 0.000000000001); @@ -1429,7 +1429,7 @@ test "dsp: 4-arg composition caller with auto-advanced out and in" { var slots: Fy.Dsp2RawRepeatedSlots = .{}; var caller = try fy.compileDsp2CompositionCaller("t-fx", &slots, 8, true); - var out = [_]f64{0} ** 4; + var out: [4]f64 = @splat(0); const in = [_]f64{ 1.0, 2.0, 3.0, 4.0 }; var state: f64 = 0; var params: f64 = 10.0; @@ -1714,7 +1714,7 @@ test "dsp: n [ ... ] times unrolls into straight-line code" { _ = try fy.callDsp2RawRepeatedWithArgsNoResult("t-fwd", 1, &fwd_args); try std.testing.expectEqual(@as(f64, 5), out); try std.testing.expectEqual(@as(f64, 5), buf[2]); - var far = [_]f64{0} ** 1001; + var far: [1001]f64 = @splat(0); far[1000] = 2.5; far[3] = 0.25; const far_args = [_]Fy.Dsp2RawArg{ .{ .ptr = @intFromPtr(&out) }, .{ .ptr = @intFromPtr(&far) } }; @@ -1755,7 +1755,7 @@ test "ustruct: counted and embedded fields" { try std.testing.expectEqual(@as(i64, 40), Fy.getInt(try fy.run("Box.p"))); try std.testing.expectEqual(@as(i64, 56), Fy.getInt(try fy.run("Box.y"))); - var box = [_]f64{0} ** 8; + var box: [8]f64 = @splat(0); box[4] = 1.5; // taps[3] const args = [_]Fy.Dsp2RawArg{.{ .ptr = @intFromPtr(&box) }}; _ = try fy.callDsp2RawRepeatedWithArgsNoResult("t-box", 1, &args); @@ -2031,12 +2031,12 @@ test "dsp ifte: versioned and if-converted bodies both compute what the arms say var ctx = [_]f64{0}; var ref_io: [48]IfIo = undefined; ifInputs(&ref_io); - var ref_st = [_]f64{ 0.5, 0 } ++ [_]f64{0} ** 6; + var ref_st = [_]f64{ 0.5, 0 } ++ @as([6]f64, @splat(0)); ifRef(&ref_io, &ref_st, ¶ms); for ([_]bool{ true, false }) |versioning| { var ios: [48]IfIo = undefined; ifInputs(&ios); - var st = [_]f64{ 0.5, 0 } ++ [_]f64{0} ** 6; + var st = [_]f64{ 0.5, 0 } ++ @as([6]f64, @splat(0)); try ifRun(&fy, "k-if1", versioning, &ios, &ctx, &st, ¶ms); try expectSameBits(@ptrCast(&ref_io), @ptrCast(&ios)); try expectSameBits(&ref_st, &st); @@ -2078,7 +2078,7 @@ test "dsp ifte: a mask from stored state or the advancing io if-converts, from c // if-converted body follows it sample by sample. var ios: [64]IfIo = undefined; ifInputs(&ios); - var st = [_]f64{0.2} ++ [_]f64{0} ** 7; + var st = [_]f64{0.2} ++ @as([7]f64, @splat(0)); var ctx = [_]f64{0}; var params = [_]f64{0}; try ifRun(&fy, "k-g-state", true, &ios, &ctx, &st, ¶ms); @@ -2273,11 +2273,11 @@ test "dsp ifte: indexed stores in converted arms happen only where their arm is for ([_]bool{ true, false }) |versioning| { var ios: [48]IfIo = undefined; ifInputs(&ios); - var st = [_]f64{0} ** 9; + var st: [9]f64 = @splat(0); var ctx = [_]f64{0}; var params = [_]f64{0}; try ifRun(&fy, "k-cst", versioning, &ios, &ctx, &st, ¶ms); - var ring = [_]f64{0} ** 8; + var ring: [8]f64 = @splat(0); var w: usize = 0; for (ios) |f| { try std.testing.expectEqual(ring[w], f.out); @@ -2294,7 +2294,7 @@ test "dsp ifte: indexed stores in converted arms happen only where their arm is // One conditional store and nothing else: the unstashed path. ifInputs(&ios); - var st1 = [_]f64{0} ** 9; + var st1: [9]f64 = @splat(0); st1[0] = 3; try ifRun(&fy, "k-cst1", versioning, &ios, &ctx, &st1, ¶ms); var last: f64 = 0; @@ -2335,7 +2335,7 @@ test "dsp ifte: spilling bodies and compound masks dispatch to the right variant var sts: [2][8]f64 = undefined; for ([_]bool{ true, false }, 0..) |versioning, v| { ifInputs(&outs[v]); - sts[v] = [_]f64{0} ** 8; + sts[v] = @as([8]f64, @splat(0)); var ctx = [_]f64{0}; try ifRun(&fy, "k-sbig", versioning, &outs[v], &ctx, &sts[v], ¶ms); } diff --git a/src/audio.zig b/src/audio.zig index 62a0f01f..0fc5a74e 100644 --- a/src/audio.zig +++ b/src/audio.zig @@ -32,7 +32,7 @@ pub const MA_NAME_CAP = 255; pub const MAX_INPUT_DEVICES = 32; pub const InputDevice = struct { id: c.ma.ma_device_id, - name: [MA_NAME_CAP:0]u8 = [_:0]u8{0} ** MA_NAME_CAP, + name: [MA_NAME_CAP:0]u8 = @splat(0), }; const AudioObjectPropertyAddress = extern struct { selector: u32, scope: u32, element: u32 }; diff --git a/src/audio_pool.zig b/src/audio_pool.zig index 77393c1c..937e7976 100644 --- a/src/audio_pool.zig +++ b/src/audio_pool.zig @@ -61,7 +61,7 @@ pub const Source = struct { cache: waveform.PeakCache = .{}, cache_l: waveform.PeakCache = .{}, cache_r: waveform.PeakCache = .{}, - path_buf: [MAX_PATH]u8 = [_]u8{0} ** MAX_PATH, + path_buf: [MAX_PATH]u8 = @splat(0), path_len: u16 = 0, name_off: u16 = 0, // basename start within path_buf /// Heap-owned so it stays put when the pool's list grows. diff --git a/src/automation.zig b/src/automation.zig index f7bfa451..b0d78222 100644 --- a/src/automation.zig +++ b/src/automation.zig @@ -111,7 +111,7 @@ pub const TargetKind = enum(u8) { volume, pan, inst, fx }; pub const Target = struct { kind: TargetKind, fx_uid: u16 = 0, - param_buf: [MAX_PARAM_ID]u8 = [_]u8{0} ** MAX_PARAM_ID, + param_buf: [MAX_PARAM_ID]u8 = @splat(0), param_len: u8 = 0, pub fn volume() Target { diff --git a/src/bench/plot.zig b/src/bench/plot.zig index 6e545a59..2eca3c63 100644 --- a/src/bench/plot.zig +++ b/src/bench/plot.zig @@ -127,7 +127,7 @@ pub const Canvas = struct { } pub fn savePng(self: *Canvas, alloc: std.mem.Allocator, path: []const u8) !void { - const z = try alloc.dupeZ(u8, path); + const z = try alloc.dupeSentinel(u8, path, 0); defer alloc.free(z); const img = c.rl.Image{ .data = self.px.ptr, diff --git a/src/bench_main.zig b/src/bench_main.zig index 9a64c69d..f4e6d176 100644 --- a/src/bench_main.zig +++ b/src/bench_main.zig @@ -293,7 +293,7 @@ fn runMachine(alloc: std.mem.Allocator, cli: *const Cli, path: []const u8) !usiz const kind: []const u8 = if (isEffect(probe)) "effect" else if (isDrum(probe)) "drum voice" else "voice"; try rep.print(alloc, "# bench: {s} ({s}, {s}, {d} voices)\n", .{ name, probe.desc.name[0..probe.desc.name_len], kind, probe.desc.voices }); try rep.print(alloc, "sr {d} block {d} build {s}", .{ @as(u32, @intFromFloat(SR)), BLOCK, @tagName(@import("builtin").mode) }); - if (@import("builtin").mode == .Debug) try rep.print(alloc, " (cpu numbers include Debug host overhead; use -Doptimize=ReleaseFast)", .{}); + if (@import("builtin").mode == .debug) try rep.print(alloc, " (cpu numbers include Debug host overhead; use -Doptimize=ReleaseFast)", .{}); if (cli.preset) |p| try rep.print(alloc, " preset {s}", .{p}); for (cli.params[0..cli.param_count]) |ps| try rep.print(alloc, " {s}={d}", .{ ps.id, ps.value }); try rep.print(alloc, "\n\n", .{}); @@ -1381,7 +1381,7 @@ fn mkdirs(alloc: std.mem.Allocator, path: []const u8) !void { while (i <= path.len) : (i += 1) { if (i == path.len or path[i] == '/') { if (i == 0) continue; - const z = try alloc.dupeZ(u8, path[0..i]); + const z = try alloc.dupeSentinel(u8, path[0..i], 0); defer alloc.free(z); if (std.c.mkdir(z, 0o755) != 0 and std.c._errno().* != 17) return error.MkdirFailed; } @@ -1389,7 +1389,7 @@ fn mkdirs(alloc: std.mem.Allocator, path: []const u8) !void { } fn writeFile(alloc: std.mem.Allocator, path: []const u8, data: []const u8) !void { - const z = try alloc.dupeZ(u8, path); + const z = try alloc.dupeSentinel(u8, path, 0); defer alloc.free(z); const f = std.c.fopen(z, "wb") orelse return error.FileOpenFailed; defer _ = std.c.fclose(f); @@ -1397,7 +1397,7 @@ fn writeFile(alloc: std.mem.Allocator, path: []const u8, data: []const u8) !void } fn readFile(alloc: std.mem.Allocator, path: []const u8) ![]u8 { - const z = try alloc.dupeZ(u8, path); + const z = try alloc.dupeSentinel(u8, path, 0); defer alloc.free(z); const f = std.c.fopen(z, "rb") orelse return error.FileNotFound; defer _ = std.c.fclose(f); diff --git a/src/c.zig b/src/c.zig index e2028bf5..d258a09c 100644 --- a/src/c.zig +++ b/src/c.zig @@ -1,7 +1,3 @@ -pub const rl = @cImport({ - @cInclude("raylib.h"); -}); - -pub const ma = @cImport({ - @cInclude("miniaudio.h"); -}); +// C headers, translated by build.zig (src/c_raylib.h, src/c_miniaudio.h). +pub const rl = @import("raylib_c"); +pub const ma = @import("miniaudio_c"); diff --git a/src/c_miniaudio.h b/src/c_miniaudio.h new file mode 100644 index 00000000..a5e342e6 --- /dev/null +++ b/src/c_miniaudio.h @@ -0,0 +1,2 @@ +// Translated by the build into the `miniaudio_c` module (src/c.zig). +#include "miniaudio.h" diff --git a/src/c_raylib.h b/src/c_raylib.h new file mode 100644 index 00000000..44346959 --- /dev/null +++ b/src/c_raylib.h @@ -0,0 +1,2 @@ +// Translated by the build into the `raylib_c` module (src/c.zig). +#include diff --git a/src/chain.zig b/src/chain.zig index 7ab886a1..61c3407c 100644 --- a/src/chain.zig +++ b/src/chain.zig @@ -34,7 +34,7 @@ pub const Effect = struct { /// Block peaks the engine writes after each render and the bay's I/O /// meters read: in L, in R, out L, out R, as f32 bits. shared_io: ?*[4]std.atomic.Value(u32) = null, - io_peak: [4]std.atomic.Value(u32) = [_]std.atomic.Value(u32){std.atomic.Value(u32).init(0)} ** 4, + io_peak: [4]std.atomic.Value(u32) = @splat(std.atomic.Value(u32).init(0)), /// Audio thread: samples its input and output have both been silent, /// saturating; past the machine's hold it isn't rendered (docs/04 /// §Idle skipping). diff --git a/src/chords.zig b/src/chords.zig index 3b8eedb8..28533510 100644 --- a/src/chords.zig +++ b/src/chords.zig @@ -220,7 +220,12 @@ pub fn perSpan(alloc: std.mem.Allocator, ch: *const Chroma, edges: []const f64) return .{ .treble = t, .bass = b }; } -const QUALITIES = std.enums.values(Quality); +const QUALITIES = blk: { + const names = @typeInfo(Quality).@"enum".field_names; + var out: [names.len]Quality = undefined; + for (names, 0..) |name, i| out[i] = @field(Quality, name); + break :blk out; +}; const STATES = 12 * QUALITIES.len + 1; const NONE = STATES - 1; @@ -231,7 +236,7 @@ const TEMPLATES: [STATES - 1][12]f32 = blk: { var t: [STATES - 1][12]f32 = undefined; const harm = [_]struct { st: u8, w: f32 }{ .{ .st = 0, .w = 1 }, .{ .st = 12, .w = 0.6 }, .{ .st = 19, .w = 0.4 }, .{ .st = 24, .w = 0.3 } }; for (QUALITIES, 0..) |q, qi| for (0..12) |root| { - var v = [_]f32{0} ** 12; + var v: [12]f32 = @splat(0); for (q.intervals()) |iv| for (harm) |h| { v[(root + iv + h.st) % 12] += h.w; }; @@ -265,7 +270,7 @@ pub fn decode(alloc: std.mem.Allocator, treble: []const [12]f32, bass: []const [ } const back = try alloc.alloc([STATES]u16, n); defer alloc.free(back); - var delta: [STATES]f32 = [_]f32{0} ** STATES; + var delta: [STATES]f32 = @splat(0); var next: [STATES]f32 = undefined; // How sharply a fit counts, and what a change costs: a chord that // fits clearly better (by 0.1) takes over within two beats; one that @@ -341,7 +346,7 @@ pub fn decode(alloc: std.mem.Allocator, treble: []const [12]f32, bass: []const [ /// The key of everything heard (docs/30 §Chords and key). pub fn key(ch: *const Chroma) struct { key: u8, scale: tune.Scale } { - var hist = [_]f32{0} ** 12; + var hist: [12]f32 = @splat(0); for (ch.treble) |v| for (v, 0..) |x, pc| { hist[pc] += x; }; diff --git a/src/clip.zig b/src/clip.zig index 981cd7b0..533ab7f9 100644 --- a/src/clip.zig +++ b/src/clip.zig @@ -88,7 +88,7 @@ pub const Note = struct { bend: [MAX_BEND]automation.Point = undefined, bend_n: u8 = 0, /// Pressure, slide and gain curves (ExprDim order). - dims: [EXPR_DIMS]Curve = [_]Curve{.{}} ** EXPR_DIMS, + dims: [EXPR_DIMS]Curve = @splat(.{}), pub fn dim(self: *Note, d: ExprDim) *Curve { return &self.dims[@intFromEnum(d)]; @@ -231,7 +231,7 @@ pub const Clip = struct { /// Length of the clip in beats. length_beats: f64, /// Display name (null-terminated within name_buf[0..name_len]). - name_buf: [MAX_NAME]u8 = [_]u8{0} ** MAX_NAME, + name_buf: [MAX_NAME]u8 = @splat(0), name_len: u8 = 0, /// Transient UI flag — not persisted, not consumed by the engine. selected: bool = false, diff --git a/src/document.zig b/src/document.zig index bc1966eb..61d8e201 100644 --- a/src/document.zig +++ b/src/document.zig @@ -123,7 +123,7 @@ fn machineId(idx: u8) []const u8 { pub fn readFile(alloc: std.mem.Allocator, given: []const u8) ![]u8 { var db: [storage.MAX_PATH]u8 = undefined; const path = package.docPath(&db, given); - const z = try alloc.dupeZ(u8, path); + const z = try alloc.dupeSentinel(u8, path, 0); defer alloc.free(z); const fd = open(z, O_RDONLY); if (fd < 0) return error.FileOpenFailed; @@ -146,7 +146,7 @@ pub fn readFile(alloc: std.mem.Allocator, given: []const u8) ![]u8 { } pub fn writeFile(alloc: std.mem.Allocator, path: []const u8, data: []const u8) !void { - const z = try alloc.dupeZ(u8, path); + const z = try alloc.dupeSentinel(u8, path, 0); defer alloc.free(z); const fd = open(z, O_WRONLY | O_CREAT | O_TRUNC, @as(c_int, 0o644)); if (fd < 0) return error.FileOpenFailed; @@ -1379,10 +1379,10 @@ fn applyClip(alloc: std.mem.Allocator, t: *track_mod.Track, co: std.json.ObjectM _ = note.addBend(pt); } }; - inline for (std.meta.fields(clip_mod.ExprDim)) |fd| { - const d: clip_mod.ExprDim = @enumFromInt(fd.value); + inline for (@typeInfo(clip_mod.ExprDim).@"enum".field_names) |dim_name| { + const d = @field(clip_mod.ExprDim, dim_name); const rg = clip_mod.dimRange(d); - if (objGet(ev.object, fd.name)) |pv| if (pv == .array) { + if (objGet(ev.object, dim_name)) |pv| if (pv == .array) { for (pv.array.items) |ptv| { const pt = parsePoint(ptv) orelse continue; _ = note.dim(d).add(pt, rg.lo, rg.hi); @@ -1514,7 +1514,7 @@ test "a clip saved as a .slabclip comes back on another track where it's put; a var root_buf: [storage.MAX_PATH]u8 = undefined; const root = storage.absolute(&root_buf, try std.fmt.bufPrint(&rb, ".zig-cache/tmp/{s}", .{tmp.sub_path})); var hb: [storage.MAX_PATH]u8 = undefined; - const home = try std.fmt.bufPrintZ(&hb, "{s}/home", .{root}); + const home = try std.mem.printSentinel(&hb, "{s}/home", .{root}, 0); _ = setenv("SLAB_HOME", home.ptr, 1); defer _ = unsetenv("SLAB_HOME"); @@ -1593,7 +1593,7 @@ test "a project saved as a package carries the outside sample it loaded, and loa const root_rel = try std.fmt.bufPrint(&rb, ".zig-cache/tmp/{s}", .{tmp.sub_path}); const root = storage.absolute(&root_buf, root_rel); var fb: [storage.MAX_PATH]u8 = undefined; - const fac = try std.fmt.bufPrintZ(&fb, "{s}/factory", .{root}); + const fac = try std.mem.printSentinel(&fb, "{s}/factory", .{root}, 0); storage.makeParents(fac); _ = setenv("SLAB_FACTORY", fac.ptr, 1); defer _ = unsetenv("SLAB_FACTORY"); @@ -1635,7 +1635,7 @@ test "a project saved as a package carries the outside sample it loaded, and loa try std.testing.expect(std.mem.indexOf(u8, doc, "\"smp\":\"samples/pluck.wav\"") != null); // The original goes; the package still loads whole. - try std.testing.expectEqual(@as(c_int, 0), std.c.unlink(try std.fmt.bufPrintZ(&ob, "{s}/elsewhere/pluck.wav", .{root}))); + try std.testing.expectEqual(@as(c_int, 0), std.c.unlink(try std.mem.printSentinel(&ob, "{s}/elsewhere/pluck.wav", .{root}, 0))); const bytes = try readFile(alloc, pkg); defer alloc.free(bytes); try std.testing.expectEqual(@as(usize, 0), package.missing(alloc, bytes).count); diff --git a/src/drums.zig b/src/drums.zig index f55034a5..821f674c 100644 --- a/src/drums.zig +++ b/src/drums.zig @@ -43,6 +43,13 @@ pub const Class = enum(u8) { } }; +const CLASSES = blk: { + const names = @typeInfo(Class).@"enum".field_names; + var out: [names.len]Class = undefined; + for (names, 0..) |name, i| out[i] = @field(Class, name); + break :blk out; +}; + pub const Feature = struct { /// The energy the hit adds, by third octaves (its first ~40 ms /// against the ~40 ms before it): the share of that under 150 Hz, @@ -104,7 +111,7 @@ pub fn feature(l: []const f64, r: ?[]const f64, rate: f64, sec: f64, next: f64) // Before, as the most each bin held over a few windows a little apart: // notes beating against each other, or a partial's phase, swing a // bin's energy, and its peak is what a hit has to beat. - var before_e: [WIN / 2]f64 = [_]f64{0} ** (WIN / 2); + var before_e: [WIN / 2]f64 = @splat(0); for (BEFORE_OFFSETS) |off| { for (&buf, 0..) |*b, i| { const ib = a0 - WIN - off + @as(isize, @intCast(i)); @@ -293,7 +300,7 @@ fn kmeans(pts: []const [4]f32, k: usize, assign: []u8, cent: *[MAX_GROUPS][4]f32 sse += dist(p, cent[best]); } var sum: [MAX_GROUPS][4]f32 = undefined; - var cnt = [_]f32{0} ** MAX_GROUPS; + var cnt: [MAX_GROUPS]f32 = @splat(0); for (sum[0..k]) |*s| s.* = .{ 0, 0, 0, 0 }; for (pts, assign) |p, a| { for (0..4) |j| sum[a][j] += p[j]; @@ -345,8 +352,8 @@ pub fn kit(alloc: std.mem.Allocator, l: []const f64, r: ?[]const f64, rate: f64, const idx = try alloc.alloc(usize, n); defer alloc.free(idx); var k: usize = 0; - var used = [_]u8{0} ** std.enums.values(Class).len; - for (std.enums.values(Class)) |cl| { + var used: [CLASSES.len]u8 = @splat(0); + for (CLASSES) |cl| { var m: usize = 0; for (class_of, 0..) |c, i| if (c == cl and hits.strength[i] >= MIN_STRENGTH) { sel[m] = pts[i]; diff --git a/src/dx7_algorithms.zig b/src/dx7_algorithms.zig index 07c7b16b..dbf561a1 100644 --- a/src/dx7_algorithms.zig +++ b/src/dx7_algorithms.zig @@ -275,7 +275,7 @@ test "alg32 routing drives the voice to six partials" { } inline for (.{ "lvl0", "lvl1", "lvl2", "lvl3", "lvl4", "lvl5" }) |f| @field(vp, f) = 0.3; - var st = [_]f64{0} ** 18; + var st: [18]f64 = @splat(0); var out: f64 = 0; var buf: [n]f64 = undefined; for (&buf) |*s| { diff --git a/src/dx7_voice_render_test.zig b/src/dx7_voice_render_test.zig index 1984bed5..06718a8c 100644 --- a/src/dx7_voice_render_test.zig +++ b/src/dx7_voice_render_test.zig @@ -190,17 +190,17 @@ test "complete voice (eg-bank + voice) matches the Zig reference" { vp.w01 = 1.0; vp.c0 = 1.0; - var ep = [_]f64{0} ** 60; + var ep: [60]f64 = @splat(0); // op0 carrier env, op1 modulator env. Others silent (ol=0). setEg(&ep, 0, 0.01, 0.004, 0.003, 0.005, 1.0, 0.8, 0.7, 0.0, 1.0, 1.0); setEg(&ep, 1, 0.008, 0.004, 0.003, 0.006, 1.0, 0.6, 0.5, 0.0, 1.0, 0.16); var fy_vp = vp; var ref_vp = vp; - var fy_vs = [_]f64{0} ** 18; - var fy_es = [_]f64{0} ** 18; - var ref_vs = [_]f64{0} ** 18; - var ref_es = [_]f64{0} ** 18; + var fy_vs: [18]f64 = @splat(0); + var fy_es: [18]f64 = @splat(0); + var ref_vs: [18]f64 = @splat(0); + var ref_es: [18]f64 = @splat(0); initEgBank(&fy_es); initEgBank(&ref_es); var fy_out: f64 = 0; @@ -231,7 +231,7 @@ test "dx7 voice envelope shapes amplitude (silent -> swell -> release)" { vp.inc1 = 0.02; vp.w01 = 1.0; vp.c0 = 1.0; - var ep = [_]f64{0} ** 60; + var ep: [60]f64 = @splat(0); // Attack carrier with a loud sustain (L3 near 0 dB) and a quick release; // modulator with its own env. (L=0.95 -> ~-0.8 dB; lower L maps deep into // dB.) Rates are brisk so the swell/release fit a short sample window — @@ -239,8 +239,8 @@ test "dx7 voice envelope shapes amplitude (silent -> swell -> release)" { setEg(&ep, 0, 0.01, 0.01, 0.005, 0.01, 1.0, 0.97, 0.95, 0.0, 1.0, 1.0); setEg(&ep, 1, 0.01, 0.01, 0.005, 0.01, 1.0, 0.8, 0.7, 0.0, 1.0, 0.16); - var vs = [_]f64{0} ** 18; - var es = [_]f64{0} ** 18; + var vs: [18]f64 = @splat(0); + var es: [18]f64 = @splat(0); initEgBank(&es); var out: f64 = 0; const n = 900; diff --git a/src/dx7_voice_test.zig b/src/dx7_voice_test.zig index 97822a22..7bf872a2 100644 --- a/src/dx7_voice_test.zig +++ b/src/dx7_voice_test.zig @@ -135,8 +135,8 @@ test "k-dx7-voice matches the Zig reference (codegen pin)" { p.c1 = 1.0; p.c2 = 0.5; - var fy_state = [_]f64{0} ** 18; - var ref_state = [_]f64{0} ** 18; + var fy_state: [18]f64 = @splat(0); + var ref_state: [18]f64 = @splat(0); var fy_out: f64 = 0; var max_err: f64 = 0; @@ -168,7 +168,7 @@ test "dx7 voice 2-op stack makes FM sidebands" { p.w01 = 1.0; p.c0 = 1.0; - var st = [_]f64{0} ** 18; + var st: [18]f64 = @splat(0); var out: f64 = 0; var buf: [n]f64 = undefined; for (&buf) |*s| { @@ -198,7 +198,7 @@ test "dx7 voice six carriers make six independent partials" { inline for (.{ "lvl0", "lvl1", "lvl2", "lvl3", "lvl4", "lvl5" }) |f| @field(p, f) = lvl; inline for (.{ "c0", "c1", "c2", "c3", "c4", "c5" }) |f| @field(p, f) = 1.0; - var st = [_]f64{0} ** 18; + var st: [18]f64 = @splat(0); var out: f64 = 0; var buf: [n]f64 = undefined; for (&buf) |*s| { diff --git a/src/engine.zig b/src/engine.zig index 3ebcc74b..c0faa7d6 100644 --- a/src/engine.zig +++ b/src/engine.zig @@ -3471,8 +3471,8 @@ test "mixAudioClips: places source at clip start and resamples by rate" { }; const spb: f64 = 100.0; // samples per beat → clip starts at sample 100 - var l = [_]f32{0} ** 8; - var r = [_]f32{0} ** 8; + var l: [8]f32 = @splat(0); + var r: [8]f32 = @splat(0); // Block starting exactly at the clip's first sample. mixAudioClips(&snap, 100, 8, &spbMap(spb), 48_000, &l, &r); @@ -3500,8 +3500,8 @@ test "mixAudioClips: silent before clip start and after source ends" { }; const spb: f64 = 4.0; // clip starts at sample 4 - var l = [_]f32{0} ** 8; - var r = [_]f32{0} ** 8; + var l: [8]f32 = @splat(0); + var r: [8]f32 = @splat(0); // Block [0,8): samples 0..3 are before the clip, 4..5 read the source, // 6..7 are past the 2-sample source (silent). mixAudioClips(&snap, 0, 8, &spbMap(spb), 48_000, &l, &r); @@ -3528,8 +3528,8 @@ test "mixAudioClips: start_sample offsets into the source (split clips)" { .start_sample = 3, // begin reading at source sample 3 .gain = 1.0, }; - var l = [_]f32{0} ** 4; - var r = [_]f32{0} ** 4; + var l: [4]f32 = @splat(0); + var r: [4]f32 = @splat(0); // Engine rate == source rate → step 1, so out[i] = data[3+i]. mixAudioClips(&snap, 0, 4, &spbMap(100.0), 48_000, &l, &r); try testing.expectApproxEqAbs(@as(f32, 3), l[0], 1e-5); @@ -3539,7 +3539,7 @@ test "mixAudioClips: start_sample offsets into the source (split clips)" { } test "declick: audio clip edges fade where they cut, not at a file's own ends or a seamless split" { - var data = [_]f64{1.0} ** 2048; + var data: [2048]f64 = @splat(1.0); // 100 samples a beat: clip A is source 0..400, B carries on 400..800, // C is cut out of the middle, 1200..1600. var snap = snap_mod.TrackSnapshot{}; @@ -3552,8 +3552,8 @@ test "declick: audio clip edges fade where they cut, not at a file's own ends or snap.audio_clips[2] = base; snap.audio_clips[2].start_beat = 10; snap.audio_clips[2].start_sample = 1200; - var l = [_]f32{0} ** 1500; - var r = [_]f32{0} ** 1500; + var l: [1500]f32 = @splat(0); + var r: [1500]f32 = @splat(0); mixAudioClipsEdged(&snap, 0, 1500, &spbMap(100.0), 48_000, &l, &r, .{ .on = true }); // A starts at the file's head: as authored. try testing.expectApproxEqAbs(@as(f32, 1), l[0], 1e-6); @@ -3577,7 +3577,7 @@ test "declick: audio clip edges fade where they cut, not at a file's own ends or test "mixAudioClips: linear fade-in/out ramps the window edges" { // 8-sample window, value 1.0 everywhere; fade in/out of 2 samples each. - var data = [_]f64{1.0} ** 8; + var data: [8]f64 = @splat(1.0); var snap = snap_mod.TrackSnapshot{}; snap.audio_clip_count = 1; snap.audio_clips[0] = .{ @@ -3592,8 +3592,8 @@ test "mixAudioClips: linear fade-in/out ramps the window edges" { .fade_out_samples = 2, .gain = 1.0, }; - var l = [_]f32{0} ** 8; - var r = [_]f32{0} ** 8; + var l: [8]f32 = @splat(0); + var r: [8]f32 = @splat(0); mixAudioClips(&snap, 0, 8, &spbMap(100.0), 48_000, &l, &r); // fade-in: pos 0 → 0.0, pos 1 → 0.5; middle → 1.0; fade-out near the end. try testing.expectApproxEqAbs(@as(f32, 0.0), l[0], 1e-5); @@ -3620,8 +3620,8 @@ test "mixAudioClips: a reversed clip reads its window end to start, fades in cli .gain = 1.0, .reversed = true, }; - var l = [_]f32{0} ** 6; - var r = [_]f32{0} ** 6; + var l: [6]f32 = @splat(0); + var r: [6]f32 = @splat(0); mixAudioClips(&snap, 0, 6, &spbMap(6.0), 48_000, &l, &r); // 7, 6, 5, 4, 3, 2 with the fade-in on the first two (0, 0.5). try testing.expectApproxEqAbs(@as(f32, 0), l[0], 1e-5); @@ -3645,8 +3645,8 @@ test "mixAudioClips: a stereo source plays its channels apart" { .dur_samples = 4, .gain = 0.5, }; - var l = [_]f32{0} ** 4; - var r = [_]f32{0} ** 4; + var l: [4]f32 = @splat(0); + var r: [4]f32 = @splat(0); mixAudioClips(&snap, 0, 4, &spbMap(4.0), 48_000, &l, &r); try testing.expectApproxEqAbs(@as(f32, 0.5), l[0], 1e-6); try testing.expectApproxEqAbs(@as(f32, -0.5), r[0], 1e-6); @@ -3675,8 +3675,8 @@ test "mixAudioClips: a warped clip reads through its markers, offset and reverse .warp_start = 0, .warp_count = 2, }; - var l = [_]f32{0} ** 16; - var r = [_]f32{0} ** 16; + var l: [16]f32 = @splat(0); + var r: [16]f32 = @splat(0); mixAudioClips(&snap, 0, 16, &spbMap(4.0), 48_000, &l, &r); for (l, 0..) |v, k| try testing.expectApproxEqAbs(@as(f32, @floatCast(data[128 + 2 * k])), v, 2e-3); @@ -3812,8 +3812,8 @@ test "mixAudioClips: missing source data is skipped" { var snap = snap_mod.TrackSnapshot{}; snap.audio_clip_count = 1; snap.audio_clips[0] = .{ .start_beat = 0, .length_beats = 4, .data = null }; - var l = [_]f32{0} ** 4; - var r = [_]f32{0} ** 4; + var l: [4]f32 = @splat(0); + var r: [4]f32 = @splat(0); mixAudioClips(&snap, 0, 4, &spbMap(10.0), 48_000, &l, &r); for (l) |v| try testing.expectEqual(@as(f32, 0), v); } @@ -4440,7 +4440,7 @@ test "a loop wrap releases the note ending on the loop end, and the ones past it const alloc = testing.allocator; const col = @import("c.zig").rl.Color{ .r = 0, .g = 0, .b = 0, .a = 255 }; const Held = struct { - var on: [128]i32 = [_]i32{0} ** 128; + var on: [128]i32 = @splat(0); fn machine_() machine.Machine { var level: f32 = 0; var m = RouteTestMachines.dc(&level); diff --git a/src/export.zig b/src/export.zig index f9cd0699..df90a6d0 100644 --- a/src/export.zig +++ b/src/export.zig @@ -106,7 +106,7 @@ extern fn slab_write_m4a(path: [*:0]const u8, ints: ?[*]const c_int, floats: ?[* /// Write `samples` (interleaved by `f.channels`) to `path` in format `f`. pub fn writeFile(alloc: std.mem.Allocator, path: []const u8, samples: []const f32, f: Format) !void { if (f.container == .alac or f.container == .aac) { - const z = try alloc.dupeZ(u8, path); + const z = try alloc.dupeSentinel(u8, path, 0); defer alloc.free(z); const ch: usize = f.channels; const frames = samples.len / ch; @@ -814,7 +814,7 @@ test "fillName fills the fields and keeps paths flat" { test "WAV cue points and the acid chunk" { const alloc = std.testing.allocator; - const x = [_]f32{0} ** 64; + const x: [64]f32 = @splat(0); const bytes = try encode(alloc, &x, .{ .bits = .pcm16, .cues = &.{ .{ .frame = 0, .name = "intro" }, .{ .frame = 20, .name = "drop!" } }, .acid = .{ .beats = 16, .num = 7, .den = 8, .bpm = 140 } }); defer alloc.free(bytes); try std.testing.expectEqual(@as(u32, @intCast(bytes.len - 8)), std.mem.readInt(u32, bytes[4..8], .little)); diff --git a/src/export_settings.zig b/src/export_settings.zig index 924f35a4..b2679363 100644 --- a/src/export_settings.zig +++ b/src/export_settings.zig @@ -332,12 +332,14 @@ pub fn read(s: *Settings, o: std.json.ObjectMap) void { }; } +const recipe_info = @typeInfo(Recipe).@"struct"; + /// The recipe's fields, each as `,"name":value`. fn writeFields(alloc: std.mem.Allocator, out: *std.ArrayList(u8), r: *const Recipe) !void { - inline for (std.meta.fields(Recipe)) |fd| { - try out.appendSlice(alloc, ",\"" ++ fd.name ++ "\":"); - const v = @field(r.*, fd.name); - switch (@typeInfo(fd.type)) { + inline for (recipe_info.field_names, recipe_info.field_types) |name, T| { + try out.appendSlice(alloc, ",\"" ++ name ++ "\":"); + const v = @field(r.*, name); + switch (@typeInfo(T)) { .bool => try out.appendSlice(alloc, if (v) "true" else "false"), .@"enum" => try jsonString(alloc, out, @tagName(v)), .int, .float => { @@ -345,20 +347,20 @@ fn writeFields(alloc: std.mem.Allocator, out: *std.ArrayList(u8), r: *const Reci try out.appendSlice(alloc, std.fmt.bufPrint(&b, "{d}", .{v}) catch "0"); }, .@"struct" => try jsonString(alloc, out, v.get()), - else => @compileError("export setting " ++ fd.name), + else => @compileError("export setting " ++ name), } } } fn readFields(r: *Recipe, o: std.json.ObjectMap) void { - inline for (std.meta.fields(Recipe)) |fd| if (o.get(fd.name)) |v| { - const p = &@field(r.*, fd.name); - switch (@typeInfo(fd.type)) { + inline for (recipe_info.field_names, recipe_info.field_types) |name, T| if (o.get(name)) |v| { + const p = &@field(r.*, name); + switch (@typeInfo(T)) { .bool => if (v == .bool) { p.* = v.bool; }, .@"enum" => if (v == .string) { - if (std.meta.stringToEnum(fd.type, v.string)) |e| p.* = e; + if (std.meta.stringToEnum(T, v.string)) |e| p.* = e; }, .int => { const x: f64 = switch (v) { diff --git a/src/exporter.zig b/src/exporter.zig index 7250c596..d59f7adc 100644 --- a/src/exporter.zig +++ b/src/exporter.zig @@ -403,7 +403,7 @@ fn emit(alloc: std.mem.Allocator, opts: *const Options, names: *Names, report: * fn exists(path: []const u8) bool { var z: [storage.MAX_PATH + 1]u8 = undefined; - const p = std.fmt.bufPrintZ(&z, "{s}", .{path}) catch return false; + const p = std.mem.printSentinel(&z, "{s}", .{path}, 0) catch return false; return std.c.access(p, 0) == 0; } diff --git a/src/extract.zig b/src/extract.zig index 64b50fe4..474dd407 100644 --- a/src/extract.zig +++ b/src/extract.zig @@ -46,7 +46,7 @@ pub fn placeNotes(alloc: std.mem.Allocator, clip: *const clip_mod.Clip, rate: f6 /// The middle pitch of a pattern's notes (a bass's is under C3). pub fn medianPitch(notes: []const clip_mod.Note) u8 { if (notes.len == 0) return 60; - var hist = [_]u32{0} ** 128; + var hist: [128]u32 = @splat(0); for (notes) |nt| hist[nt.pitch] += 1; var seen: usize = 0; for (hist, 0..) |h, p| { diff --git a/src/fft.zig b/src/fft.zig index 192b7c0a..c13b4e25 100644 --- a/src/fft.zig +++ b/src/fft.zig @@ -53,7 +53,7 @@ pub fn Fft(comptime n: usize) type { const rev: [n]u32 = blk: { @setEvalBranchQuota(100_000_000); var t: [n]u32 = undefined; - for (&t, 0..) |*v, i| v.* = @bitReverse(@as(std.meta.Int(.unsigned, bits), @intCast(i))); + for (&t, 0..) |*v, i| v.* = @bitReverse(@as(@Int(.unsigned, bits), @intCast(i))); break :blk t; }; diff --git a/src/fy_host.zig b/src/fy_host.zig index 2b6ba1a1..3c97d80a 100644 --- a/src/fy_host.zig +++ b/src/fy_host.zig @@ -54,7 +54,7 @@ const SockAddrIn = extern struct { sin_family: u8 = 2, // AF_INET sin_port: u16, // big-endian sin_addr: u32, // big-endian - sin_zero: [8]u8 = .{0} ** 8, + sin_zero: [8]u8 = @splat(0), }; extern fn socket(domain: c_int, @"type": c_int, protocol: c_int) c_int; @@ -79,7 +79,7 @@ const O_CREAT: c_int = 0x200; const O_TRUNC: c_int = 0x400; fn readFilePosix(alloc: std.mem.Allocator, path: []const u8) ![]u8 { - const z = try alloc.dupeZ(u8, path); + const z = try alloc.dupeSentinel(u8, path, 0); defer alloc.free(z); const fd = open(z, O_RDONLY); if (fd < 0) return error.FileOpenFailed; @@ -811,8 +811,8 @@ test "Stage 4: hot-patch recompiles file and callback reflects new GAIN" { // Original callback: GAIN = 0.25. Capture sample[1] as amplitude baseline. const cb_orig = try host.createAudioCallback("sine-audio"); - var l_buf = [_]f32{0.0} ** FRAMES; - var r_buf = [_]f32{0.0} ** FRAMES; + var l_buf: [FRAMES]f32 = @splat(0.0); + var r_buf: [FRAMES]f32 = @splat(0.0); var ctx = std.mem.zeroes(MachineCtx); var on_event = [_]machine_mod.NoteEvent{.{ .sample_offset = 0, @@ -895,8 +895,8 @@ test "Stage 3: audio callback runs and writes to L/R buffers" { const render = try host.createAudioCallback("sine-audio"); // Allocate L/R scratch buffers (zeroed). - var l_buf = [_]f32{0.0} ** FRAMES; - var r_buf = [_]f32{0.0} ** FRAMES; + var l_buf: [FRAMES]f32 = @splat(0.0); + var r_buf: [FRAMES]f32 = @splat(0.0); // Set up ctx + audio buffers, call the trampoline, tear down. var ctx = std.mem.zeroes(MachineCtx); diff --git a/src/kernel_probe.zig b/src/kernel_probe.zig index 03cbbdf0..372c3b12 100644 --- a/src/kernel_probe.zig +++ b/src/kernel_probe.zig @@ -1740,7 +1740,7 @@ fn putU32(dst: []u8, v: u32) void { } fn writeFile(alloc: std.mem.Allocator, path: []const u8, data: []const u8) !void { - const z = try alloc.dupeZ(u8, path); + const z = try alloc.dupeSentinel(u8, path, 0); defer alloc.free(z); const fd = open(z, O_WRONLY | O_CREAT | O_TRUNC, @as(c_int, 0o644)); if (fd < 0) return error.FileOpenFailed; @@ -1913,8 +1913,8 @@ fn runDrumVoiceCase(alloc: std.mem.Allocator, cli: Cli, host: *FyHost, cfg: Drum defer alloc.free(out); @memset(out, 0); - var state align(8) = [_]f64{0} ** MAX_DRUM_CELLS; - var params align(8) = [_]f64{0} ** MAX_DRUM_CELLS; + var state align(8) = @as([MAX_DRUM_CELLS]f64, @splat(0)); + var params align(8) = @as([MAX_DRUM_CELLS]f64, @splat(0)); if (cfg.state_f64s > MAX_DRUM_CELLS or cfg.params_f64s > MAX_DRUM_CELLS) return error.InvalidCase; @memcpy(params[0..cfg.param_defaults.len], cfg.param_defaults); diff --git a/src/keymap.zig b/src/keymap.zig index b791e202..aa5a91ba 100644 --- a/src/keymap.zig +++ b/src/keymap.zig @@ -98,12 +98,12 @@ comptime { /// injects a pointer; the voice reads its zone's cells at note-on and /// writes `hit` / `last` for the panel. pub const ZoneEdits = extern struct { - level: [MAX_ZONES]f64 = [_]f64{0} ** MAX_ZONES, // dB - tune: [MAX_ZONES]f64 = [_]f64{0} ** MAX_ZONES, // semitones - decay: [MAX_ZONES]f64 = [_]f64{0} ** MAX_ZONES, // seconds to -60 dB; 0 = off - tone: [MAX_ZONES]f64 = [_]f64{0} ** MAX_ZONES, // filter offset, octaves - cut: [MAX_ZONES]f64 = [_]f64{0} ** MAX_ZONES, // choke: 0 the pack's, 1 none, n+1 group n - hit: [MAX_ZONES]f64 = [_]f64{0} ** MAX_ZONES, // sequence number of the zone's newest note + level: [MAX_ZONES]f64 = @splat(0), // dB + tune: [MAX_ZONES]f64 = @splat(0), // semitones + decay: [MAX_ZONES]f64 = @splat(0), // seconds to -60 dB; 0 = off + tone: [MAX_ZONES]f64 = @splat(0), // filter offset, octaves + cut: [MAX_ZONES]f64 = @splat(0), // choke: 0 the pack's, 1 none, n+1 group n + hit: [MAX_ZONES]f64 = @splat(0), // sequence number of the zone's newest note last: f64 = -1, // the zone of the newest note }; @@ -489,7 +489,7 @@ test "octave copies: band-limited, decimated, shared by zones on one sample" { /// The nearest key to `key` that no zone plays, looking up first. pub fn freeKey(km: *const Keymap, key: u8) ?u8 { - var used = [_]bool{false} ** 128; + var used: [128]bool = @splat(false); for (km.zones[0..km.count]) |z| { const lo: usize = @intFromFloat(std.math.clamp(z.lo_key, 0, 127)); const hi: usize = @intFromFloat(std.math.clamp(z.hi_key, 0, 127)); @@ -797,7 +797,7 @@ fn mapMelodic(specs: []Spec) void { /// General MIDI placement by keyword; the rest fill free keys from 36 up. fn mapDrums(specs: []Spec) void { - var used = [_]bool{false} ** 128; + var used: [128]bool = @splat(false); for (specs) |*sp| { const key = gmKey(stem(sp.name)); if (key) |k| if (!used[k]) { @@ -1235,7 +1235,7 @@ test "antialias passes the band and stops what would fold" { const n = 48_000; var pool = try a.alloc(f64, n); defer a.free(pool); - var zones = [_]Zone{.{}} ** 1; + var zones: [1]Zone = @splat(.{}); zones[0].start = 0; zones[0].len = n; zones[0].sr = 48_000; @@ -1306,7 +1306,7 @@ fn loadVc(alloc: std.mem.Allocator, path: []const u8) Error!wav.Sample { } test "a .VC file loads its 8-bit RAM and segment loop" { - var bytes = [_]u8{0} ** VC_SIZE; + var bytes: [VC_SIZE]u8 = @splat(0); for (bytes[VC_RAM..][0..16_384], 0..) |*b, i| b.* = if (i % 128 < 64) 0xC0 else 0x40; bytes[VC_LOOP_ON] = 1; bytes[VC_LOOP_START] = 2; diff --git a/src/machine.zig b/src/machine.zig index ee13ab15..a933a131 100644 --- a/src/machine.zig +++ b/src/machine.zig @@ -301,7 +301,7 @@ pub const NOTE_LABEL_TEXT = 23; /// drum-lane piano roll instead of the chromatic keyboard. pub const NoteLabel = struct { pitch: u8 = 0, - label: [NOTE_LABEL_TEXT:0]u8 = [_:0]u8{0} ** NOTE_LABEL_TEXT, + label: [NOTE_LABEL_TEXT:0]u8 = @splat(0), label_len: u8 = 0, pub fn labelSlice(self: *const NoteLabel) []const u8 { diff --git a/src/machine_desc.zig b/src/machine_desc.zig index 81b97c3b..01416e47 100644 --- a/src/machine_desc.zig +++ b/src/machine_desc.zig @@ -80,7 +80,7 @@ pub const Widget = enum { pub const OptSet = struct { ctl: u16 = 0, opt: u8 = 0, - id: [MAX_TEXT:0]u8 = [_:0]u8{0} ** MAX_TEXT, + id: [MAX_TEXT:0]u8 = @splat(0), id_len: usize = 0, value: f64 = 0, @@ -90,11 +90,11 @@ pub const OptSet = struct { }; pub const Control = struct { - module: [MAX_TEXT:0]u8 = [_:0]u8{0} ** MAX_TEXT, + module: [MAX_TEXT:0]u8 = @splat(0), module_len: usize = 0, - label: [MAX_TEXT:0]u8 = [_:0]u8{0} ** MAX_TEXT, + label: [MAX_TEXT:0]u8 = @splat(0), label_len: usize = 0, - id: [MAX_TEXT:0]u8 = [_:0]u8{0} ** MAX_TEXT, + id: [MAX_TEXT:0]u8 = @splat(0), id_len: usize = 0, kind: ParamKind = .direct_f64, offset: usize = 0, @@ -108,8 +108,8 @@ pub const Control = struct { // switch_sel only: discrete options. The control stores the selected // index; sync writes option_values[index] to the param offset. option_count: usize = 0, - option_values: [MAX_OPTS]f64 = [_]f64{0} ** MAX_OPTS, - option_labels: [MAX_OPTS][MAX_TEXT:0]u8 = [_][MAX_TEXT:0]u8{[_:0]u8{0} ** MAX_TEXT} ** MAX_OPTS, + option_values: [MAX_OPTS]f64 = @splat(0), + option_labels: [MAX_OPTS][MAX_TEXT:0]u8 = @splat(@as([MAX_TEXT:0]u8, @splat(0))), pub fn optionLabelZ(self: *const Control, i: usize) [*:0]const u8 { return @ptrCast(&self.option_labels[i][0]); @@ -181,7 +181,7 @@ pub const ConstF64 = struct { /// seconds. The host allocates per channel and writes the base pointer + /// element count into that channel's state at the two introspected offsets. pub const BufferReq = struct { - name: [MAX_TEXT:0]u8 = [_:0]u8{0} ** MAX_TEXT, + name: [MAX_TEXT:0]u8 = @splat(0), name_len: usize = 0, ptr_offset: usize = 0, len_offset: usize = 0, @@ -196,12 +196,12 @@ pub const BufferReq = struct { /// base pointer, sample count, and native sample rate into PARAMS at the /// three introspected offsets (shared across voices). pub const AssetReq = struct { - name: [MAX_TEXT:0]u8 = [_:0]u8{0} ** MAX_TEXT, + name: [MAX_TEXT:0]u8 = @splat(0), name_len: usize = 0, ptr_offset: usize = 0, len_offset: usize = 0, sr_offset: usize = 0, - file: [MAX_PATH:0]u8 = [_:0]u8{0} ** MAX_PATH, + file: [MAX_PATH:0]u8 = @splat(0), file_len: usize = 0, /// `keymap` instead of `asset`: the three offsets take the sample /// pool's pointer, the zone table's pointer and the zone count. @@ -214,7 +214,7 @@ pub const AssetReq = struct { edits_offset: usize = 0, /// Keymaps: low-pass the pool at `aa_ratio` x this control's value /// (`keymap-antialias`); empty = never. - aa_control: [MAX_TEXT:0]u8 = [_:0]u8{0} ** MAX_TEXT, + aa_control: [MAX_TEXT:0]u8 = @splat(0), aa_control_len: usize = 0, aa_ratio: f64 = 0, aa_cap: usize = 0, @@ -233,7 +233,7 @@ pub const AssetReq = struct { }; pub const Strip = struct { - module: [MAX_TEXT:0]u8 = [_:0]u8{0} ** MAX_TEXT, + module: [MAX_TEXT:0]u8 = @splat(0), module_len: usize = 0, cols: usize = 1, @@ -265,7 +265,7 @@ pub const Mod = struct { /// that sets how much a source moves a destination outside the /// matrix, like a filter's ENV amount). kind: enum { source, dest, fixed } = .source, - name: [MAX_TEXT:0]u8 = [_:0]u8{0} ** MAX_TEXT, + name: [MAX_TEXT:0]u8 = @splat(0), name_len: usize = 0, /// The matrix's SRC or DEST option index. index: usize = 0, @@ -300,13 +300,13 @@ pub const MeterOffset = enum(usize) { gmin = 0, ipk, opk, msm, mss, msum, mn }; pub const DynOffset = enum(usize) { gr = 0, lvl, knee }; pub const Display = struct { - name: [MAX_TEXT:0]u8 = [_:0]u8{0} ** MAX_TEXT, + name: [MAX_TEXT:0]u8 = @splat(0), name_len: usize = 0, kind: DisplayKind = .adsr, - source: [MAX_TEXT:0]u8 = [_:0]u8{0} ** MAX_TEXT, + source: [MAX_TEXT:0]u8 = @splat(0), source_len: usize = 0, /// Meter kind only: state byte offsets, see MeterOffset. - offsets: [METER_OFFSETS]usize = [_]usize{0} ** METER_OFFSETS, + offsets: [METER_OFFSETS]usize = @splat(0), pub fn nameSlice(self: *const Display) []const u8 { return self.name[0..self.name_len]; @@ -352,7 +352,7 @@ pub const LayoutRow = struct { /// One tab of a paged panel: a name and its own layout-row tree. Built only /// when the manifest declares `page`s; otherwise the panel uses `Desc.rows`. pub const Page = struct { - name: [MAX_TEXT:0]u8 = [_:0]u8{0} ** MAX_TEXT, + name: [MAX_TEXT:0]u8 = @splat(0), name_len: usize = 0, rows: [MAX_ROWS]LayoutRow = undefined, row_count: usize = 0, @@ -367,25 +367,25 @@ pub const Page = struct { }; pub const Desc = struct { - name: [MAX_NAME]u8 = [_]u8{0} ** MAX_NAME, + name: [MAX_NAME]u8 = @splat(0), name_len: usize = 0, mode: Mode = .voice_sample, - render_word: [MAX_WORD]u8 = [_]u8{0} ** MAX_WORD, + render_word: [MAX_WORD]u8 = @splat(0), render_word_len: usize = 0, - prepare_word: [MAX_WORD]u8 = [_]u8{0} ** MAX_WORD, + prepare_word: [MAX_WORD]u8 = @splat(0), prepare_word_len: usize = 0, - note_on_word: [MAX_WORD]u8 = [_]u8{0} ** MAX_WORD, + note_on_word: [MAX_WORD]u8 = @splat(0), note_on_word_len: usize = 0, - note_off_word: [MAX_WORD]u8 = [_]u8{0} ** MAX_WORD, + note_off_word: [MAX_WORD]u8 = @splat(0), note_off_word_len: usize = 0, - block_prepare_word: [MAX_WORD]u8 = [_]u8{0} ** MAX_WORD, + block_prepare_word: [MAX_WORD]u8 = @splat(0), block_prepare_word_len: usize = 0, // Per-note expression hook (docs/22): retunes the voice to ctx.pitch. - note_expr_word: [MAX_WORD]u8 = [_]u8{0} ** MAX_WORD, + note_expr_word: [MAX_WORD]u8 = @splat(0), note_expr_word_len: usize = 0, /// A cheaper render word for blocks where the params f64 at /// `render_lite_sel` (byte offset) is exactly 0. - render_lite_word: [MAX_WORD]u8 = [_]u8{0} ** MAX_WORD, + render_lite_word: [MAX_WORD]u8 = @splat(0), render_lite_word_len: usize = 0, render_lite_sel: usize = 0, /// Params byte offset + 1 of the f64 the host sets to 1.0 while a @@ -394,14 +394,14 @@ pub const Desc = struct { key_flag: usize = 0, /// Voice machines: a control-rate word the host runs on each voice every /// `control_period` samples, counted from its note-on (docs/04). - control_word: [MAX_WORD]u8 = [_]u8{0} ** MAX_WORD, + control_word: [MAX_WORD]u8 = @splat(0), control_word_len: usize = 0, control_period: usize = 0, /// Voice machines: one region shared by every voice (docs/04 §Shared /// state), and the render word that runs over it in lockstep with the /// voices, `shared_period` samples at a time. 0 / empty = none. shared_size: usize = 0, - shared_word: [MAX_WORD]u8 = [_]u8{0} ** MAX_WORD, + shared_word: [MAX_WORD]u8 = @splat(0), shared_word_len: usize = 0, shared_period: usize = 0, /// State byte offset + 1 of the f64 a voice keeps at its own level, @@ -422,7 +422,7 @@ pub const Desc = struct { // calls each block, plus an opaque machine-built data pointer. Lets a // machine keep all its specific logic in fy (e.g. FM-86 algorithm routing) // instead of the frame. - derive_word: [MAX_WORD]u8 = [_]u8{0} ** MAX_WORD, + derive_word: [MAX_WORD]u8 = @splat(0), derive_word_len: usize = 0, derive_data: usize = 0, state_size: usize = 0, @@ -461,7 +461,7 @@ pub const Desc = struct { mods: [MAX_MODS]Mod = undefined, mod_count: usize = 0, /// The mod matrix: slots of `-src`, -dst, -amt controls. - matrix: [MAX_TEXT:0]u8 = [_:0]u8{0} ** MAX_TEXT, + matrix: [MAX_TEXT:0]u8 = @splat(0), matrix_len: usize = 0, matrix_slots: usize = 0, @@ -770,7 +770,7 @@ pub fn read(host: *FyHost) !Desc { if (out.kind == .switch_sel and out.option_count == 0) return error.InvalidMachineDesc; if (out.kind == .int_range and !(out.max > out.min)) return error.InvalidMachineDesc; const widget = asInt(ctl.widget); - if (widget < 0 or widget >= std.meta.fields(Widget).len) return error.InvalidMachineDesc; + if (widget < 0 or widget >= @typeInfo(Widget).@"enum".field_names.len) return error.InvalidMachineDesc; out.widget = @enumFromInt(widget); if (!out.widgetFits(out.widget)) return error.InvalidMachineDesc; out.span_rows = asInt(ctl.span) != 0; diff --git a/src/machine_registry.zig b/src/machine_registry.zig index c895649e..b2f9b0e6 100644 --- a/src/machine_registry.zig +++ b/src/machine_registry.zig @@ -133,7 +133,7 @@ pub const UserMachines = struct { const home = storage.home(&hb); if (home.len == 0) return; var db: [storage.MAX_PATH:0]u8 = undefined; - const dir_path = std.fmt.bufPrintZ(&db, "{s}/Machines", .{home}) catch return; + const dir_path = std.mem.printSentinel(&db, "{s}/Machines", .{home}, 0) catch return; self.scanDir(dir_path); } @@ -145,7 +145,7 @@ pub const UserMachines = struct { const id = e.d_name[0..e.d_namlen]; if (id.len == 0 or id[0] == '.') continue; var pb: [MAX_PATH:0]u8 = undefined; - const p = std.fmt.bufPrintZ(&pb, "{s}/{s}/{s}.fy", .{ dir_path, id, id }) catch continue; + const p = std.mem.printSentinel(&pb, "{s}/{s}/{s}.fy", .{ dir_path, id, id }, 0) catch continue; if (access(p, 0) != 0) continue; if (isBuiltin(id)) { std.log.warn("user machine {s} skipped: a builtin machine has that id", .{p}); @@ -173,11 +173,11 @@ pub const UserMachines = struct { test "user machines: /.fy folders, sorted, builtins and strays skipped" { var tb: [MAX_PATH:0]u8 = undefined; - const root = std.fmt.bufPrintZ(&tb, "/tmp/slab-user-machines-test-{d}", .{std.c.getpid()}) catch unreachable; + const root = std.mem.printSentinel(&tb, "/tmp/slab-user-machines-test-{d}", .{std.c.getpid()}, 0) catch unreachable; const dirs = [_][]const u8{ "", "/zeta", "/alpha", "/juno2", "/stray" }; for (dirs) |d| { var b: [MAX_PATH:0]u8 = undefined; - _ = std.c.mkdir(std.fmt.bufPrintZ(&b, "{s}{s}", .{ root, d }) catch unreachable, 0o755); + _ = std.c.mkdir(std.mem.printSentinel(&b, "{s}{s}", .{ root, d }, 0) catch unreachable, 0o755); } for ([_][]const u8{ "zeta", "alpha", "juno2" }) |id| { var b: [MAX_PATH]u8 = undefined; @@ -187,11 +187,11 @@ test "user machines: /.fy folders, sorted, builtins and strays skipped" // defers run last first: the files go, then the folders defer for (0..dirs.len) |k| { var b: [MAX_PATH:0]u8 = undefined; - _ = std.c.rmdir(std.fmt.bufPrintZ(&b, "{s}{s}", .{ root, dirs[dirs.len - 1 - k] }) catch unreachable); + _ = std.c.rmdir(std.mem.printSentinel(&b, "{s}{s}", .{ root, dirs[dirs.len - 1 - k] }, 0) catch unreachable); }; defer for ([_][]const u8{ "zeta", "alpha", "juno2" }) |id| { var b: [MAX_PATH:0]u8 = undefined; - _ = std.c.unlink(std.fmt.bufPrintZ(&b, "{s}/{s}/{s}.fy", .{ root, id, id }) catch unreachable); + _ = std.c.unlink(std.mem.printSentinel(&b, "{s}/{s}/{s}.fy", .{ root, id, id }, 0) catch unreachable); }; // the juno2 folder's skip is expected: keep its warning out of the run const saved_level = std.testing.log_level; @@ -209,9 +209,9 @@ pub const Native = enum(u8) { none, rack, fxrack }; pub const Entry = struct { native: Native = .none, - name: [MAX_NAME]u8 = [_]u8{0} ** MAX_NAME, + name: [MAX_NAME]u8 = @splat(0), name_len: u8 = 0, - path: [MAX_PATH]u8 = [_]u8{0} ** MAX_PATH, + path: [MAX_PATH]u8 = @splat(0), path_len: u16 = 0, raw_mode: fy_raw_machine_mod.Mode = .voice_sample, raw_state_size: usize = 0, diff --git a/src/machines/fy_raw_machine.zig b/src/machines/fy_raw_machine.zig index 4aba575d..bd334565 100644 --- a/src/machines/fy_raw_machine.zig +++ b/src/machines/fy_raw_machine.zig @@ -152,14 +152,14 @@ pub const host_cache = struct { overflow: bool = false, }; - var entries: [MAX]Entry = [_]Entry{.{}} ** MAX; + var entries: [MAX]Entry = @splat(.{}); // std.c.stat names a symbol std doesn't declare on aarch64 macOS. extern "c" fn stat(path: [*:0]const u8, buf: *std.c.Stat) c_int; fn stamp(path: []const u8) ?Stamp { var zb: [1024]u8 = undefined; - const z = std.fmt.bufPrintZ(&zb, "{s}", .{path}) catch return null; + const z = std.mem.printSentinel(&zb, "{s}", .{path}, 0) catch return null; var st: std.c.Stat = undefined; if (stat(z.ptr, &st) != 0) return null; const t = st.mtime(); @@ -374,7 +374,7 @@ pub const FyRawMachine = struct { // Per-region state: effect machines run L through region 0 and R through // region 1 so per-channel state never cross-talks; polyphonic voice // machines get one region per voice. - state_buf: [MAX_REGIONS * MAX_STATE]u8 align(8) = [_]u8{0} ** (MAX_REGIONS * MAX_STATE), + state_buf: [MAX_REGIONS * MAX_STATE]u8 align(8) = @splat(0), // Heap regions for state over MAX_STATE, MAX_REGIONS of state_stride // bytes; empty otherwise. Use stateRegion, never state_buf directly. big_state: []align(8) u8 = &.{}, @@ -387,15 +387,15 @@ pub const FyRawMachine = struct { shared_caller: ?RawCaller = null, shared_awake: bool = false, shared_peak: f64 = 0, - params_buf: [MAX_PARAMS]u8 align(8) = [_]u8{0} ** MAX_PARAMS, - io: [MAX_BLOCK]IoFrame = [_]IoFrame{.{}} ** MAX_BLOCK, + params_buf: [MAX_PARAMS]u8 align(8) = @splat(0), + io: [MAX_BLOCK]IoFrame = @splat(.{}), kctx: KernelCtx = .{}, /// Lane mode's right channel: its io frames (in_l holds R) and ctx /// (chan = 1), beside `io` and `kctx` for the left. - io_r: [MAX_BLOCK]IoFrame = [_]IoFrame{.{}} ** MAX_BLOCK, + io_r: [MAX_BLOCK]IoFrame = @splat(.{}), kctx_r: KernelCtx = .{}, /// A unison voice's own frames, panned into `io` after its pass. - io_u: [MAX_BLOCK]IoFrame = [_]IoFrame{.{}} ** MAX_BLOCK, + io_u: [MAX_BLOCK]IoFrame = @splat(.{}), render_lanes_slots: RawSlots = .{}, render_lite_lanes_slots: RawSlots = .{}, render_lanes_caller: ?RawCaller = null, @@ -418,7 +418,7 @@ pub const FyRawMachine = struct { // many samples each voice has before its next call. control_slots: RawSlots = .{}, control_caller: ?RawCaller = null, - voice_ctl_left: [MAX_REGIONS]usize = [_]usize{0} ** MAX_REGIONS, + voice_ctl_left: [MAX_REGIONS]usize = @splat(0), // Optional per-block dsp2 word (params sample-rate --): coefficient fills // that must not run per sample, e.g. the MS-20 svf profile region. block_prepare_caller: ?RawCaller = null, @@ -430,7 +430,7 @@ pub const FyRawMachine = struct { raw_control_bits: [MAX_CONTROLS]std.atomic.Value(u32) = undefined, // Audio-owned smoothing and snap requests adopted with control targets: // preset/project/param sets jump, drags glide. - smooth_norm: [MAX_CONTROLS]f32 = [_]f32{0} ** MAX_CONTROLS, + smooth_norm: [MAX_CONTROLS]f32 = @splat(0), // Set by a control edit (UI thread), taken by the engine's idle skip: // a sleeping machine renders again so the edit reaches its params. wake_req: std.atomic.Value(bool) = .init(false), @@ -438,21 +438,21 @@ pub const FyRawMachine = struct { // Automation (docs/22). Audio thread: this chunk's lane value per // control, and whether a gliding control has caught up with its curve // (then it follows exactly instead of lagging the 20 ms smoother). - auto_on: [MAX_CONTROLS]bool = [_]bool{false} ** MAX_CONTROLS, - auto_val: [MAX_CONTROLS]f32 = [_]f32{0} ** MAX_CONTROLS, - auto_locked: [MAX_CONTROLS]bool = [_]bool{false} ** MAX_CONTROLS, + auto_on: [MAX_CONTROLS]bool = @splat(false), + auto_val: [MAX_CONTROLS]f32 = @splat(0), + auto_locked: [MAX_CONTROLS]bool = @splat(false), // A rack's macro (docs/42 §Reaching the target), audio-owned: it wins // over the lane and the hand, and follows like a lane does. mod_shown // is the value for the panel (NaN bits: none). - mod_on: [MAX_CONTROLS]bool = [_]bool{false} ** MAX_CONTROLS, - mod_val: [MAX_CONTROLS]f32 = [_]f32{0} ** MAX_CONTROLS, - mod_shown: [MAX_CONTROLS]std.atomic.Value(u32) = [_]std.atomic.Value(u32){std.atomic.Value(u32).init(MOD_NONE)} ** MAX_CONTROLS, + mod_on: [MAX_CONTROLS]bool = @splat(false), + mod_val: [MAX_CONTROLS]f32 = @splat(0), + mod_shown: [MAX_CONTROLS]std.atomic.Value(u32) = @splat(std.atomic.Value(u32).init(MOD_NONE)), // UI writes, audio reads: a manual override of an automated control, // 0 none, 1 held (touch), 2 sticky until the transport starts. - auto_override: [MAX_CONTROLS]std.atomic.Value(u8) = [_]std.atomic.Value(u8){std.atomic.Value(u8).init(0)} ** MAX_CONTROLS, + auto_override: [MAX_CONTROLS]std.atomic.Value(u8) = @splat(std.atomic.Value(u8).init(0)), // UI thread: the automated value each control shows (null = no lane), // set by the host every frame, and a pending request for the host. - ui_auto: [MAX_CONTROLS]?f32 = [_]?f32{null} ** MAX_CONTROLS, + ui_auto: [MAX_CONTROLS]?f32 = @splat(null), auto_request: ?machine.AutoRequest = null, // The control a drag holds this frame (automation recording). touch: ?machine.Touch = null, @@ -479,13 +479,13 @@ pub const FyRawMachine = struct { // A scope-display's ring: the audio thread appends each block's // output, mono, and moves the head. UI reads an owned telemetry copy. has_scope: bool = false, - scope_buf: [SCOPE_LEN]f32 = [_]f32{0} ** SCOPE_LEN, + scope_buf: [SCOPE_LEN]f32 = @splat(0), scope_head: std.atomic.Value(usize) = .init(0), // Generic derived-params hook (params derive-data --): a machine-declared // dsp2 word run each block to compute params from controls + opaque data. derive_slots: RawSlots = .{}, derive_caller: ?RawCaller = null, - preset_dir: [512]u8 = [_]u8{0} ** 512, + preset_dir: [512]u8 = @splat(0), preset_dir_len: usize = 0, presets: presets_mod.List = .{}, current_preset_idx: i32 = -1, @@ -495,13 +495,13 @@ pub const FyRawMachine = struct { // What the current preset sets each control to (stored form: norm, or // the option index), and which controls it names - the panel's // "modified" marker compares the knobs against these. UI thread. - preset_ref: [MAX_CONTROLS]f32 = [_]f32{0} ** MAX_CONTROLS, - preset_ref_on: [MAX_CONTROLS]bool = [_]bool{false} ** MAX_CONTROLS, + preset_ref: [MAX_CONTROLS]f32 = @splat(0), + preset_ref_on: [MAX_CONTROLS]bool = @splat(false), // Host-allocated audio buffers (manifest `buffer` requests), one per // state region: per channel for effects, per voice for voice machines. // Base pointer + element count are injected into each region's state // at the request's introspected offsets. - buffer_mem: [machine_desc.MAX_BUFFERS][MAX_REGIONS][]f64 = [_][MAX_REGIONS][]f64{[_][]f64{&.{}} ** MAX_REGIONS} ** machine_desc.MAX_BUFFERS, + buffer_mem: [machine_desc.MAX_BUFFERS][MAX_REGIONS][]f64 = @splat(@as([MAX_REGIONS][]f64, @splat(&.{}))), // Read-only audio assets loaded from disk at create (manifest `asset`), // shared across voices via params. asset_versions: handoff.Latest(AssetVersion) = .init(.{}), @@ -509,27 +509,27 @@ pub const FyRawMachine = struct { asset_generation: u64 = 0, // UI-owned asset_reset_generation: u64 = 0, // UI-owned audio_reset_generation: u64 = 0, - asset_mem: [machine_desc.MAX_ASSETS]wav.Sample = [_]wav.Sample{.{ .data = &.{}, .sample_rate = 0 }} ** machine_desc.MAX_ASSETS, + asset_mem: [machine_desc.MAX_ASSETS]wav.Sample = @splat(.{ .data = &.{}, .sample_rate = 0 }), // Wavetable assets (manifest `wavetable`): the mipmapped frames built // from asset_mem, which stays loaded for the waveform display. - asset_wt: [machine_desc.MAX_ASSETS]wavetable.Table = [_]wavetable.Table{.{}} ** machine_desc.MAX_ASSETS, + asset_wt: [machine_desc.MAX_ASSETS]wavetable.Table = @splat(.{}), // Keymap assets (manifest `keymap`): the loaded pool + zone table. - asset_keymap: [machine_desc.MAX_ASSETS]keymap.Keymap = [_]keymap.Keymap{.{}} ** machine_desc.MAX_ASSETS, + asset_keymap: [machine_desc.MAX_ASSETS]keymap.Keymap = @splat(.{}), /// A keymap's pool low-passed ahead of its stored rate /// (`keymap-antialias`), what the kernel reads when present, and the /// control value it was built for. - aa_pool: [machine_desc.MAX_ASSETS][]f64 = [_][]f64{&.{}} ** machine_desc.MAX_ASSETS, - aa_built: [machine_desc.MAX_ASSETS]f64 = [_]f64{-1} ** machine_desc.MAX_ASSETS, + aa_pool: [machine_desc.MAX_ASSETS][]f64 = @splat(&.{}), + aa_built: [machine_desc.MAX_ASSETS]f64 = @splat(-1), // A valid silent target for assets that failed to load, so a kernel's // clamped read hits real zeroed memory instead of an empty slice's ptr. // Long enough for an interpolator reading around the guard index. - asset_silence: [32]f64 align(8) = [_]f64{0} ** 32, // covers the voice's 26-tap read + asset_silence: [32]f64 align(8) = @splat(0), // covers the voice's 26-tap read // The zone table an empty keymap points at: every slot unused. - empty_zones: [keymap.MAX_ZONES]keymap.Zone = [_]keymap.Zone{keymap.unused_zone} ** keymap.MAX_ZONES, + empty_zones: [keymap.MAX_ZONES]keymap.Zone = @splat(keymap.unused_zone), // Each asset's current source path (the manifest default, or what LOAD // picked), for the project file. asset_path: [machine_desc.MAX_ASSETS][storage.MAX_PATH]u8 = undefined, - asset_path_len: [machine_desc.MAX_ASSETS]usize = [_]usize{0} ** machine_desc.MAX_ASSETS, + asset_path_len: [machine_desc.MAX_ASSETS]usize = @splat(0), // Sampler zone editing, for the keymap asset: the per-zone edits the // voice reads through a params pointer, the zone the panel has // selected, the piano roll's key names for a kit, and the zone list's @@ -553,8 +553,8 @@ pub const FyRawMachine = struct { zone_follow: bool = false, zone_scroll: usize = 0, zone_last_seen: f64 = -1, - zone_hit_seen: [keymap.MAX_ZONES]f64 = [_]f64{0} ** keymap.MAX_ZONES, - zone_flash: [keymap.MAX_ZONES]f32 = [_]f32{0} ** keymap.MAX_ZONES, + zone_hit_seen: [keymap.MAX_ZONES]f64 = @splat(0), + zone_flash: [keymap.MAX_ZONES]f32 = @splat(0), // The zone the waveform's peak cache holds (maxInt: rebuild), and its // sample's peak, for the normalized drawing and the readout. wave_zone: usize = std.math.maxInt(usize), @@ -562,18 +562,18 @@ pub const FyRawMachine = struct { km_labels: [keymap.MAX_ZONES]machine.NoteLabel = undefined, km_label_count: usize = 0, // True once LOAD, a preset or a project replaced the manifest default. - asset_loaded: [machine_desc.MAX_ASSETS]bool = [_]bool{false} ** machine_desc.MAX_ASSETS, + asset_loaded: [machine_desc.MAX_ASSETS]bool = @splat(false), // Peak pyramid per asset for oscillogram drawing (UI thread only). - asset_cache: [machine_desc.MAX_ASSETS]waveform.PeakCache = [_]waveform.PeakCache{.{}} ** machine_desc.MAX_ASSETS, + asset_cache: [machine_desc.MAX_ASSETS]waveform.PeakCache = @splat(.{}), // Display name (basename) of each asset's currently loaded file. asset_label: [machine_desc.MAX_ASSETS][96]u8 = undefined, - asset_label_len: [machine_desc.MAX_ASSETS]usize = [_]usize{0} ** machine_desc.MAX_ASSETS, + asset_label_len: [machine_desc.MAX_ASSETS]usize = @splat(0), // The wavetable editor (docs/15 §Wavetable editor): a doc for each // wavetable asset EDIT has opened, whether it changed since its file // was written, and the asset the editor has open over the panel (with // its oscillator's name for the caption). - wt_docs: [machine_desc.MAX_ASSETS]?*wte.Doc = [_]?*wte.Doc{null} ** machine_desc.MAX_ASSETS, - wt_unsaved: [machine_desc.MAX_ASSETS]bool = [_]bool{false} ** machine_desc.MAX_ASSETS, + wt_docs: [machine_desc.MAX_ASSETS]?*wte.Doc = @splat(null), + wt_unsaved: [machine_desc.MAX_ASSETS]bool = @splat(false), /// A table edit since the host last asked (take_edited). wt_edited: bool = false, wt_editing: ?usize = null, @@ -590,15 +590,15 @@ pub const FyRawMachine = struct { // never freed — like the Juno-106, every voice always renders; note-on // takes the oldest un-gated voice, else steals the oldest gated one. // note_id is -1 throughout the sequencer, so matching is by pitch. - voice_pitch: [MAX_REGIONS]f32 = [_]f32{-1} ** MAX_REGIONS, - voice_gate: [MAX_REGIONS]bool = [_]bool{false} ** MAX_REGIONS, - voice_age: [MAX_REGIONS]u64 = [_]u64{0} ** MAX_REGIONS, + voice_pitch: [MAX_REGIONS]f32 = @splat(-1), + voice_gate: [MAX_REGIONS]bool = @splat(false), + voice_age: [MAX_REGIONS]u64 = @splat(0), age_counter: u64 = 0, // Samples rendered so far, and when each voice's note started on that // clock: poly legato tells a hammer-on from a chord by the gap. clock: u64 = 0, event_clock: u64 = 0, - voice_on_clock: [MAX_REGIONS]u64 = [_]u64{0} ** MAX_REGIONS, + voice_on_clock: [MAX_REGIONS]u64 = @splat(0), // Mono machines (1 voice, melodic): held-note stack, newest last, for // last-note priority. Releasing the sounding note falls back to the // newest still-held one as a legato retrigger. @@ -613,15 +613,15 @@ pub const FyRawMachine = struct { synced_tempo: f64 = 120, // The note id each voice plays (-1: a source without ids). Note-offs // and expression match by id, so a bent note is still found. - voice_note_id: [MAX_REGIONS]i32 = [_]i32{-1} ** MAX_REGIONS, + voice_note_id: [MAX_REGIONS]i32 = @splat(-1), // Idle voices are skipped entirely (docs/17 D6). A voice wakes on // note-on and goes idle once released and its contribution stays under // IDLE_FLOOR for a whole block. voice_peak is this block's max |delta| // the voice added to the shared out lane. - voice_idle: [MAX_REGIONS]bool = [_]bool{true} ** MAX_REGIONS, - voice_peak: [MAX_REGIONS]f64 = [_]f64{0} ** MAX_REGIONS, + voice_idle: [MAX_REGIONS]bool = @splat(true), + voice_peak: [MAX_REGIONS]f64 = @splat(0), // out_l before a voice's pass, to measure what that voice added. - voice_snap: [MAX_BLOCK]f64 = [_]f64{0} ** MAX_BLOCK, + voice_snap: [MAX_BLOCK]f64 = @splat(0), // Host unison (docs/08 §Unison). `unison` is the UI's settings; the // rest is the audio thread's. `pool` is the regions in use. A note's // voices share one voice_age; voice_uni_k/_n place each in its group @@ -637,10 +637,10 @@ pub const FyRawMachine = struct { uni_rng: u64 = 0x9E37_79B9_7F4A_7C15, // Some voice renders through the panned path this block. uni_panned: bool = false, - voice_uni_k: [MAX_REGIONS]u8 = [_]u8{0} ** MAX_REGIONS, - voice_uni_n: [MAX_REGIONS]u8 = [_]u8{1} ** MAX_REGIONS, - voice_jit: [MAX_REGIONS]f32 = [_]f32{0} ** MAX_REGIONS, - voice_expr: [MAX_REGIONS][4]f64 = [_][4]f64{.{ 60, 0.5, 0, 1 }} ** MAX_REGIONS, + voice_uni_k: [MAX_REGIONS]u8 = @splat(0), + voice_uni_n: [MAX_REGIONS]u8 = @splat(1), + voice_jit: [MAX_REGIONS]f32 = @splat(0), + voice_expr: [MAX_REGIONS][4]f64 = @splat(.{ 60, 0.5, 0, 1 }), // Per-frame meter ballistics (meter display kind). One per machine; a // limiter has a single meter. Updated on the UI thread from live state. meter_ui: MeterUi = .{}, @@ -1000,7 +1000,7 @@ pub const FyRawMachine = struct { self.km_label_count = 0; const km = &self.asset_keymap[ai]; if (!km.isKit()) return; - var seen = [_]bool{false} ** 128; + var seen: [128]bool = @splat(false); for (km.zones[0..km.count], km.names) |z, nm| { const key: usize = @intFromFloat(std.math.clamp(z.lo_key, 0, 127)); if (seen[key]) continue; @@ -2566,7 +2566,7 @@ fn codeHashOf(host: *FyHost, path: []const u8) u64 { for (paths[0..n]) |p| { h.update(std.fs.path.basename(p)); var zb: [1024]u8 = undefined; - const z = std.fmt.bufPrintZ(&zb, "{s}", .{p}) catch continue; + const z = std.mem.printSentinel(&zb, "{s}", .{p}, 0) catch continue; const fd = std.c.open(z.ptr, .{}); if (fd < 0) continue; defer _ = std.c.close(fd); @@ -3193,7 +3193,7 @@ fn noteOnEvent(self: *FyRawMachine, ev: machine.NoteEvent) !void { // The note's voices: a mono group is always the first regions. const n = self.uni_count; var group: [MAX_REGIONS]usize = undefined; - var taken = [_]bool{false} ** MAX_REGIONS; + var taken: [MAX_REGIONS]bool = @splat(false); for (0..n) |k| { group[k] = if (mono) k else allocVoice(self, &taken); taken[group[k]] = true; @@ -3802,8 +3802,8 @@ fn drawStrip(self: *FyRawMachine, ui: *Ui, r: Rect, view: StripView, tier: ui_ct /// its widest control and each row as tall as its tallest, at the tier's /// natural sizes. const StripTable = struct { - col_w: [MAX_CONTROLS]i32 = [_]i32{0} ** MAX_CONTROLS, - row_h: [MAX_CONTROLS]i32 = [_]i32{0} ** MAX_CONTROLS, + col_w: [MAX_CONTROLS]i32 = @splat(0), + row_h: [MAX_CONTROLS]i32 = @splat(0), cols: usize = 0, rows: usize = 0, /// Leading columns taken whole by `span-rows` controls; the grid of @@ -5308,7 +5308,7 @@ const SPEC_RELEASE_DB: f32 = 30.0; // per second const SPEC_STILL_S: f32 = 0.08; const GraphicUi = struct { - db: [GRAPHIC_COLS]f32 = [_]f32{SPEC_FLOOR_DB} ** GRAPHIC_COLS, + db: [GRAPHIC_COLS]f32 = @splat(SPEC_FLOOR_DB), last_wpos: f64 = -1, still: f32 = 0, // A band handle press: where it began, and whether it has moved far @@ -6274,17 +6274,17 @@ fn capSeg(ui: *Ui, xa: f32, la: f32, xb: f32, lb: f32, base: f32, h: f32, col: u fn formatControlValue(buf: *[16:0]u8, v: f64) [*:0]const u8 { const av = @abs(v); const s = if (av >= 10_000.0) - std.fmt.bufPrintZ(buf, "{d:.1}k", .{v / 1000.0}) + std.mem.printSentinel(buf, "{d:.1}k", .{v / 1000.0}, 0) else if (av >= 1000.0) - std.fmt.bufPrintZ(buf, "{d:.2}k", .{v / 1000.0}) + std.mem.printSentinel(buf, "{d:.2}k", .{v / 1000.0}, 0) else if (av >= 100.0) - std.fmt.bufPrintZ(buf, "{d:.0}", .{v}) + std.mem.printSentinel(buf, "{d:.0}", .{v}, 0) else if (av >= 10.0) - std.fmt.bufPrintZ(buf, "{d:.1}", .{v}) + std.mem.printSentinel(buf, "{d:.1}", .{v}, 0) else if (av >= 1.0) - std.fmt.bufPrintZ(buf, "{d:.2}", .{v}) + std.mem.printSentinel(buf, "{d:.2}", .{v}, 0) else - std.fmt.bufPrintZ(buf, "{d:.3}", .{v}); + std.mem.printSentinel(buf, "{d:.3}", .{v}, 0); return (s catch return "?").ptr; } @@ -6306,8 +6306,8 @@ test "raw DSP2 silence fixture renders zeros through generic adapter" { var ctx = std.mem.zeroes(machine.MachineCtx); ctx.sample_rate = 48_000; ctx.block_size = 32; - var l = [_]f32{1} ** 32; - var r = [_]f32{1} ** 32; + var l: [32]f32 = @splat(1); + var r: [32]f32 = @splat(1); testRender(mach, &ctx, &l, &r); for (l, r) |sl, sr| { @@ -6331,8 +6331,8 @@ test "raw DSP2 oscillator fixture responds to note events" { ctx.note_in = @ptrCast(events[0..].ptr); ctx.note_in_count = events.len; - var l = [_]f32{0} ** 64; - var r = [_]f32{0} ** 64; + var l: [64]f32 = @splat(0); + var r: [64]f32 = @splat(0); testRender(mach, &ctx, &l, &r); var pre_off_energy: f64 = 0; @@ -6362,8 +6362,8 @@ test "FM-86 plays a note end to end (routing hook + staged voice)" { ctx.note_in = @ptrCast(events[0..].ptr); ctx.note_in_count = events.len; - var l = [_]f32{0} ** 1024; - var r = [_]f32{0} ** 1024; + var l: [1024]f32 = @splat(0); + var r: [1024]f32 = @splat(0); testRender(mach, &ctx, &l, &r); // Finite, mono-duplicated, and within the clamp. @@ -6398,8 +6398,8 @@ test "FM-86 fy derive routing matches the dx7_algorithms oracle (all 32)" { var ctx = std.mem.zeroes(machine.MachineCtx); ctx.sample_rate = 48_000; ctx.block_size = 16; - var l = [_]f32{0} ** 16; - var r = [_]f32{0} ** 16; + var l: [16]f32 = @splat(0); + var r: [16]f32 = @splat(0); // The ALGO int-step selects each algorithm; the fy derive word fills the // routing from its own table. Pin all 27 routing fields against the @@ -6487,8 +6487,8 @@ test "FM-86 plays an imported DX7 preset (E.PIANO 1)" { ctx.note_in = null; ctx.note_in_count = 0; } - var l = [_]f32{0} ** 4096; - var r = [_]f32{0} ** 4096; + var l: [4096]f32 = @splat(0); + var r: [4096]f32 = @splat(0); testRender(mach, &ctx, &l, &r); var sum: f64 = 0; for (l, r) |sl, sr| { @@ -6697,8 +6697,8 @@ test "FM-86 is polyphonic — a chord sounds all three notes" { ctx.block_size = 4096; ctx.note_in = @ptrCast(events[0..].ptr); ctx.note_in_count = events.len; - var l = [_]f32{0} ** 4096; - var r = [_]f32{0} ** 4096; + var l: [4096]f32 = @splat(0); + var r: [4096]f32 = @splat(0); testRender(mach, &ctx, &l, &r); // Goertzel magnitude at each note's fundamental; all three must be present. @@ -6728,8 +6728,8 @@ test "DS-404 stays finite and bounded under live-style blocks" { } const sizes = [_]usize{ 512, 471, 64, 256, 1, 333, 128, 512 }; const pitches = [_]f32{ 36, 38, 39, 42, 45, 46, 60, 64, 0, 127 }; - var l = [_]f32{0} ** 512; - var r = [_]f32{0} ** 512; + var l: [512]f32 = @splat(0); + var r: [512]f32 = @splat(0); for ([_]f64{ 44_100, 48_000 }) |sr| { var ctx = std.mem.zeroes(machine.MachineCtx); ctx.sample_rate = sr; @@ -6771,8 +6771,8 @@ test "FM-86 survives many small live-style blocks with note churn" { var ctx = std.mem.zeroes(machine.MachineCtx); ctx.sample_rate = 44_100; ctx.block_size = 256; - var l = [_]f32{0} ** 256; - var r = [_]f32{0} ** 256; + var l: [256]f32 = @splat(0); + var r: [256]f32 = @splat(0); var blk: usize = 0; while (blk < 300) : (blk += 1) { @@ -6898,7 +6898,7 @@ fn fm86RenderBlocks(out: []f32, sizes: []const usize) !void { defer mach.deinit.?(mach.state, testing.allocator); var ctx = std.mem.zeroes(machine.MachineCtx); ctx.sample_rate = 48_000; - var r = [_]f32{0} ** 1024; + var r: [1024]f32 = @splat(0); const on_at: usize = 100; const off_at: usize = 12_000; var pos: usize = 0; @@ -6951,8 +6951,8 @@ test "raw DSP2 saturator fixture processes audio input through generic adapter" const mach = inst.machineInterface(); defer mach.deinit.?(mach.state, testing.allocator); - var in_l = [_]f32{0.75} ** 32; - var in_r = [_]f32{-0.75} ** 32; + var in_l: [32]f32 = @splat(0.75); + var in_r: [32]f32 = @splat(-0.75); const in_ports = [_][*]const f32{ &in_l, &in_r }; var ctx = std.mem.zeroes(machine.MachineCtx); ctx.sample_rate = 48_000; @@ -6960,8 +6960,8 @@ test "raw DSP2 saturator fixture processes audio input through generic adapter" ctx.audio_in = @ptrCast(&in_ports[0]); ctx.audio_in_count = 2; - var l = [_]f32{0} ** 32; - var r = [_]f32{0} ** 32; + var l: [32]f32 = @splat(0); + var r: [32]f32 = @splat(0); testRender(mach, &ctx, &l, &r); try testing.expect(l[0] > in_l[0]); @@ -6988,8 +6988,8 @@ test "raw DSP2 MS-20 fixture renders a finite note through generic adapter" { ctx.note_in = @ptrCast(events[0..].ptr); ctx.note_in_count = events.len; - var l = [_]f32{0} ** 512; - var r = [_]f32{0} ** 512; + var l: [512]f32 = @splat(0); + var r: [512]f32 = @splat(0); testRender(mach, &ctx, &l, &r); var peak: f32 = 0; @@ -7070,8 +7070,8 @@ fn testEnergy(mach: machine.Machine, events: []machine.NoteEvent, blocks: usize) var ctx = std.mem.zeroes(machine.MachineCtx); ctx.sample_rate = 48_000; ctx.block_size = 256; - var l = [_]f32{0} ** 256; - var r = [_]f32{0} ** 256; + var l: [256]f32 = @splat(0); + var r: [256]f32 = @splat(0); var e: f64 = 0; for (0..blocks) |b| { ctx.note_in = @ptrCast(events.ptr); @@ -7146,8 +7146,8 @@ fn renderEffectImpulse(path: []const u8, sets: []const ParamSet, amp_l: f32, amp defer mach.deinit.?(mach.state, testing.allocator); for (sets) |st| mach.set_param.?(mach.state, st[0], st[1]); const block = 256; - var in_l = [_]f32{0} ** block; - var in_r = [_]f32{0} ** block; + var in_l: [block]f32 = @splat(0); + var in_r: [block]f32 = @splat(0); const in_ports = [_][*]const f32{ &in_l, &in_r }; var ctx = std.mem.zeroes(machine.MachineCtx); ctx.sample_rate = 48_000; @@ -7236,15 +7236,15 @@ test "delay FREEZE holds the loop; TAPE at FB 1.1 self-oscillates but stays boun mach.set_param.?(mach.state, "delay-fb", 0); mach.set_param.?(mach.state, "delay-mix", 1); const block = 256; - var in_l = [_]f32{0} ** block; + var in_l: [block]f32 = @splat(0); const in_ports = [_][*]const f32{ &in_l, &in_l }; var ctx = std.mem.zeroes(machine.MachineCtx); ctx.sample_rate = 48_000; ctx.block_size = block; ctx.audio_in = @ptrCast(&in_ports[0]); ctx.audio_in_count = 2; - var l = [_]f32{0} ** block; - var r = [_]f32{0} ** block; + var l: [block]f32 = @splat(0); + var r: [block]f32 = @splat(0); in_l[0] = 1; testRender(mach, &ctx, &l, &r); in_l[0] = 0; @@ -7333,8 +7333,8 @@ test "shared state: voices and the shared word alternate per step, then the word var ctx = std.mem.zeroes(machine.MachineCtx); ctx.sample_rate = 48_000; ctx.block_size = block; - var l = [_]f32{0} ** block; - var r = [_]f32{0} ** block; + var l: [block]f32 = @splat(0); + var r: [block]f32 = @splat(0); const on = [_]machine.NoteEvent{ .{ .sample_offset = 0, .kind = .note_on, .channel = 0, .note_id = -1, .pitch = 60, .velocity = 1 }, .{ .sample_offset = 0, .kind = .note_on, .channel = 0, .note_id = -1, .pitch = 64, .velocity = 0.5 }, @@ -7376,8 +7376,8 @@ test "shared state: the shared word sees each step's sample clock; voice-level! var ctx = std.mem.zeroes(machine.MachineCtx); ctx.sample_rate = 48_000; ctx.block_size = block; - var l = [_]f32{0} ** block; - var r = [_]f32{0} ** block; + var l: [block]f32 = @splat(0); + var r: [block]f32 = @splat(0); const on = [_]machine.NoteEvent{.{ .sample_offset = 0, .kind = .note_on, .channel = 0, .note_id = -1, .pitch = 60, .velocity = 1 }}; ctx.note_in = &on; ctx.note_in_count = on.len; @@ -7414,8 +7414,8 @@ test "stereo flag: stereo voices write L and R, stereo effects get one true-ster var ctx = std.mem.zeroes(machine.MachineCtx); ctx.sample_rate = 48_000; ctx.block_size = block; - var l = [_]f32{0} ** block; - var r = [_]f32{0} ** block; + var l: [block]f32 = @splat(0); + var r: [block]f32 = @splat(0); { const inst = try FyRawMachine.create(testing.allocator, "machines/raw_fixtures/stereo_voice.fy"); const mach = inst.machineInterface(); @@ -7433,8 +7433,8 @@ test "stereo flag: stereo voices write L and R, stereo effects get one true-ster const inst = try FyRawMachine.create(testing.allocator, "machines/raw_fixtures/stereo_swap.fy"); const mach = inst.machineInterface(); defer mach.deinit.?(mach.state, testing.allocator); - var in_l = [_]f32{0.5} ** block; - var in_r = [_]f32{-0.75} ** block; + var in_l: [block]f32 = @splat(0.5); + var in_r: [block]f32 = @splat(-0.75); const ports = [_][*]const f32{ &in_l, &in_r }; ctx.audio_in = @ptrCast(&ports[0]); ctx.audio_in_count = 2; @@ -7452,8 +7452,8 @@ test "mono note stack: last-note priority, releasing the sounding note falls bac var ctx = std.mem.zeroes(machine.MachineCtx); ctx.sample_rate = 48_000; ctx.block_size = block; - var l = [_]f32{0} ** block; - var r = [_]f32{0} ** block; + var l: [block]f32 = @splat(0); + var r: [block]f32 = @splat(0); const Ev = struct { fn on(p: f32) machine.NoteEvent { return .{ .sample_offset = 0, .kind = .note_on, .channel = 0, .note_id = -1, .pitch = p, .velocity = 0.8 }; @@ -7491,8 +7491,8 @@ test "knob smoothing: drags glide over ~20 ms, param sets snap" { var ctx = std.mem.zeroes(machine.MachineCtx); ctx.sample_rate = 48_000; ctx.block_size = block; - var l = [_]f32{0} ** block; - var r = [_]f32{0} ** block; + var l: [block]f32 = @splat(0); + var r: [block]f32 = @splat(0); testRender(mach, &ctx, &l, &r); // settle inst.setControlNormSnap(ci, 0.0); @@ -7523,8 +7523,8 @@ test "voice service: idle voices are skipped, wake on note-on, sleep after relea var ctx = std.mem.zeroes(machine.MachineCtx); ctx.sample_rate = 48_000; ctx.block_size = block; - var l = [_]f32{0} ** block; - var r = [_]f32{0} ** block; + var l: [block]f32 = @splat(0); + var r: [block]f32 = @splat(0); const on = [_]machine.NoteEvent{.{ .sample_offset = 0, .kind = .note_on, .channel = 0, .note_id = -1, .pitch = 60, .velocity = 0.8 }}; ctx.note_in = &on; ctx.note_in_count = 1; @@ -7559,7 +7559,7 @@ test "delay SYNC follows ctx.tempo: quarter-note echo lands on the beat" { mach.set_param.?(mach.state, "delay-mix", 1); const block = 256; - var in_l = [_]f32{0} ** block; + var in_l: [block]f32 = @splat(0); const in_ports = [_][*]const f32{ &in_l, &in_l }; var ctx = std.mem.zeroes(machine.MachineCtx); ctx.sample_rate = 48_000; @@ -7567,8 +7567,8 @@ test "delay SYNC follows ctx.tempo: quarter-note echo lands on the beat" { ctx.tempo_bpm = cs.bpm; ctx.audio_in = @ptrCast(&in_ports[0]); ctx.audio_in_count = 2; - var l = [_]f32{0} ** block; - var r = [_]f32{0} ** block; + var l: [block]f32 = @splat(0); + var r: [block]f32 = @splat(0); var best: f32 = 0; var best_at: usize = 0; var pos: usize = 0; @@ -7692,8 +7692,8 @@ test "verb: sweeping SIZE and DECAY while it rings stays finite and bounded" { ctx.block_size = block; ctx.audio_in = @ptrCast(&in_ports[0]); ctx.audio_in_count = 2; - var l = [_]f32{0} ** block; - var r = [_]f32{0} ** block; + var l: [block]f32 = @splat(0); + var r: [block]f32 = @splat(0); var seed: u32 = 1; for (0..600) |bi| { for (&in_l, &in_r) |*a, *b| { @@ -7782,8 +7782,8 @@ test "raw DSP2 reverb machine: wide decorrelated tail" { // Centered impulse in: the tail must ring on both channels but differ // between them (decorrelated tap sets), and stay finite. const block = 512; - var in_l = [_]f32{0} ** block; - var in_r = [_]f32{0} ** block; + var in_l: [block]f32 = @splat(0); + var in_r: [block]f32 = @splat(0); in_l[0] = 0.9; in_r[0] = 0.9; const in_ports = [_][*]const f32{ &in_l, &in_r }; @@ -7793,8 +7793,8 @@ test "raw DSP2 reverb machine: wide decorrelated tail" { ctx.audio_in = @ptrCast(&in_ports[0]); ctx.audio_in_count = 2; - var l = [_]f32{0} ** block; - var r = [_]f32{0} ** block; + var l: [block]f32 = @splat(0); + var r: [block]f32 = @splat(0); var tail_l: f64 = 0; var tail_r: f64 = 0; var lr_diff: f64 = 0; @@ -7993,8 +7993,8 @@ test "raw DSP2 juno machine: polyphonic chord through the voice pool" { ctx.note_in = @ptrCast(events[0..].ptr); ctx.note_in_count = events.len; - var l = [_]f32{0} ** 512; - var r = [_]f32{0} ** 512; + var l: [512]f32 = @splat(0); + var r: [512]f32 = @splat(0); testRender(mach, &ctx, &l, &r); // Three voices gated on distinct pitches. @@ -8137,8 +8137,8 @@ test "raw DSP2 sampler machine: asset loads and polyphonic notes sound" { ctx.note_in = @ptrCast(events[0..].ptr); ctx.note_in_count = events.len; - var l = [_]f32{0} ** 512; - var r = [_]f32{0} ** 512; + var l: [512]f32 = @splat(0); + var r: [512]f32 = @splat(0); var energy: f64 = 0; var blk: usize = 0; while (blk < 8) : (blk += 1) { @@ -8354,7 +8354,7 @@ const keymap_test = struct { ctx.block_size = 512; ctx.note_in = @ptrCast(events.ptr); ctx.note_in_count = @intCast(events.len); - var r = [_]f32{0} ** 512; + var r: [512]f32 = @splat(0); for (0..blocks) |bi| { testRender(mach, &ctx, out[bi * 512 ..][0..512], &r); ctx.note_in_count = 0; @@ -8401,7 +8401,7 @@ test "sampler keymap: a folder of note-named samples maps by key, pitch follows try testing.expect(inst.loadAssetRuntime(0, T.dir ++ "/tones")); try testing.expectEqual(@as(usize, 2), inst.asset_keymap[0].count); - var out = [_]f32{0} ** (512 * 16); + var out: [(512 * 16)]f32 = @splat(0); // E3 falls in C3's half (0..60): 130.81 * 2^(4/12) var e1 = [_]machine.NoteEvent{T.on(52, 1)}; T.play(inst, &e1, 16, &out); @@ -8427,7 +8427,7 @@ test "sampler keymap: smpl root and loop hold a note past the sample's end" { const z = inst.asset_keymap[0].zones[0]; try testing.expectEqual(@as(f64, 71), z.root); try testing.expectEqual(keymap.LOOP_ON, z.loop_mode); - var out = [_]f32{0} ** (512 * 24); + var out: [(512 * 24)]f32 = @splat(0); var ev = [_]machine.NoteEvent{T.on(71, 1)}; T.play(inst, &ev, 24, &out); // 0.25 s in, long past the 0.1 s sample, still sounding at pitch @@ -8449,7 +8449,7 @@ test "sampler keymap: drum folder, one-shots ignore note-off, closed hat chokes try testing.expect(inst.loadAssetRuntime(0, T.dir ++ "/kit")); try testing.expectEqual(@as(usize, 3), inst.asset_keymap[0].count); - var out = [_]f32{0} ** (512 * 8); + var out: [(512 * 8)]f32 = @splat(0); // kick on and straight off: a one-shot keeps playing var k = [_]machine.NoteEvent{ T.on(36, 1), T.off(36) }; T.play(inst, &k, 8, &out); @@ -8457,7 +8457,7 @@ test "sampler keymap: drum folder, one-shots ignore note-off, closed hat chokes try testing.expectApproxEqRel(@as(f64, 60), T.freq(out[512..]), 0.02); // let the kick finish var none = [_]machine.NoteEvent{}; - var long = [_]f32{0} ** (512 * 48); + var long: [(512 * 48)]f32 = @splat(0); T.play(inst, &none, 48, &long); // open hat rings; a closed hat silences it within a few ms @@ -8496,10 +8496,10 @@ test "sampler MODEL sets the engine knobs; VARI and FIXED at a low RATE stay in try testing.expectApproxEqRel(@as(f64, 26040), normToValue(inst.desc.controls[rate], inst.controlNorm(rate)), 1e-3); try testing.expectEqual(@as(usize, 2), switchIndex(inst.desc.controls[find(inst, "smp-engine")], inst.controlNorm(find(inst, "smp-engine")))); - var vari = [_]f32{0} ** (512 * 8); - var fixed = [_]f32{0} ** (512 * 8); + var vari: [(512 * 8)]f32 = @splat(0); + var fixed: [(512 * 8)]f32 = @splat(0); var none = [_]machine.NoteEvent{}; - var tail = [_]f32{0} ** (512 * 64); + var tail: [(512 * 64)]f32 = @splat(0); // the filter keeps the images out of the zero-crossing count applyControlValue(inst, "smp-filter", 2000); applyControlValue(inst, "smp-rate", 8000); @@ -8537,9 +8537,9 @@ test "sampler zones: CUT overrides the pack's choke per zone" { unreachable; } }.of; - var out = [_]f32{0} ** (512 * 8); + var out: [(512 * 8)]f32 = @splat(0); var none = [_]machine.NoteEvent{}; - var long = [_]f32{0} ** (512 * 96); + var long: [(512 * 96)]f32 = @splat(0); // OFF on the open hat: the closed hat no longer cuts it inst.zone_edits.cut[idx(km, "open hat")] = 1; @@ -8587,7 +8587,7 @@ test "sampler zones: a reversed copy loads from the zones JSON, plays and writes try testing.expectEqual(@as(f64, -6), inst.zone_edits.level[1]); try testing.expectEqual(@as(f64, -3), inst.zone_edits.level[0]); - var out = [_]f32{0} ** (512 * 4); + var out: [(512 * 4)]f32 = @splat(0); var ev = [_]machine.NoteEvent{T.on(60, 1)}; T.play(inst, &ev, 4, &out); try testing.expect(T.rms(out[0..]) > 0.01); @@ -8618,7 +8618,7 @@ test "sampler keymap: sfz round robin alternates per key" { const mach = inst.machineInterface(); defer mach.deinit.?(mach.state, a); try testing.expect(inst.loadAssetRuntime(0, T.dir ++ "/rr/test.sfz")); - var out = [_]f32{0} ** (512 * 24); + var out: [(512 * 24)]f32 = @splat(0); var ev = [_]machine.NoteEvent{T.on(57, 1)}; for ([_]f64{ 220, 330, 220, 330 }) |want| { // 24 blocks outlast the 0.2 s one-shot, so notes don't overlap @@ -8642,7 +8642,7 @@ test "sampler keymap: a release zone plays at note-off, in the same voice" { defer mach.deinit.?(mach.state, a); try testing.expect(inst.loadAssetRuntime(0, T.dir ++ "/relz/test.sfz")); applyControlValue(inst, "smp-rel", 0.01); - var out = [_]f32{0} ** (512 * 12); + var out: [(512 * 12)]f32 = @splat(0); // held: only the body, at pitch var on = [_]machine.NoteEvent{T.on(57, 1)}; T.play(inst, &on, 12, &out); @@ -8658,9 +8658,9 @@ test "sampler keymap: a release zone plays at note-off, in the same voice" { try testing.expectApproxEqRel(want, T.rms(out[2048 .. 512 * 8]), 0.1); // a second note-off starts nothing var none = [_]machine.NoteEvent{}; - var tail = [_]f32{0} ** (512 * 32); + var tail: [(512 * 32)]f32 = @splat(0); T.play(inst, &none, 32, &tail); - var again = [_]f32{0} ** (512 * 8); + var again: [(512 * 8)]f32 = @splat(0); T.play(inst, &off, 8, &again); try testing.expect(T.rms(&again) < 1e-4); } @@ -8675,13 +8675,13 @@ test "sampler keymap: swapping to a smaller keymap mid-note doesn't read the old const mach = inst.machineInterface(); defer mach.deinit.?(mach.state, a); try testing.expect(inst.loadAssetRuntime(0, T.dir ++ "/swap/long_A3.wav")); - var out = [_]f32{0} ** (512 * 200); + var out: [(512 * 200)]f32 = @splat(0); // a note deep into the 3 s sample, far past the short one's end var ev = [_]machine.NoteEvent{T.on(57, 1)}; T.play(inst, &ev, 200, &out); try testing.expect(inst.loadAssetRuntime(0, T.dir ++ "/swap/short_A3.wav")); var none = [_]machine.NoteEvent{}; - var after = [_]f32{0} ** (512 * 4); + var after: [(512 * 4)]f32 = @splat(0); T.play(inst, &none, 4, &after); for (after) |x| try testing.expectEqual(@as(f32, 0), x); // and the new map plays @@ -8700,8 +8700,8 @@ test "unfairlight: 16 KB of voice RAM at RATE, segment loops, in tune on the car try testing.expect(inst.loadAssetRuntime(0, T.dir ++ "/cmi/tone_A3.wav")); applyControlValue(inst, "cmi-rate", 16384); applyControlValue(inst, "cmi-filter", 255); - var out = [_]f32{0} ** (512 * 150); - var tail = [_]f32{0} ** (512 * 40); + var out: [(512 * 150)]f32 = @splat(0); + var tail: [(512 * 40)]f32 = @splat(0); // a fifth up, held: in tune, and the RAM (16384 samples at 16384 Hz, // 1 s at the root, 2/3 s a fifth up) runs out var ev = [_]machine.NoteEvent{T.on(64, 1)}; @@ -8759,7 +8759,7 @@ test "sampler keymap: sfz velocity layers and ranges" { defer mach.deinit.?(mach.state, a); try testing.expect(inst.loadAssetRuntime(0, T.dir ++ "/sfz/test.sfz")); try testing.expectEqual(@as(usize, 2), inst.asset_keymap[0].count); - var out = [_]f32{0} ** (512 * 12); + var out: [(512 * 12)]f32 = @splat(0); var soft = [_]machine.NoteEvent{T.on(57, 0.3)}; T.play(inst, &soft, 12, &out); try testing.expectApproxEqRel(@as(f64, 220), T.freq(out[1024..]), 0.01); @@ -8780,7 +8780,7 @@ test "sampler CLOCK engine: drop-sample playback at BITS stays in tune" { applyControlValue(inst, "smp-bits", 8); applyControlValue(inst, "smp-filter", 8000); applyControlValue(inst, "smp-trk", 1); - var out = [_]f32{0} ** (512 * 8); + var out: [(512 * 8)]f32 = @splat(0); // the bundled pluck is A3 = 220 Hz; an octave up var ev = [_]machine.NoteEvent{T.on(69, 1)}; T.play(inst, &ev, 8, &out); @@ -8810,12 +8810,12 @@ test "sampler zones: per-zone level, tune and decay; kit labels; edits follow na try testing.expectEqualStrings("KICK", labels[0].labelSlice()); try testing.expectEqualStrings("CLAP", labels[1].labelSlice()); - var out = [_]f32{0} ** (512 * 8); + var out: [(512 * 8)]f32 = @splat(0); var ev = [_]machine.NoteEvent{T.on(39, 1)}; T.play(inst, &ev, 8, &out); const flat = T.rms(out[512 * 2 ..]); var none = [_]machine.NoteEvent{}; - var long = [_]f32{0} ** (512 * 96); + var long: [(512 * 96)]f32 = @splat(0); T.play(inst, &none, 96, &long); // LEVEL -12 dB on the clap only, +12 st TUNE on it @@ -8830,7 +8830,7 @@ test "sampler zones: per-zone level, tune and decay; kit labels; edits follow na // DECAY 50 ms on the kick: gone 0.2 s in inst.zone_edits.decay[1] = 0.05; var k = [_]machine.NoteEvent{T.on(36, 1)}; - var kout = [_]f32{0} ** (512 * 24); + var kout: [(512 * 24)]f32 = @splat(0); T.play(inst, &k, 24, &kout); try testing.expect(T.rms(kout[512 * 20 ..]) < 1e-4); try testing.expect(T.rms(kout[0..1024]) > 0.05); @@ -8896,7 +8896,7 @@ const TestHome = struct { const abs = storage.absolute(&h.buf, rel); h.len = abs.len; var zb: [storage.MAX_PATH]u8 = undefined; - _ = setenv("SLAB_HOME", (try std.fmt.bufPrintZ(&zb, "{s}", .{abs})).ptr, 1); + _ = setenv("SLAB_HOME", (try std.mem.printSentinel(&zb, "{s}", .{abs}, 0)).ptr, 1); return h; } @@ -8931,10 +8931,10 @@ test "raw machine presets: a project's presets live in its package, the library' try testing.expectEqualStrings("User/kick-fat", inst.presets.names[l].slice()); var fb: [storage.MAX_PATH]u8 = undefined; - const in_pkg = try std.fmt.bufPrintZ(&fb, "{s}/presets/drum2/kick-fat.preset", .{storage.projectDir()}); + const in_pkg = try std.mem.printSentinel(&fb, "{s}/presets/drum2/kick-fat.preset", .{storage.projectDir()}, 0); try testing.expect(std.c.access(in_pkg.ptr, 0) == 0); var hb: [storage.MAX_PATH]u8 = undefined; - const in_home = try std.fmt.bufPrintZ(&hb, "{s}/Presets/drum2/kick-fat.preset", .{home.path()}); + const in_home = try std.mem.printSentinel(&hb, "{s}/Presets/drum2/kick-fat.preset", .{home.path()}, 0); try testing.expect(std.c.access(in_home.ptr, 0) == 0); // A new instance sees both banks; factory presets can't be renamed. @@ -9019,7 +9019,7 @@ test "automation drives a knob per chunk, follows the curve, and yields to a han snap.auto_point_count = 2; snap.lanes[0] = .{ .kind = .inst, .control = @intCast(ci), .points_start = 0, .points_count = 2 }; snap.lane_count = 1; - var cursors = [_]u32{0} ** snapshot.MAX_LANES_PER_TRACK; + var cursors: [snapshot.MAX_LANES_PER_TRACK]u32 = @splat(0); const view = snapshot.AutoView{ .snap = &snap, .cursors = &cursors, .kind = .inst }; const block = 256; @@ -9028,8 +9028,8 @@ test "automation drives a knob per chunk, follows the curve, and yields to a han ctx.tempo_bpm = 120; ctx.block_size = block; ctx.automation = &view; - var l = [_]f32{0} ** block; - var r = [_]f32{0} ** block; + var l: [block]f32 = @splat(0); + var r: [block]f32 = @splat(0); inst.setControlNormSnap(ci, 0.9); // Playing forward from beat 2: it glides onto the curve, then follows it @@ -9071,8 +9071,8 @@ test "note ids: a note-off finds its voice by id after the voice was bent" { ctx.sample_rate = 48_000; ctx.tempo_bpm = 120; ctx.block_size = block; - var l = [_]f32{0} ** block; - var r = [_]f32{0} ** block; + var l: [block]f32 = @splat(0); + var r: [block]f32 = @splat(0); // Two notes, then bend note 1 onto note 2's pitch: they're one pitch now. var evs = [_]machine.NoteEvent{ .{ .sample_offset = 0, .kind = .note_on, .channel = 0, .note_id = 1, .pitch = 60, .velocity = 0.9 }, @@ -9128,8 +9128,8 @@ test "every pitched voice machine takes note expression" { ctx.sample_rate = 48_000; ctx.tempo_bpm = 120; ctx.block_size = 256; - var l = [_]f32{0} ** 256; - var r = [_]f32{0} ** 256; + var l: [256]f32 = @splat(0); + var r: [256]f32 = @splat(0); var evs = [_]machine.NoteEvent{ .{ .sample_offset = 0, .kind = .note_on, .channel = 0, .note_id = 3, .pitch = 48, .velocity = 0.9 }, .{ .sample_offset = 128, .kind = .expression, .channel = 0, .note_id = 3, .pitch = 60, .velocity = 0 }, @@ -9160,7 +9160,7 @@ test "an automated cutoff sweep renders like the knob set by hand at each chunk" snap.auto_point_count = 2; snap.lanes[0] = .{ .kind = .inst, .control = @intCast(ci), .points_start = 0, .points_count = 2 }; snap.lane_count = 1; - var cursors = [_]u32{0} ** snapshot.MAX_LANES_PER_TRACK; + var cursors: [snapshot.MAX_LANES_PER_TRACK]u32 = @splat(0); const view = snapshot.AutoView{ .snap = &snap, .cursors = &cursors, .kind = .inst }; a.setControlNormSnap(ci, 0.2); b.setControlNormSnap(ci, 0.2); @@ -9172,10 +9172,10 @@ test "an automated cutoff sweep renders like the knob set by hand at each chunk" ctx.sample_rate = sr; ctx.tempo_bpm = 120; var on = [_]machine.NoteEvent{.{ .sample_offset = 0, .kind = .note_on, .channel = 0, .note_id = 1, .pitch = 48, .velocity = 0.9 }}; - var la = [_]f32{0} ** block; - var ra = [_]f32{0} ** block; - var lb = [_]f32{0} ** 32; - var rb = [_]f32{0} ** 32; + var la: [block]f32 = @splat(0); + var ra: [block]f32 = @splat(0); + var lb: [32]f32 = @splat(0); + var rb: [32]f32 = @splat(0); var max_diff: f32 = 0; var energy: f32 = 0; var pos: usize = 0; @@ -9217,8 +9217,8 @@ test "per-note gain scales the sampler voice from its onset" { ctx.sample_rate = 48_000; ctx.tempo_bpm = 120; ctx.block_size = 64; - var l = [_]f32{0} ** 64; - var r = [_]f32{0} ** 64; + var l: [64]f32 = @splat(0); + var r: [64]f32 = @splat(0); var evs = [_]machine.NoteEvent{ .{ .sample_offset = 0, .kind = .note_on, .channel = 0, .note_id = 1, .pitch = 60, .velocity = 0.9 }, .{ .sample_offset = 0, .kind = .expression, .channel = 0, .note_id = 1, .pitch = 60, .velocity = 0, .value = -30 }, @@ -9355,8 +9355,8 @@ test "sidechain: verb2 GATED opens on the key, not the input" { // An impulse fills the tank at block 0; the key stays silent until a // hit at block 8. Wet only, so what comes out is the gated tail. const block = 512; - var in_l = [_]f32{0} ** block; - var key_l = [_]f32{0} ** block; + var in_l: [block]f32 = @splat(0); + var key_l: [block]f32 = @splat(0); const ports = [_][*]const f32{ &in_l, &in_l, &key_l, &key_l }; var ctx = std.mem.zeroes(machine.MachineCtx); ctx.sample_rate = 48_000; @@ -9366,8 +9366,8 @@ test "sidechain: verb2 GATED opens on the key, not the input" { mach.set_param.?(mach.state, "verb-mode", 1); // GATED mach.set_param.?(mach.state, "verb-mix", 1.0); mach.set_param.?(mach.state, "verb-decay", 0.95); - var l = [_]f32{0} ** block; - var r = [_]f32{0} ** block; + var l: [block]f32 = @splat(0); + var r: [block]f32 = @splat(0); var before: f64 = 0; var after: f64 = 0; for (0..16) |blk| { @@ -9565,8 +9565,8 @@ test "unison: a poly note takes a group from the pool, steals and releases whole u.setCount(4); try testing.expectEqual(@as(u8, 2), u.notes()); - var l = [_]f32{0} ** 128; - var r = [_]f32{0} ** 128; + var l: [128]f32 = @splat(0); + var r: [128]f32 = @splat(0); uniBlock(mach, &.{uniNote(.note_on, 60)}, &l, &r); try testing.expectEqual(@as(usize, 4), uniGroup(inst, 60)); uniBlock(mach, &.{uniNote(.note_on, 64)}, &l, &r); @@ -9577,7 +9577,7 @@ test "unison: a poly note takes a group from the pool, steals and releases whole try testing.expectEqual(@as(usize, 0), uniGroup(inst, 60)); try testing.expectEqual(@as(usize, 4), uniGroup(inst, 64)); // Each group holds places 0..3 of four. - var places = [_]bool{false} ** 4; + var places: [4]bool = @splat(false); for (0..inst.pool) |v| if (inst.voice_gate[v] and inst.voice_pitch[v] == 67) { try testing.expectEqual(@as(u8, 4), inst.voice_uni_n[v]); places[inst.voice_uni_k[v]] = true; @@ -9608,8 +9608,8 @@ test "unison: DETUNE spreads a group's pitch and retunes it while held" { u.setCount(3); u.setDetune(0); const hz_off = try fyFieldOffset(inst, "JunoState.note-hz"); - var l = [_]f32{0} ** 128; - var r = [_]f32{0} ** 128; + var l: [128]f32 = @splat(0); + var r: [128]f32 = @splat(0); uniBlock(mach, &.{uniNote(.note_on, 69)}, &l, &r); var hz: [3]f64 = undefined; for (0..inst.pool) |v| if (inst.voice_gate[v]) { @@ -9688,8 +9688,8 @@ test "unison: a mono machine plays its clones as one voice, legato and all" { const u = mach.unison.?; try testing.expect(u.mono()); u.setCount(3); - var l = [_]f32{0} ** 128; - var r = [_]f32{0} ** 128; + var l: [128]f32 = @splat(0); + var r: [128]f32 = @splat(0); uniBlock(mach, &.{uniNote(.note_on, 48)}, &l, &r); try testing.expectEqual(@as(usize, 3), inst.pool); try testing.expectEqual(@as(usize, 3), uniGroup(inst, 48)); @@ -9755,10 +9755,10 @@ test "wavetable editor: LIBRARY copies the table into the home folder, the proje saveTableToLibrary(inst, ai); saveTableToLibrary(inst, ai); // a second copy doesn't overwrite the first var pb: [storage.MAX_PATH]u8 = undefined; - const first = try std.fmt.bufPrintZ(&pb, "{s}/Wavetables/sync.wav", .{home.path()}); + const first = try std.mem.printSentinel(&pb, "{s}/Wavetables/sync.wav", .{home.path()}, 0); try testing.expect(std.c.access(first.ptr, 0) == 0); var qb: [storage.MAX_PATH]u8 = undefined; - const second = try std.fmt.bufPrintZ(&qb, "{s}/Wavetables/sync-2.wav", .{home.path()}); + const second = try std.mem.printSentinel(&qb, "{s}/Wavetables/sync-2.wav", .{home.path()}, 0); try testing.expect(std.c.access(second.ptr, 0) == 0); try testing.expectEqualStrings("sync-2", inst.wt_lib[0..inst.wt_lib_len]); // Still the project's table, still to be written with the project. @@ -9835,8 +9835,8 @@ test "poly legato: an overlapping single note takes over the ringing voice" { var ctx = std.mem.zeroes(machine.MachineCtx); ctx.sample_rate = 48_000; ctx.block_size = 4096; - var l = [_]f32{0} ** 4096; - var r = [_]f32{0} ** 4096; + var l: [4096]f32 = @splat(0); + var r: [4096]f32 = @splat(0); const gated = struct { fn count(m: *FyRawMachine) usize { var n: usize = 0; @@ -10007,7 +10007,7 @@ test "industrial buffer2: frozen repeat survives new input, reverses, and reset const n = 24000; var il: [n]f32 = undefined; for (&il, 0..) |*x, i| x.* = @floatCast(0.2 * @sin(2 * std.math.pi * 400 * @as(f64, @floatFromInt(i)) / 48000)); - const zero = [_]f32{0} ** n; + const zero: [n]f32 = @splat(0); var ol: [n]f32 = undefined; var or_: [n]f32 = undefined; industrialStream(mach, 48000, 128, &il, &zero, &ol, &or_); @@ -10116,7 +10116,7 @@ test "industrial buffer2: dense pitch-jitter feedback stays bounded and frozen-e mach.set_param.?(mach.state, p[0], p[1]); var il: [256]f32 = undefined; for (&il, 0..) |*x, i| x.* = @as(f32, @floatFromInt(i % 31)) / 31 - 0.5; - const zero = [_]f32{0} ** 256; + const zero: [256]f32 = @splat(0); var ol: [256]f32 = undefined; var or_: [256]f32 = undefined; for ([_]f64{ 44100, 48000, 96000 }) |sr| { @@ -10174,7 +10174,7 @@ test "phaser2: static 4/6/12-stage notch positions and feedback response at thre mach.set_param.?(mach.state, "ph-spread", 0); const n = 24000; var il: [n]f32 = undefined; - const zero = [_]f32{0} ** n; + const zero: [n]f32 = @splat(0); var ol: [n]f32 = undefined; var or_: [n]f32 = undefined; for ([_]f64{ 44100, 48000, 96000 }) |sr| { @@ -10348,7 +10348,7 @@ test "phaser2: no-op automation keeps the synchronized sweep aligned at a nonzer snap.auto_point_count = 2; snap.lanes[0] = .{ .kind = .inst, .control = @intCast(ci), .points_start = 0, .points_count = 2 }; snap.lane_count = 1; - var cursors = [_]u32{0} ** snapshot.MAX_LANES_PER_TRACK; + var cursors: [snapshot.MAX_LANES_PER_TRACK]u32 = @splat(0); const view = snapshot.AutoView{ .snap = &snap, .cursors = &cursors, .kind = .inst }; const block = 256; var input: [block]f32 = undefined; @@ -10517,8 +10517,8 @@ test "realtime: spectrum windows are copied from DSP into owned UI storage" { const inst = try FyRawMachine.create(a, "machines/geq8/geq8.fy"); defer deinitImpl(inst, a); inst.requestDisplay(); - var il = [_]f32{0.25} ** 256; - var ir = [_]f32{-0.25} ** 256; + var il: [256]f32 = @splat(0.25); + var ir: [256]f32 = @splat(-0.25); var l: [256]f32 = undefined; var r: [256]f32 = undefined; const ports = [2][*]const f32{ &il, &ir }; diff --git a/src/machines/rack.zig b/src/machines/rack.zig index 3a3add23..1bd11722 100644 --- a/src/machines/rack.zig +++ b/src/machines/rack.zig @@ -88,7 +88,7 @@ const CONTROL_IDS = [CONTROLS][]const u8{ "rk-m1", "rk-m2", "rk-m3", "rk-m4", "r const MACRO_LABELS = [MAX_MACROS][]const u8{ "MACRO 1", "MACRO 2", "MACRO 3", "MACRO 4", "MACRO 5", "MACRO 6", "MACRO 7", "MACRO 8" }; pub const Name = struct { - buf: [MAX_LABEL + 1]u8 = [_]u8{0} ** (MAX_LABEL + 1), + buf: [MAX_LABEL + 1]u8 = @splat(0), len: u8 = 0, pub fn slice(self: *const Name) []const u8 { @@ -120,7 +120,7 @@ pub const Mapping = struct { min: f32 = 0, max: f32 = 1, program: formula.Program = .{}, - text: [formula.MAX_TEXT + 1]u8 = [_]u8{0} ** (formula.MAX_TEXT + 1), + text: [formula.MAX_TEXT + 1]u8 = @splat(0), text_len: u8 = 0, /// The text doesn't compile; `program` is the last one that did. bad: bool = false, @@ -164,7 +164,7 @@ pub const Lane = struct { effects: std.ArrayList(Effect) = .empty, /// The line that holds this lane back to the slowest lane (audio). line: *chain_mod.DryLine, - label: [MAX_LABEL + 1]u8 = [_]u8{0} ** (MAX_LABEL + 1), + label: [MAX_LABEL + 1]u8 = @splat(0), label_len: u8 = 0, // Settings are copied from the UI configuration at block boundaries. lo: u8 = 0, @@ -258,12 +258,12 @@ pub const Rack = struct { current_preset: i32 = -1, /// A lane or an insert came or went since the project last asked. edited: bool = false, - macro_names: [MAX_MACROS]Name = [_]Name{.{}} ** MAX_MACROS, + macro_names: [MAX_MACROS]Name = @splat(.{}), maps: [MAX_MAPS]Mapping = undefined, map_count: usize = 0, /// The automated value each control shows (null: no lane), set by the /// host every frame. - ui_auto: [CONTROLS]?f32 = [_]?f32{null} ** CONTROLS, + ui_auto: [CONTROLS]?f32 = @splat(null), touch: ?machine.Touch = null, // The macro block (UI): the picked macro and mapping, MAP armed, the // control a MAP click already took, and the name and formula fields. @@ -278,8 +278,8 @@ pub const Rack = struct { // Macros, UI writes and audio reads: the hand-set value in knob space // (CHAIN's raw 0..127), and a count a jump (preset, project) bumps. - hand: [CONTROLS]std.atomic.Value(u32) = [_]std.atomic.Value(u32){std.atomic.Value(u32).init(0)} ** CONTROLS, - snaps: [CONTROLS]std.atomic.Value(u32) = [_]std.atomic.Value(u32){std.atomic.Value(u32).init(0)} ** CONTROLS, + hand: [CONTROLS]std.atomic.Value(u32) = @splat(std.atomic.Value(u32).init(0)), + snaps: [CONTROLS]std.atomic.Value(u32) = @splat(std.atomic.Value(u32).init(0)), wake: std.atomic.Value(bool) = .init(false), /// Audio: what each macro plays now, the smoothed hand value unless a /// lane or an outer rack's macro drives it. @@ -2198,7 +2198,7 @@ test "rack: a split sends each key to its lane, transposed; POLY releases the ol p.instrument.set_param.?(p.instrument.state, "cmi-loop", 1); } - var out = [_]f32{0} ** (512 * 24); + var out: [(512 * 24)]f32 = @splat(0); rack.publishLanes(); // A2 on the lower lane: the bundled pluck (A3) an octave down play(m, &.{noteOn(45, 1)}, &out); @@ -2278,7 +2278,7 @@ test "rack: the first form's parts load as lanes and play as they did" { try testing.expectEqual(@as(u8, 2), rack.lanes[0].poly); try testing.expect(rack.lanes[1].mute); try testing.expectEqual(@as(usize, 0), rack.lanes[0].effects.items.len); - var out = [_]f32{0} ** (512 * 8); + var out: [(512 * 8)]f32 = @splat(0); play(m, &.{noteOn(45, 1)}, &out); try testing.expect(rms(out[512 * 2 ..]) > 0.01); // the muted upper lane takes nothing @@ -2332,7 +2332,7 @@ test "fxrack: a dry lane is held back to a latent lane, and the rack reports tha wet.mute = true; // only the dry path is heard rack.publishLanes(); - var in = [_]f32{0} ** (256 * 8); + var in: [(256 * 8)]f32 = @splat(0); in[300] = 1; var ol: [in.len]f32 = undefined; var or_: [in.len]f32 = undefined; @@ -2477,11 +2477,11 @@ test "rack: an automated macro sweeps its target as automating the target does" snap.auto_point_count = 2; snap.lanes[0] = .{ .kind = .inst, .control = @intCast(drive), .points_start = 0, .points_count = 2 }; snap.lane_count = 1; - var cursors = [_]u32{0} ** snapshot.MAX_LANES_PER_TRACK; + var cursors: [snapshot.MAX_LANES_PER_TRACK]u32 = @splat(0); const view = snapshot.AutoView{ .snap = &snap, .cursors = &cursors, .kind = .inst }; var snap2 = snap; snap2.lanes[0].control = 0; - var cursors2 = [_]u32{0} ** snapshot.MAX_LANES_PER_TRACK; + var cursors2: [snapshot.MAX_LANES_PER_TRACK]u32 = @splat(0); const view2 = snapshot.AutoView{ .snap = &snap2, .cursors = &cursors2, .kind = .inst }; var in: [256 * 120]f32 = undefined; diff --git a/src/main.zig b/src/main.zig index 1024efa0..af56e7a6 100644 --- a/src/main.zig +++ b/src/main.zig @@ -67,6 +67,7 @@ const pane = @import("ui/pane_input.zig"); const gesture = @import("ui/gesture.zig"); const commands = @import("ui/commands.zig"); const ui_style = @import("ui/style.zig"); +const ui_draw = @import("ui/draw.zig"); const ui_gallery = @import("ui/gallery.zig"); const layout_mod = @import("ui/layout.zig"); const transport_bar = @import("ui/transport_bar.zig"); @@ -198,11 +199,11 @@ const EditClipboard = struct { }; const StatusMessage = struct { - buf: [128:0]u8 = [_:0]u8{0} ** 128, + buf: [128:0]u8 = @splat(0), until: f64 = 0, fn set(self: *StatusMessage, comptime fmt: []const u8, args: anytype) void { - const s = std.fmt.bufPrintZ(&self.buf, fmt, args) catch "status"; + const s = std.mem.printSentinel(&self.buf, fmt, args, 0) catch "status"; self.buf[s.len] = 0; self.until = c.rl.GetTime() + 2.0; } @@ -717,7 +718,7 @@ fn enterBundleResources() void { const macos = std.fs.path.dirname(exe) orelse return; if (!std.mem.endsWith(u8, macos, ".app/Contents/MacOS")) return; var rb: [storage.MAX_PATH]u8 = undefined; - const res = std.fmt.bufPrintZ(&rb, "{s}/../Resources", .{macos}) catch return; + const res = std.mem.printSentinel(&rb, "{s}/../Resources", .{macos}, 0) catch return; _ = chdir(res); } @@ -896,7 +897,7 @@ pub fn main(init: std.process.Init) !void { // Master bus — a standalone Track (silent instrument, effects-only, // its volume() is the master fader and meter() the master meter). It // lives outside tracks_buf so no audio-track index ever shifts. - var master = try track_mod.Track.init(alloc, "Master", @bitCast(ui_style.face_hi), silent_machine); + var master = try track_mod.Track.init(alloc, "Master", ui_draw.rl(ui_style.face_hi), silent_machine); master.kind = .master; master.setVolume(1.0); // unity — Track defaults to 0.8, which would quiet the mix defer master.deinit(alloc); @@ -1201,7 +1202,7 @@ pub fn main(init: std.process.Init) !void { } c.rl.BeginDrawing(); - c.rl.ClearBackground(@bitCast(ui_style.chassis)); + c.rl.ClearBackground(ui_draw.rl(ui_style.chassis)); const rec_busy = recorder.isRecording() or rec_finishing; // An offline render (export, bounce, freeze) owns the engine with @@ -2467,7 +2468,8 @@ test "slices' SFZ: each on its key, at its pitch, one shot" { const root = storage.absolute(&root_buf, try std.fmt.bufPrint(&rb, ".zig-cache/tmp/{s}", .{tmp.sub_path})); var sfz: std.ArrayList(u8) = .empty; defer sfz.deinit(alloc); - const tone = [_]f32{ 0.5, -0.5, 0.25, -0.25 } ** 100; + var tone: [400]f32 = undefined; + for (&tone, 0..) |*t, i| t.* = ([_]f32{ 0.5, -0.5, 0.25, -0.25 })[i % 4]; for (0..2) |k| { var nb: [24]u8 = undefined; const file = try std.fmt.bufPrint(&nb, "slice-{d:0>2}.wav", .{k + 1}); @@ -3568,7 +3570,7 @@ const ExtractBench = struct { var rb: [storage.MAX_PATH]u8 = undefined; const root = storage.absolute(&self.root_buf, try std.fmt.bufPrint(&rb, ".zig-cache/tmp/{s}", .{self.tmp.sub_path})); var hb: [storage.MAX_PATH]u8 = undefined; - _ = env.setenv("SLAB_HOME", (try std.fmt.bufPrintZ(&hb, "{s}/home", .{root})).ptr, 1); + _ = env.setenv("SLAB_HOME", (try std.mem.printSentinel(&hb, "{s}/home", .{root}, 0)).ptr, 1); storage.setProject(null); const buf = try alloc.alloc(f32, x.len); defer alloc.free(buf); @@ -5684,7 +5686,7 @@ fn screenRect() ui_geom.Rect { /// Default color for the n-th new track (the track palette, cycled). fn trackColor(n: usize) c.rl.Color { - return @bitCast(ui_style.track[n % ui_style.track.len]); + return ui_draw.rl(ui_style.track[n % ui_style.track.len]); } fn uiRect(r: c.rl.Rectangle) ui_geom.Rect { @@ -6174,7 +6176,7 @@ fn renderHeadless(alloc: std.mem.Allocator, project: []const u8, cli: Cli) !void var transport: transport_mod.Transport = .{}; transport.sample_rate = audio_mod.SAMPLE_RATE; - var master = try track_mod.Track.init(alloc, "Master", @bitCast(ui_style.face_hi), silent_machine); + var master = try track_mod.Track.init(alloc, "Master", ui_draw.rl(ui_style.face_hi), silent_machine); master.kind = .master; master.setVolume(1.0); defer master.deinit(alloc); @@ -6739,7 +6741,7 @@ fn commitPreset(rename: *RenameState, tracks: []track_mod.Track, dirty: *bool, s status.set("Preset name empty", .{}); return; } - var name_buf: [track_mod.MAX_NAME + 1:0]u8 = [_:0]u8{0} ** (track_mod.MAX_NAME + 1); + var name_buf: [track_mod.MAX_NAME + 1:0]u8 = @splat(0); @memcpy(name_buf[0..name.len], name); const name_z: [*:0]const u8 = @ptrCast(&name_buf[0]); switch (rename.kind) { @@ -7480,7 +7482,7 @@ test "bounce: the selected clip lands on a new track below its source, the origi var root_buf: [storage.MAX_PATH]u8 = undefined; const root = storage.absolute(&root_buf, try std.fmt.bufPrint(&rb, ".zig-cache/tmp/{s}", .{tmp.sub_path})); var hb: [storage.MAX_PATH]u8 = undefined; - _ = env.setenv("SLAB_HOME", (try std.fmt.bufPrintZ(&hb, "{s}/home", .{root})).ptr, 1); + _ = env.setenv("SLAB_HOME", (try std.mem.printSentinel(&hb, "{s}/home", .{root}, 0)).ptr, 1); defer _ = env.unsetenv("SLAB_HOME"); storage.setProject(null); @@ -7567,7 +7569,7 @@ test "bounce provenance: a recipe stays fresh, goes stale with its source, re-bo var root_buf: [storage.MAX_PATH]u8 = undefined; const root = storage.absolute(&root_buf, try std.fmt.bufPrint(&rb, ".zig-cache/tmp/{s}", .{tmp.sub_path})); var hb: [storage.MAX_PATH]u8 = undefined; - _ = env.setenv("SLAB_HOME", (try std.fmt.bufPrintZ(&hb, "{s}/home", .{root})).ptr, 1); + _ = env.setenv("SLAB_HOME", (try std.mem.printSentinel(&hb, "{s}/home", .{root}, 0)).ptr, 1); defer _ = env.unsetenv("SLAB_HOME"); storage.setProject(null); @@ -7707,7 +7709,7 @@ test "freeze: a track renders to its frozen audio, plays the same frozen, and go var root_buf: [storage.MAX_PATH]u8 = undefined; const root = storage.absolute(&root_buf, try std.fmt.bufPrint(&rb, ".zig-cache/tmp/{s}", .{tmp.sub_path})); var hb: [storage.MAX_PATH]u8 = undefined; - _ = env.setenv("SLAB_HOME", (try std.fmt.bufPrintZ(&hb, "{s}/home", .{root})).ptr, 1); + _ = env.setenv("SLAB_HOME", (try std.mem.printSentinel(&hb, "{s}/home", .{root}, 0)).ptr, 1); defer _ = env.unsetenv("SLAB_HOME"); storage.setProject(null); diff --git a/src/meter.zig b/src/meter.zig index 52e4f7b5..6752e3ee 100644 --- a/src/meter.zig +++ b/src/meter.zig @@ -59,7 +59,7 @@ fn defaultGroups(num: u8, den: u8, out: *[MAX_GROUPS]u8) []const u8 { /// no storage behind it. `len` 0 means "use the default". pub const Groups = struct { len: u8 = 0, - v: [MAX_GROUPS]u8 = [_]u8{0} ** MAX_GROUPS, + v: [MAX_GROUPS]u8 = @splat(0), /// Groups from a slice; longer than MAX_GROUPS is truncated (and so /// won't sum to the numerator, falling back to the default). @@ -357,7 +357,7 @@ pub const MAX_POINTS: usize = 256; /// (docs/07 §runtime-change) is a separate slice. pub const MeterStore = struct { buf: [MAX_POINTS]MeterPoint = - [_]MeterPoint{.{ .start_bar = 0, .numerator = 4, .denominator = 4 }} ** MAX_POINTS, + @splat(.{ .start_bar = 0, .numerator = 4, .denominator = 4 }), len: usize = 1, pub fn map(self: *const MeterStore) MeterMap { diff --git a/src/ml.zig b/src/ml.zig index 648f881b..74204dca 100644 --- a/src/ml.zig +++ b/src/ml.zig @@ -38,7 +38,7 @@ pub const Output = struct { pub const Model = struct { handle: *anyopaque, /// Why the last call failed. - err: [256]u8 = [_]u8{0} ** 256, + err: [256]u8 = @splat(0), pub fn load(path: []const u8) !Model { var pz: [1024:0]u8 = undefined; diff --git a/src/moog_ladder_test.zig b/src/moog_ladder_test.zig index 7f763526..5503036e 100644 --- a/src/moog_ladder_test.zig +++ b/src/moog_ladder_test.zig @@ -18,8 +18,8 @@ const SR: f64 = 48_000.0; const Rig = struct { host: FyHost, /// One MoogLadder (13 fields) and one Dec4 (26). - m: [13]f64 = [_]f64{0} ** 13, - d: [26]f64 = [_]f64{0} ** 26, + m: [13]f64 = @splat(0), + d: [26]f64 = @splat(0), fn step(self: *Rig, x: f64, cutoff: f64, res: f64) !f64 { var out: f64 = 0; @@ -50,8 +50,8 @@ fn db(x: f64) f64 { } fn gainAt(rig: *Rig, hz: f64, cutoff: f64) !f64 { - rig.m = [_]f64{0} ** 13; - rig.d = [_]f64{0} ** 26; + rig.m = @splat(0); + rig.d = @splat(0); var ys: [4800]f64 = undefined; for (0..4800 + 2400) |n| { const x = 0.01 * @sin(2.0 * std.math.pi * hz * @as(f64, @floatFromInt(n)) / SR); @@ -63,8 +63,8 @@ fn gainAt(rig: *Rig, hz: f64, cutoff: f64) !f64 { /// Frequency of the self-oscillation: zero crossings over one second. fn oscHz(rig: *Rig, cutoff: f64) !f64 { - rig.m = [_]f64{0} ** 13; - rig.d = [_]f64{0} ** 26; + rig.m = @splat(0); + rig.d = @splat(0); var prev: f64 = 0; var first: ?usize = null; var last: usize = 0; diff --git a/src/native_app.zig b/src/native_app.zig index d8ba012c..92676f37 100644 --- a/src/native_app.zig +++ b/src/native_app.zig @@ -100,9 +100,9 @@ pub const TitleBar = struct { const base = std.fs.path.basename(std.mem.trimEnd(u8, path, "/")); const name = if (!chosen) "Untitled" else if (std.mem.endsWith(u8, base, ".slab")) base[0 .. base.len - 5] else base; var tbuf: [256]u8 = undefined; - const title = std.fmt.bufPrintZ(&tbuf, "SLAB \u{2014} {s}", .{name}) catch "SLAB"; + const title = std.mem.printSentinel(&tbuf, "SLAB \u{2014} {s}", .{name}, 0) catch "SLAB"; var pbuf: [1025]u8 = undefined; - const zpath: ?[*:0]const u8 = if (chosen) (std.fmt.bufPrintZ(&pbuf, "{s}", .{path}) catch null) else null; + const zpath: ?[*:0]const u8 = if (chosen) (std.mem.printSentinel(&pbuf, "{s}", .{path}, 0) catch null) else null; slab_set_window_document(window, title.ptr, zpath, @intFromBool(dirty)); } }; diff --git a/src/native_dialog.zig b/src/native_dialog.zig index 75fa9599..27f14cb5 100644 --- a/src/native_dialog.zig +++ b/src/native_dialog.zig @@ -16,21 +16,21 @@ extern fn slab_open(target: [*:0]const u8) void; /// Open a folder in Finder, or a link in the browser. pub fn open(target: []const u8) void { var zb: [2048]u8 = undefined; - const z = std.fmt.bufPrintZ(&zb, "{s}", .{target}) catch return; + const z = std.mem.printSentinel(&zb, "{s}", .{target}, 0) catch return; slab_open(z.ptr); } /// Show a file selected in a Finder window. pub fn reveal(path: []const u8) void { var zb: [1024]u8 = undefined; - const z = std.fmt.bufPrintZ(&zb, "{s}", .{path}) catch return; + const z = std.mem.printSentinel(&zb, "{s}", .{path}, 0) catch return; slab_reveal(z.ptr); } /// Move a file to the Trash. False if it couldn't. pub fn trash(path: []const u8) bool { var zb: [1024]u8 = undefined; - const z = std.fmt.bufPrintZ(&zb, "{s}", .{path}) catch return false; + const z = std.mem.printSentinel(&zb, "{s}", .{path}, 0) catch return false; return slab_trash(z.ptr) == 1; } @@ -41,7 +41,7 @@ pub fn openProject(alloc: std.mem.Allocator) !?[]u8 { } pub fn saveProject(alloc: std.mem.Allocator, default_name: []const u8) !?[]u8 { - const z = try alloc.dupeZ(u8, default_name); + const z = try alloc.dupeSentinel(u8, default_name, 0); defer alloc.free(z); const raw = slab_save_project_dialog(z) orelse return null; defer slab_free_dialog_path(raw); @@ -66,7 +66,7 @@ pub fn openKeymap(alloc: std.mem.Allocator) !?[]u8 { /// Native folder panel (an export's destination), opening at `start`. /// Returns the chosen folder, or null if cancelled. Caller owns it. pub fn chooseFolder(alloc: std.mem.Allocator, start: []const u8) !?[]u8 { - const z = try alloc.dupeZ(u8, start); + const z = try alloc.dupeSentinel(u8, start, 0); defer alloc.free(z); const raw = slab_choose_folder_dialog(z) orelse return null; defer slab_free_dialog_path(raw); @@ -77,9 +77,9 @@ pub fn chooseFolder(alloc: std.mem.Allocator, start: []const u8) !?[]u8 { /// Returns the chosen path, or null if cancelled. Caller owns the /// returned slice. pub fn saveAudioFile(alloc: std.mem.Allocator, default_name: []const u8, ext: []const u8) !?[]u8 { - const z = try alloc.dupeZ(u8, default_name); + const z = try alloc.dupeSentinel(u8, default_name, 0); defer alloc.free(z); - const ez = try alloc.dupeZ(u8, ext); + const ez = try alloc.dupeSentinel(u8, ext, 0); defer alloc.free(ez); const raw = slab_save_audio_dialog(z, ez) orelse return null; defer slab_free_dialog_path(raw); diff --git a/src/oversample_test.zig b/src/oversample_test.zig index 55fd33ed..faad8f82 100644 --- a/src/oversample_test.zig +++ b/src/oversample_test.zig @@ -32,7 +32,7 @@ fn db(x: f64) f64 { /// Decimate a 4x-rate sine at `hz`; return the output amplitude at `probe`. fn decimated(host: *FyHost, hz: f64, probe: f64) !f64 { - var state = [_]f64{0} ** 26; + var state: [26]f64 = @splat(0); var out: f64 = 0; var ys: [4800]f64 = undefined; var n: usize = 0; @@ -78,8 +78,8 @@ test "4x interpolator: images rejected" { try host.compile(PROBES); Fy.Builtins.fyPtr = @intFromPtr(&host.fy); - var state = [_]f64{0} ** 26; - var quad = [_]f64{0} ** 4; + var state: [26]f64 = @splat(0); + var quad: [4]f64 = @splat(0); var ys: [4 * 4800]f64 = undefined; const hz = 5000.0; for (0..4800 + 480) |n| { diff --git a/src/package.zig b/src/package.zig index 0bab9f6b..438dfa69 100644 --- a/src/package.zig +++ b/src/package.zig @@ -58,7 +58,7 @@ const O_CREAT: c_int = 0x200; const O_TRUNC: c_int = 0x400; fn z(buf: []u8, path: []const u8) ?[*:0]const u8 { - const s = std.fmt.bufPrintZ(buf, "{s}", .{path}) catch return null; + const s = std.mem.printSentinel(buf, "{s}", .{path}, 0) catch return null; return s.ptr; } @@ -200,7 +200,7 @@ pub const Sha = [32]u8; const HashCache = struct { const N = 1024; const Entry = struct { path: [MAX_PATH]u8 = undefined, len: usize = 0, size: i64 = 0, sec: i64 = 0, nsec: i64 = 0, sha: Sha = undefined }; - var entries: [N]Entry = [_]Entry{.{}} ** N; + var entries: [N]Entry = @splat(.{}); var next: usize = 0; }; @@ -637,7 +637,7 @@ test "package: a bare file becomes a package, files are collected once, Clean Up // The factory is a folder of its own here: the test's files sit in the // repo, which a dev build takes for the factory. var fab: [MAX_PATH]u8 = undefined; - const fac = try std.fmt.bufPrintZ(&fab, "{s}/factory", .{root}); + const fac = try std.mem.printSentinel(&fab, "{s}/factory", .{root}, 0); try writeFileMaking(try std.fmt.bufPrint(&ab, "{s}/machines/sampler/assets/default.wav", .{fac}), "RIFF-f"); _ = setenv("SLAB_FACTORY", fac.ptr, 1); defer _ = unsetenv("SLAB_FACTORY"); diff --git a/src/packs.zig b/src/packs.zig index e3f38f5c..b3e23378 100644 --- a/src/packs.zig +++ b/src/packs.zig @@ -327,7 +327,7 @@ pub const Catalog = struct { ne += 1; } var lib_env: [storage.MAX_PATH + 16:0]u8 = undefined; - const le = std.fmt.bufPrintZ(&lib_env, "SLAB_LIBRARY={s}", .{lib}) catch return "PATH TOO LONG"; + const le = std.mem.printSentinel(&lib_env, "SLAB_LIBRARY={s}", .{lib}, 0) catch return "PATH TOO LONG"; env_buf[ne] = le.ptr; var fa: SpawnActions = null; diff --git a/src/pitch.zig b/src/pitch.zig index 24d01923..130ace39 100644 --- a/src/pitch.zig +++ b/src/pitch.zig @@ -172,7 +172,7 @@ fn frame(x: []const f32, center: isize, lo: f64, hi: f64, buf: *[NFFT]C, prod: * for (tr[0..nt], 0..) |t, i| if (dn[t] < dn[tr[gmin]]) { gmin = i; }; - var p: [64]f64 = [_]f64{0} ** 64; + var p: [64]f64 = @splat(0); for (PRIOR, 0..) |w, i| { const s = @as(f64, @floatFromInt(i + 1)) / THRESHOLDS; var hit: ?usize = null; diff --git a/src/presets.zig b/src/presets.zig index 82ad7021..339feb0f 100644 --- a/src/presets.zig +++ b/src/presets.zig @@ -56,7 +56,7 @@ pub const MAX_NAME = 63; pub const MAX_FILE = 8192; pub const Name = struct { - text: [MAX_NAME:0]u8 = [_:0]u8{0} ** MAX_NAME, + text: [MAX_NAME:0]u8 = @splat(0), len: u8 = 0, pub fn slice(self: *const Name) []const u8 { @@ -333,7 +333,7 @@ fn sortList(list: *List) void { /// Read `/.preset` into buf; null on any failure. pub fn readFileBuf(buf: []u8, dir_path: []const u8, name: []const u8) ?[]const u8 { var path_buf: [1024:0]u8 = undefined; - const path = std.fmt.bufPrintZ(&path_buf, "{s}/{s}.preset", .{ dir_path, name }) catch return null; + const path = std.mem.printSentinel(&path_buf, "{s}/{s}.preset", .{ dir_path, name }, 0) catch return null; const fd = open(path.ptr, O_RDONLY); if (fd < 0) return null; defer _ = close(fd); @@ -357,12 +357,12 @@ pub fn writeFile(dir_path: []const u8, name: []const u8, content: []const u8) bo _ = mkdir(@ptrCast(&dir_z[0]), 0o755); // EEXIST is fine if (std.mem.indexOfScalar(u8, name, '/')) |slash| { var sub_z: [1024:0]u8 = undefined; - const sub = std.fmt.bufPrintZ(&sub_z, "{s}/{s}", .{ dir_path, name[0..slash] }) catch return false; + const sub = std.mem.printSentinel(&sub_z, "{s}/{s}", .{ dir_path, name[0..slash] }, 0) catch return false; _ = mkdir(sub.ptr, 0o755); } var path_buf: [1024:0]u8 = undefined; - const path = std.fmt.bufPrintZ(&path_buf, "{s}/{s}.preset", .{ dir_path, name }) catch return false; + const path = std.mem.printSentinel(&path_buf, "{s}/{s}.preset", .{ dir_path, name }, 0) catch return false; const fd = open(path.ptr, O_WRONLY | O_CREAT | O_TRUNC, @as(c_uint, 0o644)); if (fd < 0) return false; defer _ = close(fd); @@ -382,8 +382,8 @@ pub fn writeFile(dir_path: []const u8, name: []const u8, content: []const u8) bo pub fn renameFile(dir_path: []const u8, old_name: []const u8, new_name: []const u8) bool { var old_z: [1024:0]u8 = undefined; var new_z: [1024:0]u8 = undefined; - const old_p = std.fmt.bufPrintZ(&old_z, "{s}/{s}.preset", .{ dir_path, old_name }) catch return false; - const new_p = std.fmt.bufPrintZ(&new_z, "{s}/{s}.preset", .{ dir_path, new_name }) catch return false; + const old_p = std.mem.printSentinel(&old_z, "{s}/{s}.preset", .{ dir_path, old_name }, 0) catch return false; + const new_p = std.mem.printSentinel(&new_z, "{s}/{s}.preset", .{ dir_path, new_name }, 0) catch return false; // Don't overwrite a different existing preset. if (!std.mem.eql(u8, old_name, new_name)) { const probe = open(new_p.ptr, O_RDONLY); diff --git a/src/preview.zig b/src/preview.zig index 8a992d82..4c03d1d2 100644 --- a/src/preview.zig +++ b/src/preview.zig @@ -20,7 +20,7 @@ pub const Previewer = struct { path_len: usize = 0, /// Data the audio thread may still hold, and the request after which /// it doesn't. - retired: [MAX_RETIRED]?Retired = [_]?Retired{null} ** MAX_RETIRED, + retired: [MAX_RETIRED]?Retired = @splat(null), playing: bool = false, pub fn init(alloc: std.mem.Allocator) Previewer { diff --git a/src/recorder.zig b/src/recorder.zig index c7bc004e..41a0cca7 100644 --- a/src/recorder.zig +++ b/src/recorder.zig @@ -157,7 +157,7 @@ pub fn takeStart(start_sample: u64, round_trip: u32, backlog: u32, pdc: u32) u64 return start_sample -| late; } -const ZEROS = [_]f32{0} ** 512; +const ZEROS: [512]f32 = @splat(0); const CHUNK = 256; pub const Recorder = struct { @@ -204,7 +204,7 @@ pub const Recorder = struct { data_bytes: u64 = 0, take_counter: u32 = 0, - path_buf: [MAX_PATH]u8 = [_]u8{0} ** MAX_PATH, + path_buf: [MAX_PATH]u8 = @splat(0), path_len: usize = 0, // A folder set with setDir; empty: storage.recordingsDir at each take. dir_buf: [MAX_PATH]u8 = undefined, @@ -533,7 +533,7 @@ pub const Recorder = struct { fn buildTakePath(self: *Recorder) void { while (self.take_counter < 9999) { self.take_counter += 1; - const s = std.fmt.bufPrintZ(&self.path_buf, "{s}/take-{d:0>3}.wav", .{ self.dir(), self.take_counter }) catch break; + const s = std.mem.printSentinel(&self.path_buf, "{s}/take-{d:0>3}.wav", .{ self.dir(), self.take_counter }, 0) catch break; self.path_len = s.len; if (access(s.ptr, 0) != 0) return; } @@ -559,7 +559,7 @@ test "ring push/pop round-trips and reports overrun on overflow" { try testing.expectEqualSlices(f32, &a, out[0..5]); // Capacity 8 → at most 8 frames live at once; a 10-frame push truncates. - const big = [_]f32{0} ** 10; + const big: [10]f32 = @splat(0); try testing.expectEqual(@as(usize, 8), ring.push(&big)); } @@ -585,7 +585,7 @@ test "realtime: recording stop drains a callback already inside capture" { defer rec.deinit(); rec.setDir("/tmp/slab-recording-audit"); try rec.start(); - const path = try a.dupeZ(u8, rec.path_buf[0..rec.path_len]); + const path = try a.dupeSentinel(u8, rec.path_buf[0..rec.path_len], 0); defer a.free(path); defer _ = struct { extern "c" fn unlink(p: [*:0]const u8) c_int; @@ -598,7 +598,7 @@ test "realtime: recording stop drains a callback already inside capture" { const pause = timespec{ .sec = 0, .nsec = 20 * std.time.ns_per_ms }; _ = nanosleep(&pause, null); try testing.expect(!rec.isFinished()); - const samples = [_]f32{0.25} ** 64; + const samples: [64]f32 = @splat(0.25); try testing.expectEqual(samples.len, rec.ring.push(&samples)); _ = rec.frames_captured.fetchAdd(samples.len, .monotonic); rec.endCapture(); @@ -679,7 +679,7 @@ test "an overrun comes back as silence, so the rest of the take keeps its time" try testing.expectEqual(@as(usize, 16), rec.ring.pop(&out)); // drained: the 8 owed frames go in as zeros before the next block transport.sample_pos.store(24, .monotonic); - var b = [_]f32{0.5} ** 4; + var b: [4]f32 = @splat(0.5); Recorder.captureFn(&rec, &b, 4, 1, 0); const n = rec.ring.pop(&out); try testing.expectEqual(@as(usize, 12), n); @@ -694,7 +694,7 @@ test "a take notices the transport jumping, and stamps its first block's backlog var rec = try testRecorder(&transport, 1024); defer rec.deinit(); defer rec.state.store(@intFromEnum(State.idle), .seq_cst); - var a = [_]f32{0} ** 64; + var a: [64]f32 = @splat(0); transport.sample_pos.store(1000, .monotonic); Recorder.captureFn(&rec, &a, 64, 1, 512); transport.sample_pos.store(1064, .monotonic); diff --git a/src/storage.zig b/src/storage.zig index 2376b24f..e11cf7f4 100644 --- a/src/storage.zig +++ b/src/storage.zig @@ -237,9 +237,9 @@ pub fn freshPath(buf: []u8, dir: []const u8, stem: []const u8, ext: []const u8) var n: usize = 1; while (n < 1000) : (n += 1) { const p = if (n == 1) - std.fmt.bufPrintZ(buf, "{s}/{s}{s}", .{ dir, stem, ext }) catch return "" + std.mem.printSentinel(buf, "{s}/{s}{s}", .{ dir, stem, ext }, 0) catch return "" else - std.fmt.bufPrintZ(buf, "{s}/{s}-{d}{s}", .{ dir, stem, n, ext }) catch return ""; + std.mem.printSentinel(buf, "{s}/{s}-{d}{s}", .{ dir, stem, n, ext }, 0) catch return ""; if (access(p.ptr, 0) != 0) return p; } return ""; @@ -252,7 +252,7 @@ pub const HOME_DIRS = [_][]const u8{ "Projects", "Presets", "Wavetables", "Clips fn makeDir(path: []const u8) void { var zb: [MAX_PATH]u8 = undefined; - const z = std.fmt.bufPrintZ(&zb, "{s}", .{path}) catch return; + const z = std.mem.printSentinel(&zb, "{s}", .{path}, 0) catch return; _ = mkdir(z.ptr, 0o755); // EEXIST is fine } @@ -346,7 +346,7 @@ extern "c" fn write(fd: c_int, buf: [*]const u8, count: usize) isize; fn readFile(alloc: std.mem.Allocator, path: []const u8) ?[]u8 { var zb: [MAX_PATH]u8 = undefined; - const z = std.fmt.bufPrintZ(&zb, "{s}", .{path}) catch return null; + const z = std.mem.printSentinel(&zb, "{s}", .{path}, 0) catch return null; const fd = open(z.ptr, 0); if (fd < 0) return null; defer _ = close(fd); @@ -369,7 +369,7 @@ fn readFile(alloc: std.mem.Allocator, path: []const u8) ?[]u8 { fn writeFile(path: []const u8, bytes: []const u8) void { var zb: [MAX_PATH]u8 = undefined; - const z = std.fmt.bufPrintZ(&zb, "{s}", .{path}) catch return; + const z = std.mem.printSentinel(&zb, "{s}", .{path}, 0) catch return; const fd = open(z.ptr, 0x0001 | 0x0200 | 0x0400, @as(c_uint, 0o644)); // O_WRONLY|O_CREAT|O_TRUNC if (fd < 0) return; defer _ = close(fd); @@ -452,7 +452,7 @@ test "storage: the home folder is created, and settings move it" { const base = root_buf[0..root.len]; var sb: [MAX_PATH]u8 = undefined; - const settings = try std.fmt.bufPrintZ(&sb, "{s}/settings.json", .{base}); + const settings = try std.mem.printSentinel(&sb, "{s}/settings.json", .{base}, 0); var hb: [MAX_PATH]u8 = undefined; const moved = try std.fmt.bufPrint(&hb, "{s}/My Slab", .{base}); const saved_home = home_setting_len; @@ -478,7 +478,7 @@ test "storage: the home folder is created, and settings move it" { ensureHome(); for (HOME_DIRS) |d| { var pb: [MAX_PATH]u8 = undefined; - const p = try std.fmt.bufPrintZ(&pb, "{s}/{s}", .{ moved, d }); + const p = try std.mem.printSentinel(&pb, "{s}/{s}", .{ moved, d }, 0); try std.testing.expect(access(p.ptr, 0) == 0); } } diff --git a/src/stretch.zig b/src/stretch.zig index 5bd662ea..443e2c10 100644 --- a/src/stretch.zig +++ b/src/stretch.zig @@ -98,9 +98,9 @@ pub const Stretcher = struct { reset_phase: bool = true, prev_pos: f64 = 0, /// Output phases (the mid's), per bin. - psi: [BINS]f32 = [_]f32{0} ** BINS, - ring_l: [N]f32 = [_]f32{0} ** N, - ring_r: [N]f32 = [_]f32{0} ** N, + psi: [BINS]f32 = @splat(0), + ring_l: [N]f32 = @splat(0), + ring_r: [N]f32 = @splat(0), bl: [N]C = undefined, br: [N]C = undefined, mag: [BINS]f32 = undefined, @@ -531,7 +531,7 @@ fn splitmix(x0: u64) u64 { /// A track's stretchers: one per clip sounding at once. pub const Bank = struct { pub const SIZE = 4; - st: [SIZE]Stretcher = [_]Stretcher{.{}} ** SIZE, + st: [SIZE]Stretcher = @splat(.{}), /// SMEAR's, made on the UI thread when a clip first needs them. smear: [2]std.atomic.Value(?*Smearer) = @splat(.init(null)), tick: u64 = 0, @@ -637,9 +637,9 @@ test "stretch: at ratio 1 it gives the source back" { defer alloc.destroy(st); st.* = .{}; st.claim(1, 0, .mix, 0); - var l = [_]f32{0} ** 20_000; - var r = [_]f32{0} ** 20_000; - const g = [_]f32{1} ** 512; + var l: [20_000]f32 = @splat(0); + var r: [20_000]f32 = @splat(0); + const g: [512]f32 = @splat(1); var a: usize = 0; while (a < l.len) : (a += 512) { const n = @min(512, l.len - a); @@ -657,9 +657,9 @@ test "stretch: twice as long, same pitch; an octave up, same length" { defer alloc.free(x); const st = try alloc.create(Stretcher); defer alloc.destroy(st); - var l = [_]f32{0} ** 24_000; - var r = [_]f32{0} ** 24_000; - const g = [_]f32{1} ** 24_000; + var l: [24_000]f32 = @splat(0); + var r: [24_000]f32 = @splat(0); + const g: [24_000]f32 = @splat(1); st.* = .{}; st.claim(1, 0, .mix, 0); st.render(.{ .l = x.ptr, .r = null, .len = x.len, .reversed = false }, Lin{ .rate = 0.5, .p0 = 20_000 }, 0, &l, &r, &g); @@ -686,9 +686,9 @@ test "stretch: a stereo image keeps its channels apart" { defer alloc.destroy(st); st.* = .{}; st.claim(1, 0, .mix, 0); - var l = [_]f32{0} ** 24_000; - var r = [_]f32{0} ** 24_000; - const g = [_]f32{1} ** 24_000; + var l: [24_000]f32 = @splat(0); + var r: [24_000]f32 = @splat(0); + const g: [24_000]f32 = @splat(1); st.render(.{ .l = xl.ptr, .r = xr.ptr, .len = xl.len, .reversed = false }, Lin{ .rate = 0.7, .p0 = 20_000 }, 0, &l, &r, &g); try std.testing.expectApproxEqAbs(@as(f64, 300), hz(l[4096..20_000]), 1.5); try std.testing.expectApproxEqAbs(@as(f64, 1200), hz(r[4096..20_000]), 3); @@ -700,9 +700,9 @@ test "voice: grains keep the pitch and the level, stretched and squeezed" { defer alloc.free(x); const st = try alloc.create(Stretcher); defer alloc.destroy(st); - var l = [_]f32{0} ** 24_000; - var r = [_]f32{0} ** 24_000; - const g = [_]f32{1} ** 24_000; + var l: [24_000]f32 = @splat(0); + var r: [24_000]f32 = @splat(0); + const g: [24_000]f32 = @splat(1); for ([_]f64{ 0.6, 1.5 }) |rate| { st.* = .{}; st.claim(1, 0, .voice, 1920); @@ -771,7 +771,7 @@ const Psola = struct { fn hit(_: Psola, _: f64, _: f64) bool { return false; } - fn tuneAt(self: Psola, s: f64) TuneGrain { + pub fn tuneAt(self: Psola, s: f64) TuneGrain { const k = @round((s - self.mark0) / self.period); return .{ .center = self.mark0 + k * self.period, .period = self.period, .beta = self.beta }; } @@ -799,9 +799,9 @@ test "tune: grains move the pitch, keep the level, and stretch time" { const st = try alloc.create(Stretcher); defer alloc.destroy(st); const n = 47 * 512; - var l = [_]f32{0} ** n; - var r = [_]f32{0} ** n; - const g = [_]f32{1} ** n; + var l: [n]f32 = @splat(0); + var r: [n]f32 = @splat(0); + const g: [n]f32 = @splat(1); const Case = struct { rate: f64, beta: f64 }; for ([_]Case{ .{ .rate = 1, .beta = 1 }, .{ .rate = 1, .beta = std.math.pow(f64, 2, 2.0 / 12.0) }, .{ .rate = 0.5, .beta = std.math.pow(f64, 2, -1.0 / 12.0) } }) |cs| { st.* = .{}; @@ -871,7 +871,7 @@ test "realtime audit benchmark: smear first block and peak render cost" { var peak: f64 = 0; var l: [256]f32 = undefined; var r: [256]f32 = undefined; - const gain = [_]f32{1} ** 256; + const gain: [256]f32 = @splat(1); for (0..160) |block| { @memset(&l, 0); @memset(&r, 0); diff --git a/src/track.zig b/src/track.zig index 63897bf1..82b6f7f5 100644 --- a/src/track.zig +++ b/src/track.zig @@ -77,7 +77,7 @@ pub const Send = struct { }; pub const Track = struct { - name_buf: [MAX_NAME]u8 = [_]u8{0} ** MAX_NAME, + name_buf: [MAX_NAME]u8 = @splat(0), name_len: u8 = 0, kind: Kind = .audio, /// Where the post-fader signal goes: a bus's track index, or @@ -137,7 +137,7 @@ pub const Track = struct { /// clip first needs them, freed with the track. stretch: ?*stretch_mod.Bank = null, /// Audio-thread-owned per-lane segment cursors (automation.evalCursor). - auto_cursors: [snap_mod.MAX_LANES_PER_TRACK]u32 = [_]u32{0} ** snap_mod.MAX_LANES_PER_TRACK, + auto_cursors: [snap_mod.MAX_LANES_PER_TRACK]u32 = @splat(0), /// Manual overrides of automated volume/pan (docs/22 §Manual changes): /// 0 none, 1 held (touch), 2 sticky until the transport starts. vol_override: std.atomic.Value(u8) = std.atomic.Value(u8).init(0), diff --git a/src/transients.zig b/src/transients.zig index b182ce72..3a8541eb 100644 --- a/src/transients.zig +++ b/src/transients.zig @@ -55,7 +55,7 @@ pub fn detect(alloc: std.mem.Allocator, l: []const f64, r: ?[]const f64, rate: f defer alloc.free(odf); var win: [N]f64 = undefined; for (&win, 0..) |*w, i| w.* = 0.5 - 0.5 * @cos(2 * std.math.pi * @as(f64, @floatFromInt(i)) / N); - var prev = [_]f32{0} ** BINS; + var prev: [BINS]f32 = @splat(0); var buf: [N]Complex = undefined; for (0..frames) |k| { const c0: isize = @as(isize, @intCast(k * HOP)) - N / 2; diff --git a/src/tune.zig b/src/tune.zig index 16db15f6..e6a44599 100644 --- a/src/tune.zig +++ b/src/tune.zig @@ -248,7 +248,7 @@ fn smooth(t: *const Tuning, k: usize, tau: f32, key: u8, mask: u12, one_note: bo /// The key a take is sung in, from how long each pitch class is held /// (docs/30 §Tune). pub fn detectKey(t: *const Tuning) struct { key: u8, scale: Scale } { - var hist = [_]f32{0} ** 12; + var hist: [12]f32 = @splat(0); for (t.held) |h| if (h != 0) { hist[@intCast(@mod(@as(i32, @intFromFloat(@round(h))), 12))] += 1; }; diff --git a/src/ui/audio_clip_editor.zig b/src/ui/audio_clip_editor.zig index 562ea47c..3dfb28f2 100644 --- a/src/ui/audio_clip_editor.zig +++ b/src/ui/audio_clip_editor.zig @@ -466,7 +466,7 @@ fn tuneTools(ui: *Ui, row: *Rect, clip: *clip_mod.Clip, src: *const audio_pool_m } const SCALES = blk: { - var out: [@typeInfo(tune_mod.Scale).@"enum".fields.len][]const u8 = undefined; + var out: [@typeInfo(tune_mod.Scale).@"enum".field_names.len][]const u8 = undefined; for (&out, 0..) |*o, i| o.* = @as(tune_mod.Scale, @enumFromInt(i)).label(); break :blk out; }; diff --git a/src/ui/browser.zig b/src/ui/browser.zig index 24d2b7a8..3e655f7d 100644 --- a/src/ui/browser.zig +++ b/src/ui/browser.zig @@ -248,8 +248,8 @@ fn kindIn(mask: u8, k: Kind) bool { fn matches(it: *const Item, query: []const u8) bool { var words = std.mem.tokenizeScalar(u8, query, ' '); while (words.next()) |w| { - if (std.ascii.indexOfIgnoreCase(it.name, w) == null and - std.ascii.indexOfIgnoreCase(it.folder, w) == null and + if (std.ascii.findIgnoreCase(it.name, w) == null and + std.ascii.findIgnoreCase(it.folder, w) == null and !std.ascii.startsWithIgnoreCase(KIND_NAMES[@intFromEnum(it.kind)], w)) return false; } return true; @@ -402,7 +402,7 @@ fn typeAhead(ui: *Ui, st: *State, focused: bool) void { }; } -const Counts = struct { kinds: [KIND_CHIPS.len]u32 = [_]u32{0} ** KIND_CHIPS.len, favs: u32 = 0 }; +const Counts = struct { kinds: [KIND_CHIPS.len]u32 = @splat(0), favs: u32 = 0 }; /// What each chip counts: the source and search, ignoring the kind mask. fn chipCounts(st: *const State, items: []const Item) Counts { @@ -1532,10 +1532,10 @@ fn ellipsizeMiddle(buf: []u8, f: *const core.Font, s: []const u8, w: i32) []cons /// `s` in `r`, the spans matching a query word bold and underlined. fn highlighted(ui: *Ui, r: Rect, s: []const u8, query: []const u8, col: Color, playing: bool) void { - var mark = [_]bool{false} ** 128; + var mark: [128]bool = @splat(false); var words = std.mem.tokenizeScalar(u8, query, ' '); while (words.next()) |w| { - if (std.ascii.indexOfIgnoreCase(s, w)) |at| { + if (std.ascii.findIgnoreCase(s, w)) |at| { for (at..@min(at + w.len, mark.len)) |i| mark[i] = true; } } diff --git a/src/ui/clip_editor.zig b/src/ui/clip_editor.zig index 82d148e7..d033134d 100644 --- a/src/ui/clip_editor.zig +++ b/src/ui/clip_editor.zig @@ -78,7 +78,7 @@ fn collapsed() bool { /// notes on a key the kit doesn't map stay visible and editable. fn setNoteMap(map: []const machine_mod.NoteLabel, clip: ?*const Clip) void { note_map = map; - var seen = [_]bool{false} ** 128; + var seen: [128]bool = @splat(false); for (map) |*nl| seen[nl.pitch & 127] = true; if (map.len > 0) if (clip) |cl| for (cl.notes.items) |n| { seen[n.pitch & 127] = true; diff --git a/src/ui/core.zig b/src/ui/core.zig index 5a1da417..f048e663 100644 --- a/src/ui/core.zig +++ b/src/ui/core.zig @@ -125,7 +125,7 @@ pub const Trail = struct { id: Id = 0, used: u64 = 0, pts: [TRAIL][TRAIL_PTS]f32 = undefined, - lens: [TRAIL]usize = [_]usize{0} ** TRAIL, + lens: [TRAIL]usize = @splat(0), head: usize = 0, }; const TRAIL_SLOTS = 8; @@ -180,8 +180,8 @@ pub const Ui = struct { wants_frame: bool = true, frame: u64 = 0, white: atlas_mod.Region, - memos: [MEMO_SLOTS]Memo = [_]Memo{.{}} ** MEMO_SLOTS, - trails: [TRAIL_SLOTS]Trail = [_]Trail{.{}} ** TRAIL_SLOTS, + memos: [MEMO_SLOTS]Memo = @splat(.{}), + trails: [TRAIL_SLOTS]Trail = @splat(.{}), last_click_t: f64 = -1, last_click_x: f32 = 0, @@ -334,7 +334,7 @@ pub const Ui = struct { /// Whole-window present for a screen the Ui owns entirely (gallery). pub fn present(ui: *Ui) void { c.rl.BeginDrawing(); - c.rl.ClearBackground(@bitCast(style.chassis)); + c.rl.ClearBackground(draw.rl(style.chassis)); ui.render(); c.rl.EndDrawing(); } @@ -783,7 +783,7 @@ fn hashKey(h: *std.hash.Wyhash, key: anytype) void { const v: u64 = @intFromEnum(key); h.update(std.mem.asBytes(&v)); }, - .@"struct" => |s| inline for (s.fields) |f| hashKey(h, @field(key, f.name)), + .@"struct" => |s| inline for (s.field_names) |name| hashKey(h, @field(key, name)), else => @compileError("unsupported id key type " ++ @typeName(T)), } } diff --git a/src/ui/draw.zig b/src/ui/draw.zig index 624ceeb6..77a96bd8 100644 --- a/src/ui/draw.zig +++ b/src/ui/draw.zig @@ -52,8 +52,14 @@ pub const DrawList = struct { } }; -inline fn rl(col: Color) c.rl.Color { - return @bitCast(col); +/// The same color as raylib's struct (zig 0.17 no longer bit-casts +/// between extern structs). +pub inline fn rl(col: Color) c.rl.Color { + return .{ .r = col.r, .g = col.g, .b = col.b, .a = col.a }; +} + +pub inline fn fromRl(col: c.rl.Color) Color { + return .{ .r = col.r, .g = col.g, .b = col.b, .a = col.a }; } pub const Renderer = struct { diff --git a/src/ui/export_dialog.zig b/src/ui/export_dialog.zig index 4e5aa1a4..5501bab3 100644 --- a/src/ui/export_dialog.zig +++ b/src/ui/export_dialog.zig @@ -9,6 +9,7 @@ const std = @import("std"); const core = @import("core.zig"); const style = @import("style.zig"); +const ui_draw = @import("draw.zig"); const ctl = @import("controls.zig"); const dialog = @import("dialog.zig"); const text_field = @import("text_field.zig"); @@ -450,7 +451,7 @@ fn tracksTab(ui: *Ui, body_in: Rect, state: *State, cx: Context) void { var on = on_now; if (ctl.ledToggle(ui, c.led, "on", &on, !live)) t.stem.on = on; const name_r = c.name; - ui.rect(Rect.xywh(name_r.x, name_r.y + 4, 4, name_r.h - 8), @bitCast(t.color)); + ui.rect(Rect.xywh(name_r.x, name_r.y + 4, 4, name_r.h - 8), ui_draw.fromRl(t.color)); ui.textIn(&ui.fonts.body, Rect.xywh(name_r.x + 10, name_r.y, name_r.w - 10, name_r.h), t.name(), if (live and on) style.text else style.text_mute, .left, false); ui.textIn(&ui.fonts.legend, c.kind, kindOf(cx.tracks, ti), style.text_mute, .left, false); const h = ctl.displayHeight(false); @@ -909,10 +910,10 @@ test "the footer sums up every format" { var presets = xs.UserPresets{}; const cx = Context{ .settings = &s, .presets = &presets, .tracks = &.{}, .project = "song", .bpm = 120, .range_secs = .{ 192.4, null, null, null } }; var buf: [160]u8 = undefined; - inline for (std.meta.fields(export_mod.Container)) |f| { - s.recipe.container = @enumFromInt(f.value); - inline for (std.meta.fields(export_mod.Bits)) |b| { - s.recipe.bits = @enumFromInt(b.value); + inline for (@typeInfo(export_mod.Container).@"enum".field_names) |cn| { + s.recipe.container = @field(export_mod.Container, cn); + inline for (@typeInfo(export_mod.Bits).@"enum".field_names) |bn| { + s.recipe.bits = @field(export_mod.Bits, bn); const out = summary(&buf, cx); try std.testing.expect(std.mem.startsWith(u8, out, "1 FILE · ")); } diff --git a/src/ui/font.zig b/src/ui/font.zig index 715e8813..d59dd30e 100644 --- a/src/ui/font.zig +++ b/src/ui/font.zig @@ -18,7 +18,7 @@ pub const Glyph = struct { }; pub const Font = struct { - glyphs: [256]Glyph = [_]Glyph{.{}} ** 256, + glyphs: [256]Glyph = @splat(.{}), ascent: i32 = 0, descent: i32 = 0, /// Nominal cell width (these are monospaced fonts). diff --git a/src/ui/gallery_browser.zig b/src/ui/gallery_browser.zig index 70534259..5560b0e2 100644 --- a/src/ui/gallery_browser.zig +++ b/src/ui/gallery_browser.zig @@ -484,8 +484,8 @@ fn kindIn(mask: u8, k: Kind) bool { fn matches(it: *const Item, query: []const u8) bool { var words = std.mem.tokenizeScalar(u8, query, ' '); while (words.next()) |w| { - if (std.ascii.indexOfIgnoreCase(it.nameS(), w) == null and - std.ascii.indexOfIgnoreCase(it.folderS(), w) == null and + if (std.ascii.findIgnoreCase(it.nameS(), w) == null and + std.ascii.findIgnoreCase(it.folderS(), w) == null and !std.ascii.startsWithIgnoreCase(KIND_NAMES[@intFromEnum(it.kind)], w)) return false; } return true; @@ -657,7 +657,7 @@ fn chips(ui: *Ui, r: Rect, st: *State) void { const body = ui.plate(r, .{}); var row = body.insetXY(4, 3); // Counts under the current source and search, ignoring the kind mask. - var counts = [_]u16{0} ** KIND_CHIPS.len; + var counts: [KIND_CHIPS.len]u16 = @splat(0); var favs: u16 = 0; for (st.items[0..st.n_items]) |*it| { if (!sourceIn(st.tab, it.source) or !matches(it, st.query.text())) continue; @@ -1223,7 +1223,7 @@ fn tagChips(ui: *Ui, r: *Rect, it: *const Item) void { var nf: usize = 0; for (words) |wd| { if (nf == found.len) break; - if (std.ascii.indexOfIgnoreCase(it.nameS(), wd.w) == null) continue; + if (std.ascii.findIgnoreCase(it.nameS(), wd.w) == null) continue; if (nf == 1 and std.mem.eql(u8, found[0], wd.tag)) continue; found[nf] = wd.tag; nf += 1; @@ -1518,10 +1518,10 @@ fn ellipsizeMiddle(buf: []u8, f: *const core.Font, s: []const u8, w: i32) []cons /// Draw `s` in `r`, the spans matching any query word in bold white. fn highlighted(ui: *Ui, r: Rect, s: []const u8, query: []const u8, col: Color, playing: bool) void { - var mark = [_]bool{false} ** 64; + var mark: [64]bool = @splat(false); var words = std.mem.tokenizeScalar(u8, query, ' '); while (words.next()) |w| { - if (std.ascii.indexOfIgnoreCase(s, w)) |at| { + if (std.ascii.findIgnoreCase(s, w)) |at| { for (at..@min(at + w.len, mark.len)) |i| mark[i] = true; } } diff --git a/src/ui/gallery_concoction.zig b/src/ui/gallery_concoction.zig index e7439e05..52a5b990 100644 --- a/src/ui/gallery_concoction.zig +++ b/src/ui/gallery_concoction.zig @@ -275,8 +275,8 @@ pub const State = struct { // ── The modulation model ───────────────────────────────────────────── const Mods = struct { - src: [SRCS.len]f32 = [_]f32{0} ** SRCS.len, - dst: [DSTS.len]f32 = [_]f32{0} ** DSTS.len, + src: [SRCS.len]f32 = @splat(0), + dst: [DSTS.len]f32 = @splat(0), since: f64 = 0, lfo_ph: [2]f32 = .{ 0, 0 }, note: i32 = 0, diff --git a/src/ui/machine_bay.zig b/src/ui/machine_bay.zig index 96e9a095..7129b873 100644 --- a/src/ui/machine_bay.zig +++ b/src/ui/machine_bay.zig @@ -139,8 +139,8 @@ var fx_drag: ReorderDrag = .{}; // Note-activity LED glow, keyed by track index. Bumped to 1.0 when the // engine's note sequence advances, decayed each frame for a soft pulse. -var led_seen: [16]u32 = [_]u32{0} ** 16; -var led_glow: [16]f32 = [_]f32{0} ** 16; +var led_seen: [16]u32 = @splat(0); +var led_glow: [16]f32 = @splat(0); pub fn panelW(mach: *const Machine) i32 { if (mach.panel_w_fn) |f| return @intFromFloat(@round(f(mach.state))); diff --git a/src/ui/menu.zig b/src/ui/menu.zig index 8cbddbed..9174bb96 100644 --- a/src/ui/menu.zig +++ b/src/ui/menu.zig @@ -141,7 +141,7 @@ var just_opened: bool = false; var armed: bool = true; var open_mx: f32 = 0; var open_my: f32 = 0; -var levels: [MAX_DEPTH]Level = [_]Level{.{}} ** MAX_DEPTH; +var levels: [MAX_DEPTH]Level = @splat(.{}); var level_count: usize = 0; /// Last frame's level rects, for this frame's outside-click test. var prev_rects: [MAX_DEPTH]Rect = undefined; diff --git a/src/ui/preset_browser.zig b/src/ui/preset_browser.zig index 74b4709a..88dc43ba 100644 --- a/src/ui/preset_browser.zig +++ b/src/ui/preset_browser.zig @@ -69,7 +69,7 @@ fn bankOf(name: []const u8) []const u8 { fn matches(name: []const u8, query: []const u8) bool { var it = std.mem.tokenizeScalar(u8, query, ' '); while (it.next()) |w| { - if (std.ascii.indexOfIgnoreCase(name, w) == null) return false; + if (std.ascii.findIgnoreCase(name, w) == null) return false; } return true; } diff --git a/src/ui/sprites.zig b/src/ui/sprites.zig index b0692d1b..5a186a1f 100644 --- a/src/ui/sprites.zig +++ b/src/ui/sprites.zig @@ -116,7 +116,7 @@ pub const Art = struct { sliders: [3]SliderArt, /// Lever positions: up, mid, down. lever: [3]Region, - leds: [std.meta.fields(LedShape).len]LedArt, + leds: [@typeInfo(LedShape).@"enum".field_names.len]LedArt, /// Halo versions of each display-font glyph (same index as the font). display_halo: [256]Region, }; @@ -127,7 +127,7 @@ pub fn build(a: *Atlas, display_font: *const font_mod.Font) !Art { inline for (.{ Size.s, Size.m, Size.l }, 0..) |sz, i| art.knobs[i] = try buildKnob(a, sz); inline for (.{ SliderKind.fader, SliderKind.slider, SliderKind.mini }, 0..) |k, i| art.sliders[i] = try buildSlider(a, k); for (0..3) |i| art.lever[i] = try buildLever(a, @intCast(i)); - inline for (std.meta.fields(LedShape), 0..) |f, i| art.leds[i] = try buildLed(a, @field(LedShape, f.name)); + inline for (@typeInfo(LedShape).@"enum".field_names, 0..) |name, i| art.leds[i] = try buildLed(a, @field(LedShape, name)); for (&art.display_halo, 0..) |*h, i| { const g = &display_font.glyphs[i]; h.* = if (g.present and g.src.w > 0) try buildHalo(a, g.src) else .{}; diff --git a/src/ui/transport_bar.zig b/src/ui/transport_bar.zig index 9ac9d45f..68912532 100644 --- a/src/ui/transport_bar.zig +++ b/src/ui/transport_bar.zig @@ -177,9 +177,9 @@ pub fn draw(ui: *Ui, r: Rect, a: Args) Result { } const SNAP_LABELS = blk: { - const fields = @typeInfo(snap_mod.Setting).@"enum".fields; - var out: [fields.len][]const u8 = undefined; - for (fields, 0..) |f, i| out[i] = std.mem.span(@as(snap_mod.Setting, @enumFromInt(f.value)).label()); + const names = @typeInfo(snap_mod.Setting).@"enum".field_names; + var out: [names.len][]const u8 = undefined; + for (names, 0..) |name, i| out[i] = std.mem.span(@field(snap_mod.Setting, name).label()); break :blk out; }; diff --git a/src/warp.zig b/src/warp.zig index d00ec537..29ea234a 100644 --- a/src/warp.zig +++ b/src/warp.zig @@ -35,8 +35,7 @@ pub const Mode = enum(u8) { } pub fn parse(s: []const u8) ?Mode { - inline for (std.meta.fields(Mode)) |f| if (std.mem.eql(u8, s, f.name)) return @field(Mode, f.name); - return null; + return std.meta.stringToEnum(Mode, s); } }; @@ -88,8 +87,7 @@ pub fn stretches(m: Mode) bool { } pub fn parseEnum(comptime E: type, s: []const u8) ?E { - inline for (std.meta.fields(E)) |f| if (std.mem.eql(u8, s, f.name)) return @field(E, f.name); - return null; + return std.meta.stringToEnum(E, s); } // ── The map ────────────────────────────────────────────────────────── diff --git a/src/wav.zig b/src/wav.zig index 92642b77..829d133f 100644 --- a/src/wav.zig +++ b/src/wav.zig @@ -616,7 +616,7 @@ test "parse stereo folds to mono" { } test "rejects non-RIFF" { - var buf = [_]u8{0} ** 64; + var buf: [64]u8 = @splat(0); @memcpy(buf[0..4], "JUNK"); try testing.expectError(Error.NotRiffWave, parse(testing.allocator, &buf)); } @@ -696,7 +696,7 @@ test "a FLAC from slab's encoder loads sample-exact: 16 and 24 bits, mono and st const bytes = try flac.encode(alloc, ints, .{ .sample_rate = 32_000, .bits = bits, .channels = ch }); defer alloc.free(bytes); var pb: [256]u8 = undefined; - const path = try std.fmt.bufPrintZ(&pb, "/tmp/slab-wav-flac-{d}-{d}-{d}.flac", .{ std.c.getpid(), bits, ch }); + const path = try std.mem.printSentinel(&pb, "/tmp/slab-wav-flac-{d}-{d}-{d}.flac", .{ std.c.getpid(), bits, ch }, 0); try writeTestFile(path, bytes); const scale: f64 = @floatFromInt(@as(i32, 1) << (bits - 1)); diff --git a/src/waveform.zig b/src/waveform.zig index f10d766a..c1a57038 100644 --- a/src/waveform.zig +++ b/src/waveform.zig @@ -26,8 +26,8 @@ const BASE: usize = 64; pub const PeakCache = struct { // Concatenated pyramid: levels[level] is a slice into `peaks`. peaks: []Peak = &.{}, - level_off: [24]usize = [_]usize{0} ** 24, - level_len: [24]usize = [_]usize{0} ** 24, + level_off: [24]usize = @splat(0), + level_len: [24]usize = @splat(0), level_count: usize = 0, sample_count: usize = 0, diff --git a/src/wavetable.zig b/src/wavetable.zig index ff91897c..e44b2853 100644 --- a/src/wavetable.zig +++ b/src/wavetable.zig @@ -46,7 +46,7 @@ pub fn mipHarmonics(m: usize) usize { pub const STRIDE: usize = mipOffset(MIPS); /// One frame of zeros: what an oscillator reads with no table loaded. -pub const silent_frame = [_]f64{0} ** STRIDE; +pub const silent_frame: [STRIDE]f64 = @splat(0); pub const Table = struct { data: []f64 = &.{}, diff --git a/src/wavetable_cache.zig b/src/wavetable_cache.zig index 30bbc244..af3cf1aa 100644 --- a/src/wavetable_cache.zig +++ b/src/wavetable_cache.zig @@ -20,7 +20,7 @@ const Entry = struct { refs: usize = 0, }; -var entries: [MAX]Entry = [_]Entry{.{}} ** MAX; +var entries: [MAX]Entry = @splat(.{}); var lock: @import("fy").UnfairLock = .{}; fn take() void { diff --git a/src/wavetable_edit.zig b/src/wavetable_edit.zig index 52440ea6..fb8a83bb 100644 --- a/src/wavetable_edit.zig +++ b/src/wavetable_edit.zig @@ -40,7 +40,7 @@ pub const Doc = struct { count: usize = 1, /// Keyframes: the frames a morph keeps and fills between. The first /// and the last frame are always keys. - keys: Keys = Keys.initEmpty(), + keys: Keys = Keys.empty, /// The frame being edited. sel: usize = 0, undo_s: [UNDO]Snap = undefined, @@ -125,7 +125,7 @@ pub const Doc = struct { fn reset(d: *Doc, count: usize) void { d.count = count; - d.keys = Keys.initEmpty(); + d.keys = Keys.empty; d.sel = 0; d.clearHistory(); d.markResized(); @@ -377,7 +377,7 @@ pub const Doc = struct { const tail = (d.count - i - 1) * N; std.mem.copyBackwards(f32, d.data[(i + 2) * N ..][0..tail], d.data[(i + 1) * N ..][0..tail]); @memcpy(d.frame(i + 1), d.frameConst(i)); - var keys = Keys.initEmpty(); + var keys = Keys.empty; for (0..d.count) |k| if (d.keys.isSet(k)) keys.set(if (k > i) k + 1 else k); d.keys = keys; d.count += 1; @@ -389,7 +389,7 @@ pub const Doc = struct { if (d.count == 1) return; const tail = (d.count - i - 1) * N; std.mem.copyForwards(f32, d.data[i * N ..][0..tail], d.data[(i + 1) * N ..][0..tail]); - var keys = Keys.initEmpty(); + var keys = Keys.empty; for (0..d.count) |k| { if (k != i and d.keys.isSet(k)) keys.set(if (k > i) k - 1 else k); } diff --git a/src/wavetable_file.zig b/src/wavetable_file.zig index b01af21b..60b0b8cd 100644 --- a/src/wavetable_file.zig +++ b/src/wavetable_file.zig @@ -63,7 +63,7 @@ pub fn save(alloc: std.mem.Allocator, doc: *const wte.Doc, path: []const u8) boo const bytes = encode(alloc, doc) catch return false; defer alloc.free(bytes); var zb: [1024]u8 = undefined; - const z = std.fmt.bufPrintZ(&zb, "{s}", .{path}) catch return false; + const z = std.mem.printSentinel(&zb, "{s}", .{path}, 0) catch return false; const fd = open(z.ptr, O_WRONLY | O_CREAT | O_TRUNC, @as(c_uint, 0o644)); if (fd < 0) return false; defer _ = close(fd); @@ -83,13 +83,13 @@ pub fn tablesDir(buf: []u8, project_path: []const u8) []const u8 { pub fn makeDir(path: []const u8) void { var zb: [1024]u8 = undefined; - const z = std.fmt.bufPrintZ(&zb, "{s}", .{path}) catch return; + const z = std.mem.printSentinel(&zb, "{s}", .{path}, 0) catch return; _ = mkdir(z.ptr, 0o755); // EEXIST is fine } pub fn exists(path: []const u8) bool { var zb: [1024]u8 = undefined; - const z = std.fmt.bufPrintZ(&zb, "{s}", .{path}) catch return false; + const z = std.mem.printSentinel(&zb, "{s}", .{path}, 0) catch return false; return access(z.ptr, 0) == 0; } From e770a36aa0650efa210dd64880fc98c59cda2a04 Mon Sep 17 00:00:00 2001 From: Marcin Gasperowicz Date: Wed, 21 Oct 2026 20:30:33 +0100 Subject: [PATCH 8/9] =?UTF-8?q?machine:=20rack=20zones,=20collapsed=20face?= =?UTF-8?q?,=20insert=20reorder,=20group/ungroup=20and=20automation=20insi?= =?UTF-8?q?de=20racks=20(docs/42=20=C2=A7Build=20phases=204,=206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rack panel gets KEY/VEL/CHAIN zone tabs, a fold button that shows only the named macros, MAP outlines on mappable controls, hand moves on a mapped control that drive its macro, and drag-reordering of a lane's inserts. Lanes carry stable uids. Automation targets gain a path of up to three (lane uid, slot) steps under the rack's own target; Machine.child/inner walk containers, AutoView.into hands a nested rack its view, and files write `inst/L.P:id` positions. Shift-click selects a run of devices in the bay; ⌘G groups it into a new Rack or FX Rack with settings and automation moved one step down, and ⌘⇧G ungroups a one-lane rack with no mappings (src/rack_group.zig). Co-Authored-By: Claude Opus 5.5 --- src/automation.zig | 49 +++- src/chain.zig | 2 + src/describe.zig | 19 ++ src/document.zig | 121 +++++++- src/engine.zig | 9 +- src/machine.zig | 25 ++ src/machines/fy_raw_machine.zig | 1 + src/machines/rack.zig | 482 ++++++++++++++++++++++++++++---- src/main.zig | 68 ++++- src/rack_group.zig | 382 +++++++++++++++++++++++++ src/snapshot.zig | 13 +- src/track.zig | 8 +- src/ui/core.zig | 3 + src/ui/lane_targets.zig | 2 +- src/ui/machine_bay.zig | 75 ++++- 15 files changed, 1164 insertions(+), 95 deletions(-) create mode 100644 src/rack_group.zig diff --git a/src/automation.zig b/src/automation.zig index b0d78222..108733b4 100644 --- a/src/automation.zig +++ b/src/automation.zig @@ -105,12 +105,52 @@ pub const MAX_PARAM_ID = 24; pub const TargetKind = enum(u8) { volume, pan, inst, fx }; +/// Racks nest this deep (docs/42 §Automation inside a rack). +pub const MAX_PATH = 3; + +/// One step into a rack: its lane (by the lane's uid, stable while lanes +/// come and go), and the machine in it (0 is the lane's instrument, +/// otherwise an insert's uid in the lane's chain). +pub const Step = struct { + lane: u8 = 0, + slot: u16 = 0, +}; + +/// From the machine a target's kind names, down through racks to the +/// machine that holds the control. Empty for every machine on a track. +pub const Path = struct { + steps: [MAX_PATH]Step = @splat(.{}), + len: u8 = 0, + + pub fn slice(self: *const Path) []const Step { + return self.steps[0..self.len]; + } + + pub fn eql(a: Path, b: Path) bool { + if (a.len != b.len) return false; + for (a.steps[0..a.len], b.steps[0..b.len]) |x, y| if (x.lane != y.lane or x.slot != y.slot) return false; + return true; + } + + /// This path one step deeper; unchanged when it is already MAX_PATH deep. + pub fn plus(self: Path, step: Step) Path { + var p = self; + if (p.len < MAX_PATH) { + p.steps[p.len] = step; + p.len += 1; + } + return p; + } +}; + /// What a lane drives. Effects are addressed by their instance uid (stable /// under reordering); machine controls by param id (stable under machine /// swaps and manifest reloads — a lane whose id is missing goes quiet). pub const Target = struct { kind: TargetKind, fx_uid: u16 = 0, + /// Into a rack on that slot, down to the machine with the control. + path: Path = .{}, param_buf: [MAX_PARAM_ID]u8 = @splat(0), param_len: u8 = 0, @@ -134,8 +174,15 @@ pub const Target = struct { return self.param_buf[0..self.param_len]; } + /// The same control, `path` deeper. + pub fn inside(self: Target, path: Path) Target { + var t = self; + t.path = path; + return t; + } + pub fn eql(a: Target, b: Target) bool { - if (a.kind != b.kind) return false; + if (a.kind != b.kind or !a.path.eql(b.path)) return false; return switch (a.kind) { .volume, .pan => true, .inst => std.mem.eql(u8, a.param(), b.param()), diff --git a/src/chain.zig b/src/chain.zig index 61c3407c..e8ef7131 100644 --- a/src/chain.zig +++ b/src/chain.zig @@ -170,7 +170,9 @@ pub const State = struct { errdefer alloc.destroy(io); io.* = @splat(.init(0)); try effects.append(alloc, .{ .mach = mach, .idx = idx, .uid = self.next_uid, .shared_io = io }); + // 0 is no insert: a rack lane's instrument in automation paths. self.next_uid +%= 1; + if (self.next_uid == 0) self.next_uid = 1; self.publish(instrument, effects.items); } diff --git a/src/describe.zig b/src/describe.zig index 6f3f7e34..fbe9ad39 100644 --- a/src/describe.zig +++ b/src/describe.zig @@ -9,6 +9,7 @@ const registry_mod = @import("machine_registry.zig"); const machine_desc = @import("machine_desc.zig"); const document_mod = @import("document.zig"); const FyHost = @import("fy_host.zig").FyHost; +const rack_mod = @import("machines/rack.zig"); pub fn run(alloc: std.mem.Allocator, out_path: []const u8) !void { var out: std.ArrayList(u8) = .empty; @@ -36,6 +37,9 @@ pub fn run(alloc: std.mem.Allocator, out_path: []const u8) !void { first = false; try writeMachine(alloc, &out, path, d); } + // The native racks (docs/42): no fy manifest, a fixed control set. + try writeRack(alloc, &out, rack_mod.PATH, "Rack", "instrument"); + try writeRack(alloc, &out, rack_mod.FX_PATH, "FX Rack", "effect"); try out.appendSlice(alloc, "]}\n"); try document_mod.writeFile(alloc, out_path, out.items); } @@ -99,6 +103,21 @@ fn writeMachine(alloc: std.mem.Allocator, out: *std.ArrayList(u8), path: []const try out.appendSlice(alloc, "]}"); } +/// A rack's controls: macros `rk-m1`…`rk-m8` (0..1) and `rk-chain` +/// (0..127, the chain selector zones key on). +fn writeRack(alloc: std.mem.Allocator, out: *std.ArrayList(u8), path: []const u8, name: []const u8, kind: []const u8) !void { + const id = std.fs.path.basename(std.fs.path.dirname(path) orelse path); + try out.appendSlice(alloc, ",\n{\"id\":"); + try document_mod.appendJsonString(alloc, out, id); + try out.appendSlice(alloc, ",\"name\":"); + try document_mod.appendJsonString(alloc, out, name); + try out.appendSlice(alloc, ",\"path\":"); + try document_mod.appendJsonString(alloc, out, path); + try fmt(alloc, out, ",\"kind\":\"{s}\",\"native\":true,\"voices\":0,\"stereo\":true,\"note_pitch\":false,\"sidechain\":false,\"note_labels\":[],\"params\":[", .{kind}); + for (0..8) |i| try fmt(alloc, out, "\n {{\"id\":\"rk-m{d}\",\"module\":\"MACROS\",\"label\":\"MACRO {d}\",\"type\":\"float\",\"min\":0,\"max\":1,\"default\":0,\"curve\":\"linear\"}},", .{ i + 1, i + 1 }); + try out.appendSlice(alloc, "\n {\"id\":\"rk-chain\",\"module\":\"MACROS\",\"label\":\"CHAIN\",\"type\":\"int\",\"min\":0,\"max\":127,\"default\":0,\"curve\":\"linear\"}]}"); +} + fn fmt(alloc: std.mem.Allocator, out: *std.ArrayList(u8), comptime f: []const u8, args: anytype) !void { const s = try std.fmt.allocPrint(alloc, f, args); defer alloc.free(s); diff --git a/src/document.zig b/src/document.zig index 61d8e201..5ccc6ee6 100644 --- a/src/document.zig +++ b/src/document.zig @@ -570,20 +570,35 @@ fn appendEffects(alloc: std.mem.Allocator, out: *std.ArrayList(u8), t: *const tr } /// A lane's target as the file names it: `volume`, `pan`, `inst:`, -/// `fx:` (N = the effect's chain position now). Null when the -/// target no longer resolves. +/// `fx:` (N = the effect's chain position now). A control inside a +/// rack adds a `/L.P` per step down (L its lane from 1, P its place in the +/// lane: 0 the instrument, n the n-th insert), as `inst/2.1:amp-gain` +/// (docs/42 §Automation inside a rack). Null when the target no longer +/// resolves. fn laneTargetName(buf: []u8, t: *const track_mod.Track, target: automation.Target) ?[]const u8 { - return switch (target.kind) { - .volume => "volume", - .pan => "pan", - .inst => std.fmt.bufPrint(buf, "inst:{s}", .{target.param()}) catch null, + var w = std.Io.Writer.fixed(buf); + var m: *const machine_mod.Machine = switch (target.kind) { + .volume => return "volume", + .pan => return "pan", + .inst => blk: { + w.writeAll("inst") catch return null; + break :blk &t.machine; + }, .fx => blk: { - for (t.effects.items, 0..) |*fx, i| { - if (fx.uid == target.fx_uid) break :blk std.fmt.bufPrint(buf, "fx{d}:{s}", .{ i, target.param() }) catch null; - } - break :blk null; + for (t.effects.items, 0..) |*fx, i| if (fx.uid == target.fx_uid) { + w.print("fx{d}", .{i}) catch return null; + break :blk &fx.mach; + }; + return null; }, }; + for (target.path.slice()) |st| { + const ch = m.childAt(st) orelse return null; + w.print("/{d}.{d}", .{ @as(usize, ch.lane) + 1, ch.pos }) catch return null; + m = ch.mach; + } + w.print(":{s}", .{target.param()}) catch return null; + return w.buffered(); } /// Knob-space lane value → real units (fader gain, pan, control value). @@ -639,15 +654,35 @@ fn parseLaneTarget(t: *const track_mod.Track, name: []const u8) ?automation.Targ if (std.mem.eql(u8, name, "volume")) return automation.Target.volume(); if (std.mem.eql(u8, name, "pan")) return automation.Target.pan(); const colon = std.mem.indexOfScalar(u8, name, ':') orelse return null; - const head = name[0..colon]; + var steps = std.mem.splitScalar(u8, name[0..colon], '/'); + const head = steps.first(); const id = name[colon + 1 ..]; - if (std.mem.eql(u8, head, "inst")) return automation.Target.control(.inst, 0, id); - if (head.len > 2 and std.mem.startsWith(u8, head, "fx")) { + var target: automation.Target = undefined; + var m: *const machine_mod.Machine = undefined; + if (std.mem.eql(u8, head, "inst")) { + target = automation.Target.control(.inst, 0, id); + m = &t.machine; + } else if (head.len > 2 and std.mem.startsWith(u8, head, "fx")) { const n = std.fmt.parseInt(usize, head[2..], 10) catch return null; if (n >= t.effects.items.len) return null; - return automation.Target.control(.fx, t.effects.items[n].uid, id); + target = automation.Target.control(.fx, t.effects.items[n].uid, id); + m = &t.effects.items[n].mach; + } else return null; + // Down through racks: lane L, place P (the rack's machines must be in). + while (steps.next()) |sv| { + const dot = std.mem.indexOfScalar(u8, sv, '.') orelse return null; + const lane = std.fmt.parseInt(usize, sv[0..dot], 10) catch return null; + const pos = std.fmt.parseInt(usize, sv[dot + 1 ..], 10) catch return null; + if (lane < 1 or target.path.len >= automation.MAX_PATH) return null; + const f = m.child orelse return null; + var i: usize = 0; + const ch = while (f(m.state, i)) |ch| : (i += 1) { + if (ch.lane == lane - 1 and ch.pos == pos) break ch; + } else return null; + target.path = target.path.plus(ch.step); + m = ch.mach; } - return null; + return target; } fn applyLanes(alloc: std.mem.Allocator, t: *track_mod.Track, av: std.json.Value) !void { @@ -1797,6 +1832,62 @@ test "JSON project round-trips an FX Rack in an effect slot, lanes and inserts" try std.testing.expectEqual(@as(f32, -6), got.lanes[1].level_db); } +test "automation inside a rack round-trips by lane and place: fx0/2.1:" { + const alloc = std.testing.allocator; + var reg = registry_mod.Registry.init(alloc); + defer reg.deinit(); + try reg.loadFyMachine("machines/sat2/sat2.fy"); + try reg.loadNative(); + setRegistry(®); + defer active_reg = null; + var pool = audio_pool_mod.AudioPool.init(alloc); + defer pool.deinit(); + setPool(&pool); + defer active_pool = null; + var master = try track_mod.Track.init(alloc, "Master", .{ .r = 0, .g = 0, .b = 0, .a = 255 }, test_machine); + master.kind = .master; + defer master.deinit(alloc); + setMaster(&master); + defer active_master = null; + var transport: transport_mod.Transport = .{}; + transport.sample_rate = 48_000; + + const idx = reg.findById("fxrack").?; + const fx = try reg.instantiate(idx); + const rk: *rack_mod.Rack = @ptrCast(@alignCast(fx.state)); + _ = try rk.addLane(null); + _ = try rk.addLane(reg.findById("sat2").?); + var tracks = [_]track_mod.Track{ + try track_mod.Track.init(alloc, "Fx", .{ .r = 1, .g = 2, .b = 3, .a = 255 }, test_machine), + }; + defer for (&tracks) |*t| t.deinit(alloc); + try tracks[0].addEffect(alloc, fx, @intCast(idx)); + const step = automation.Step{ .lane = rk.lanes[1].uid, .slot = rk.lanes[1].effects.items[0].uid }; + const target = automation.Target.control(.fx, tracks[0].effects.items[0].uid, "sat-drive").inside(automation.Path.plus(.{}, step)); + try std.testing.expect(tracks[0].targetMachine(target) != null); + const lane = try tracks[0].laneFor(alloc, target, false); + _ = try lane.insert(alloc, .{ .beat = 0, .value = 0.25 }); + _ = try lane.insert(alloc, .{ .beat = 4, .value = 0.75 }); + + const bytes = try serialize(alloc, tracks[0..], &transport); + defer alloc.free(bytes); + try std.testing.expect(std.mem.indexOf(u8, bytes, "\"target\":\"fx0/2.1:sat-drive\"") != null); + + var loaded_buf: [1]track_mod.Track = undefined; + var loaded_count: usize = 0; + var lt: transport_mod.Transport = .{}; + lt.sample_rate = 48_000; + try apply(alloc, bytes, ®, loaded_buf[0..], &loaded_count, <, test_machine); + defer for (loaded_buf[0..loaded_count]) |*t| t.deinit(alloc); + const lt0 = &loaded_buf[0]; + try std.testing.expectEqual(@as(usize, 1), lt0.lanes.items.len); + const back = lt0.lanes.items[0]; + try std.testing.expectEqual(@as(u8, 1), back.target.path.len); + const got: *rack_mod.Rack = @ptrCast(@alignCast(lt0.effects.items[0].mach.state)); + try std.testing.expectEqual(got.lanes[1].effects.items[0].mach.state, lt0.targetMachine(back.target).?.state); + try std.testing.expectApproxEqAbs(@as(f32, 0.75), back.points.items[1].value, 1e-5); +} + test "JSON project round-trips instrument-by-id, settings, and effect chain" { const alloc = std.testing.allocator; var reg = registry_mod.Registry.init(alloc); diff --git a/src/engine.zig b/src/engine.zig index c0faa7d6..31e52a70 100644 --- a/src/engine.zig +++ b/src/engine.zig @@ -2378,8 +2378,13 @@ fn renderEffectsKeyed( if (base_ctx.automation) |p| { const inst: *const snap_mod.AutoView = @ptrCast(@alignCast(p)); fx_view = inst.*; - fx_view.kind = .fx; - fx_view.fx_uid = fx.uid; + if (inst.path.len > 0) { + // A rack lane's chain: the insert is the last step's slot. + fx_view.path.steps[inst.path.len - 1].slot = fx.uid; + } else { + fx_view.kind = .fx; + fx_view.fx_uid = fx.uid; + } ctx.automation = &fx_view; } @memset(next_l, 0); diff --git a/src/machine.zig b/src/machine.zig index a933a131..25c8de1c 100644 --- a/src/machine.zig +++ b/src/machine.zig @@ -279,6 +279,12 @@ pub const TakeAutoRequestFn = *const fn (state: *anyopaque) ?AutoRequest; /// space), for automation recording. Taking it clears it. pub const Touch = struct { control: u16, knob: f32 }; pub const TakeTouchFn = *const fn (state: *anyopaque) ?Touch; +/// UI thread: the `i`-th machine a container holds, null past the last (a +/// rack's lanes, each its instrument then its inserts; docs/42 §Automation +/// inside a rack). `lane` is the lane's place in the rack and `pos` the +/// machine's in its lane: 0 for the instrument, n for the n-th insert. +pub const Child = struct { step: @import("automation.zig").Step, lane: u8, pos: u16, mach: *Machine }; +pub const ChildFn = *const fn (state: *anyopaque, i: usize) ?Child; /// Audio thread, between renders: a rack's macro drives control `i` at /// `knob` (knob space), over its automation and its hand-set value, until /// it is set to null (docs/42 §Reaching the target). @@ -503,6 +509,8 @@ pub const Machine = struct { take_auto_request: ?TakeAutoRequestFn = null, take_touch: ?TakeTouchFn = null, set_mod: ?SetModFn = null, + /// A container's machines (a rack's), for automation inside it. + child: ?ChildFn = null, /// Delay compensation (docs/07 §PDC); none = no latency. latency: ?LatencyFn = null, /// Idle skipping (docs/04 §Idle skipping); none = no stored sound @@ -554,6 +562,23 @@ pub const Machine = struct { return null; } + /// The machine one step inside this one, if it holds it. + pub fn childAt(self: *const Machine, step: @import("automation.zig").Step) ?Child { + const f = self.child orelse return null; + var i: usize = 0; + while (f(self.state, i)) |ch| : (i += 1) { + if (ch.step.lane == step.lane and ch.step.slot == step.slot) return ch; + } + return null; + } + + /// The machine `path` leads to from this one (itself when it's empty). + pub fn inner(self: *const Machine, path: @import("automation.zig").Path) ?*const Machine { + var m: *const Machine = self; + for (path.slice()) |st| m = (m.childAt(st) orelse return null).mach; + return m; + } + /// The note map to draw: the live one if the machine has it. pub fn noteLabels(self: *const Machine) []const NoteLabel { if (self.note_labels_fn) |f| return f(self.state); diff --git a/src/machines/fy_raw_machine.zig b/src/machines/fy_raw_machine.zig index bd334565..9d8d7c76 100644 --- a/src/machines/fy_raw_machine.zig +++ b/src/machines/fy_raw_machine.zig @@ -3912,6 +3912,7 @@ fn drawAutomatable(self: *FyRawMachine, ui: *Ui, kr: Rect, gi: usize, ctl: *cons const was_pressed = ui.in.pressed; drawControl(self, ui, kr, gi, ctl, tier); modTarget(self, kr, gi); + if (ui.map_mode) ui.bevel(kr.inset(1), ui_style.accent.alpha(160), ui_style.accent.alpha(160)); if (kr.contains(ui.in.ix(), ui.in.iy())) { if (modDestFor(self, gi)) |m| self.mod_hot_next = m.index; } diff --git a/src/machines/rack.zig b/src/machines/rack.zig index 1bd11722..0af8f0c4 100644 --- a/src/machines/rack.zig +++ b/src/machines/rack.zig @@ -79,6 +79,10 @@ pub const FX_PATH = "machines/fxrack/fxrack"; pub const Kind = enum { instrument, effect }; +/// A lane's three zones (docs/42 §UI): the keys, velocities and CHAIN +/// values it plays at. +pub const Zone = enum { key, vel, chain }; + pub const MAX_MACROS = 8; pub const MAX_MAPS = 64; /// Control index of CHAIN, after the macros. @@ -158,6 +162,9 @@ pub const Lane = struct { /// The chain's publication; `instrument` and `effects` are its /// editable side (UI only). chain: *chain_mod.State, + /// Which lane it is while others come and go: automation inside the + /// rack addresses it by this (docs/42 §Automation inside a rack). + uid: u8 = 0, instrument: Machine = silent, /// Registry index of the instrument; null in an effect rack. reg_idx: ?u8 = null, @@ -192,13 +199,22 @@ pub const Lane = struct { return self.label[0..self.label_len]; } - fn setLabel(self: *Lane, s: []const u8) void { + pub fn setLabel(self: *Lane, s: []const u8) void { const n = @min(s.len, MAX_LABEL); @memcpy(self.label[0..n], s[0..n]); self.label[n] = 0; self.label_len = @intCast(n); } + /// The lane's `z` zone, lo and hi. + pub fn zone(self: *Lane, z: Zone) [2]*u8 { + return switch (z) { + .key => .{ &self.lo, &self.hi }, + .vel => .{ &self.vlo, &self.vhi }, + .chain => .{ &self.clo, &self.chi }, + }; + } + fn takes(self: *const Lane, pitch: f32, vel: f32) bool { const k: i32 = @intFromFloat(@round(pitch)); const v: i32 = @intFromFloat(@round(std.math.clamp(vel, 0, 1) * 127)); @@ -248,12 +264,15 @@ pub const Rack = struct { versions: handoff.Latest(LaneVersion) = .init(.{}), retired: handoff.Retirement(RetiredLane, 128) = .{}, generation: u64 = 0, // UI-owned + next_lane_uid: u8 = 0, publication_depth: usize = 0, audio_lanes: [MAX_LANES]Lane = undefined, audio_count: usize = 0, // UI selected: usize = 0, + /// Folded to its face: the macro knobs alone (docs/42 §Collapsed face). + collapsed: bool = false, presets: presets_mod.List = .{}, current_preset: i32 = -1, /// A lane or an insert came or went since the project last asked. @@ -275,6 +294,12 @@ pub const Rack = struct { name_for: ?usize = null, formula_tb: text_field.TextBuf = .{}, formula_for: ?usize = null, + /// Which zones the lane map shows and the range fields edit. + zone_tab: Zone = .key, + /// Where a drag on the lane map started (0..127). + zone_from: ?u8 = null, + /// An insert's name tile pressed (`armed`), dragging once it travels. + fx_drag: struct { armed: bool = false, active: bool = false, src: usize = 0, press_x: f32 = 0 } = .{}, // Macros, UI writes and audio reads: the hand-set value in knob space // (CHAIN's raw 0..127), and a count a jump (preset, project) bumps. @@ -342,6 +367,7 @@ pub const Rack = struct { .set_auto_ui = setAutoUiImpl, .take_touch = takeTouchImpl, .set_mod = setModImpl, + .child = childImpl, .write_params_json = writeParamsJsonImpl, .set_param = setParamImpl, .panel_w_fn = panelWImpl, @@ -441,13 +467,24 @@ pub const Rack = struct { return m; } + /// The next uid no lane has. + fn freeLaneUid(self: *Rack) u8 { + while (true) { + const u = self.next_lane_uid; + self.next_lane_uid +%= 1; + for (self.lanes[0..self.count]) |*l| { + if (l.uid == u) break; + } else return u; + } + } + fn newLane(self: *Rack, inst: Machine, reg_idx: ?u8) !*Lane { if (self.count >= MAX_LANES) return error.RackFull; const ch = try chain_mod.State.create(self.alloc, inst); errdefer ch.destroy(self.alloc); const line = try self.alloc.create(chain_mod.DryLine); @memset(std.mem.asBytes(line), 0); - self.lanes[self.count] = .{ .chain = ch, .instrument = inst, .reg_idx = reg_idx, .line = line }; + self.lanes[self.count] = .{ .chain = ch, .uid = self.freeLaneUid(), .instrument = inst, .reg_idx = reg_idx, .line = line }; self.count += 1; self.publishLanes(); return &self.lanes[self.count - 1]; @@ -698,6 +735,28 @@ pub const Rack = struct { } } + /// Move lane `from` to `to`, its mappings with it. + pub fn moveLane(self: *Rack, from: usize, to: usize) void { + if (from >= self.count or to >= self.count or from == to) return; + const l = self.lanes[from]; + if (from < to) { + var j = from; + while (j < to) : (j += 1) self.lanes[j] = self.lanes[j + 1]; + } else { + var j = from; + while (j > to) : (j -= 1) self.lanes[j] = self.lanes[j - 1]; + } + self.lanes[to] = l; + for (self.maps[0..self.map_count]) |*m| { + if (m.lane == from) { + m.lane = @intCast(to); + } else if (from < to and m.lane > from and m.lane <= to) { + m.lane -= 1; + } else if (to < from and m.lane >= to and m.lane < from) m.lane += 1; + } + self.publishLanes(); + } + fn retireLane(self: *Rack, generation: u64, l: *Lane) void { self.retired.append(generation, .{ .chain = l.chain, .instrument = l.instrument, .effects = l.effects, .line = l.line }); } @@ -736,7 +795,11 @@ pub const Rack = struct { /// Apply one machine's saved settings (params, then files, then zone /// edits, then its own state), as a project load does for a track's. + /// With no params, a named preset is the settings (rigs written by + /// hand: {"machine":"amp2","preset":"guitar-clean"}). fn applyMachineJson(m: Machine, po: std.json.ObjectMap) void { + const preset: ?usize = if (po.get("preset")) |nv| (if (nv == .string) presetIndex(m, nv.string) else null) else null; + if (po.get("params") == null) if (preset) |pi| if (m.apply_preset) |apply| apply(m.state, @intCast(pi)); if (po.get("params")) |pv| if (pv == .object) if (m.set_param) |set| { var it = pv.object.iterator(); while (it.next()) |kv| set(m.state, kv.key_ptr.*, jsonF64(kv.value_ptr.*)); @@ -749,12 +812,30 @@ pub const Rack = struct { }; if (po.get("zones")) |zv| if (m.apply_zones_json) |f| f(m.state, zv); if (po.get("state")) |sv| if (m.apply_state_json) |f| f(m.state, sv); - // The preset it was set from, for its title strip (the label only). - if (po.get("preset")) |nv| if (nv == .string) if (m.mark_preset) |mark| if (m.preset_count) |count| if (m.preset_name) |name_of| { - for (0..count(m.state)) |i| { - if (std.mem.eql(u8, std.mem.span(name_of(m.state, @intCast(i))), nv.string)) mark(m.state, @intCast(i)); - } - }; + // The preset it was set from, for its title strip. + if (preset) |pi| if (m.mark_preset) |mark| mark(m.state, @intCast(pi)); + } + + /// Give `to` the settings of `from`, the same machine (registry entry + /// `reg_idx`), through the form a rack saves it in: params, files, zones, + /// state and preset label. Grouping into a rack and out of one use it. + pub fn copySettings(self: *Rack, from: Machine, to: Machine, reg_idx: u8) !void { + var out: std.ArrayList(u8) = .empty; + defer out.deinit(self.alloc); + try self.writeMachineJson(&out, self.alloc, from, reg_idx); + try out.append(self.alloc, '}'); + var parsed = try std.json.parseFromSlice(std.json.Value, self.alloc, out.items, .{}); + defer parsed.deinit(); + if (parsed.value == .object) applyMachineJson(to, parsed.value.object); + } + + fn presetIndex(m: Machine, name: []const u8) ?usize { + const count = m.preset_count orelse return null; + const name_of = m.preset_name orelse return null; + for (0..count(m.state)) |i| { + if (std.mem.eql(u8, std.mem.span(name_of(m.state, @intCast(i))), name)) return i; + } + return null; } fn machineOf(self: *Rack, v: ?std.json.Value) ?usize { @@ -815,6 +896,7 @@ pub const Rack = struct { l.chain.publish(l.instrument, l.effects.items); } self.applyMacrosJson(v.object); + if (v.object.get("collapsed")) |x| self.collapsed = x == .bool and x.bool; } else if (v.object.get("parts")) |pv| { // The first form: instrument lanes, no inserts. if (pv != .array or self.kind != .instrument) return; @@ -969,6 +1051,7 @@ pub const Rack = struct { } try out.append(alloc, ']'); try self.writeMacrosJson(out, alloc); + if (self.collapsed) try out.appendSlice(alloc, ",\"collapsed\":true"); try out.append(alloc, '}'); } @@ -1145,12 +1228,20 @@ fn renderLanes(self: *Rack, ctx: *const machine.MachineCtx, pos: usize, n: usize const chain_at: u8 = @intFromFloat(std.math.clamp(self.eff[CHAIN], 0, 127)); var solo = false; for (self.audio_lanes[0..self.audio_count]) |*p| solo = solo or p.solo; + const view: ?*const snapshot.AutoView = if (ctx.automation) |v| @ptrCast(@alignCast(v)) else null; for (self.audio_lanes[0..self.audio_count], 0..) |*p, li| { - // The lanes' machines aren't the track's: no automation reaches them. + // The track's lanes reach a lane's machines a step down the rack: + // its instrument at slot 0, each insert at its uid (renderEffects + // moves the step's slot along the chain). + var lane_view: snapshot.AutoView = undefined; var sub = ctx.*; sub.block_size = @intCast(n); sub.ppq_position = beat; sub.automation = null; + if (view) |v| { + lane_view = v.into(.{ .lane = p.uid, .slot = 0 }); + sub.automation = &lane_view; + } sub.note_out = null; sub.note_out_cap = 0; sub.note_out_count = null; @@ -1373,6 +1464,26 @@ fn takeTouchImpl(state: *anyopaque) ?machine.Touch { return t; } +/// UI: the lanes' machines in order, each lane's instrument (slot 0) then +/// its inserts (their uids). +fn childImpl(state: *anyopaque, i: usize) ?machine.Child { + const self: *Rack = @ptrCast(@alignCast(state)); + var k = i; + for (self.lanes[0..self.count], 0..) |*l, li| { + const has_inst = l.reg_idx != null; + const n = l.effects.items.len + @intFromBool(has_inst); + if (k >= n) { + k -= n; + continue; + } + if (has_inst and k == 0) return .{ .step = .{ .lane = l.uid, .slot = 0 }, .lane = @intCast(li), .pos = 0, .mach = &l.instrument }; + const j = k - @intFromBool(has_inst); + const fx = &l.effects.items[j]; + return .{ .step = .{ .lane = l.uid, .slot = fx.uid }, .lane = @intCast(li), .pos = @intCast(j + 1), .mach = &fx.mach }; + } + return null; +} + /// A rack in a lane: the outer rack's macro drives this one's. fn setModImpl(state: *anyopaque, i: usize, knob: ?f32) void { const self: *Rack = @ptrCast(@alignCast(state)); @@ -1502,6 +1613,8 @@ fn applyPresetImpl(state: *anyopaque, index: machine.PresetIndex) void { defer parsed.deinit(); self.applyJson(parsed.value); self.current_preset = index; + // A rig opens as its face: the macros it was built to be played by. + self.collapsed = true; } fn savePresetNamedImpl(state: *anyopaque, name_z: [*:0]const u8) ?machine.PresetIndex { @@ -1586,6 +1699,7 @@ fn chainW(self: *Rack) i32 { fn panelWImpl(state: *anyopaque) f32 { const self: *Rack = @ptrCast(@alignCast(state)); + if (self.collapsed) return @floatFromInt(MACRO_W); return @floatFromInt(MACRO_W + SIDE_W + chainW(self)); } @@ -1648,6 +1762,10 @@ fn drawPanelImpl(state: *anyopaque, ui: *Ui, rect: Rect) void { self.collect(); defer self.publishLanes(); var body = rect; + if (self.collapsed) { + drawFace(self, ui, body); + return; + } drawMacros(self, ui, body.cutLeft(MACRO_W)); const side = body.cutLeft(SIDE_W); drawLanes(self, ui, side); @@ -1675,6 +1793,8 @@ fn drawMacros(self: *Rack, ui: *Ui, r: Rect) void { var head = col.cutTop(ROW_H); _ = col.cutTop(2); const mapb = head.cutRight(34); + _ = head.cutRight(2); + foldButton(self, ui, head.cutRight(ROW_H)); _ = ui.engraved(&ui.fonts.legend, head.x + 2, head.y + 3, "MACROS", ui_style.text_dim); if (ctl.button(ui, mapb, "map", &self.map_armed, .{ .kind = .latch, .label = "MAP", .flush = true, .lit = ui_style.accent })) self.map_held = null; menu.tip(ui, mapb, "Map: click a control in the lane, or LV or PN, to put it on the picked macro"); @@ -1686,19 +1806,7 @@ fn drawMacros(self: *Rack, ui: *Ui, r: Rect) void { for (0..4) |ci| { const i = ri * 4 + ci; const kr = if (ci == 3) row else row.cutLeft(cw); - ui.pushId(i); - defer ui.popId(); - var v = self.ui_auto[i] orelse self.macroBase(i); - const before = v; - var vb: [8]u8 = undefined; - const readout = std.fmt.bufPrint(&vb, "{d}", .{@as(i32, @intFromFloat(@round(v * 100)))}) catch "?"; - _ = ctl.knob(ui, kr, "knob", &v, .{ .size = .m, .label = if (self.macro_names[i].len > 0) self.macro_names[i].slice() else KNOB_LABELS[i], .readout = readout }); - if (v != before) self.setMacro(i, v, false); - if (ui.active == ui.id("knob")) { - self.touch = .{ .control = @intCast(i), .knob = v }; - if (self.sel_macro != i) self.sel_map = null; - self.sel_macro = i; - } + macroKnob(self, ui, kr, i); if (i == self.sel_macro) ui.rect(Rect.xywh(kr.x + 6, kr.bottom() - 1, kr.w - 12, 1), if (self.map_armed) ui_style.accent else ui_style.text_dim); } } @@ -1800,8 +1908,71 @@ fn drawMacros(self: *Rack, ui: *Ui, r: Rect) void { menu.tip(ui, frow, "Shapes the macro x (0..1) before the range: x^2, 1 - x, x * (1 - m4), step(x, 3)"); } -/// MAP armed: the first frame a hand takes a control in the open lane, -/// that control goes on the picked macro over its whole range. +/// Macro `i`'s knob, its name for a legend; taking it picks the macro. +fn macroKnob(self: *Rack, ui: *Ui, kr: Rect, i: usize) void { + ui.pushId(i); + defer ui.popId(); + var v = self.ui_auto[i] orelse self.macroBase(i); + const before = v; + var vb: [8]u8 = undefined; + const readout = std.fmt.bufPrint(&vb, "{d}", .{@as(i32, @intFromFloat(@round(v * 100)))}) catch "?"; + _ = ctl.knob(ui, kr, "knob", &v, .{ .size = .m, .label = if (self.macro_names[i].len > 0) self.macro_names[i].slice() else KNOB_LABELS[i], .readout = readout }); + if (v != before) self.setMacro(i, v, false); + if (ui.active == ui.id("knob")) { + self.touch = .{ .control = @intCast(i), .knob = v }; + if (self.sel_macro != i) self.sel_map = null; + self.sel_macro = i; + } +} + +fn foldButton(self: *Rack, ui: *Ui, r: Rect) void { + if (ctl.button(ui, r, "fold", null, .{ .glyph = if (self.collapsed) .tri_right else .tri_left, .flush = true })) { + self.collapsed = !self.collapsed; + self.edited = true; + } + menu.tip(ui, r, if (self.collapsed) "Open the rack: its lanes and their chains" else "Fold the rack to its macro knobs"); +} + +/// The folded rack: the macros that do something (named or mapped; all +/// eight when none are), four a row, under a strip with the unfold button. +fn drawFace(self: *Rack, ui: *Ui, r: Rect) void { + var col = ui.plate(r, .{}).inset(3); + var head = col.cutTop(ROW_H); + _ = col.cutTop(2); + foldButton(self, ui, head.cutRight(ROW_H)); + _ = ui.engraved(&ui.fonts.legend, head.x + 2, head.y + 3, "MACROS", ui_style.text_dim); + var shown: [MAX_MACROS]usize = undefined; + var n: usize = 0; + for (0..MAX_MACROS) |i| { + const used = self.macro_names[i].len > 0 or for (self.maps[0..self.map_count]) |*m| { + if (m.macro == i) break true; + } else false; + if (used) { + shown[n] = i; + n += 1; + } + } + if (n == 0) { + for (0..MAX_MACROS) |i| shown[i] = i; + n = MAX_MACROS; + } + const cell = ctl.knobCell(.m, true); + var k: usize = 0; + while (k < n) { + var row = col.cutTop(cell[1]); + const cw = @divFloor(row.w, 4); + for (0..4) |ci| { + if (k >= n) break; + macroKnob(self, ui, row.cutLeft(cw), shown[k]); + _ = ci; + k += 1; + } + } +} + +/// A hand on a control in the open lane: with MAP armed, the first frame +/// puts it on the picked macro over its whole range; otherwise, a control +/// a macro owns moves that macro instead (docs/42 §Reaching the target). fn pollMapTouch(self: *Rack) void { if (self.selected >= self.count) return; const li = self.selected; @@ -1809,15 +1980,106 @@ fn pollMapTouch(self: *Rack) void { var held = false; if (l.reg_idx != null) if (l.instrument.take_touch) |tt| if (tt(l.instrument.state)) |t| { held = true; - self.mapTouched(li, null, l.instrument, t.control); + if (self.map_armed) self.mapTouched(li, null, l.instrument, t.control) else handOnMapped(self, li, null, t); }; for (l.effects.items) |*fx| if (fx.mach.take_touch) |tt| if (tt(fx.mach.state)) |t| { held = true; - self.mapTouched(li, fx.uid, fx.mach, t.control); + if (self.map_armed) self.mapTouched(li, fx.uid, fx.mach, t.control) else handOnMapped(self, li, fx.uid, t); }; if (!held) self.map_held = null; } +/// UI: the macros as the formulas see them. +fn macroValues(self: *const Rack) [MAX_MACROS]f32 { + var out: [MAX_MACROS]f32 = undefined; + for (&out, 0..) |*v, i| v.* = self.ui_auto[i] orelse self.macroBase(i); + return out; +} + +/// The macro value that puts mapping `m`'s target nearest `knob`: the +/// formula has no inverse in general, so the best of 257 points, then a +/// finer look around it. +fn invertMapping(m: *const Mapping, knob: f32, macros: [MAX_MACROS]f32) f32 { + var ms = macros; + var best: f32 = 0; + var best_d: f32 = std.math.inf(f32); + var lo: f32 = 0; + var span: f32 = 1; + for (0..2) |_| { + for (0..257) |k| { + const x = std.math.clamp(lo + span * @as(f32, @floatFromInt(k)) / 256, 0, 1); + ms[m.macro] = x; + const d = @abs(m.value(x, &ms) - knob); + // Ties go to the point nearest where the macro is. + if (d < best_d - 1e-6 or (d <= best_d + 1e-6 and @abs(x - macros[m.macro]) < @abs(best - macros[m.macro]))) { + best = x; + best_d = d; + } + } + lo = best - 1.0 / 256.0; + span = 2.0 / 256.0; + } + return best; +} + +fn handOnMapped(self: *Rack, li: usize, slot: ?u16, t: machine.Touch) void { + const macros = macroValues(self); + for (self.maps[0..self.map_count]) |*m| { + if (m.target != .control or m.lane != li or m.slot != slot or m.control != t.control) continue; + if (@abs(m.value(macros[m.macro], ¯os) - t.knob) < 1e-4) return; + self.setMacro(m.macro, invertMapping(m, t.knob, macros), false); + self.touch = .{ .control = m.macro, .knob = self.macroBase(m.macro) }; + return; + } +} + +fn zoneText(buf: []u8, z: Zone, v: u8) []const u8 { + return if (z == .key) noteName(buf, v) else std.fmt.bufPrint(buf, "{d}", .{v}) catch "?"; +} + +/// The lane map: a stripe a lane over 0..127 for the shown zone, the +/// selected lane bright; a press and drag sets the selected lane's range. +/// Octave ticks for keys, CHAIN's position for chain zones. +fn drawZoneMap(self: *Rack, ui: *Ui, map: Rect, chain_now: f32) void { + ui.rect(map, ui_style.well); + const inner_w = map.w - 4; + for (self.lanes[0..self.count], 0..) |*p, pi| { + const z = p.zone(self.zone_tab); + const x0 = map.x + 2 + @divFloor(inner_w * @as(i32, z[0].*), 128); + const x1 = map.x + 2 + @divFloor(inner_w * (@as(i32, z[1].*) + 1), 128); + const col = if (p.mute) ui_style.text_mute else LANE_COLORS[pi]; + ui.rect(Rect.xywh(x0, map.y + 2 + @as(i32, @intCast(pi)) * 3, @max(1, x1 - x0), 2), if (pi == self.selected) col else col.alpha(140)); + } + switch (self.zone_tab) { + .key => { + var k: i32 = 0; + while (k < 128) : (k += 12) ui.rect(Rect.xywh(map.x + 2 + @divFloor(inner_w * k, 128), map.bottom() - 2, 1, 2), ui_style.text_mute); + }, + .chain => { + const cx = map.x + 2 + @divFloor(inner_w * (@as(i32, @intFromFloat(chain_now)) * 2 + 1), 256); + ui.rect(Rect.xywh(cx, map.y, 1, map.h), ui_style.accent); + }, + .vel => {}, + } + if (self.selected >= self.count) return; + const b = ui.behaviorEx(ui.id("zone-map"), map, .{ .focusable = false }); + const at: u8 = @intCast(std.math.clamp(@divFloor((ui.in.ix() - map.x - 2) * 128, @max(1, inner_w)), 0, 127)); + if (b.pressed) self.zone_from = at; + if (b.held) if (self.zone_from) |from| { + const z = self.lanes[self.selected].zone(self.zone_tab); + const lo = @max(@min(from, at), @as(u8, if (self.zone_tab == .vel) 1 else 0)); + const hi = @max(from, at); + if (z[0].* != lo or z[1].* != hi) { + if (b.pressed) beforeEdit(self); + z[0].* = lo; + z[1].* = hi; + self.edited = true; + self.publishLanes(); + } + }; + if (!b.held) self.zone_from = null; + menu.tip(ui, map, "Drag to set the selected lane's zone"); +} fn drawLanes(self: *Rack, ui: *Ui, r: Rect) void { const inst = self.kind == .instrument; @@ -1890,31 +2152,51 @@ fn drawLanes(self: *Rack, ui: *Ui, r: Rect) void { } _ = side.cutTop(3); - if (inst and self.count > 0) { - // key map: a stripe per lane over the 128 keys - const map = side.cutTop(@intCast(2 * self.count + 6)); - ui.rect(map, ui_style.well); - for (self.lanes[0..self.count], 0..) |*p, pi| { - const x0 = map.x + 2 + @divFloor((map.w - 4) * @as(i32, p.lo), 128); - const x1 = map.x + 2 + @divFloor((map.w - 4) * (@as(i32, p.hi) + 1), 128); - const col = if (p.mute) ui_style.text_mute else LANE_COLORS[pi]; - ui.rect(Rect.xywh(x0, map.y + 2 + @as(i32, @intCast(pi)) * 2, @max(1, x1 - x0), 2), if (pi == self.selected) col else col.alpha(140)); + // Zones: KEY, VEL and CHN tabs (an effect rack has CHAIN only), a + // stripe a lane over 0..127 that a drag sets for the selected lane, and + // the selected lane's range of it. + if (!inst) self.zone_tab = .chain; + if (self.count > 0) { + var tabs = side.cutTop(ROW_H); + _ = side.cutTop(2); + const chain_r = tabs.cutRight(64); + _ = tabs.cutRight(4); + if (inst) { + const tc = cells(tabs, 3); + inline for (.{ .{ Zone.key, "KEY", "Key zones: the notes each lane plays" }, .{ Zone.vel, "VEL", "Velocity zones: how hard a note must be for each lane" }, .{ Zone.chain, "CHN", "Chain zones: the CHAIN values each lane plays at" } }, 0..) |t, ti| { + var on = self.zone_tab == t[0]; + if (ctl.button(ui, tc[ti], t[1], &on, .{ .kind = .latch, .label = t[1], .flush = true, .lit = ui_style.text })) self.zone_tab = t[0]; + menu.tip(ui, tc[ti], t[2]); + } + } else _ = ui.engraved(&ui.fonts.legend, tabs.x + 2, tabs.y + 3, "CHAIN ZONES", ui_style.text_dim); + // CHAIN itself, which the chain zones answer to. + var cb: [8]u8 = undefined; + const chain_now = self.ui_auto[CHAIN] orelse self.macroBase(CHAIN); + const dc = field(ui, chain_r, "chain", "CHAIN", std.fmt.bufPrint(&cb, "{d}", .{@as(i32, @intFromFloat(chain_now))}) catch "?", 8); + if (dc != 0) { + self.setMacro(CHAIN, chain_now + @as(f32, @floatFromInt(dc)), true); + self.touch = .{ .control = CHAIN, .knob = self.macroBase(CHAIN) }; + self.edited = true; } - var k: i32 = 0; - while (k < 128) : (k += 12) ui.rect(Rect.xywh(map.x + 2 + @divFloor((map.w - 4) * k, 128), map.bottom() - 2, 1, 2), ui_style.text_mute); + menu.tip(ui, chain_r, "CHAIN: lanes play while it sits inside their chain zone"); + + const map = side.cutTop(@intCast(3 * self.count + 6)); _ = side.cutTop(3); + drawZoneMap(self, ui, map, chain_now); } if (self.selected >= self.count) return; const p = &self.lanes[self.selected]; var s = p.*; // edit a copy, publish at the block boundary var buf: [16]u8 = undefined; - if (inst) { - const c1 = cells(side.cutTop(FIELD_H), 4); - step7(&s.lo, field(ui, c1[0], "lo", "LO", noteName(&buf, s.lo), 12), 0, s.hi); - step7(&s.hi, field(ui, c1[1], "hi", "HI", noteName(&buf, s.hi), 12), s.lo, 127); - step7(&s.vlo, field(ui, c1[2], "vlo", "V", std.fmt.bufPrint(&buf, "{d}", .{s.vlo}) catch "?", 16), 1, s.vhi); - step7(&s.vhi, field(ui, c1[3], "vhi", "-", std.fmt.bufPrint(&buf, "{d}", .{s.vhi}) catch "?", 16), s.vlo, 127); + { + const z = s.zone(self.zone_tab); + const c1 = cells(side.cutTop(FIELD_H), 2); + const big: i32 = if (self.zone_tab == .key) 12 else 16; + const lo_lo: i32 = if (self.zone_tab == .vel) 1 else 0; + var b2: [16]u8 = undefined; + step7(z[0], field(ui, c1[0], "zlo", "LO", zoneText(&buf, self.zone_tab, z[0].*), big), lo_lo, z[1].*); + step7(z[1], field(ui, c1[1], "zhi", "HI", zoneText(&b2, self.zone_tab, z[1].*), big), z[0].*, 127); _ = side.cutTop(2); } const c2 = cells(side.cutTop(FIELD_H), 4); @@ -1937,13 +2219,16 @@ fn drawLanes(self: *Rack, ui: *Ui, r: Rect) void { s.pan = std.math.clamp(s.pan + 0.1 * @as(f32, @floatFromInt(dpan)), -1, 1); } if (@abs(s.pan) < 0.05) s.pan = 0; - if (s.lo != p.lo or s.hi != p.hi or s.vlo != p.vlo or s.vhi != p.vhi or s.transpose != p.transpose or - s.poly != p.poly or s.level_db != p.level_db or s.pan != p.pan) + if (s.lo != p.lo or s.hi != p.hi or s.vlo != p.vlo or s.vhi != p.vhi or s.clo != p.clo or s.chi != p.chi or + s.transpose != p.transpose or s.poly != p.poly or s.level_db != p.level_db or s.pan != p.pan) { + if (s.lo != p.lo or s.hi != p.hi or s.vlo != p.vlo or s.vhi != p.vhi or s.clo != p.clo or s.chi != p.chi) self.edited = true; p.lo = s.lo; p.hi = s.hi; p.vlo = s.vlo; p.vhi = s.vhi; + p.clo = s.clo; + p.chi = s.chi; p.transpose = s.transpose; p.poly = s.poly; p.level_db = s.level_db; @@ -1963,8 +2248,28 @@ fn drawChain(self: *Rack, ui: *Ui, r: Rect) void { const l = &self.lanes[li]; ui.pushId(@intFromPtr(l.chain)); defer ui.popId(); + const map_was = ui.map_mode; + ui.map_mode = self.map_armed; + defer ui.map_mode = map_was; var res = bay.Result{}; var x = r.x; + // Dragging an insert's name tile: the drop slot is the first card whose + // middle lies right of the pointer, as in the bay. + var drop_idx: usize = l.effects.items.len; + var drop_x: i32 = 0; + { + var ex = x + (if (l.reg_idx != null) bay.panelW(&l.instrument) else 0); + for (l.effects.items, 0..) |*fx, j| { + const fw = bay.effectW(fx); + if (self.fx_drag.active and drop_idx == l.effects.items.len and ui.in.mx < @as(f32, @floatFromInt(ex + @divFloor(fw, 2)))) { + drop_idx = j; + drop_x = ex; + } + ex += fw; + } + if (drop_idx == l.effects.items.len) drop_x = ex; + } + var reorder: ?[2]usize = null; if (l.reg_idx != null) { const w = bay.panelW(&l.instrument); const out = bay.drawDevice(ui, Rect.xywh(x, r.y, w, r.h), &l.instrument, .instrument, null, !l.mute, null, self.reg, &res); @@ -1978,16 +2283,32 @@ fn drawChain(self: *Rack, ui: *Ui, r: Rect) void { const w = bay.effectW(fx); const out = bay.drawDevice(ui, Rect.xywh(x, r.y, w, r.h), &fx.mach, .{ .effect = j }, fx, !fx.bypass.load(.monotonic), null, self.reg, &res); if (out.toggle) self.setEffectBypass(li, j, !fx.bypass.load(.monotonic)); - // A click on an insert's name opens its replace menu, as the bay's. - if (out.name_released) { - const key = pane.keyFromIds(bay.REPLACE_MENU_KEY, @intFromPtr(fx.mach.state), 0); - if (!menu.isOpen(key)) { - bay.scanRegistryPresets(self.reg); - menu.openBelow(key, out.name_rect); + if (out.name_pressed and !self.fx_drag.armed) self.fx_drag = .{ .armed = true, .src = j, .press_x = ui.in.mx }; + if (out.name_released and self.fx_drag.armed and self.fx_drag.src == j) { + if (self.fx_drag.active) { + // Taking the source out shifts the cards after it left. + const to = if (drop_idx > j) drop_idx - 1 else drop_idx; + if (to != j) reorder = .{ j, to }; + } else { + // A click, not a drag: the insert's replace menu, as the bay's. + const key = pane.keyFromIds(bay.REPLACE_MENU_KEY, @intFromPtr(fx.mach.state), 0); + if (!menu.isOpen(key)) { + bay.scanRegistryPresets(self.reg); + menu.openBelow(key, out.name_rect); + } } + self.fx_drag = .{}; } + if (self.fx_drag.active and self.fx_drag.src == j) ui.overlayRect(Rect.xywh(x, r.y, w, r.h), ui_style.accent.alpha(40)); x += w; } + if (self.fx_drag.armed and ui.in.down and @abs(ui.in.mx - self.fx_drag.press_x) > 6) self.fx_drag.active = true; + if (self.fx_drag.armed and !ui.in.down and !ui.in.released) self.fx_drag = .{}; + if (self.fx_drag.active) ui.overlayRect(Rect.xywh(drop_x - 1, r.y, 2, r.h), ui_style.accent); + if (reorder) |mv| { + beforeEdit(self); + self.moveEffect(li, mv[0], mv[1]); + } if (l.reg_idx == null and l.effects.items.len == 0) { const hint = Rect.xywh(x, r.y, 160, r.h); ui.rect(hint, ui_style.well); @@ -2502,6 +2823,65 @@ test "rack: an automated macro sweeps its target as automating the target does" try testing.expectEqualSlices(f32, ar, br); } +test "rack: a track lane down the rack's path automates the insert inside as on a track" { + const a = testing.allocator; + var t: SatRack = undefined; + try t.init(a); + defer t.deinit(a); + const alone = try t.reg.instantiate(t.reg.findById("sat2").?); + defer alone.deinit.?(alone.state, a); + alone.reset(alone.state); + const drive = alone.controlIndex("sat-drive").?; + // A dry lane first, so the insert's lane isn't the first. + _ = try t.rack.addLane(null); + t.rack.moveLane(1, 0); + const lane = &t.rack.lanes[1]; + const step = automation.Step{ .lane = lane.uid, .slot = lane.effects.items[0].uid }; + // The path resolves to that insert, and back to its place. + const found = t.m.childAt(step).?; + try testing.expectEqual(@as(u8, 1), found.lane); + try testing.expectEqual(@as(u16, 1), found.pos); + try testing.expectEqual(lane.effects.items[0].mach.state, found.mach.state); + try testing.expectEqual(lane.effects.items[0].mach.state, t.m.inner(automation.Path.plus(.{}, step)).?.state); + + var snap = std.mem.zeroes(snapshot.TrackSnapshot); + snap.auto_points[0] = .{ .beat = 0, .value = 0 }; + snap.auto_points[1] = .{ .beat = 1, .value = 1 }; + snap.auto_point_count = 2; + snap.lanes[0] = .{ .kind = .fx, .fx_uid = 3, .control = @intCast(drive), .points_start = 0, .points_count = 2 }; + snap.lane_count = 1; + var cursors: [snapshot.MAX_LANES_PER_TRACK]u32 = @splat(0); + const view = snapshot.AutoView{ .snap = &snap, .cursors = &cursors, .kind = .fx, .fx_uid = 3 }; + var snap2 = snap; + snap2.lanes[0].path = automation.Path.plus(.{}, step); + var cursors2: [snapshot.MAX_LANES_PER_TRACK]u32 = @splat(0); + const view2 = snapshot.AutoView{ .snap = &snap2, .cursors = &cursors2, .kind = .fx, .fx_uid = 3 }; + + var in: [256 * 120]f32 = undefined; + for (&in, 0..) |*x, i| x.* = 0.3 * @sin(@as(f32, @floatFromInt(i)) * 0.031); + const al = try a.alloc(f32, in.len); + defer a.free(al); + const ar = try a.alloc(f32, in.len); + defer a.free(ar); + const bl = try a.alloc(f32, in.len); + defer a.free(bl); + const br = try a.alloc(f32, in.len); + defer a.free(br); + processAuto(alone, &in, al, ar, &view); + // The dry lane is silent here: only the insert's lane is heard. + t.rack.lanes[0].mute = true; + t.rack.publishLanes(); + processAuto(t.m, &in, bl, br, &view2); + try testing.expect(rms(al[al.len - 4096 ..]) > 1.5 * rms(al[0..4096])); + try testing.expectEqualSlices(f32, al, bl); + try testing.expectEqualSlices(f32, ar, br); + + // Removing the lane before it leaves the path on the same insert. + t.rack.removeLane(0); + try testing.expectEqual(lane.effects.items.len, 1); + try testing.expectEqual(t.rack.lanes[0].effects.items[0].mach.state, t.m.childAt(step).?.mach.state); +} + test "rack: a formula shapes the macro, a stepped target snaps, unmapping lets go" { const a = testing.allocator; var t: SatRack = undefined; diff --git a/src/main.zig b/src/main.zig index af56e7a6..2b495e93 100644 --- a/src/main.zig +++ b/src/main.zig @@ -69,6 +69,7 @@ const commands = @import("ui/commands.zig"); const ui_style = @import("ui/style.zig"); const ui_draw = @import("ui/draw.zig"); const ui_gallery = @import("ui/gallery.zig"); +const rack_group = @import("rack_group.zig"); const layout_mod = @import("ui/layout.zig"); const transport_bar = @import("ui/transport_bar.zig"); const ui_core = @import("ui/core.zig"); @@ -148,6 +149,7 @@ test { _ = @import("engine.zig"); _ = @import("track.zig"); _ = @import("document.zig"); + _ = @import("rack_group.zig"); _ = @import("meter_gen.zig"); _ = @import("automation.zig"); _ = @import("ui/automation_lane.zig"); @@ -1592,9 +1594,10 @@ pub fn main(init: std.process.Init) !void { status.set("Added {s}", .{tracks[ti].name()}); } else |err| status.set("Can't add: {s}", .{@errorName(err)}); } - // ⌘G groups the selection (or the selected track). + // ⌘G groups the selection (or the selected track); with devices + // selected in the bay it groups those into a rack instead (below). var route_edit = ares.route; - if (route_edit == null and !modal and !rename.active() and ui.in.cmd and c.rl.IsKeyPressed(c.rl.KEY_G)) if (selected_track) |st| { + if (route_edit == null and machine_bay.selection == null and !modal and !rename.active() and ui.in.cmd and c.rl.IsKeyPressed(c.rl.KEY_G)) if (selected_track) |st| { route_edit = .{ .track = st, .what = .group, .selection = true }; }; // Dev hook: SLAB_SHOT_GROUP=NAME,NAME,… selects those tracks and @@ -1784,6 +1787,27 @@ pub fn main(init: std.process.Init) !void { uni_panel.open(ti, at); }; if (mbres.minimize) layout.machine_bay_collapsed = !layout.machine_bay_collapsed; + // ⌘G: the devices selected in the bay go into a rack; ⌘⇧G puts the + // one selected rack's lane back (docs/42 §UI). + if (!modal and !rename.active() and ui.in.cmd and c.rl.IsKeyPressed(c.rl.KEY_G)) if (machine_bay.selection) |sel| if (bay_dev) |dev| if (sel.dev == dev) { + machine_bay.selection = null; + if (!bay_is_bus) pushHistorySnapshot(alloc, &history, tracks, &transport); + if (ui.in.shift) { + if (sel.first != sel.last) { + status.set("Select one rack to ungroup", .{}); + } else if (rack_group.ungroup(alloc, ®, dev, sel.first)) { + status.set("Ungrouped the rack", .{}); + dirty = true; + } else |err| status.set("Can't ungroup: {s}", .{switch (err) { + error.NotARack => "not a rack", + error.RackNotSimple => "it has more than one lane, or mappings", + else => @errorName(err), + }}); + } else if (rack_group.group(alloc, ®, dev, sel.first, sel.last)) |g| { + if (g.dropped_keys) status.set("Grouped into a rack; sidechain keys don't reach rack lanes yet", .{}) else status.set("Grouped into a rack", .{}); + dirty = true; + } else |err| status.set("Can't group: {s}", .{@errorName(err)}); + }; if (mbres.add_machine) |reg_idx| { if (bay_dev) |dev| { const entry = ®.entries[reg_idx]; @@ -6482,16 +6506,22 @@ const AutoRecorder = struct { /// and null into every control no lane drives. fn syncAutomationUi(tracks: []track_mod.Track, beat: f64) void { for (tracks) |*t| { - syncMachineAutoUi(t, &t.machine, .inst, 0, beat); - for (t.effects.items) |*fx| syncMachineAutoUi(t, &fx.mach, .fx, fx.uid, beat); + syncMachineAutoUi(t, &t.machine, .inst, 0, .{}, beat); + for (t.effects.items) |*fx| syncMachineAutoUi(t, &fx.mach, .fx, fx.uid, .{}, beat); } } -fn syncMachineAutoUi(t: *track_mod.Track, m: *const @import("machine.zig").Machine, kind: automation.TargetKind, uid: u16, beat: f64) void { +/// `m` and, in a rack, every machine inside it, `path` down from the +/// track's slot (docs/42 §Automation inside a rack). +fn syncMachineAutoUi(t: *track_mod.Track, m: *const @import("machine.zig").Machine, kind: automation.TargetKind, uid: u16, path: automation.Path, beat: f64) void { + if (m.child) |f| if (path.len < automation.MAX_PATH) { + var i: usize = 0; + while (f(m.state, i)) |ch| : (i += 1) syncMachineAutoUi(t, ch.mach, kind, uid, path.plus(ch.step), beat); + }; const set = m.set_auto_ui orelse return; const info = m.control_info orelse return; for (0..m.controlCount()) |i| { - const target = automation.Target.control(kind, uid, info(m.state, i).id); + const target = automation.Target.control(kind, uid, info(m.state, i).id).inside(path); if (!t.isAutomated(target)) { set(m.state, i, null); continue; @@ -6506,11 +6536,19 @@ fn clearAutomationOverrides(tracks: []track_mod.Track) void { for (tracks) |*t| { t.vol_override.store(0, .monotonic); t.pan_override.store(0, .monotonic); - if (t.machine.clear_overrides) |f| f(t.machine.state); - for (t.effects.items) |*fx| if (fx.mach.clear_overrides) |f| f(fx.mach.state); + clearMachineOverrides(&t.machine, 0); + for (t.effects.items) |*fx| clearMachineOverrides(&fx.mach, 0); } } +fn clearMachineOverrides(m: *const @import("machine.zig").Machine, depth: usize) void { + if (m.clear_overrides) |f| f(m.state); + if (m.child) |f| if (depth < automation.MAX_PATH) { + var i: usize = 0; + while (f(m.state, i)) |ch| : (i += 1) clearMachineOverrides(ch.mach, depth + 1); + }; +} + /// Serve panels' Show/Clear automation requests. Returns true on an edit. fn serviceAutomationRequests( alloc: std.mem.Allocator, @@ -6522,12 +6560,12 @@ fn serviceAutomationRequests( ) bool { var edited = false; for (tracks, 0..) |*t, ti| { - if (serviceMachineAutoRequest(alloc, history, tracks, transport, t, &t.machine, .inst, 0, status)) |e| { + if (serviceMachineAutoRequest(alloc, history, tracks, transport, t, &t.machine, .inst, 0, .{}, status)) |e| { edited = edited or e; selected_track.* = ti; } for (t.effects.items) |*fx| { - if (serviceMachineAutoRequest(alloc, history, tracks, transport, t, &fx.mach, .fx, fx.uid, status)) |e| { + if (serviceMachineAutoRequest(alloc, history, tracks, transport, t, &fx.mach, .fx, fx.uid, .{}, status)) |e| { edited = edited or e; selected_track.* = ti; } @@ -6546,14 +6584,22 @@ fn serviceMachineAutoRequest( m: *const @import("machine.zig").Machine, kind: automation.TargetKind, uid: u16, + path: automation.Path, status: *StatusMessage, ) ?bool { + // A rack's machines ask too, a step further down. + if (m.child) |f| if (path.len < automation.MAX_PATH) { + var i: usize = 0; + while (f(m.state, i)) |ch| : (i += 1) { + if (serviceMachineAutoRequest(alloc, history, tracks, transport, t, ch.mach, kind, uid, path.plus(ch.step), status)) |e| return e; + } + }; const take = m.take_auto_request orelse return null; const req = take(m.state) orelse return null; const info_fn = m.control_info orelse return false; if (req.control >= m.controlCount()) return false; const info = info_fn(m.state, req.control); - const target = automation.Target.control(kind, uid, info.id); + const target = automation.Target.control(kind, uid, info.id).inside(path); switch (req.action) { .show => { t.lanes_shown = true; diff --git a/src/rack_group.zig b/src/rack_group.zig new file mode 100644 index 00000000..d0bdd94e --- /dev/null +++ b/src/rack_group.zig @@ -0,0 +1,382 @@ +//! Group a run of a track's devices into a rack, and back (docs/42 §UI, +//! "Group into a rack"). The run's machines are copied into the rack's +//! first lane through their saved form, and every automation lane that +//! drove them (the track's and its clips') moves one step down the rack, +//! so grouping loses nothing (§Automation inside a rack). Ungrouping a +//! one-lane rack with no mappings does the reverse. +//! +//! Positions in a track's chain: 0 is the instrument, n the n-th effect. + +const std = @import("std"); +const machine = @import("machine.zig"); +const automation = @import("automation.zig"); +const track_mod = @import("track.zig"); +const registry_mod = @import("machine_registry.zig"); +const rack_mod = @import("machines/rack.zig"); + +const Track = track_mod.Track; +const Registry = registry_mod.Registry; +const Rack = rack_mod.Rack; + +pub const Error = error{ NothingToGroup, NotARack, RackNotSimple, PathTooDeep, NoRackMachine } || anyerror; + +/// What grouping moved where: the lanes' targets that named a moved +/// machine, the one they name now, and the step down the rack in front of +/// their path. +const Move = struct { + kind: automation.TargetKind, + fx_uid: u16, + to_kind: automation.TargetKind, + to_fx_uid: u16, + step: automation.Step, +}; + +fn retarget(t: *Track, alloc: std.mem.Allocator, moves: []const Move) void { + retargetList(&t.lanes, alloc, moves); + for (t.clips.items) |*clip| retargetList(&clip.lanes, alloc, moves); +} + +/// Each lane whose target names a moved machine names its new place; one +/// that would go past MAX_PATH is dropped (it would drive nothing). +fn retargetList(lanes: *std.ArrayList(automation.Lane), alloc: std.mem.Allocator, moves: []const Move) void { + var i: usize = 0; + while (i < lanes.items.len) { + const lane = &lanes.items[i]; + const tg = &lane.target; + const mv = for (moves) |m| { + if (m.kind == tg.kind and (tg.kind != .fx or m.fx_uid == tg.fx_uid)) break m; + } else { + i += 1; + continue; + }; + if (tg.path.len >= automation.MAX_PATH) { + lane.deinit(alloc); + _ = lanes.orderedRemove(i); + continue; + } + var path = automation.Path{}; + path = path.plus(mv.step); + for (tg.path.slice()) |st| path = path.plus(st); + tg.kind = mv.to_kind; + tg.fx_uid = mv.to_fx_uid; + tg.path = path; + i += 1; + } +} + +pub const Grouped = struct { + /// Sidechain keys don't reach a rack lane's inserts yet (docs/42 §Model): + /// a moved insert that had one lost it. + dropped_keys: bool = false, +}; + +/// Group positions `first`..`last` of `t`'s chain into a new rack: an +/// instrument Rack when the run starts at the instrument, an FX Rack in the +/// run's place otherwise. +pub fn group(alloc: std.mem.Allocator, reg: *Registry, t: *Track, first: usize, last: usize) Error!Grouped { + const has_inst = !t.isBus() and t.machine_idx != null; + const with_inst = first == 0; + if (with_inst and !has_inst) return error.NothingToGroup; + if (last < first or last > t.effects.items.len or (!with_inst and first == 0)) return error.NothingToGroup; + const rack_idx = reg.findById(if (with_inst) "rack" else "fxrack") orelse return error.NoRackMachine; + const m = try reg.instantiatePrepared(rack_idx); + var installed = false; + errdefer if (!installed) if (m.deinit) |d| d(m.state, alloc); + m.reset(m.state); + const rk: *Rack = @ptrCast(@alignCast(m.state)); + + var moves: [rack_mod.MAX_LANE_EFFECTS + 1]Move = undefined; + var nm: usize = 0; + const lane = if (with_inst) blk: { + const l = try rk.addLane(t.machine_idx.?); + try rk.copySettings(t.machine, l.instrument, t.machine_idx.?); + break :blk l; + } else blk: { + const l = try rk.addLane(null); + break :blk l; + }; + // Track positions first..last → effects (first or 1)-1 .. last-1. + const fx_lo: usize = if (with_inst) 0 else first - 1; + const fx_hi: usize = last; // exclusive + if (fx_hi - fx_lo > rack_mod.MAX_LANE_EFFECTS) return error.NothingToGroup; + var out = Grouped{}; + for (t.effects.items[fx_lo..fx_hi]) |*fx| { + const idx = fx.idx orelse continue; + try rk.addEffect(0, idx); + const nf = &lane.effects.items[lane.effects.items.len - 1]; + try rk.copySettings(fx.mach, nf.mach, idx); + if (fx.bypass.load(.monotonic)) nf.bypass.store(true, .monotonic); + if (fx.key != @import("routing.zig").NONE) out.dropped_keys = true; + moves[nm] = .{ .kind = .fx, .fx_uid = fx.uid, .to_kind = .fx, .to_fx_uid = 0, .step = .{ .lane = lane.uid, .slot = nf.uid } }; + nm += 1; + } + lane.chain.publish(lane.instrument, lane.effects.items); + // Lanes inside a rack get its first inserted machine's name. + if (!with_inst) if (lane.effects.items.len > 0) { + if (fx_lo < t.effects.items.len) if (t.effects.items[fx_lo].idx) |i| lane.setLabel(reg.entries[i].nameSlice()); + }; + + // Install: the rack takes the run's place. + if (with_inst) { + try t.replaceMachine(alloc, m); + installed = true; + t.machine_idx = @intCast(rack_idx); + moves[nm] = .{ .kind = .inst, .fx_uid = 0, .to_kind = .inst, .to_fx_uid = 0, .step = .{ .lane = lane.uid, .slot = 0 } }; + nm += 1; + for (moves[0 .. nm - 1]) |*mv| mv.to_kind = .inst; + var j = fx_hi; + while (j > fx_lo) : (j -= 1) try t.removeEffect(alloc, j - 1); + } else { + var j = fx_hi; + while (j > fx_lo) : (j -= 1) try t.removeEffect(alloc, j - 1); + try t.addEffect(alloc, m, @intCast(rack_idx)); + installed = true; + t.moveEffect(t.effects.items.len - 1, fx_lo); + const uid = t.effects.items[fx_lo].uid; + for (moves[0..nm]) |*mv| mv.to_fx_uid = uid; + } + retarget(t, alloc, moves[0..nm]); + return out; +} + +/// The rack at position `pos`, if it is one. +pub fn rackAt(t: *Track, pos: usize) ?*Rack { + const m: *machine.Machine = if (pos == 0) &t.machine else if (pos - 1 < t.effects.items.len) &t.effects.items[pos - 1].mach else return null; + if (m.child == null) return null; + return @ptrCast(@alignCast(m.state)); +} + +/// Put the one lane of the rack at `pos` back on the track in its place. +pub fn ungroup(alloc: std.mem.Allocator, reg: *Registry, t: *Track, pos: usize) Error!void { + const rk = rackAt(t, pos) orelse return error.NotARack; + if (rk.count != 1 or rk.map_count != 0) return error.RackNotSimple; + const lane = &rk.lanes[0]; + const lane_uid = lane.uid; + const with_inst = pos == 0; + const rack_fx_uid: u16 = if (with_inst) 0 else t.effects.items[pos - 1].uid; + + // New machines first: the rack stays until they are all made. + var made: [rack_mod.MAX_LANE_EFFECTS]struct { mach: machine.Machine, idx: u8, bypass: bool, slot: u16 } = undefined; + var n: usize = 0; + errdefer for (made[0..n]) |x| if (x.mach.deinit) |d| d(x.mach.state, alloc); + var inst: ?machine.Machine = null; + errdefer if (inst) |im| if (im.deinit) |d| d(im.state, alloc); + if (with_inst) { + const ri = lane.reg_idx orelse return error.RackNotSimple; + const im = try reg.instantiatePrepared(ri); + im.reset(im.state); + inst = im; + try rk.copySettings(lane.instrument, im, ri); + } + for (lane.effects.items) |*fx| { + const idx = fx.idx orelse continue; + const fm = try reg.instantiatePrepared(idx); + fm.reset(fm.state); + made[n] = .{ .mach = fm, .idx = idx, .bypass = fx.bypass.load(.monotonic), .slot = fx.uid }; + n += 1; + try rk.copySettings(fx.mach, fm, idx); + } + + // Lanes: the rack's own (its macros) go; the lane's machines' come out. + const rack_kind: automation.TargetKind = if (with_inst) .inst else .fx; + dropRackLanes(t, alloc, rack_kind, rack_fx_uid); + + var at: usize = if (with_inst) 0 else pos - 1; + var inst_ri: ?u8 = null; + if (with_inst) { + inst_ri = lane.reg_idx; + const im = inst.?; + inst = null; + try t.replaceMachine(alloc, im); + t.machine_idx = inst_ri; + unpath(t, .inst, 0, lane_uid, 0, .inst, 0); + } else { + try t.removeEffect(alloc, pos - 1); + } + const count = n; + n = 0; + for (made[0..count]) |x| { + try t.addEffect(alloc, x.mach, x.idx); + t.moveEffect(t.effects.items.len - 1, at); + const new_uid = t.effects.items[at].uid; + if (x.bypass) { + t.effects.items[at].bypass.store(true, .monotonic); + } + unpath(t, rack_kind, rack_fx_uid, lane_uid, x.slot, .fx, new_uid); + at += 1; + } + if (count > 0) t.chain.publish(t.machine, t.effects.items); +} + +/// Drop lanes that drive the rack's own controls (no path under it). +fn dropRackLanes(t: *Track, alloc: std.mem.Allocator, kind: automation.TargetKind, fx_uid: u16) void { + dropRackLanesIn(&t.lanes, alloc, kind, fx_uid); + for (t.clips.items) |*clip| dropRackLanesIn(&clip.lanes, alloc, kind, fx_uid); +} + +fn dropRackLanesIn(lanes: *std.ArrayList(automation.Lane), alloc: std.mem.Allocator, kind: automation.TargetKind, fx_uid: u16) void { + var i: usize = 0; + while (i < lanes.items.len) { + const tg = lanes.items[i].target; + if (tg.kind == kind and (kind != .fx or tg.fx_uid == fx_uid) and tg.path.len == 0) { + lanes.items[i].deinit(alloc); + _ = lanes.orderedRemove(i); + } else i += 1; + } +} + +/// Lanes under the rack (kind, fx_uid) whose first step is (lane, slot) +/// now name (to_kind, to_fx_uid), one step shorter. +fn unpath(t: *Track, kind: automation.TargetKind, fx_uid: u16, lane: u8, slot: u16, to_kind: automation.TargetKind, to_fx_uid: u16) void { + unpathIn(t.lanes.items, kind, fx_uid, lane, slot, to_kind, to_fx_uid); + for (t.clips.items) |*clip| unpathIn(clip.lanes.items, kind, fx_uid, lane, slot, to_kind, to_fx_uid); +} + +fn unpathIn(lanes: []automation.Lane, kind: automation.TargetKind, fx_uid: u16, lane: u8, slot: u16, to_kind: automation.TargetKind, to_fx_uid: u16) void { + for (lanes) |*l| { + const tg = &l.target; + if (tg.kind != kind or (kind == .fx and tg.fx_uid != fx_uid) or tg.path.len == 0) continue; + const s0 = tg.path.steps[0]; + if (s0.lane != lane or s0.slot != slot) continue; + var path = automation.Path{}; + for (tg.path.slice()[1..]) |s| path = path.plus(s); + tg.kind = to_kind; + tg.fx_uid = to_fx_uid; + tg.path = path; + } +} + +// ── tests ──────────────────────────────────────────────────────────── + +const testing = std.testing; + +fn testRender(_: *anyopaque, _: *const machine.MachineCtx, l: []f32, r: []f32) void { + @memset(l, 0); + @memset(r, 0); +} +fn testReset(_: *anyopaque) void {} +fn testDraw(_: *anyopaque, _: *@import("ui/core.zig").Ui, _: @import("ui/geom.zig").Rect) void {} +var test_state: u8 = 0; +const silent = machine.Machine{ .name = "none", .state = &test_state, .render = testRender, .reset = testReset, .draw_panel = testDraw }; + +fn paramsOf(alloc: std.mem.Allocator, m: machine.Machine) ![]u8 { + var out: std.ArrayList(u8) = .empty; + try m.write_params_json.?(m.state, &out, alloc); + return out.toOwnedSlice(alloc); +} + +test "group: an instrument and its first insert go into a rack lane with their settings and automation, and come back" { + const alloc = testing.allocator; + var reg = Registry.init(alloc); + defer reg.deinit(); + try reg.loadFyMachine("machines/ms20/ms20.fy"); + try reg.loadFyMachine("machines/sat2/sat2.fy"); + try reg.loadFyMachine("machines/delay2/delay2.fy"); + try reg.loadNative(); + + const ms20 = reg.findById("ms20").?; + const inst = try reg.instantiatePrepared(ms20); + inst.reset(inst.state); + var t = try Track.init(alloc, "Lead", .{ .r = 1, .g = 2, .b = 3, .a = 255 }, inst); + defer t.deinit(alloc); + t.machine_idx = @intCast(ms20); + for ([_][]const u8{ "sat2", "delay2" }) |id| { + const idx = reg.findById(id).?; + const fx = try reg.instantiatePrepared(idx); + fx.reset(fx.state); + try t.addEffect(alloc, fx, @intCast(idx)); + } + // Settings off their defaults, to see them carried. + inst.set_param.?(inst.state, "cutoff", 900); + const sat = &t.effects.items[0]; + sat.mach.set_param.?(sat.mach.state, "sat-drive", 21); + sat.bypass.store(true, .monotonic); + const inst_params = try paramsOf(alloc, inst); + defer alloc.free(inst_params); + const sat_params = try paramsOf(alloc, sat.mach); + defer alloc.free(sat_params); + const delay_uid = t.effects.items[1].uid; + + const cut = try t.laneFor(alloc, automation.Target.control(.inst, 0, "cutoff"), false); + _ = try cut.insert(alloc, .{ .beat = 0, .value = 0.3 }); + const drv = try t.laneFor(alloc, automation.Target.control(.fx, sat.uid, "sat-drive"), false); + _ = try drv.insert(alloc, .{ .beat = 0, .value = 0.6 }); + const dly = try t.laneFor(alloc, automation.Target.control(.fx, delay_uid, "delay-mix"), false); + _ = try dly.insert(alloc, .{ .beat = 0, .value = 0.2 }); + + _ = try group(alloc, ®, &t, 0, 1); + try testing.expectEqual(reg.findById("rack").?, t.machine_idx.?); + try testing.expectEqual(@as(usize, 1), t.effects.items.len); + try testing.expectEqual(delay_uid, t.effects.items[0].uid); + const rk = rackAt(&t, 0).?; + try testing.expectEqual(@as(usize, 1), rk.count); + const lane = &rk.lanes[0]; + try testing.expectEqual(@as(usize, 1), lane.effects.items.len); + const got_inst = try paramsOf(alloc, lane.instrument); + defer alloc.free(got_inst); + try testing.expectEqualStrings(inst_params, got_inst); + const got_sat = try paramsOf(alloc, lane.effects.items[0].mach); + defer alloc.free(got_sat); + try testing.expectEqualStrings(sat_params, got_sat); + try testing.expect(lane.effects.items[0].bypass.load(.monotonic)); + // The lanes went down the rack; the delay's stayed. + const c0 = t.lanes.items[0].target; + try testing.expectEqual(automation.TargetKind.inst, c0.kind); + try testing.expectEqual(@as(u8, 1), c0.path.len); + try testing.expectEqual(@as(u16, 0), c0.path.steps[0].slot); + try testing.expectEqual(lane.instrument.state, t.targetMachine(c0).?.state); + const d0 = t.lanes.items[1].target; + try testing.expectEqual(automation.TargetKind.inst, d0.kind); + try testing.expectEqual(lane.effects.items[0].mach.state, t.targetMachine(d0).?.state); + try testing.expectEqual(automation.TargetKind.fx, t.lanes.items[2].target.kind); + try testing.expectEqual(@as(u8, 0), t.lanes.items[2].target.path.len); + + // A lane on the rack's own macro goes away with it. + _ = try (try t.laneFor(alloc, automation.Target.control(.inst, 0, "rk-m1"), false)).insert(alloc, .{ .beat = 0, .value = 1 }); + try ungroup(alloc, ®, &t, 0); + try testing.expectEqual(ms20, t.machine_idx.?); + try testing.expectEqual(@as(usize, 2), t.effects.items.len); + const back_inst = try paramsOf(alloc, t.machine); + defer alloc.free(back_inst); + try testing.expectEqualStrings(inst_params, back_inst); + try testing.expect(t.effects.items[0].bypass.load(.monotonic)); + try testing.expectEqual(@as(usize, 3), t.lanes.items.len); + for (t.lanes.items) |*l| { + try testing.expectEqual(@as(u8, 0), l.target.path.len); + try testing.expect(t.targetMachine(l.target) != null); + } + try testing.expectEqual(t.effects.items[0].uid, t.lanes.items[1].target.fx_uid); +} + +test "group: a run of effects becomes an FX Rack in its place" { + const alloc = testing.allocator; + var reg = Registry.init(alloc); + defer reg.deinit(); + try reg.loadFyMachine("machines/sat2/sat2.fy"); + try reg.loadFyMachine("machines/delay2/delay2.fy"); + try reg.loadNative(); + var t = try Track.init(alloc, "Bus", .{ .r = 1, .g = 2, .b = 3, .a = 255 }, silent); + defer t.deinit(alloc); + for ([_][]const u8{ "delay2", "sat2", "sat2", "delay2" }) |id| { + const idx = reg.findById(id).?; + const fx = try reg.instantiatePrepared(idx); + fx.reset(fx.state); + try t.addEffect(alloc, fx, @intCast(idx)); + } + const mid = t.effects.items[2].uid; + _ = try (try t.laneFor(alloc, automation.Target.control(.fx, mid, "sat-drive"), false)).insert(alloc, .{ .beat = 0, .value = 0.5 }); + _ = try group(alloc, ®, &t, 2, 3); + try testing.expectEqual(@as(usize, 3), t.effects.items.len); + try testing.expectEqual(reg.findById("fxrack").?, t.effects.items[1].idx.?); + const rk = rackAt(&t, 2).?; + try testing.expectEqual(@as(usize, 2), rk.lanes[0].effects.items.len); + const tg = t.lanes.items[0].target; + try testing.expectEqual(t.effects.items[1].uid, tg.fx_uid); + try testing.expectEqual(rk.lanes[0].effects.items[1].mach.state, t.targetMachine(tg).?.state); + + try ungroup(alloc, ®, &t, 2); + try testing.expectEqual(@as(usize, 4), t.effects.items.len); + const back = t.lanes.items[0].target; + try testing.expectEqual(@as(u8, 0), back.path.len); + try testing.expectEqual(t.effects.items[2].uid, back.fx_uid); +} diff --git a/src/snapshot.zig b/src/snapshot.zig index cf01be6f..a6f3aaf1 100644 --- a/src/snapshot.zig +++ b/src/snapshot.zig @@ -109,6 +109,8 @@ pub const AudioClipSnap = struct { pub const LaneSnap = struct { kind: automation.TargetKind, fx_uid: u16 = 0, + /// Into a rack: the lane drives a machine there (docs/42). + path: automation.Path = .{}, control: u16 = 0, points_start: u32, points_count: u32, @@ -137,6 +139,8 @@ pub const AutoView = struct { cursors: *[MAX_LANES_PER_TRACK]u32, kind: automation.TargetKind, fx_uid: u16 = 0, + /// The machine sits in a rack on that slot, this far down. + path: automation.Path = .{}, /// Lanes read here instead of at the machine's beat: the playhead is /// held (stopped, tails ringing) while synced LFOs keep running. hold_beat: ?f64 = null, @@ -151,7 +155,14 @@ pub const AutoView = struct { } pub fn matches(self: *const AutoView, l: LaneSnap) bool { - return l.kind == self.kind and (self.kind != .fx or l.fx_uid == self.fx_uid); + return l.kind == self.kind and (self.kind != .fx or l.fx_uid == self.fx_uid) and l.path.eql(self.path); + } + + /// The view of the machine one step into the rack this one shows. + pub fn into(self: *const AutoView, step: automation.Step) AutoView { + var v = self.*; + v.path = self.path.plus(step); + return v; } pub fn points(self: *const AutoView, l: LaneSnap) []const automation.Point { diff --git a/src/track.zig b/src/track.zig index 82b6f7f5..7bd0361f 100644 --- a/src/track.zig +++ b/src/track.zig @@ -389,11 +389,12 @@ pub const Track = struct { /// The machine a control target lives on, if it still exists. pub fn targetMachine(self: *const Track, target: automation.Target) ?*const machine.Machine { - return switch (target.kind) { - .volume, .pan => null, + const m: *const machine.Machine = switch (target.kind) { + .volume, .pan => return null, .inst => &self.machine, - .fx => if (self.effectByUid(target.fx_uid)) |fx| &fx.mach else null, + .fx => if (self.effectByUid(target.fx_uid)) |fx| &fx.mach else return null, }; + return m.inner(target.path); } /// UI edits the chain; audio keeps the previous version until adoption. @@ -860,6 +861,7 @@ pub const Track = struct { var ls = snap_mod.LaneSnap{ .kind = lane.target.kind, .fx_uid = lane.target.fx_uid, + .path = lane.target.path, .points_start = dst.auto_point_count, .points_count = @intCast(pts.len), }; diff --git a/src/ui/core.zig b/src/ui/core.zig index f048e663..c24fc082 100644 --- a/src/ui/core.zig +++ b/src/ui/core.zig @@ -177,6 +177,9 @@ pub const Ui = struct { /// the machine bay sets it around a panel so every control in it, /// however deeply scoped, feeds that machine's title display. touch_scope: ?Id = null, + /// A rack's MAP is armed around the panels it draws: every control a + /// macro can take is outlined (docs/42 §UI). + map_mode: bool = false, wants_frame: bool = true, frame: u64 = 0, white: atlas_mod.Region, diff --git a/src/ui/lane_targets.zig b/src/ui/lane_targets.zig index d80a4288..28a42f08 100644 --- a/src/ui/lane_targets.zig +++ b/src/ui/lane_targets.zig @@ -113,7 +113,7 @@ pub fn laneInfo(buf: []u8, t: *const Track, lane: *const automation.Lane) LaneIn .fmt = .{ .mach = null, .kind = lane.target.kind }, }; const info = m.control_info.?(m.state, ci); - const prefix: []const u8 = if (lane.target.kind == .fx) m.name else ""; + const prefix: []const u8 = if (lane.target.kind == .fx or lane.target.path.len > 0) m.name else ""; const name = std.fmt.bufPrint(buf, "{s}{s}{s} {s}", .{ prefix, if (prefix.len > 0) " " else "", info.module, info.label }) catch info.label; break :blk .{ .name = name, diff --git a/src/ui/machine_bay.zig b/src/ui/machine_bay.zig index 7129b873..cc4c5f41 100644 --- a/src/ui/machine_bay.zig +++ b/src/ui/machine_bay.zig @@ -137,6 +137,37 @@ const ReorderDrag = struct { }; var fx_drag: ReorderDrag = .{}; +/// A run of devices Shift-clicked on one chain, for ⌘G (group into a rack) +/// and ⌘⇧G (ungroup one): chain positions, 0 the instrument, n the n-th +/// effect (docs/42 §UI). +pub const Selection = struct { dev: *const Track, first: usize, last: usize }; +pub var selection: ?Selection = null; + +/// Shift-click on position `p`: start a run, grow it to `p`, or drop a +/// lone pick clicked again. +fn shiftSelect(dev: *const Track, p: usize) void { + if (selection) |*sel| if (sel.dev == dev) { + if (sel.first == p and sel.last == p) { + selection = null; + } else { + sel.first = @min(sel.first, p); + sel.last = @max(sel.last, p); + } + return; + }; + selection = .{ .dev = dev, .first = p, .last = p }; +} + +fn selected(dev: *const Track, p: usize) bool { + const sel = selection orelse return false; + return sel.dev == dev and p >= sel.first and p <= sel.last; +} + +fn markSelected(ui: *Ui, card: Rect) void { + ui.rect(Rect.xywh(card.x, card.y + TITLE_H - 2, card.w, 2), ui_style.accent); + ui.overlayRect(card, ui_style.accent.alpha(24)); +} + // Note-activity LED glow, keyed by track index. Bumped to 1.0 when the // engine's note sequence advances, decayed each frame for a soft pulse. var led_seen: [16]u32 = @splat(0); @@ -198,6 +229,9 @@ pub fn draw(ui: *Ui, r_legacy: c.rl.Rectangle, device: ?*Track, track_idx: ?usiz emptyBay(ui, area, "SELECT A TRACK"); return result; }; + if (selection) |sel| if (sel.dev != t or sel.last > t.effects.items.len) { + selection = null; + }; // Measure the chain: (instrument) + effects + the trailing "+". const has_instrument = !is_bus and t.machine_idx != null; @@ -234,8 +268,13 @@ pub fn draw(ui: *Ui, r_legacy: c.rl.Rectangle, device: ?*Track, track_idx: ?usiz glow = led_glow[ti]; if (glow > 0) ui.animate(); }; - const out = drawDevice(ui, Rect.xywh(x, area.y, inst_w, area.h), &t.machine, .instrument, null, t.isEnabled(), glow, reg, &result); + const card = Rect.xywh(x, area.y, inst_w, area.h); + const out = drawDevice(ui, card, &t.machine, .instrument, null, t.isEnabled(), glow, reg, &result); if (out.toggle) t.toggleEnabled(); + if (out.name_pressed) { + if (ui.in.shift) shiftSelect(t, 0) else selection = null; + } + if (selected(t, 0)) markSelected(ui, card); x += inst_w; } @@ -260,7 +299,13 @@ pub fn draw(ui: *Ui, r_legacy: c.rl.Rectangle, device: ?*Track, track_idx: ?usiz const card = Rect.xywh(x, area.y, fw, area.h); const out = drawDevice(ui, card, &fx.mach, .{ .effect = i }, fx, !t.effectBypassed(i), null, reg, &result); if (out.toggle) t.toggleEffectBypass(i); - if (out.name_pressed and !fx_drag.armed) fx_drag = .{ .armed = true, .src = i, .press_x = ui.in.mx }; + if (out.name_pressed and ui.in.shift) { + shiftSelect(t, i + 1); + } else if (out.name_pressed and !fx_drag.armed) { + selection = null; + fx_drag = .{ .armed = true, .src = i, .press_x = ui.in.mx }; + } + if (selected(t, i + 1)) markSelected(ui, card); if (out.name_released and fx_drag.armed and fx_drag.src == i) { if (fx_drag.active) { var to = drop_idx; @@ -454,12 +499,12 @@ pub fn drawDevice(ui: *Ui, card: Rect, mach: *Machine, ref: DeviceRef, fx: ?*con _ = ctl.button(ui, name_r, "name-cap", null, .{ .flush = true, .disabled = true }); ui.textIn(&ui.fonts.body_bold, Rect.xywh(name_r.x + 6, name_r.y, name_r.w - 8 - led_w, name_r.h - 1), name, if (ui.isHot(nid)) ui_style.text else ui_style.text_dim, .left, true); if (glow) |g| ctl.led(ui, name_r.right() - led_w - 2, name_r.y + @divFloor(name_r.h - 1 - 5, 2), .round5, if (g > 0.2) .on else .off, ui_style.led_green); - const what: []const u8 = if (is_inst) "Replace machine" else "Drag to reorder, click to replace"; + const what: []const u8 = if (is_inst) "Replace machine. Shift-click to select, \u{2318}G groups into a rack" else "Drag to reorder, click to replace. Shift-click to select, \u{2318}G groups into a rack"; const lat = mach.latencySamples(); var lbuf: [96]u8 = undefined; menu.tip(ui, name_r, if (lat == 0) what else std.fmt.bufPrint(&lbuf, "{s}. Latency {d} smp ({d:.1} ms)", .{ what, lat, latencyMs(lat, sample_rate) }) catch what); const replace_key = pane.keyFromIds(REPLACE_MENU_KEY, @intFromPtr(mach.state), 0); - if (is_inst and nb.clicked and !menu.isOpen(replace_key)) { + if (is_inst and nb.clicked and !ui.in.shift and !menu.isOpen(replace_key)) { scanRegistryPresets(reg); menu.openBelow(replace_key, name_r); } @@ -572,13 +617,23 @@ pub const PickerFilter = enum { all, instruments, effects }; pub fn machinePickerMenuFor(menu_key: u64, reg: *const Registry, filter: PickerFilter) ?AddPick { if (!menu.isOpen(menu_key)) return null; const menu_count = @min(reg.count, registry_mod.MAX_MACHINES); - var items: [registry_mod.MAX_MACHINES]menu.Item = undefined; + var items: [registry_mod.MAX_MACHINES + 2]menu.Item = undefined; var n: usize = 0; - for (reg.entries[0..menu_count], 0..) |*e, i| { - if (filter == .instruments and !e.in_notes) continue; - if (filter == .effects and (!e.in_audio or e.in_notes)) continue; - items[n] = .{ .label = std.mem.span(e.nameZ()), .id = @intCast(i), .submenu = add_scan_cache[i].count > 0 }; - n += 1; + // Machines first, then the racks under their own heading (docs/42). + for ([_]bool{ false, true }) |natives| { + const head = n; + for (reg.entries[0..menu_count], 0..) |*e, i| { + if ((e.native != .none) != natives) continue; + if (filter == .instruments and !e.in_notes) continue; + if (filter == .effects and (!e.in_audio or e.in_notes)) continue; + if (natives and n == head) { + items[n] = .{ .separator = true }; + items[n + 1] = .{ .label = "RACKS", .enabled = false }; + n += 2; + } + items[n] = .{ .label = std.mem.span(e.nameZ()), .id = @intCast(i), .submenu = add_scan_cache[i].count > 0 }; + n += 1; + } } if (menu.pick(menu_key, items[0..n])) |id| return .{ .reg_idx = @intCast(id) }; From 9975d48d3777cc239d5618bf3b8cb94749360eeb Mon Sep 17 00:00:00 2001 From: Marcin Gasperowicz Date: Wed, 21 Oct 2026 20:30:33 +0100 Subject: [PATCH 9/9] =?UTF-8?q?machine:=20rigs=20under=20RACKS=20in=20the?= =?UTF-8?q?=20picker,=20rack=20builders=20in=20slabkit,=20describe=20lists?= =?UTF-8?q?=20racks=20(docs/42=20=C2=A7Build=20phases=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Factory rigs: Fretwire Clean and Crunch Rig, Bass DI + Amp and Parallel Squash (FX Rack); the first Rack's split and layer presets are rewritten as lanes. The machine picker lists Rack and FX Rack under a RACKS heading. `slab --describe` emits both with rk-m1..8 and rk-chain. slabkit gains rack()/lane()/macro()/map(), rig presets by name, and automation paths into racks; songs/rack_rigs.py demos it. docs/42 becomes the reference, with docs/19, 20 and 22 updated. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 22 + docs/19-project-format.md | 43 +- docs/20-machine-reference.md | 46 + docs/22-automation.md | 12 +- docs/42-racks.md | 157 +- .../fxrack/presets/parallel-squash.preset | 1 + machines/rack/presets/bass-di-amp.preset | 1 + machines/rack/presets/bass-pad-split.preset | 2 +- machines/rack/presets/epiano-pad-layer.preset | 2 +- .../rack/presets/fretwire-clean-rig.preset | 1 + .../rack/presets/fretwire-crunch-rig.preset | 1 + songs/rack_rigs.py | 55 + songs/rack_rigs.slab/project.json | 2308 +++++++++++++++++ tools/slabkit/__init__.py | 3 +- tools/slabkit/machines.py | 4 +- tools/slabkit/rack.py | 264 ++ tools/slabkit/song.py | 53 +- 17 files changed, 2890 insertions(+), 85 deletions(-) create mode 100644 machines/fxrack/presets/parallel-squash.preset create mode 100644 machines/rack/presets/bass-di-amp.preset create mode 100644 machines/rack/presets/fretwire-clean-rig.preset create mode 100644 machines/rack/presets/fretwire-crunch-rig.preset create mode 100644 songs/rack_rigs.py create mode 100644 songs/rack_rigs.slab/project.json create mode 100644 tools/slabkit/rack.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c6e2bbc0..bb9cef5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,28 @@ GitHub are its section here (`tools/release.sh` copies them), so add to - **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 diff --git a/docs/19-project-format.md b/docs/19-project-format.md index 7ba9154e..865c771b 100644 --- a/docs/19-project-format.md +++ b/docs/19-project-format.md @@ -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 diff --git a/docs/20-machine-reference.md b/docs/20-machine-reference.md index fc46d044..9bacc864 100644 --- a/docs/20-machine-reference.md +++ b/docs/20-machine-reference.md @@ -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-` | @@ -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-` | @@ -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. @@ -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. diff --git a/docs/22-automation.md b/docs/22-automation.md index d25e32cd..6444fd69 100644 --- a/docs/22-automation.md +++ b/docs/22-automation.md @@ -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 @@ -501,6 +506,9 @@ option indices. - `inst:` - `fx:`: `N` is the effect's 0-based position in the chain at save time. + - `inst/.

:` or `fx/.

:`, with up to three + `/.

` 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 diff --git a/docs/42-racks.md b/docs/42-racks.md index 78c4c2fa..b09db17c 100644 --- a/docs/42-racks.md +++ b/docs/42-racks.md @@ -1,13 +1,14 @@ # 42 — Racks: chains in lanes, macros, rigs -**Status: phases 1–3 built, and part of phase 4.** Rack lanes hold -chains, the FX Rack plays lanes of inserts, racks nest and align their -lanes' latency, and rack state is in its second form. Macros map to -controls inside through ranges and formulas, with the macro block and -MAP mode on the panel. CHAIN zones on the panel, the collapsed face, -insert reordering, grouping, rigs and inner automation are still to -build. The Rack that ships today -(`src/machines/rack.zig`) is described under "Where we start". +**Status: built (phases 1–6).** This is the reference for racks. +Rack lanes hold chains, the FX Rack plays lanes of inserts, racks nest +three deep and align their lanes' latency. Macros map to controls inside +through ranges and formulas. The panel has the macro block, MAP mode, +KEY/VEL/CHAIN zone tabs, the collapsed face and insert reordering; ⌘G +groups devices into a rack and ⌘⇧G ungroups. Factory rigs ship under +RACKS in the picker, slabkit builds racks, and automation reaches +controls inside a rack by path. "Where we start" describes the first +Rack, which the second form replaced; its files still load. A rack packages machines into one device. Each of its lanes holds a chain, an instrument and its effects or just effects, and the lanes play @@ -21,7 +22,7 @@ hardware look. ## Where we start -The current Rack is an instrument rack only: +The first Rack was an instrument rack only: - Up to 8 parts. Each is one instrument with a key range, a velocity range, transpose, level, pan, a polyphony cap and mute. @@ -198,26 +199,38 @@ left to right: 3. **The selected lane's chain:** its devices are drawn inline as nested bay columns, under a bracket header that shows the depth. -**Collapsed face.** The ▾ in the title strip collapses the rack to its -macro knobs. A rack loaded from a preset opens collapsed, showing only -its macros. That face is the pseudomachine: on the track it reads as one -device, GUITAR RIG, with four knobs. +**Collapsed face.** The fold button in the title strip collapses the +rack to its named macro knobs. A rack loaded from a preset opens +collapsed. That face is the pseudomachine: on the track it reads as one +device, GUITAR RIG, with four knobs. The fold state saves as +`"collapsed"` in the rack's state. **MAP mode.** Pick a macro and arm MAP. Every mappable control in the -open lane gets an outline, and clicking one adds a mapping. Under the +open lane gets an amber outline, and clicking one adds a mapping. Under the macros, a table lists the picked macro's mappings: target, MIN, MAX and the formula as a text field. Dragging MIN or MAX on a row sets the range -the same way dragging the target's knob does. +the same way dragging the target's knob does. With MAP off, turning a +mapped control by hand moves the macro instead: the macro takes the +value whose mapping lands closest to where the hand put the control. + +**Zones.** KEY, VEL and CHAIN tabs under the lane list show one bar per +lane; drag a bar's ends to set its range. CHAIN is also a field beside +the macros (`rk-chain`, 0–127). + +**Reordering.** Drag an insert's title strip along the lane's chain to +move it. The colors and widgets are the existing tokens: amber marks the armed MAP and the selected lane and nothing else. No new widget kinds are needed except the zone bars and the mapping table rows. -**Group into a rack.** Select a run of devices on a track (the -instrument and some effects, or only effects) and press ⌘G. The run -moves into a new rack's first lane, with its settings and automation -intact (see "Automation inside a rack"). Ungroup does the reverse when -the rack has one lane and no mappings. +**Group into a rack.** Shift-click devices in the bay to select a run +(the instrument and some effects, or only effects) and press ⌘G. The +run moves into a new rack's first lane, with its settings and automation +intact (see "Automation inside a rack"); an instrument run makes a Rack, +an effect run an FX Rack. With a rack selected, ⌘⇧G ungroups it when it +has one lane and no mappings. ⌘G groups tracks only while no devices are +selected in the bay. ## Presets: rigs @@ -227,8 +240,8 @@ settings, macros and mappings. - Factory rack presets live in `machines/rack/presets/`, banked by folder. -- The machine picker lists them under **RACKS**, filtered by kind. - Picking one inserts a rack and loads the preset. +- The machine picker lists Rack and FX Rack under **RACKS**, filtered + by kind; their preset submenus hold the rigs. - The bay's preset menu saves User and Project racks as it does for any machine. @@ -236,7 +249,7 @@ Existing preset storage carries this with no new item kind (docs/25): racks are a machine with presets. The one change is that a preset can hold `state` beside `params`, which the rack already relies on. -**Factory rigs to ship with phase 5:** +**Factory rigs:** - **Fretwire Clean Rig.** Guitar → amp2 (clean) → delay2 → verb2. Macros: PICKUP (SELECT stepped), TONE (TONE pot + amp treble), ECHO, @@ -248,8 +261,8 @@ hold `state` beside `params`, which the rack already relies on. levels, opposite ways), GROWL, PUNCH. - **Parallel Squash** (effect rack). Lane 1 is dry, lane 2 is a heavy comp2 with sat2. Macro AMOUNT. -- **Split and layer.** The current rack presets, rewritten in the new - form. +- **Split and layer.** The first Rack's presets (`bass-pad-split`, + `epiano-pad-layer`), rewritten in the second form. ## Automation inside a rack @@ -257,17 +270,27 @@ The macros cover most needs. Inner controls can also be automated directly, so grouping loses nothing: - **The target path** extends `automation.Target` with a path of up to - three `(lane, slot uid)` steps under the rack's own target. Files - write slot positions, which load back to uids. -- **In files** it reads as the rack's target, then the path: - `fx1/2.1:amp-gain` is the rack in effect slot 1, lane 2, slot 1, control - `amp-gain`. -- **Resolving lanes.** `publishLanes` resolves the path to the inner - machine. `AutoView` gains the path, so `matches` stays a cheap compare. -- **Mapped controls.** A mapped control can't hold automation of its - own: the macro owns it. Grouping moves such lanes onto the macro when - the mapping is one-to-one, and otherwise keeps the lane and leaves the - control unmapped. + three steps under the rack's own target. Each step is a lane uid and a + slot (0 for the lane's instrument, else the insert's uid), so lanes + and inserts can move without breaking it. Files write positions, + which load back to uids. +- **In files** it reads as the rack's target, then `/L.P` per step: + lane L counted from 1, place P 0 for the lane's instrument and n for + its n-th insert. `fx1/2.1:amp-gain` is the rack in effect slot 1, + lane 2, first insert, control `amp-gain`; `inst/1.1/2.1:amp-gain` goes + through the track's instrument rack and the FX Rack in its lane 1. +- **Resolving lanes.** `Machine.child` lists a container's machines and + `Machine.inner(path)` walks it; the track resolves a lane to the inner + machine when it publishes. `AutoView` gains the path, so `matches` + stays a cheap compare, and `into(step)` hands a nested rack the view + one step down. +- **Grouping** retargets the moved machines' lanes (the track's and its + clips') one step down the new rack, and ungrouping strips that step. + A lane past three steps is dropped. Ungrouping also drops the lanes on + the rack's own macros, which go with it. A mapped control's own lane + still plays, but the macro overrides it while it moves. +- The lane menu names an inner target with the machine first + (`AMP2 GAIN`). ## Files and compatibility @@ -313,20 +336,38 @@ directly, so grouping loses nothing: ## slabkit -```python -from slabkit import Song, fx, rack, lane, macro +`tools/slabkit/rack.py`: -rig = rack( - lanes=[lane("CLEAN", "guitar", "twin-humbucker", - fx=[fx("amp2", "guitar-clean"), fx("delay2", "slapback"), fx("verb2", "chamber")])], - macros=[macro("DRIVE", 0.3, maps=[("CLEAN.1:amp-gain", 2, 7.5, "x^2"), - ("CLEAN.1:amp-level", 0.8, 0.5)])]) +```python +from slabkit import Song, fx, rack + +rig = rack("Guitar") +clean = rig.lane("CLEAN", "guitar", "twin-humbucker", + fx=[fx("amp2", "guitar-clean"), fx("delay2", "slapback")]) +drive = rig.macro(1, "DRIVE", 0.3) +rig.map(drive, clean, "amp-gain", insert=1, min=0.2, max=0.95, formula="x^2") +rig.map(rig.macro(2, "LEVEL", 1.0), clean, "level", min=-40, max=0) t = song.track("GTR", rig) # or song.track("GTR", "rack", "fretwire-clean-rig") -t.automate("rk-m1", {"chorus": (0.3, 0.8)}) +t.automate("rk-m1", (0, 0.3), (32, 0.8)) +t.automate("inst/1.1:amp-treble", (0, 0.5), (32, 0.7)) ``` -`describe.zig` lists the rack, its controls and its presets, so -`slab --describe` and the machine database see it. +- `rack(name, kind="instrument"|"effect", preset=None, chain=0, + collapsed=False)`; `lane(name, machine, preset, params, fx, keys=, + vel=, chain= (lo, hi) zones, transpose, level, pan, poly, mute)`; a + lane's machine may be another `rack()`, an insert `fx(rack(kind= + "effect"))`. +- `macro(n, name, value)` returns the macro for `map(macro, lane, + target, insert=0, min, max, formula)`. Targets are `"level"`, + `"pan"`, or a control id (prefix optional), checked against the + machine's range. +- `fx("fxrack", "parallel-squash")` and `song.track(name, "rack", + "")` load rigs as saved; automation paths into them resolve + against the preset's state. +- `songs/rack_rigs.py` is the demo. + +`slab --describe` lists `rack` and `fxrack` (`"native": true`) with +`rk-m1`…`rk-m8` and `rk-chain` under MACROS. ## Build phases @@ -373,25 +414,23 @@ bit-exact. macros round-trip; an automated macro sweep equals automating the control itself, sample for sample; a formula, a stepped target, unmapping; CHAIN zones; a macro reaching a nested rack's macro. -4. **UI.** *The macro block is built:* eight knobs, NAME, MAP (click a +4. **UI.** *Built.* The macro block: eight knobs, NAME, MAP (click a control in the open lane, or LV/PN, to map it to the picked macro over its whole range), and the picked macro's mappings with × , MIN and MAX steps in the target's units and the f(x) field, red until it - parses. Still to build: CHAIN zone editing, the collapsed face, - outlines on mappable controls in MAP mode, hand moves on a mapped - knob reaching the macro, dragging inserts to reorder, and ⌘G group - and ungroup. -5. **Rigs.** - - Factory rack presets, the RACKS section of the picker, and the - slabkit `rack/lane/macro` builders with `describe`. - - Docs: this file moves from proposal to reference, plus docs/19, - docs/20, docs/22 and CHANGELOG. -6. **Inner automation.** Target paths, AutoView paths, and moving lanes - on group. + parses. KEY/VEL/CHAIN zone tabs, the collapsed face, MAP outlines, + hand moves on a mapped knob reaching the macro, insert drag + reordering, and ⌘G group / ⌘⇧G ungroup from a bay selection. +5. **Rigs.** *Built.* Factory rack presets, the RACKS section of the + picker, the slabkit builders and `describe`, and these docs. +6. **Inner automation.** *Built.* Target paths, AutoView paths, and + moving lanes on group and ungroup. Tests: a path lane drives the + insert inside as a track lane would; `fx0/2.1:` round-trips; group + and ungroup keep automation. ## Open choices -These are my defaults. Each is worth a word before phase 3. +These were the defaults going in, and are what got built. - **8 macros.** Live 12 allows up to 16. Eight fits two rows at the large tier and has been enough for Live rigs for a decade. diff --git a/machines/fxrack/presets/parallel-squash.preset b/machines/fxrack/presets/parallel-squash.preset new file mode 100644 index 00000000..5347993c --- /dev/null +++ b/machines/fxrack/presets/parallel-squash.preset @@ -0,0 +1 @@ +{"slab": "preset", "schema": 1, "machine": "fxrack", "note": "Parallel compression: the dry signal beside a crushed, saturated copy. AMOUNT brings the copy up under it.", "rack": 2, "kind": "effect", "lanes": [{"name": "DRY", "effects": []}, {"name": "SQUASH", "effects": [{"machine": "comp2", "preset": "drum-smash"}, {"machine": "sat2", "preset": "drum-crush"}]}], "macros": [{"name": "AMOUNT", "value": 0.5}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}], "chain": 0, "maps": [{"macro": 1, "lane": 2, "target": "level", "min": -40, "max": 0, "formula": "x^0.5"}]} diff --git a/machines/rack/presets/bass-di-amp.preset b/machines/rack/presets/bass-di-amp.preset new file mode 100644 index 00000000..c93d1eb4 --- /dev/null +++ b/machines/rack/presets/bass-di-amp.preset @@ -0,0 +1 @@ +{"slab": "preset", "schema": 1, "machine": "rack", "note": "Fretwire Bass split into a compressed DI and a 15-inch amp. BLEND crossfades them, GROWL drives the amp, PUNCH squeezes the DI.", "rack": 2, "kind": "instrument", "lanes": [{"name": "BASS", "instrument": {"machine": "bass", "preset": "p-bass"}, "effects": [{"machine": "fxrack", "state": {"rack": 2, "kind": "effect", "lanes": [{"name": "DI", "effects": [{"machine": "comp2", "preset": "bass-leveler"}]}, {"name": "AMP", "effects": [{"machine": "amp2", "preset": "bass-vintage-15"}]}], "macros": [{"name": "BLEND", "value": 0.5}, {"name": "GROWL", "value": 0.4}, {"name": "PUNCH", "value": 0.4}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}], "chain": 0, "maps": [{"macro": 1, "lane": 1, "target": "level", "min": -40, "max": 0, "formula": "clamp(2 - 2*x, 0, 1)"}, {"macro": 1, "lane": 2, "target": "level", "min": -40, "max": 0, "formula": "clamp(2*x, 0, 1)"}, {"macro": 2, "lane": 2, "insert": 1, "control": "amp-gain", "min": 0.15, "max": 0.85}, {"macro": 3, "lane": 1, "insert": 1, "control": "comp-thresh", "min": -10, "max": -32}]}}]}], "macros": [{"name": "BLEND", "value": 0.5}, {"name": "GROWL", "value": 0.4}, {"name": "PUNCH", "value": 0.4}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}], "chain": 0, "maps": [{"macro": 1, "lane": 1, "insert": 1, "control": "rk-m1", "min": 0, "max": 1}, {"macro": 2, "lane": 1, "insert": 1, "control": "rk-m2", "min": 0, "max": 1}, {"macro": 3, "lane": 1, "insert": 1, "control": "rk-m3", "min": 0, "max": 1}]} diff --git a/machines/rack/presets/bass-pad-split.preset b/machines/rack/presets/bass-pad-split.preset index e04ef6cc..6b9e66da 100644 --- a/machines/rack/presets/bass-pad-split.preset +++ b/machines/rack/presets/bass-pad-split.preset @@ -1 +1 @@ -{"slab": "preset", "schema": 1, "machine": "rack", "note": "Juno bass under the split point, string pad over it", "parts": [{"machine": "juno2", "name": "juno-bass", "lo": 0, "hi": 52, "vlo": 1, "vhi": 127, "transpose": 0, "level": 0, "pan": 0, "poly": 1, "mute": false, "params": {"jn-lfo-rate": 1.5, "jn-vib": 0, "jn-range": 0, "jn-saw": 1, "jn-pulse": 0, "jn-pwm": 0, "jn-pwmod": 0, "jn-sub": 0.8, "jn-noise": 0, "jn-detune": 0, "jn-hpf": 0, "jn-cutoff": 700, "jn-res": 0.25, "jn-env": 0.358, "jn-lfovcf": 0.0, "jn-kybd": 0.3, "jn-atk": 0.002, "jn-dec": 0.25, "jn-sus": 0.3, "jn-rel": 0.12, "jn-vca-mode": 0, "jn-level": 0.531}}, {"machine": "juno2", "name": "breezy-string-pad", "lo": 53, "hi": 127, "vlo": 1, "vhi": 127, "transpose": 0, "level": -3, "pan": 0, "poly": 0, "mute": false, "params": {"jn-lfo-rate": 0.7, "jn-vib": 0, "jn-range": 1, "jn-saw": 1, "jn-pulse": 1, "jn-pwm": 0.4, "jn-pwmod": 1, "jn-sub": 0, "jn-noise": 0, "jn-detune": 0.5, "jn-hpf": 1, "jn-cutoff": 1500, "jn-res": 0.05, "jn-env": 0.131, "jn-lfovcf": 0.0, "jn-kybd": 0.5, "jn-atk": 0.6, "jn-dec": 1.6, "jn-sus": 0.85, "jn-rel": 1.4, "jn-vca-mode": 0, "jn-level": 0.158}}]} +{"slab": "preset", "schema": 1, "machine": "rack", "note": "Juno bass under the split point, string pad over it", "rack": 2, "kind": "instrument", "lanes": [{"name": "juno-bass", "lo": 0, "hi": 52, "vlo": 1, "vhi": 127, "transpose": 0, "level": 0, "pan": 0, "poly": 1, "mute": false, "instrument": {"machine": "juno2", "params": {"jn-lfo-rate": 1.5, "jn-vib": 0, "jn-range": 0, "jn-saw": 1, "jn-pulse": 0, "jn-pwm": 0, "jn-pwmod": 0, "jn-sub": 0.8, "jn-noise": 0, "jn-detune": 0, "jn-hpf": 0, "jn-cutoff": 700, "jn-res": 0.25, "jn-env": 0.358, "jn-lfovcf": 0.0, "jn-kybd": 0.3, "jn-atk": 0.002, "jn-dec": 0.25, "jn-sus": 0.3, "jn-rel": 0.12, "jn-vca-mode": 0, "jn-level": 0.531}}, "effects": []}, {"name": "breezy-string-pad", "lo": 53, "hi": 127, "vlo": 1, "vhi": 127, "transpose": 0, "level": -3, "pan": 0, "poly": 0, "mute": false, "instrument": {"machine": "juno2", "params": {"jn-lfo-rate": 0.7, "jn-vib": 0, "jn-range": 1, "jn-saw": 1, "jn-pulse": 1, "jn-pwm": 0.4, "jn-pwmod": 1, "jn-sub": 0, "jn-noise": 0, "jn-detune": 0.5, "jn-hpf": 1, "jn-cutoff": 1500, "jn-res": 0.05, "jn-env": 0.131, "jn-lfovcf": 0.0, "jn-kybd": 0.5, "jn-atk": 0.6, "jn-dec": 1.6, "jn-sus": 0.85, "jn-rel": 1.4, "jn-vca-mode": 0, "jn-level": 0.158}}, "effects": []}], "macros": [{"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}], "chain": 0, "maps": []} diff --git a/machines/rack/presets/epiano-pad-layer.preset b/machines/rack/presets/epiano-pad-layer.preset index eacbdca4..c06b6163 100644 --- a/machines/rack/presets/epiano-pad-layer.preset +++ b/machines/rack/presets/epiano-pad-layer.preset @@ -1 +1 @@ -{"slab": "preset", "schema": 1, "machine": "rack", "note": "FM e-piano layered over a quiet Juno pad", "parts": [{"machine": "fm86", "name": "e-piano-1", "lo": 0, "hi": 127, "vlo": 1, "vhi": 127, "transpose": 0, "level": 0, "pan": 0, "poly": 0, "mute": false, "params": {"algo": 5, "feedback": 0.5, "op1-ratio": 1.0, "op1-level": 1.0, "op1-r1": 0.007407, "op1-l1": 1.0, "op1-r2": 6e-06, "op1-l2": 0.8125, "op1-r3": 6e-06, "op1-l3": 0.0078, "op1-r4": 0.000407, "op1-l4": 0.0078, "op2-ratio": 14.0, "op2-level": 0.05731, "op2-r1": 0.006702, "op2-l1": 1.0, "op2-r2": 7.4e-05, "op2-l2": 0.8125, "op2-r3": 1.7e-05, "op2-l3": 0.0078, "op2-r4": 0.001224, "op2-l4": 0.0078, "op3-ratio": 1.0, "op3-level": 1.0, "op3-r1": 0.006702, "op3-l1": 1.0, "op3-r2": 4e-06, "op3-l2": 0.9688, "op3-r3": 4e-06, "op3-l3": 0.0078, "op3-r4": 7.4e-05, "op3-l4": 0.0078, "op4-ratio": 1.0, "op4-level": 0.8409, "op4-r1": 0.006702, "op4-l1": 1.0, "op4-r2": 9e-06, "op4-l2": 0.9688, "op4-r3": 4e-06, "op4-l3": 0.0078, "op4-r4": 7.4e-05, "op4-l4": 0.0078, "op5-ratio": 1.0, "op5-level": 1.0, "op5-r1": 0.006702, "op5-l1": 1.0, "op5-r2": 4e-06, "op5-l2": 0.9688, "op5-r3": 4e-06, "op5-l3": 0.0078, "op5-r4": 7.4e-05, "op5-l4": 0.0078, "op6-ratio": 1.0, "op6-level": 0.35355, "op6-r1": 0.006702, "op6-l1": 1.0, "op6-r2": 9e-06, "op6-l2": 0.9688, "op6-r3": 4e-06, "op6-l3": 0.0078, "op6-r4": 7.4e-05, "op6-l4": 0.0078, "master": 0.25}}, {"machine": "juno2", "name": "lush-pad", "lo": 0, "hi": 127, "vlo": 1, "vhi": 127, "transpose": 0, "level": -9, "pan": 0, "poly": 0, "mute": false, "params": {"jn-lfo-rate": 0.8, "jn-vib": 0.1, "jn-range": 1, "jn-saw": 1, "jn-pulse": 1, "jn-pwm": 0.55, "jn-pwmod": 1, "jn-sub": 0.35, "jn-noise": 0, "jn-detune": 0.35, "jn-hpf": 1, "jn-cutoff": 900, "jn-res": 0.05, "jn-env": 0.234, "jn-lfovcf": 0.074, "jn-kybd": 0.6, "jn-atk": 0.6, "jn-dec": 1.2, "jn-sus": 0.75, "jn-rel": 1.8, "jn-vca-mode": 0, "jn-level": 0.148}}]} +{"slab": "preset", "schema": 1, "machine": "rack", "note": "FM e-piano layered over a quiet Juno pad", "rack": 2, "kind": "instrument", "lanes": [{"name": "e-piano-1", "lo": 0, "hi": 127, "vlo": 1, "vhi": 127, "transpose": 0, "level": 0, "pan": 0, "poly": 0, "mute": false, "instrument": {"machine": "fm86", "params": {"algo": 5, "feedback": 0.5, "op1-ratio": 1.0, "op1-level": 1.0, "op1-r1": 0.007407, "op1-l1": 1.0, "op1-r2": 6e-06, "op1-l2": 0.8125, "op1-r3": 6e-06, "op1-l3": 0.0078, "op1-r4": 0.000407, "op1-l4": 0.0078, "op2-ratio": 14.0, "op2-level": 0.05731, "op2-r1": 0.006702, "op2-l1": 1.0, "op2-r2": 7.4e-05, "op2-l2": 0.8125, "op2-r3": 1.7e-05, "op2-l3": 0.0078, "op2-r4": 0.001224, "op2-l4": 0.0078, "op3-ratio": 1.0, "op3-level": 1.0, "op3-r1": 0.006702, "op3-l1": 1.0, "op3-r2": 4e-06, "op3-l2": 0.9688, "op3-r3": 4e-06, "op3-l3": 0.0078, "op3-r4": 7.4e-05, "op3-l4": 0.0078, "op4-ratio": 1.0, "op4-level": 0.8409, "op4-r1": 0.006702, "op4-l1": 1.0, "op4-r2": 9e-06, "op4-l2": 0.9688, "op4-r3": 4e-06, "op4-l3": 0.0078, "op4-r4": 7.4e-05, "op4-l4": 0.0078, "op5-ratio": 1.0, "op5-level": 1.0, "op5-r1": 0.006702, "op5-l1": 1.0, "op5-r2": 4e-06, "op5-l2": 0.9688, "op5-r3": 4e-06, "op5-l3": 0.0078, "op5-r4": 7.4e-05, "op5-l4": 0.0078, "op6-ratio": 1.0, "op6-level": 0.35355, "op6-r1": 0.006702, "op6-l1": 1.0, "op6-r2": 9e-06, "op6-l2": 0.9688, "op6-r3": 4e-06, "op6-l3": 0.0078, "op6-r4": 7.4e-05, "op6-l4": 0.0078, "master": 0.25}}, "effects": []}, {"name": "lush-pad", "lo": 0, "hi": 127, "vlo": 1, "vhi": 127, "transpose": 0, "level": -9, "pan": 0, "poly": 0, "mute": false, "instrument": {"machine": "juno2", "params": {"jn-lfo-rate": 0.8, "jn-vib": 0.1, "jn-range": 1, "jn-saw": 1, "jn-pulse": 1, "jn-pwm": 0.55, "jn-pwmod": 1, "jn-sub": 0.35, "jn-noise": 0, "jn-detune": 0.35, "jn-hpf": 1, "jn-cutoff": 900, "jn-res": 0.05, "jn-env": 0.234, "jn-lfovcf": 0.074, "jn-kybd": 0.6, "jn-atk": 0.6, "jn-dec": 1.2, "jn-sus": 0.75, "jn-rel": 1.8, "jn-vca-mode": 0, "jn-level": 0.148}}, "effects": []}], "macros": [{"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}], "chain": 0, "maps": []} diff --git a/machines/rack/presets/fretwire-clean-rig.preset b/machines/rack/presets/fretwire-clean-rig.preset new file mode 100644 index 00000000..acc0b351 --- /dev/null +++ b/machines/rack/presets/fretwire-clean-rig.preset @@ -0,0 +1 @@ +{"slab": "preset", "schema": 1, "machine": "rack", "note": "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.", "rack": 2, "kind": "instrument", "lanes": [{"name": "CLEAN", "instrument": {"machine": "guitar", "preset": "twin-humbucker"}, "effects": [{"machine": "amp2", "preset": "guitar-clean"}, {"machine": "delay2", "preset": "slapback"}, {"machine": "verb2", "preset": "small-room"}]}], "macros": [{"name": "PICKUP", "value": 0.5}, {"name": "TONE", "value": 0.6}, {"name": "ECHO", "value": 0.3}, {"name": "ROOM", "value": 0.35}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}], "chain": 0, "maps": [{"macro": 1, "lane": 1, "control": "gt-sel", "min": 0, "max": 2}, {"macro": 2, "lane": 1, "control": "gt-tone", "min": 1500, "max": 9000}, {"macro": 2, "lane": 1, "insert": 1, "control": "amp-treble", "min": 0.3, "max": 0.75}, {"macro": 3, "lane": 1, "insert": 2, "control": "delay-mix", "min": 0, "max": 0.4}, {"macro": 4, "lane": 1, "insert": 3, "control": "verb-mix", "min": 0, "max": 0.45}]} diff --git a/machines/rack/presets/fretwire-crunch-rig.preset b/machines/rack/presets/fretwire-crunch-rig.preset new file mode 100644 index 00000000..0a3359a4 --- /dev/null +++ b/machines/rack/presets/fretwire-crunch-rig.preset @@ -0,0 +1 @@ +{"slab": "preset", "schema": 1, "machine": "rack", "note": "Fretwire into a crunch amp. DRIVE turns the gain up and the amp's level down with it, so the rig holds its loudness.", "rack": 2, "kind": "instrument", "lanes": [{"name": "CRUNCH", "instrument": {"machine": "guitar", "preset": "p90-crunch"}, "effects": [{"machine": "amp2", "preset": "guitar-crunch"}, {"machine": "delay2", "preset": "slapback"}, {"machine": "verb2", "preset": "small-room"}]}], "macros": [{"name": "PICKUP", "value": 1.0}, {"name": "DRIVE", "value": 0.4}, {"name": "TONE", "value": 0.55}, {"name": "ROOM", "value": 0.3}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}, {"name": "", "value": 0}], "chain": 0, "maps": [{"macro": 1, "lane": 1, "control": "gt-sel", "min": 0, "max": 2}, {"macro": 2, "lane": 1, "insert": 1, "control": "amp-gain", "min": 0.2, "max": 0.95}, {"macro": 2, "lane": 1, "insert": 1, "control": "amp-level", "min": 0, "max": -6}, {"macro": 3, "lane": 1, "control": "gt-tone", "min": 1500, "max": 9000}, {"macro": 3, "lane": 1, "insert": 1, "control": "amp-treble", "min": 0.3, "max": 0.75}, {"macro": 4, "lane": 1, "insert": 3, "control": "verb-mix", "min": 0, "max": 0.45}]} diff --git a/songs/rack_rigs.py b/songs/rack_rigs.py new file mode 100644 index 00000000..bf0b0748 --- /dev/null +++ b/songs/rack_rigs.py @@ -0,0 +1,55 @@ +"""Rack Rigs: a short demo of racks built from slabkit (docs/42). +Original material. + +96 BPM, A minor. KEYS is a rack built here: a Rhodes and a Juno pad on +one track, the pad playing only above C4, with a WASH macro that brings +the pad up and the Rhodes' tine bark down. BASS is the bass-di-amp rig +from its preset, and DRUMS go through the parallel-squash effect rack. +The automation rides WASH across the song and reaches inside the bass +rig to the amp's gain (lane 1's insert 1, a rack inside the rack). + + PYTHONPATH=tools python3 songs/rack_rigs.py + zig-out/bin/slab songs/rack_rigs.slab +""" +from slabkit import Song, fx, rack + +song = Song("Rack Rigs", bpm=96, key="A minor") + +intro = song.section("intro", 4) +verse = song.section("verse", 8) +lift = song.section("lift", 4) + +PROG = "Am9 Fmaj7 Dm9 E7sus4" + +# ── keys: Rhodes everywhere, a pad on top of the register ────────────── +keys = rack("Keys") +ep = keys.lane("EP", "rhodes", "mellow") +pad = keys.lane("PAD", "juno2", "lush-pad", keys=(60, 127), level=-6) +wash = keys.macro(1, "WASH", 0.3) +keys.map(wash, pad, "level", min=-40, max=0) +keys.map(wash, ep, "level", min=0, max=-8) +kt = song.track("KEYS", keys, volume=0.6, fx=[fx("verb2", "long-melancholy-verb", mix=0.18)]) +for sec in (intro, verse, lift): + kt.clip(sec).chords(PROG, near=60, voices=4, vel=78) +kt.automate("rk-m1", (0, 0.1), (verse.end, 0.6), (lift.end, 1.0)) + +# ── bass: the DI + amp rig, as saved ─────────────────────────────────── +bass = song.track("BASS", "rack", "bass-di-amp", volume=0.75) +for sec in (verse, lift): + bass.clip(sec).bass(PROG, pattern="x..x..x.x...x...", octave=2) +# The amp's gain inside the inner effect rack: lane 1's first insert is +# the fxrack, whose lane 2 has the amp as insert 1. +bass.automate("inst/1.1/2.1:amp-gain", (verse.start, 0.3), (lift.end, 0.8)) + +# ── drums through the parallel squash rack ───────────────────────────── +drums = song.track("DRUMS", "drum2", "dry-punch-kit", volume=0.7, fx=[fx("fxrack", "parallel-squash")]) +for sec in (verse, lift): + drums.clip(sec).drums({ + "kick": "x.......x.x.....", + "snare": "....x.......x...", + "ch": "x.x.x.x.x.x.x.x.", + }) + +song.save() +if __name__ == "__main__": + song.render(stems=True) diff --git a/songs/rack_rigs.slab/project.json b/songs/rack_rigs.slab/project.json new file mode 100644 index 00000000..e762e0a4 --- /dev/null +++ b/songs/rack_rigs.slab/project.json @@ -0,0 +1,2308 @@ +{ + "slab": "project", + "schema": 1, + "transport": { + "bpm": 96.0, + "loop": { + "on": false, + "start": 0.0, + "end": 64.0 + } + }, + "meter": [ + { + "bar": 0, + "num": 4, + "den": 4 + } + ], + "sections": [ + { + "beat": 0.0, + "name": "intro", + "color": 4 + }, + { + "beat": 16.0, + "name": "verse", + "color": 5 + }, + { + "beat": 48.0, + "name": "lift", + "color": 6 + } + ], + "end": 64.0, + "tracks": [ + { + "name": "KEYS", + "color": [ + 230, + 90, + 120 + ], + "volume": 0.6, + "pan": 0.0, + "mute": false, + "solo": false, + "instrument": { + "machine": "rack", + "params": { + "rk-m1": 0.3, + "rk-m2": 0.0, + "rk-m3": 0.0, + "rk-m4": 0.0, + "rk-m5": 0.0, + "rk-m6": 0.0, + "rk-m7": 0.0, + "rk-m8": 0.0, + "rk-chain": 0 + }, + "state": { + "rack": 2, + "kind": "instrument", + "lanes": [ + { + "name": "EP", + "instrument": { + "machine": "rhodes", + "params": { + "rd-tine-q": 0.85, + "rd-bar-q": 0.55, + "rd-bar-detune": 0.4, + "rd-pickup-drive": 0.15, + "rd-bark": 0.12, + "rd-bark-decay": 0.5, + "rd-warmth": 0.75, + "rd-damper": 0.5, + "rd-level": 1.0, + "rd-voicing": 0.2 + }, + "preset": "mellow" + }, + "effects": [] + }, + { + "name": "PAD", + "instrument": { + "machine": "juno2", + "params": { + "jn-lfo-rate": 0.8, + "jn-vib": 0.1, + "jn-range": 1, + "jn-saw": 1, + "jn-pulse": 1, + "jn-pwm": 0.55, + "jn-pwmod": 1, + "jn-sub": 0.35, + "jn-noise": 0, + "jn-detune": 0.35, + "jn-hpf": 1, + "jn-cutoff": 900, + "jn-res": 0.05, + "jn-env": 0.234, + "jn-lfovcf": 0.074, + "jn-kybd": 0.6, + "jn-atk": 0.6, + "jn-dec": 1.2, + "jn-sus": 0.75, + "jn-rel": 1.8, + "jn-vca-mode": 0, + "jn-level": 0.148 + }, + "preset": "lush-pad" + }, + "effects": [], + "lo": 60, + "hi": 127, + "level": -6 + } + ], + "macros": [ + { + "name": "WASH", + "value": 0.3 + }, + { + "name": "", + "value": 0.0 + }, + { + "name": "", + "value": 0.0 + }, + { + "name": "", + "value": 0.0 + }, + { + "name": "", + "value": 0.0 + }, + { + "name": "", + "value": 0.0 + }, + { + "name": "", + "value": 0.0 + }, + { + "name": "", + "value": 0.0 + } + ], + "chain": 0, + "maps": [ + { + "macro": 1, + "lane": 2, + "target": "level", + "min": -40, + "max": 0 + }, + { + "macro": 1, + "lane": 1, + "target": "level", + "min": 0, + "max": -8 + } + ] + } + }, + "effects": [ + { + "machine": "verb2", + "params": { + "verb-predelay": 0.06, + "verb-decay": 40.0, + "verb-damp": 4000, + "verb-tone": 7500, + "verb-mix": 0.18, + "verb-mod": 14 + }, + "bypass": false, + "preset": "long-melancholy-verb" + } + ], + "clips": [ + { + "type": "note", + "name": "intro", + "start": 0.0, + "len": 16.0, + "notes": [ + { + "pitch": 57, + "start": 0.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 59, + "start": 0.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 60, + "start": 0.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 64, + "start": 0.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 57, + "start": 4.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 60, + "start": 4.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 64, + "start": 4.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 65, + "start": 4.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 57, + "start": 8.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 60, + "start": 8.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 62, + "start": 8.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 64, + "start": 8.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 57, + "start": 12.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 59, + "start": 12.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 62, + "start": 12.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 64, + "start": 12.0, + "len": 3.88, + "vel": 78 + } + ] + }, + { + "type": "note", + "name": "verse", + "start": 16.0, + "len": 32.0, + "notes": [ + { + "pitch": 57, + "start": 0.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 59, + "start": 0.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 60, + "start": 0.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 64, + "start": 0.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 57, + "start": 4.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 60, + "start": 4.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 64, + "start": 4.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 65, + "start": 4.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 57, + "start": 8.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 60, + "start": 8.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 62, + "start": 8.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 64, + "start": 8.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 57, + "start": 12.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 59, + "start": 12.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 62, + "start": 12.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 64, + "start": 12.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 57, + "start": 16.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 59, + "start": 16.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 60, + "start": 16.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 64, + "start": 16.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 57, + "start": 20.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 60, + "start": 20.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 64, + "start": 20.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 65, + "start": 20.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 57, + "start": 24.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 60, + "start": 24.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 62, + "start": 24.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 64, + "start": 24.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 57, + "start": 28.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 59, + "start": 28.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 62, + "start": 28.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 64, + "start": 28.0, + "len": 3.88, + "vel": 78 + } + ] + }, + { + "type": "note", + "name": "lift", + "start": 48.0, + "len": 16.0, + "notes": [ + { + "pitch": 57, + "start": 0.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 59, + "start": 0.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 60, + "start": 0.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 64, + "start": 0.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 57, + "start": 4.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 60, + "start": 4.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 64, + "start": 4.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 65, + "start": 4.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 57, + "start": 8.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 60, + "start": 8.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 62, + "start": 8.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 64, + "start": 8.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 57, + "start": 12.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 59, + "start": 12.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 62, + "start": 12.0, + "len": 3.88, + "vel": 78 + }, + { + "pitch": 64, + "start": 12.0, + "len": 3.88, + "vel": 78 + } + ] + } + ], + "automation": [ + { + "target": "inst:rk-m1", + "points": [ + [ + 0, + 0.1 + ], + [ + 48.0, + 0.6 + ], + [ + 64.0, + 1.0 + ] + ] + } + ] + }, + { + "name": "BASS", + "color": [ + 64, + 206, + 174 + ], + "volume": 0.75, + "pan": 0.0, + "mute": false, + "solo": false, + "instrument": { + "machine": "rack", + "params": { + "rk-m1": 0.5, + "rk-m2": 0.4, + "rk-m3": 0.4, + "rk-m4": 0, + "rk-m5": 0, + "rk-m6": 0, + "rk-m7": 0, + "rk-m8": 0, + "rk-chain": 0 + }, + "preset": "bass-di-amp", + "state": { + "rack": 2, + "kind": "instrument", + "lanes": [ + { + "name": "BASS", + "instrument": { + "machine": "bass", + "preset": "p-bass" + }, + "effects": [ + { + "machine": "fxrack", + "state": { + "rack": 2, + "kind": "effect", + "lanes": [ + { + "name": "DI", + "effects": [ + { + "machine": "comp2", + "preset": "bass-leveler" + } + ] + }, + { + "name": "AMP", + "effects": [ + { + "machine": "amp2", + "preset": "bass-vintage-15" + } + ] + } + ], + "macros": [ + { + "name": "BLEND", + "value": 0.5 + }, + { + "name": "GROWL", + "value": 0.4 + }, + { + "name": "PUNCH", + "value": 0.4 + }, + { + "name": "", + "value": 0 + }, + { + "name": "", + "value": 0 + }, + { + "name": "", + "value": 0 + }, + { + "name": "", + "value": 0 + }, + { + "name": "", + "value": 0 + } + ], + "chain": 0, + "maps": [ + { + "macro": 1, + "lane": 1, + "target": "level", + "min": -40, + "max": 0, + "formula": "clamp(2 - 2*x, 0, 1)" + }, + { + "macro": 1, + "lane": 2, + "target": "level", + "min": -40, + "max": 0, + "formula": "clamp(2*x, 0, 1)" + }, + { + "macro": 2, + "lane": 2, + "insert": 1, + "control": "amp-gain", + "min": 0.15, + "max": 0.85 + }, + { + "macro": 3, + "lane": 1, + "insert": 1, + "control": "comp-thresh", + "min": -10, + "max": -32 + } + ] + } + } + ] + } + ], + "macros": [ + { + "name": "BLEND", + "value": 0.5 + }, + { + "name": "GROWL", + "value": 0.4 + }, + { + "name": "PUNCH", + "value": 0.4 + }, + { + "name": "", + "value": 0 + }, + { + "name": "", + "value": 0 + }, + { + "name": "", + "value": 0 + }, + { + "name": "", + "value": 0 + }, + { + "name": "", + "value": 0 + } + ], + "chain": 0, + "maps": [ + { + "macro": 1, + "lane": 1, + "insert": 1, + "control": "rk-m1", + "min": 0, + "max": 1 + }, + { + "macro": 2, + "lane": 1, + "insert": 1, + "control": "rk-m2", + "min": 0, + "max": 1 + }, + { + "macro": 3, + "lane": 1, + "insert": 1, + "control": "rk-m3", + "min": 0, + "max": 1 + } + ] + } + }, + "effects": [], + "clips": [ + { + "type": "note", + "name": "verse", + "start": 16.0, + "len": 32.0, + "notes": [ + { + "pitch": 45, + "start": 0.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 45, + "start": 0.75, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 45, + "start": 1.5, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 45, + "start": 2.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 45, + "start": 3.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 41, + "start": 4.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 41, + "start": 4.75, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 41, + "start": 5.5, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 41, + "start": 6.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 41, + "start": 7.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 38, + "start": 8.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 38, + "start": 8.75, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 38, + "start": 9.5, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 38, + "start": 10.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 38, + "start": 11.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 40, + "start": 12.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 40, + "start": 12.75, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 40, + "start": 13.5, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 40, + "start": 14.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 40, + "start": 15.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 45, + "start": 16.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 45, + "start": 16.75, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 45, + "start": 17.5, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 45, + "start": 18.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 45, + "start": 19.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 41, + "start": 20.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 41, + "start": 20.75, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 41, + "start": 21.5, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 41, + "start": 22.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 41, + "start": 23.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 38, + "start": 24.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 38, + "start": 24.75, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 38, + "start": 25.5, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 38, + "start": 26.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 38, + "start": 27.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 40, + "start": 28.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 40, + "start": 28.75, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 40, + "start": 29.5, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 40, + "start": 30.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 40, + "start": 31.0, + "len": 0.2125, + "vel": 108 + } + ] + }, + { + "type": "note", + "name": "lift", + "start": 48.0, + "len": 16.0, + "notes": [ + { + "pitch": 45, + "start": 0.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 45, + "start": 0.75, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 45, + "start": 1.5, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 45, + "start": 2.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 45, + "start": 3.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 41, + "start": 4.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 41, + "start": 4.75, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 41, + "start": 5.5, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 41, + "start": 6.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 41, + "start": 7.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 38, + "start": 8.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 38, + "start": 8.75, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 38, + "start": 9.5, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 38, + "start": 10.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 38, + "start": 11.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 40, + "start": 12.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 40, + "start": 12.75, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 40, + "start": 13.5, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 40, + "start": 14.0, + "len": 0.2125, + "vel": 108 + }, + { + "pitch": 40, + "start": 15.0, + "len": 0.2125, + "vel": 108 + } + ] + } + ], + "automation": [ + { + "target": "inst/1.1/2.1:amp-gain", + "points": [ + [ + 16.0, + 0.3 + ], + [ + 64.0, + 0.8 + ] + ] + } + ] + }, + { + "name": "DRUMS", + "color": [ + 150, + 110, + 230 + ], + "volume": 0.7, + "pan": 0.0, + "mute": false, + "solo": false, + "instrument": { + "machine": "drum2", + "params": { + "kick-tune": 54, + "kick-sweep": 5, + "kick-bend": 0.04, + "kick-decay": 0.24, + "kick-click": 0.45, + "kick-drive": 1.5, + "kick-level": 0.9, + "snare-tune": 205, + "snare-decay": 0.13, + "snare-snap": 0.7, + "snare-sdec": 0.08, + "snare-tone": 2600, + "snare-level": 0.85, + "clap-tone": 1200, + "clap-spread": 0.01, + "clap-decay": 0.14, + "clap-level": 0.8, + "hat-tune": 1.05, + "hat-tone": 1.3, + "hat-chdec": 0.035, + "hat-ohdec": 0.4, + "hat-level": 0.85, + "tom-tune": 120, + "tom-sweep": 1.2, + "tom-decay": 0.25, + "tom-drive": 1.4, + "tom-level": 0.82, + "master-accent": 0.55, + "master-drive": 1.2, + "master-level": 0.9 + }, + "preset": "dry-punch-kit" + }, + "effects": [ + { + "machine": "fxrack", + "params": { + "rk-m1": 0.5, + "rk-m2": 0, + "rk-m3": 0, + "rk-m4": 0, + "rk-m5": 0, + "rk-m6": 0, + "rk-m7": 0, + "rk-m8": 0, + "rk-chain": 0 + }, + "bypass": false, + "state": { + "rack": 2, + "kind": "effect", + "lanes": [ + { + "name": "DRY", + "effects": [] + }, + { + "name": "SQUASH", + "effects": [ + { + "machine": "comp2", + "preset": "drum-smash" + }, + { + "machine": "sat2", + "preset": "drum-crush" + } + ] + } + ], + "macros": [ + { + "name": "AMOUNT", + "value": 0.5 + }, + { + "name": "", + "value": 0 + }, + { + "name": "", + "value": 0 + }, + { + "name": "", + "value": 0 + }, + { + "name": "", + "value": 0 + }, + { + "name": "", + "value": 0 + }, + { + "name": "", + "value": 0 + }, + { + "name": "", + "value": 0 + } + ], + "chain": 0, + "maps": [ + { + "macro": 1, + "lane": 2, + "target": "level", + "min": -40, + "max": 0, + "formula": "x^0.5" + } + ] + }, + "preset": "parallel-squash" + } + ], + "clips": [ + { + "type": "note", + "name": "verse", + "start": 16.0, + "len": 32.0, + "notes": [ + { + "pitch": 36, + "start": 0.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 0.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 0.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 38, + "start": 1.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 1.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 1.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 2.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 2.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 2.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 2.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 38, + "start": 3.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 3.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 3.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 4.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 4.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 4.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 38, + "start": 5.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 5.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 5.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 6.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 6.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 6.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 6.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 38, + "start": 7.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 7.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 7.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 8.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 8.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 8.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 38, + "start": 9.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 9.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 9.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 10.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 10.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 10.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 10.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 38, + "start": 11.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 11.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 11.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 12.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 12.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 12.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 38, + "start": 13.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 13.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 13.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 14.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 14.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 14.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 14.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 38, + "start": 15.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 15.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 15.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 16.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 16.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 16.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 38, + "start": 17.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 17.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 17.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 18.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 18.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 18.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 18.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 38, + "start": 19.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 19.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 19.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 20.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 20.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 20.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 38, + "start": 21.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 21.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 21.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 22.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 22.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 22.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 22.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 38, + "start": 23.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 23.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 23.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 24.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 24.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 24.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 38, + "start": 25.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 25.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 25.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 26.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 26.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 26.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 26.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 38, + "start": 27.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 27.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 27.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 28.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 28.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 28.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 38, + "start": 29.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 29.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 29.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 30.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 30.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 30.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 30.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 38, + "start": 31.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 31.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 31.5, + "len": 0.1, + "vel": 100 + } + ] + }, + { + "type": "note", + "name": "lift", + "start": 48.0, + "len": 16.0, + "notes": [ + { + "pitch": 36, + "start": 0.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 0.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 0.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 38, + "start": 1.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 1.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 1.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 2.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 2.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 2.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 2.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 38, + "start": 3.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 3.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 3.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 4.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 4.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 4.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 38, + "start": 5.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 5.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 5.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 6.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 6.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 6.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 6.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 38, + "start": 7.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 7.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 7.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 8.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 8.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 8.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 38, + "start": 9.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 9.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 9.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 10.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 10.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 10.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 10.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 38, + "start": 11.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 11.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 11.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 12.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 12.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 12.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 38, + "start": 13.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 13.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 13.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 14.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 14.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 36, + "start": 14.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 14.5, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 38, + "start": 15.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 15.0, + "len": 0.1, + "vel": 100 + }, + { + "pitch": 42, + "start": 15.5, + "len": 0.1, + "vel": 100 + } + ] + } + ] + } + ], + "master": { + "volume": 1.0, + "pan": 0.0, + "subsonic": false, + "effects": [] + } +} \ No newline at end of file diff --git a/tools/slabkit/__init__.py b/tools/slabkit/__init__.py index 8fead889..2c3cb3de 100644 --- a/tools/slabkit/__init__.py +++ b/tools/slabkit/__init__.py @@ -14,10 +14,11 @@ from .rhythm import steps, euclid from .machines import machines, machine, presets, preset from .song import Song, Section, Track, Bus, Clip, fx +from .rack import Rack, rack from .analyze import analyze_wav __all__ = [ - "Song", "Section", "Track", "Clip", "fx", + "Song", "Section", "Track", "Clip", "fx", "Rack", "rack", "Key", "Chord", "note", "note_name", "SCALES", "steps", "euclid", "machines", "machine", "presets", "preset", diff --git a/tools/slabkit/machines.py b/tools/slabkit/machines.py index f2dbed5e..5abaa484 100644 --- a/tools/slabkit/machines.py +++ b/tools/slabkit/machines.py @@ -74,6 +74,8 @@ def __init__(self, d): self.note_pitch = d["note_pitch"] # The detector takes a sidechain key: fx(..., key=track) (docs/23). self.sidechain = d.get("sidechain", False) + # Rack and FX Rack (docs/42): built in Zig, set up with slabkit.rack. + self.native = d.get("native", False) self.note_labels = {n["label"].lower(): n["pitch"] for n in d["note_labels"]} self.params = {p["id"]: Param(p) for p in d["params"]} # Most machines prefix every id ("jn-cutoff", "comp-thresh"); knowing @@ -218,7 +220,7 @@ def _preset_file(mid, name): def preset(mid, name): """A preset's params, keyed by param id. `name` may omit the bank when it is unique ("glass-lead" finds "slab/glass-lead").""" - return dict(_preset_file(mid, name)["params"]) + return dict(_preset_file(mid, name).get("params") or {}) def preset_assets(mid, name): diff --git a/tools/slabkit/rack.py b/tools/slabkit/rack.py new file mode 100644 index 00000000..1d818d5b --- /dev/null +++ b/tools/slabkit/rack.py @@ -0,0 +1,264 @@ +"""Racks (docs/42): lanes of chains under eight macros, as Python. + + amp = rack("Bass rig") + di = amp.lane("DI", "bass", preset="p-bass", fx=[fx("comp2", "bass-leveler")]) + cab = amp.lane("AMP", "bass", preset="p-bass", fx=[fx("amp2", "bass-vintage-15")]) + blend = amp.macro(1, "BLEND", 0.5) + amp.map(blend, di, "level", min=-40, max=0, formula="clamp(2 - 2*x, 0, 1)") + amp.map(blend, cab, "level", min=-40, max=0, formula="clamp(2*x, 0, 1)") + amp.map(amp.macro(2, "GROWL", 0.4), cab, "amp-gain", insert=1, min=0.15, max=0.85) + song.track("Bass", amp) + +An effect rack is `rack(kind="effect")`, put in fx=[…] like any insert; +its lanes have no instrument (`lane("DRY")`, `lane("WET", fx=[…])`). +A saved rig loads with `rack(preset="bass-di-amp")`, or as a track's +machine and preset: `song.track("Bass", "rack", "bass-di-amp")`. +""" +import copy +import json + +from .machines import SlabError, machine, preset as load_preset, preset_assets, preset_name, _preset_files + +MAX_LANES = 16 +MAX_MACROS = 8 +MAX_DEPTH = 3 +# What a preset file holds besides the rack's own state. +PRESET_KEYS = ("slab", "schema", "machine", "note", "params") + + +class Lane: + def __init__(self, rack, name, machine_id, preset, params, fx, keys, vel, chain, transpose, level, pan, poly, mute): + self.rack = rack + self.name = name + self.machine_id = machine_id + self.preset = preset + self.params = params or {} + self.fx = list(fx) + self.keys, self.vel, self.chain = keys, vel, chain + self.transpose, self.level, self.pan, self.poly, self.mute = transpose, level, pan, poly, mute + + @property + def number(self): + """The lane's place, from 1: what maps and automation paths name.""" + return self.rack.lanes.index(self) + 1 + + def _machine(self, slot): + """The machine at slot 0 (the instrument) or the n-th insert, as + (machine, rack or None).""" + if slot == 0: + if self.machine_id is None: + raise SlabError(f"rack {self.rack.name} lane {self.name}: an effect rack's lane has no instrument") + if isinstance(self.machine_id, Rack): + return machine(self.machine_id.machine_id), self.machine_id + return machine(self.machine_id), None + if not 1 <= slot <= len(self.fx): + raise SlabError(f"rack {self.rack.name} lane {self.name}: insert {slot}, but the lane has {len(self.fx)}") + f = self.fx[slot - 1] + return machine(f.machine_id), f.rack + + def build(self, where, depth): + out = {"name": self.name} + if self.rack.kind == "instrument": + out["instrument"] = _machine_json(self.machine_id, self.preset, self.params, f"{where} lane {self.name}", depth) + out["effects"] = [f.build(f"{where} lane {self.name} fx {i}", depth=depth + 1) for i, f in enumerate(self.fx)] + for key, rng, hi in (("", self.keys, 127), ("v", self.vel, 127), ("c", self.chain, 127)): + if rng is not None: + lo_, hi_ = rng + if not 0 <= lo_ <= hi_ <= hi: + raise SlabError(f"{where} lane {self.name}: zone {rng} must be lo ≤ hi within 0..127") + out[key + "lo"], out[key + "hi"] = lo_, hi_ + for key, v in (("transpose", self.transpose), ("level", self.level), ("pan", self.pan), ("poly", self.poly)): + if v: + out[key] = v + if self.mute: + out["mute"] = True + return out + + +class Macro: + def __init__(self, number, name, value): + self.number, self.name, self.value = number, name, value + + +class Rack: + """An instrument rack (kind="instrument", a track's machine) or an + effect rack (kind="effect", an insert).""" + + def __init__(self, name="Rack", kind="instrument", chain=0, collapsed=False): + if kind not in ("instrument", "effect"): + raise SlabError(f"rack {name}: kind is 'instrument' or 'effect', not {kind!r}") + self.name = name + self.kind = kind + self.lanes = [] + self.macros = [Macro(i + 1, "", 0.0) for i in range(MAX_MACROS)] + self.chain = chain + self.collapsed = collapsed + self.maps = [] + # A rig loaded from a preset keeps its saved state as is. + self._state = None + self.preset = None + + @property + def machine_id(self): + return "rack" if self.kind == "instrument" else "fxrack" + + def lane(self, name, machine_id=None, preset=None, params=None, fx=(), keys=None, vel=None, chain=None, + transpose=0, level=0.0, pan=0.0, poly=0, mute=False): + """A lane: an instrument (another rack works too) and its inserts. + keys/vel/chain are (lo, hi) zones 0..127 — notes, velocities, and + the CHAIN value that play this lane. level is dB, transpose + semitones, poly the lane's voice cap (0 = the machine's).""" + self._own() + if len(self.lanes) >= MAX_LANES: + raise SlabError(f"rack {self.name}: max {MAX_LANES} lanes") + if self.kind == "instrument" and machine_id is None: + raise SlabError(f"rack {self.name} lane {name}: an instrument rack's lane needs an instrument") + if self.kind == "effect" and machine_id is not None: + raise SlabError(f"rack {self.name} lane {name}: an effect rack's lanes are inserts only (fx=[…])") + if machine_id is not None and not isinstance(machine_id, Rack) and machine(machine_id).kind != "instrument": + raise SlabError(f"rack {self.name} lane {name}: {machine_id} is an effect; put it in fx=[…]") + if isinstance(machine_id, Rack) and machine_id.kind != "instrument": + raise SlabError(f"rack {self.name} lane {name}: an effect rack goes in fx=[…]") + l = Lane(self, name, machine_id, preset, params, fx, keys, vel, chain, transpose, level, pan, poly, mute) + self.lanes.append(l) + return l + + def macro(self, number, name=None, value=None): + """Macro 1..8: name it, set its value (0..1); returns it for map().""" + if not 1 <= number <= MAX_MACROS: + raise SlabError(f"rack {self.name}: macros are 1..{MAX_MACROS}, not {number}") + self._own() + m = self.macros[number - 1] + if name is not None: + m.name = name + if value is not None: + if not 0 <= value <= 1: + raise SlabError(f"rack {self.name}: macro {number} value {value} is outside [0, 1]") + m.value = value + return m + + def map(self, macro, lane, target, insert=0, min=0.0, max=1.0, formula=None): + """Macro → target. target is "level" (dB), "pan", or a control of + the lane's instrument (insert=0) or n-th insert ("amp-gain"; the + machine prefix may be left off). min/max are in the control's units; + formula shapes x (the macro, 0..1) first: "clamp(2*x, 0, 1)".""" + self._own() + number = macro.number if isinstance(macro, Macro) else macro + if not 1 <= number <= MAX_MACROS: + raise SlabError(f"rack {self.name}: macros are 1..{MAX_MACROS}, not {number}") + if lane not in self.lanes: + raise SlabError(f"rack {self.name}: map to a lane of this rack") + out = {"macro": number, "lane": lane.number} + if target in ("level", "pan"): + out["target"] = target + else: + m, _ = lane._machine(insert) + pid = m.resolve(target) + p = m.params[pid] + where = f"rack {self.name} map {pid}" + p.coerce(min, where), p.coerce(max, where) + if insert: + out["insert"] = insert + out["control"] = pid + out["min"], out["max"] = min, max + if formula: + out["formula"] = formula + self.maps.append((lane, out)) + return self + + def _own(self): + if self._state is not None: + raise SlabError(f"rack {self.name} is the preset {self.preset!r}; build a new rack() to edit lanes") + + @property + def params(self): + """The rack's controls as a project stores them.""" + state = self._state + macros = state.get("macros", []) if state else [{"value": m.value} for m in self.macros] + out = {f"rk-m{i + 1}": m.get("value", 0) for i, m in enumerate(macros[:MAX_MACROS])} + out["rk-chain"] = state.get("chain", 0) if state else self.chain + return out + + def build(self, where="rack", depth=1): + if depth > MAX_DEPTH: + raise SlabError(f"{where}: racks nest {MAX_DEPTH} deep at most") + if self._state is not None: + return copy.deepcopy(self._state) + if not self.lanes: + raise SlabError(f"{where}: rack {self.name} has no lanes") + maps = [] + for lane, m in self.maps: + maps.append({**m, "lane": lane.number}) + return { + "rack": 2, "kind": self.kind, + "lanes": [l.build(where, depth) for l in self.lanes], + "macros": [{"name": m.name, "value": m.value} for m in self.macros], + "chain": self.chain, "maps": maps, + **({"collapsed": True} if self.collapsed else {}), + } + + # Automation paths (docs/42 §Automation): "1.2:amp-gain" is lane 1's + # second insert; "1.0:..." the lane's instrument; "1.1/2.0:..." goes + # one rack deeper. + def resolve_path(self, steps, pid, where): + """The machine and param id at a path, walked through the saved + state, so a rig loaded from a preset resolves too.""" + state = self.build(where) + for depth, (lane_n, slot) in enumerate(steps): + lanes = state.get("lanes", []) + if not 1 <= lane_n <= len(lanes): + raise SlabError(f"{where}: lane {lane_n}, but the rack has {len(lanes)}") + lane = lanes[lane_n - 1] + if slot == 0: + if "instrument" not in lane: + raise SlabError(f"{where}: an effect rack's lane has no instrument (place 0)") + entry = lane["instrument"] + else: + fxs = lane.get("effects", []) + if not 1 <= slot <= len(fxs): + raise SlabError(f"{where}: insert {slot}, but lane {lane_n} has {len(fxs)}") + entry = fxs[slot - 1] + if depth < len(steps) - 1: + if "state" not in entry: + raise SlabError(f"{where}: lane {lane_n} place {slot} is {entry['machine']}, not a rack") + state = entry["state"] + m = machine(entry["machine"]) + return m, m.resolve(pid) + + +def rack(name="Rack", kind="instrument", preset=None, chain=0, collapsed=False): + """A new rack, or a saved rig with preset= (from machines/rack/presets + or machines/fxrack/presets).""" + r = Rack(name, kind, chain, collapsed) + if preset: + r._state = rack_preset_state(r.machine_id, preset) + r.preset = preset_name(r.machine_id, preset) + r.kind = r._state.get("kind", kind) + return r + + +def rack_preset_state(mid, name): + with open(_preset_files(mid)[preset_name(mid, name)]) as f: + data = json.load(f) + return {k: v for k, v in data.items() if k not in PRESET_KEYS} + + +def _machine_json(machine_id, preset, params, where, depth): + """A lane's instrument as the rack saves it.""" + if isinstance(machine_id, Rack): + return {"machine": machine_id.machine_id, "params": machine_id.params, "state": machine_id.build(where, depth + 1)} + m = machine(machine_id) + if m.native: + r = rack(kind=m.kind, preset=preset) if preset else None + if r is None: + raise SlabError(f"{where}: a {m.id} needs a Rack object or a preset") + return _machine_json(r, None, None, where, depth) + values = dict(load_preset(m.id, preset)) if preset else {} + values.update(m.params_from(params or {}, where)) + out = {"machine": m.id, "params": values} + if preset: + out["preset"] = preset_name(m.id, preset) + assets = preset_assets(m.id, preset) + if assets: + out["assets"] = assets + return out diff --git a/tools/slabkit/song.py b/tools/slabkit/song.py index 0112e03c..8ba82bb1 100644 --- a/tools/slabkit/song.py +++ b/tools/slabkit/song.py @@ -17,6 +17,7 @@ from . import rhythm from .machines import ROOT, SLAB, SlabError, library_path, machine, ref as file_ref, preset as load_preset, preset_assets, preset_name +from .rack import Rack, rack as make_rack from .theory import Chord, Key, guitar_voicing, note, voice_lead MAX_TRACKS = 32 # buses count (docs/23) @@ -60,6 +61,12 @@ def __repr__(self): class FX: def __init__(self, machine_id, preset=None, bypass=False, key=None, **params): + # An effect rack (docs/42): a Rack, or "fxrack" with a rig preset. + self.rack = machine_id if isinstance(machine_id, Rack) else None + if self.rack is None and machine_id == "fxrack" and preset: + self.rack = make_rack(kind="effect", preset=preset) + if self.rack is not None: + machine_id, preset = self.rack.machine_id, None self.machine_id = machine_id self.preset = preset self.bypass = bypass @@ -67,13 +74,21 @@ def __init__(self, machine_id, preset=None, bypass=False, key=None, **params): self.key = key self.params = params - def build(self, where, index=None): + def build(self, where, index=None, depth=1): m = machine(self.machine_id) if m.kind != "effect": raise SlabError(f"{where}: {self.machine_id} is an instrument, not an effect") + if m.native and self.rack is None: + raise SlabError(f"{where}: an fxrack takes a rack(kind='effect') or a preset") values = dict(load_preset(m.id, self.preset)) if self.preset else {} + if self.rack is not None: + values = self.rack.params values.update(m.params_from(self.params, where)) out = {"machine": m.id, "params": values, "bypass": self.bypass} + if self.rack is not None: + out["state"] = self.rack.build(where, depth) + if self.rack.preset: + out["preset"] = self.rack.preset if self.preset: out["preset"] = preset_name(m.id, self.preset) if self.key is not None: @@ -601,11 +616,24 @@ def __init__(self, song, name, machine_id, preset=None, params=None, volume=0.8, # and sends as (bus, linear level, pre). self.output = output self.sends = [] + # An instrument rack (docs/42): a Rack, or "rack" with a rig preset. + self.rack = machine_id if isinstance(machine_id, Rack) else None + if self.rack is None and machine_id == "rack": + if not preset: + raise SlabError(f"track {name}: a rack track takes a rack(...) or a preset") + self.rack = make_rack(preset=preset) + if self.rack is not None: + if self.rack.kind != "instrument": + raise SlabError(f"track {name}: an effect rack goes in fx=[…]") + machine_id, preset = self.rack.machine_id, None self.machine = machine(machine_id) if self.machine.kind != "instrument": raise SlabError(f"track {name}: {machine_id} is an effect; put it in fx=[…]") self.preset = preset_name(machine_id, preset) if preset else None self.params = dict(load_preset(machine_id, preset)) if preset else {} + if self.rack is not None: + self.params = self.rack.params + self.preset = self.rack.preset if params: self.set(**params) self.volume = volume @@ -724,6 +752,24 @@ def check(v): raise SlabError(f"{where}: pan {v} is outside [-1, 1]") return v return "pan", check, False + m = re.fullmatch(r"(inst|fx\d+)((?:/\d+\.\d+)+):(.+)", target) + if m: + # Inside a rack (docs/42 §Automation): lane.place steps, place 0 + # the lane's instrument, n its n-th insert. + head, path = m.group(1), m.group(2) + if head == "inst": + outer = self.rack + else: + i = int(head[2:]) + if i >= len(self.fx): + raise SlabError(f"{where}: {target} names effect {i}, but the track has {len(self.fx)}") + outer = self.fx[i].rack + if outer is None: + raise SlabError(f"{where}: {target} steps into {head}, which isn't a rack") + steps = [tuple(int(x) for x in st.split(".")) for st in path.strip("/").split("/")] + mach, pid = outer.resolve_path(steps, m.group(3), where) + param = mach.params[pid] + return f"{head}{path}:{pid}", (lambda v: param.coerce(v, where)), param.type != "float" m = re.fullmatch(r"fx(\d+):(.+)", target) if m: i = int(m.group(1)) @@ -967,7 +1013,8 @@ def build(self, index=None): **({"preset": self.preset} if self.preset else {}), **({"assets": self.assets} if self.assets else {}), **({"unison": self.unison} if self.unison else {}), - **({"zones": self.zones} if self.zones else {})}, + **({"zones": self.zones} if self.zones else {}), + **({"state": self.rack.build(where)} if self.rack is not None else {})}, "effects": [f.build(f"{where} fx {i}", index) for i, f in enumerate(self.fx)], "clips": [c.to_json() for c in sorted(self.clips, key=lambda c: c.start)], **({"automation": _lanes_json(self.lanes)} if self.lanes else {}), @@ -1017,7 +1064,7 @@ def audio(self, *a, **k): self._no("clips") def _auto_target(self, target): - if target not in ("volume", "pan") and not re.fullmatch(r"fx\d+:.+", target): + if target not in ("volume", "pan") and not re.fullmatch(r"fx\d+[:/].+", target): self._no(f"instrument param {target!r} to automate") return super()._auto_target(target)