From 6dac78bc1350b9f29a3a8157226c4ed83f800d43 Mon Sep 17 00:00:00 2001 From: kosmar Date: Thu, 23 Jul 2026 00:09:05 +0200 Subject: [PATCH 01/11] feat: add Golden Gate app Fibonacci-spaced gate/pitch generator (app ID 30) with speed override, output modes, and configurator manual including gesture tables. --- configurator/src/components/ManualTab.tsx | 78 ++ configurator/src/components/manual/Apps.tsx | 3 +- .../src/components/manual/ManualApp.tsx | 4 +- configurator/src/components/manual/Md.tsx | 178 +++- faderpunk/src/apps/fibonacci_gate.rs | 765 ++++++++++++++++++ faderpunk/src/apps/mod.rs | 1 + 6 files changed, 992 insertions(+), 37 deletions(-) create mode 100644 faderpunk/src/apps/fibonacci_gate.rs diff --git a/configurator/src/components/ManualTab.tsx b/configurator/src/components/ManualTab.tsx index 72abb5d91..7d9d0d2b8 100644 --- a/configurator/src/components/ManualTab.tsx +++ b/configurator/src/components/ManualTab.tsx @@ -1592,6 +1592,84 @@ On load, both registers are restored at the next phrase boundary so the recalled }, ], }, + { + appId: 30, + title: "Golden Gate", + description: "Fibonacci-spaced gates — successive ratios approach φ", + color: "Violet", + icon: "sequence-square", + params: [ + "MIDI Channel", + "MIDI Note", + "MIDI CC", + "GATE %", + "Speed", + "Color", + "MIDI Out", + ], + storage: [ + "Cycle length", + "Fibonacci depth", + "Muted", + "Reversed", + "Output mode", + "Speed override", + ], + text: `Golden Gate is a one-channel gate / pitch generator whose hit spacing comes from the Fibonacci sequence (1, 1, 2, 3, 5, 8, 13, and so on). Successive Fibonacci ratios converge on **φ** (the golden ratio ≈ 1.618), so as the pattern uses deeper gaps the rhythm feels increasingly “golden” — still built from whole-number steps, not continuous φ timing by default. + +#### What you hear + +At shallow depth the hits are denser and more regular; deeper Fibonacci values open larger holes, so the same cycle length feels sparser and more asymmetric. Reverse plays the gaps of *this* filled cycle backwards (not the whole table from its largest value). In pitch modes the jack carries 1V/oct while MIDI sends related notes — either chromatic Fibonacci intervals (12-TET) or near-φ intervals (~833 cents × gap), with MIDI using nearest note + pitch bend (±2 semitone bend range assumed). + +#### Gestures + +| Control | Action | +| --- | --- | +| **Fader** | Cycle length N in steps (bar-aligned; Fibonacci gaps fill, then reset) | +| **Shift + Fader** | Fibonacci depth — how many sequence values rotate | +| **Button + Fader** | Speed — up = 16th, middle = 8th, down = quarter (overrides Configurator **Speed**) | +| **Short press** | Reset to downbeat | +| **Long press** | Mute (no fader move) | +| **Shift + short** | Reverse this cycle’s gap order (Button LED: white↔off fade) | +| **Shift + long** | Cycle output mode: gate+note (app color) → gate+CC (orange) → 12-TET pitch (red) → φ pitch (pink) | + +#### Faders & LEDs + +| Control | Edits | Visual feedback | +| --- | --- | --- | +| **Fader** | Cycle length | **Top LED** = cycle progress in app color. **Bottom LED** flashes on each hit | +| **Shift + Fader** | Depth | No dedicated meter — Top stays on cycle progress while you edit | +| **Button + Fader** | Speed | No dedicated meter — Top stays on cycle progress while you edit | +| **Button LED** | — | Output mode color (app color / orange / red / pink); white↔off on reverse; off when muted |`, + channels: [ + { + jackTitle: "Gate / Pitch Out", + jackDescription: + "Note and CC modes: gate pulse. Pitch modes: 1V/oct CV (MIDI note in parallel).", + faderTitle: "Cycle length", + faderDescription: + "How many steps before the pattern wraps (about 8–32).", + faderPlusShiftTitle: "Fibonacci depth", + faderPlusShiftDescription: + "How far into 1, 1, 2, 3, 5 and beyond the gaps go — shallow = denser, deep = sparser.", + faderPlusFnTitle: "Speed", + faderPlusFnDescription: + "Upper third: 16th · Middle: 8th · Lower third: quarter. Overrides the Speed parameter.", + fnTitle: "Reset / Mute", + fnDescription: + "Short press: restart at the downbeat. Long press: mute (do not move the fader).", + fnPlusShiftTitle: "Reverse / Output mode", + fnPlusShiftDescription: + "Short: reverse this cycle’s gaps. Long: cycle Note → CC → 12-TET pitch → φ pitch.", + ledTop: "Progress through the cycle (app color)", + ledTopPlusShift: + "No depth meter — stays on cycle progress while Shift is held", + ledTopPlusFn: + "No speed meter — stays on cycle progress while the button is held", + ledBottom: "Flash on each hit", + }, + ], + }, ]; export const ManualTab = () => { diff --git a/configurator/src/components/manual/Apps.tsx b/configurator/src/components/manual/Apps.tsx index f6d471c26..c4bd8c52f 100644 --- a/configurator/src/components/manual/Apps.tsx +++ b/configurator/src/components/manual/Apps.tsx @@ -27,7 +27,8 @@ export const Apps = ({ apps }: Props) => ( button 2 mutes Output B)
  • - Long press (no shift) — AD Envelope, LFO, LFO+ + Long press (no shift) — AD Envelope, LFO, LFO+, Golden + Gate
  • Shift + long press on button 0 / 2 / 4 / 6 — Sequencer diff --git a/configurator/src/components/manual/ManualApp.tsx b/configurator/src/components/manual/ManualApp.tsx index b8cc66fa7..9155ce8c1 100644 --- a/configurator/src/components/manual/ManualApp.tsx +++ b/configurator/src/components/manual/ManualApp.tsx @@ -295,9 +295,9 @@ export const ManualApp = ({ app }: Props) => { ) : null} -

    +

    {app.text} -

    +
    { + if (!buf.length) return; + out.push({ kind: "md", text: buf.join("\n") }); + buf = []; + }; + + const isRow = (line: string) => /^\s*\|.*\|\s*$/.test(line); + const isSep = (line: string) => + /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line); + + const cells = (line: string) => + line + .trim() + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|") + .map((c) => c.trim()); + + let i = 0; + while (i < lines.length) { + if ( + isRow(lines[i]) && + i + 1 < lines.length && + isSep(lines[i + 1]) + ) { + flushMd(); + const headers = cells(lines[i]); + i += 2; + const rows: string[][] = []; + while (i < lines.length && isRow(lines[i]) && !isSep(lines[i])) { + rows.push(cells(lines[i])); + i++; + } + out.push({ kind: "table", headers, rows }); + continue; + } + buf.push(lines[i]); + i++; + } + flushMd(); + return out; +} + +const mdComponents = { + p: ({ children }: { children?: ReactNode }) => ( +

    {children}

    + ), + h4: ({ children }: { children?: ReactNode }) => ( +

    {children}

    + ), + strong: ({ children }: { children?: ReactNode }) => ( + {children} + ), + em: ({ children }: { children?: ReactNode }) => {children}, + code: ({ children }: { children?: ReactNode }) => ( + {children} + ), + ul: ({ children }: { children?: ReactNode }) => ( +
      {children}
    + ), + ol: ({ children }: { children?: ReactNode }) => ( +
      {children}
    + ), + a: ({ href, children }: { href?: string; children?: ReactNode }) => ( + + {children} + + ), +}; + +const InlineMd = ({ text }: { text: string }) => ( + + p: ({ children }) => <>{children}, + }} + > + {text} + +); + +const ManualTable = ({ + headers, + rows, +}: { + headers: string[]; + rows: string[][]; +}) => ( +
    + + + + {headers.map((h) => ( + + ))} + + + + {rows.map((row, ri) => ( + + {headers.map((_, ci) => ( + + ))} + + ))} + +
    + +
    + +
    +
    +); + export const Md = ({ children }: MdProps) => { if (typeof children !== "string") return <>{children}; + const segments = splitMarkdownTables(children); return ( - <>{children}, - h4: ({ children }) => ( -

    {children}

    - ), - strong: ({ children }) => ( - {children} - ), - em: ({ children }) => {children}, - code: ({ children }) => ( - - {children} - +
    + {segments.map((seg, i) => + seg.kind === "table" ? ( + + ) : ( + + {seg.text} + ), - ul: ({ children }) => ( -
      {children}
    - ), - ol: ({ children }) => ( -
      {children}
    - ), - a: ({ href, children }) => ( - - {children} - - ), - }} - > - {children} - + )} +
    ); }; diff --git a/faderpunk/src/apps/fibonacci_gate.rs b/faderpunk/src/apps/fibonacci_gate.rs new file mode 100644 index 000000000..13ea78deb --- /dev/null +++ b/faderpunk/src/apps/fibonacci_gate.rs @@ -0,0 +1,765 @@ +use embassy_futures::{ + join::{join, join5}, + select::{select, select3}, +}; +use embassy_sync::{blocking_mutex::raw::NoopRawMutex, signal::Signal}; +use heapless::Vec; +use serde::{Deserialize, Serialize}; + +use libfp::{ + ext::FromValue, latch::LatchLayer, AppIcon, Brightness, ClockDivision, Color, Config, MidiCc, + MidiChannel, MidiNote, MidiOut, Param, Range, Value, APP_MAX_PARAMS, +}; + +use crate::app::{ + App, AppParams, AppStorage, ClockEvent, Led, ManagedStorage, ParamStore, SceneEvent, +}; + +pub const CHANNELS: usize = 1; +pub const PARAMS: usize = 7; + +const LED_BRIGHTNESS: Brightness = Brightness::Mid; +/// Reverse gesture LED feedback length (white↔off fade), same as Heat Pump invert. +const REVERSE_FADE_MS: u16 = 500; + +/// Fibonacci gaps (in steps) between consecutive hits. +const FIB: [u16; 10] = [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]; +const MIN_CYCLE: u32 = 8; +const MAX_CYCLE: u32 = 32; +/// Pitch mode: cap intervals to two octaves. +const MAX_SEMIS: u16 = 24; + +/// Output modes, cycled on the device via shift + long press. +const MODE_GATE_NOTE: u8 = 0; +const MODE_GATE_CC: u8 = 1; +const MODE_PITCH: u8 = 2; +/// Pitch CV/MIDI with φ-spaced intervals (≈833¢), not 12-TET. +const MODE_PITCH_PHI: u8 = 3; + +/// ≈1200·log₂(φ) cents — one golden-ratio frequency step. +const PHI_CENTS: u32 = 833; + +pub static CONFIG: Config = Config::new( + "Golden Gate", + "Fibonacci-spaced gates — successive ratios approach φ", + Color::Violet, + AppIcon::SequenceSquare, +) +.add_param(Param::MidiChannel { + name: "MIDI Channel", +}) +.add_param(Param::MidiNote { name: "MIDI Note" }) +.add_param(Param::MidiCc { name: "MIDI CC" }) +.add_param(Param::i32 { + name: "GATE %", + min: 1, + max: 100, +}) +.add_param(Param::Enum { + name: "Speed", + variants: &["16th", "8th", "Quarter"], +}) +.add_param(Param::Color { + name: "Color", + variants: &[ + Color::Blue, + Color::Green, + Color::Rose, + Color::Orange, + Color::Cyan, + Color::Pink, + Color::Violet, + Color::Yellow, + ], +}) +.add_param(Param::MidiOut); + +pub struct Params { + midi_channel: MidiChannel, + midi_out: MidiOut, + note: MidiNote, + cc: MidiCc, + gatel: i32, + speed: usize, + color: Color, +} + +impl AppParams for Params { + fn from_values(values: &[Value]) -> Option { + if values.len() < PARAMS { + return None; + } + Some(Self { + midi_channel: MidiChannel::from_value(values[0]), + note: MidiNote::from_value(values[1]), + cc: MidiCc::from_value(values[2]), + gatel: i32::from_value(values[3]), + speed: usize::from_value(values[4]), + color: Color::from_value(values[5]), + midi_out: MidiOut::from_value(values[6]), + }) + } + + fn to_values(&self) -> Vec { + let mut vec = Vec::new(); + vec.push(self.midi_channel.into()).unwrap(); + vec.push(self.note.into()).unwrap(); + vec.push(self.cc.into()).unwrap(); + vec.push(self.gatel.into()).unwrap(); + vec.push(self.speed.into()).unwrap(); + vec.push(self.color.into()).unwrap(); + vec.push(self.midi_out.into()).unwrap(); + vec + } +} + +#[derive(Serialize, Deserialize)] +pub struct Storage { + /// Main fader: max Fibonacci depth (raw 12-bit). + fader_saved: u16, + /// Shift fader: cycle length N in steps (raw 12-bit). + shift_fader_saved: u16, + muted: bool, + reversed: bool, + /// 0 = gate+note, 1 = gate+CC, 2 = pitch 12-TET, 3 = pitch φ. + out_mode: u8, + /// 0 = 16th, 1 = 8th, 2 = quarter; 255 = follow the Speed param. + speed_saved: u8, +} + +impl Default for Storage { + fn default() -> Self { + Self { + fader_saved: 2048, + shift_fader_saved: 2048, + muted: false, + reversed: false, + out_mode: MODE_GATE_NOTE, + speed_saved: 255, + } + } +} +impl AppStorage for Storage {} + +fn cycle_from_value(value: u16) -> u32 { + MIN_CYCLE + value as u32 * (MAX_CYCLE - MIN_CYCLE) / 4095 +} + +fn depth_from_value(value: u16) -> u32 { + // 2..=FIB.len() gap values in rotation + 2 + value as u32 * (FIB.len() as u32 - 2) / 4095 +} + +/// Gaps used to fill one cycle of length `cycle` (forward Fibonacci order). +fn cycle_gaps(cycle: u32, depth: u32) -> heapless::Vec { + let mut gaps = heapless::Vec::new(); + let depth = depth.max(1) as usize; + let mut pos = 0u32; + let mut i = 0usize; + while pos < cycle { + let g = FIB[i % depth]; + if gaps.push(g).is_err() { + break; + } + pos += g as u32; + i += 1; + } + gaps +} + +/// Precompute the hit mask for one cycle. Forward: cumulative Fibonacci gaps +/// until ≥ N. Reverse: the *same* gaps that filled this cycle, in reverse +/// order (not FIB[depth-1]…0 from the depth window — that ignored N). +fn build_mask(cycle: u32, depth: u32, reversed: bool) -> u32 { + let mut gaps = cycle_gaps(cycle, depth); + if reversed { + gaps.reverse(); + } + let mut mask = 0u32; + let mut pos = 0u32; + for &g in gaps.iter() { + if pos >= cycle { + break; + } + mask |= 1 << pos; + pos += g as u32; + } + mask +} + +/// Gap at a hit step (for pitch modes), matching `build_mask`. +fn gap_at_step(step: u32, cycle: u32, depth: u32, reversed: bool) -> u16 { + let mut gaps = cycle_gaps(cycle, depth); + if reversed { + gaps.reverse(); + } + let mut pos = 0u32; + for &g in gaps.iter() { + if pos == step { + return g.min(MAX_SEMIS); + } + if pos > step { + break; + } + pos += g as u32; + } + 1 +} + +/// 1V/oct at 0-10V range: full scale is 120 semitones / 12000 cents. +fn semis_to_counts(semis: u16) -> u16 { + (semis as u32 * 4095 / 120) as u16 +} + +fn cents_to_counts(cents: u32) -> u16 { + (cents * 4095 / 12_000).min(4095) as u16 +} + +/// Nearest MIDI note + pitch bend (±2 semitone range assumed) for a +/// cent offset above `base`. +fn note_and_bend(base: MidiNote, cents_offset: u32) -> (MidiNote, u16) { + let semis = ((cents_offset + 50) / 100) as i8; + let bend_cents = cents_offset as i32 - semis as i32 * 100; + let n = { base }.transpose(semis); + // ±200¢ ↔ full 14-bit bend (±2 semitone synth range) + let bend = (8192i32 + bend_cents * 8192 / 200).clamp(0, 16_383) as u16; + (n, bend) +} + +fn is_pitch_mode(mode: u8) -> bool { + mode == MODE_PITCH || mode == MODE_PITCH_PHI +} + +/// Ticks per step at 24 PPQN: 16th / 8th / quarter. +fn div_for_speed(speed: u8) -> u32 { + match speed { + 1 => 12, + 2 => 24, + _ => 6, + } +} + +fn mode_color(mode: u8, led_color: Color) -> Color { + match mode { + MODE_GATE_CC => Color::Orange, + MODE_PITCH => Color::Red, + MODE_PITCH_PHI => Color::Pink, + _ => led_color, + } +} + +#[embassy_executor::task(pool_size = 16 / CHANNELS)] +pub async fn wrapper(app: App, exit_signal: &'static Signal) { + let param_store = ParamStore::::new( + app.app_id, + app.layout_id, + Params { + midi_channel: MidiChannel::default(), + midi_out: MidiOut([false, false, false]), + note: MidiNote::from(36), + cc: MidiCc::default(), + gatel: 50, + speed: 0, + color: Color::Violet, + }, + ); + let storage = ManagedStorage::::new(app.app_id, app.layout_id); + + param_store.load().await; + storage.load().await; + + let app_loop = async { + loop { + select3( + run(&app, ¶m_store, &storage), + param_store.param_handler(), + storage.saver_task(), + ) + .await; + } + }; + + select(app_loop, app.exit_handler(exit_signal)).await; +} + +pub async fn run( + app: &App, + params: &ParamStore, + storage: &ManagedStorage, +) { + let (midi_out, midi_chan, note, cc, gatel, param_speed, led_color) = params.query(|p| { + ( + p.midi_out, + p.midi_channel, + p.note, + p.cc, + p.gatel as u32, + p.speed, + p.color, + ) + }); + + let mut clock = app.use_clock(); + let ticks = clock.get_ticker(); + let faders = app.use_faders(); + let buttons = app.use_buttons(); + let leds = app.use_leds(); + + let midi = app.use_midi_output(midi_out, midi_chan, false); + + let glob_muted = app.make_global(false); + let glob_reversed = app.make_global(false); + let glob_cycle = app.make_global(16_u32); + let glob_depth = app.make_global(5_u32); + let glob_mask = app.make_global(0_u32); + let glob_mode = app.make_global(MODE_GATE_NOTE); + let glob_speed = app.make_global(0_u8); + let glob_reset = app.make_global(false); + let long_press_fired = app.make_global(false); + let glob_fader_moved = app.make_global(false); + let glob_latch_layer = app.make_global(LatchLayer::Main); + // Remaining ms of reverse LED fade; 0 = inactive. + let glob_reverse_fade = app.make_global(0u16); + // true = none→white, false = white→none. + let glob_reverse_fade_up = app.make_global(false); + + let (fader_saved, shift_fader_saved, muted, reversed, out_mode, speed_saved) = + storage.query(|s| { + ( + s.fader_saved, + s.shift_fader_saved, + s.muted, + s.reversed, + s.out_mode, + s.speed_saved, + ) + }); + + // Both handles address the same port; the hardware mode (GPO vs DAC) + // follows whichever make_* call ran last. Reconfigured on mode toggle. + let cv_jack = app.make_out_jack(0, Range::_0_10V).await; + let gate_jack = app.make_gate_jack(0, 4095).await; + if is_pitch_mode(out_mode) { + app.make_out_jack(0, Range::_0_10V).await; + cv_jack.set_value(0); + } + + // make_gate_jack drives the port high on configure; force a known-off + // state (also clears any note left sounding by a prior run() that was + // dropped mid-gate, e.g. on a param change respawn). + midi.send_note_off(note).await; + if is_pitch_mode(out_mode) { + midi.send_pitch_bend(8192).await; + } + if !is_pitch_mode(out_mode) { + gate_jack.set_low().await; + } + + glob_muted.set(muted); + glob_reversed.set(reversed); + glob_mode.set(out_mode); + glob_speed.set(if speed_saved <= 2 { + speed_saved + } else { + param_speed as u8 + }); + glob_depth.set(depth_from_value(fader_saved)); + glob_cycle.set(cycle_from_value(shift_fader_saved)); + glob_mask.set(build_mask( + glob_cycle.get(), + glob_depth.get(), + glob_reversed.get(), + )); + + if muted { + leds.unset(0, Led::Button); + } else { + leds.set( + 0, + Led::Button, + mode_color(out_mode, led_color), + LED_BRIGHTNESS, + ); + } + + let fut_clock = async { + let mut note_on: Option = None; + let mut cc_on = false; + let mut cached_mode = glob_mode.get(); + let mut step = 0u32; + let mut cached_speed = glob_speed.get(); + let mut div = div_for_speed(cached_speed); + let mut gate_step = (div * gatel / 100).clamp(1, div - 1); + + loop { + match clock.wait_for_event(ClockDivision::_1).await { + ClockEvent::Reset | ClockEvent::Stop => { + if let Some(n) = note_on.take() { + midi.send_note_off(n).await; + } + if is_pitch_mode(cached_mode) { + midi.send_pitch_bend(8192).await; + } + if cc_on { + midi.send_cc(cc, 0).await; + cc_on = false; + } + step = 0; + glob_reset.set(false); + if !is_pitch_mode(cached_mode) { + gate_jack.set_low().await; + } + leds.unset(0, Led::Top); + leds.unset(0, Led::Bottom); + } + ClockEvent::Tick => { + let clkn = ticks() as u32; + + // Mode changed on the device: reconfigure the jack. + let mode = glob_mode.get(); + if mode != cached_mode { + if is_pitch_mode(mode) { + app.make_out_jack(0, Range::_0_10V).await; + cv_jack.set_value(0); + } else { + app.make_gate_jack(0, 4095).await; + gate_jack.set_low().await; + } + // Leaving φ mode: recenter any pitch bend left behind. + if cached_mode == MODE_PITCH_PHI { + midi.send_pitch_bend(8192).await; + } + cached_mode = mode; + } + + let speed = glob_speed.get(); + if speed != cached_speed { + cached_speed = speed; + div = div_for_speed(speed); + gate_step = (div * gatel / 100).clamp(1, div - 1); + } + + if clkn.is_multiple_of(div) { + if glob_reset.get() { + glob_reset.set(false); + step = 0; + } + + let cycle = glob_cycle.get(); + if step >= cycle { + step = 0; + } + + let hit = glob_mask.get() & (1 << step) != 0; + if hit && !glob_muted.get() { + match cached_mode { + MODE_GATE_CC => { + gate_jack.set_high().await; + midi.send_cc(cc, 4095).await; + cc_on = true; + } + MODE_PITCH => { + let semis = gap_at_step( + step, + glob_cycle.get(), + glob_depth.get(), + glob_reversed.get(), + ); + cv_jack.set_value(semis_to_counts(semis)); + let n = { note }.transpose(semis as i8); + midi.send_note_on(n, 4095).await; + note_on = Some(n); + } + MODE_PITCH_PHI => { + let gap = gap_at_step( + step, + glob_cycle.get(), + glob_depth.get(), + glob_reversed.get(), + ); + let cents = (gap as u32 * PHI_CENTS).min(2400); + cv_jack.set_value(cents_to_counts(cents)); + let (n, bend) = note_and_bend(note, cents); + midi.send_pitch_bend(bend).await; + midi.send_note_on(n, 4095).await; + note_on = Some(n); + } + _ => { + gate_jack.set_high().await; + midi.send_note_on(note, 4095).await; + note_on = Some(note); + } + } + leds.set(0, Led::Bottom, led_color, Brightness::High); + } + + step += 1; + } + + if clkn % div == gate_step { + if let Some(n) = note_on.take() { + midi.send_note_off(n).await; + if cached_mode == MODE_PITCH_PHI { + midi.send_pitch_bend(8192).await; + } + } + if cc_on { + midi.send_cc(cc, 0).await; + cc_on = false; + } + if !is_pitch_mode(cached_mode) { + gate_jack.set_low().await; + } + leds.set(0, Led::Bottom, led_color, Brightness::Off); + } + + // Top LED: bar progress by default; Fibonacci depth while + // Shift is held, speed while the button is held — same + // "preview whatever the fader is adjusting" convention as + // Heat Pump / Grooves. Runs every tick (not just on hit + // steps) so it tracks fader moves immediately. + match glob_latch_layer.get() { + LatchLayer::Main => { + let cycle = glob_cycle.get().max(1); + leds.set( + 0, + Led::Top, + led_color, + Brightness::Custom(((step % cycle) * 255 / cycle) as u8), + ); + } + LatchLayer::Alt => { + // cycle ranges MIN_CYCLE..=MAX_CYCLE — normalize to 0..255. + let cyc = glob_cycle.get(); + let span = (MAX_CYCLE - MIN_CYCLE).max(1); + let norm = ((cyc.saturating_sub(MIN_CYCLE)) * 255 / span) as u8; + leds.set(0, Led::Top, Color::Red, Brightness::Custom(norm)); + } + LatchLayer::Third => { + let speed_color = match glob_speed.get() { + 0 => Color::Cyan, // 16th + 1 => Color::Yellow, // 8th + _ => Color::Orange, // quarter + }; + leds.set(0, Led::Top, speed_color, LED_BRIGHTNESS); + } + } + } + _ => {} + } + } + }; + + let fut_buttons = async { + loop { + buttons.wait_for_any_down().await; + if buttons.is_shift_pressed() { + long_press_fired.set(false); + buttons.wait_for_up(0).await; + if !long_press_fired.get() { + // Shift + short press: reverse direction through the list. + let reversed = glob_reversed.toggle(); + storage.modify_and_save(|s| s.reversed = reversed); + glob_mask.set(build_mask(glob_cycle.get(), glob_depth.get(), reversed)); + // Reverse on → white→none; reverse off → none→white. + glob_reverse_fade_up.set(!reversed); + glob_reverse_fade.set(REVERSE_FADE_MS); + } + } else { + long_press_fired.set(false); + glob_fader_moved.set(false); + buttons.wait_for_up(0).await; + if !long_press_fired.get() { + // Short press: reset the sequence to the downbeat. + glob_reset.set(true); + } else if !glob_fader_moved.get() { + // Long press (without moving the fader): toggle mute. + let muted = glob_muted.toggle(); + storage.modify_and_save(|s| s.muted = muted); + if muted { + midi.send_note_off(note).await; + midi.send_cc(cc, 0).await; + if !is_pitch_mode(glob_mode.get()) { + gate_jack.set_low().await; + } + leds.unset(0, Led::Button); + leds.unset(0, Led::Bottom); + } else { + leds.set( + 0, + Led::Button, + mode_color(glob_mode.get(), led_color), + LED_BRIGHTNESS, + ); + } + } + } + } + }; + + let long_press = async { + loop { + buttons.wait_for_any_long_press().await; + long_press_fired.set(true); + + if buttons.is_shift_pressed() { + // Shift + long press: cycle output mode (Note / CC / Pitch). + let mode = (glob_mode.get() + 1) % 4; + glob_mode.set(mode); + storage.modify_and_save(|s| s.out_mode = mode); + if !glob_muted.get() { + leds.set(0, Led::Button, mode_color(mode, led_color), LED_BRIGHTNESS); + } + } + } + }; + + let fut_faders = async { + let mut latch = app.make_latch(faders.get_value()); + loop { + faders.wait_for_change_at(0).await; + let latch_layer = glob_latch_layer.get(); + + // Any fader movement while the button is held counts, even before + // the latch picks up — otherwise releasing would still mute. + if latch_layer == LatchLayer::Third { + glob_fader_moved.set(true); + } + + let target_value = match latch_layer { + LatchLayer::Main => storage.query(|s| s.fader_saved), + LatchLayer::Alt => storage.query(|s| s.shift_fader_saved), + // Center of the current speed's fader zone (up = 16th, down = quarter) + LatchLayer::Third => (2 - glob_speed.get()) as u16 * 1365 + 683, + }; + + if let Some(new_value) = latch.update(faders.get_value(), latch_layer, target_value) { + match latch_layer { + LatchLayer::Main => { + glob_depth.set(depth_from_value(new_value)); + glob_mask.set(build_mask( + glob_cycle.get(), + glob_depth.get(), + glob_reversed.get(), + )); + storage.modify_and_save(|s| s.fader_saved = new_value); + } + LatchLayer::Alt => { + glob_cycle.set(cycle_from_value(new_value)); + glob_mask.set(build_mask( + glob_cycle.get(), + glob_depth.get(), + glob_reversed.get(), + )); + storage.modify_and_save(|s| s.shift_fader_saved = new_value); + } + LatchLayer::Third => { + // Button held + fader: pick speed (up = 16th, down = quarter). + glob_fader_moved.set(true); + let speed = (2u8).saturating_sub((new_value / 1366).min(2) as u8); + if speed != glob_speed.get() { + glob_speed.set(speed); + storage.modify_and_save(|s| s.speed_saved = speed); + } + } + } + } + } + }; + + let scene_handler = async { + loop { + match app.wait_for_scene_event().await { + SceneEvent::LoadScene(scene) => { + storage.load_from_scene(scene).await; + let (fader_saved, shift_fader_saved, muted, reversed, out_mode, speed_saved) = + storage.query(|s| { + ( + s.fader_saved, + s.shift_fader_saved, + s.muted, + s.reversed, + s.out_mode, + s.speed_saved, + ) + }); + + glob_muted.set(muted); + glob_reversed.set(reversed); + glob_mode.set(out_mode); + if speed_saved <= 2 { + glob_speed.set(speed_saved); + } + glob_depth.set(depth_from_value(fader_saved)); + glob_cycle.set(cycle_from_value(shift_fader_saved)); + glob_mask.set(build_mask( + glob_cycle.get(), + glob_depth.get(), + glob_reversed.get(), + )); + + if muted { + midi.send_note_off(note).await; + midi.send_cc(cc, 0).await; + leds.unset(0, Led::Button); + } else { + leds.set( + 0, + Led::Button, + mode_color(out_mode, led_color), + LED_BRIGHTNESS, + ); + } + } + SceneEvent::SaveScene(scene) => { + storage.save_to_scene(scene).await; + } + } + } + }; + + let shift = async { + loop { + app.delay_millis(1).await; + let latch_active_layer = if buttons.is_shift_pressed() && !buttons.is_button_pressed(0) + { + LatchLayer::Alt + } else if !buttons.is_shift_pressed() && buttons.is_button_pressed(0) { + LatchLayer::Third + } else { + LatchLayer::Main + }; + glob_latch_layer.set(latch_active_layer); + + // Reverse gesture feedback (white↔off), mirrors Heat Pump invert. + let fade_left = glob_reverse_fade.get(); + if fade_left > 0 { + let elapsed = REVERSE_FADE_MS.saturating_sub(fade_left); + let bright = if glob_reverse_fade_up.get() { + ((elapsed as u32 * 255) / REVERSE_FADE_MS as u32) as u8 + } else { + (((REVERSE_FADE_MS - elapsed) as u32 * 255) / REVERSE_FADE_MS as u32) as u8 + }; + leds.set(0, Led::Button, Color::White, Brightness::Custom(bright)); + let next = fade_left.saturating_sub(1); + glob_reverse_fade.set(next); + if next == 0 && !glob_muted.get() { + leds.set( + 0, + Led::Button, + mode_color(glob_mode.get(), led_color), + LED_BRIGHTNESS, + ); + } else if next == 0 && glob_muted.get() { + leds.unset(0, Led::Button); + } + } + } + }; + + join( + long_press, + join5(fut_clock, fut_buttons, fut_faders, scene_handler, shift), + ) + .await; +} diff --git a/faderpunk/src/apps/mod.rs b/faderpunk/src/apps/mod.rs index 0bfbba74b..6b0e709d1 100644 --- a/faderpunk/src/apps/mod.rs +++ b/faderpunk/src/apps/mod.rs @@ -26,4 +26,5 @@ register_apps!( 25 => automator, 26 => genseq, 27 => bernoulli, + 30 => fibonacci_gate, ); From dbd61b26c2d9d099669479f0022a5e688c63f8c1 Mon Sep 17 00:00:00 2001 From: kosmar Date: Fri, 24 Jul 2026 12:36:56 +0200 Subject: [PATCH 02/11] feat(golden-gate): sync app with playground CV jack / UX state --- faderpunk/src/apps/fibonacci_gate.rs | 217 ++++++++++++++++++++++----- 1 file changed, 181 insertions(+), 36 deletions(-) diff --git a/faderpunk/src/apps/fibonacci_gate.rs b/faderpunk/src/apps/fibonacci_gate.rs index 13ea78deb..c080d2931 100644 --- a/faderpunk/src/apps/fibonacci_gate.rs +++ b/faderpunk/src/apps/fibonacci_gate.rs @@ -7,8 +7,11 @@ use heapless::Vec; use serde::{Deserialize, Serialize}; use libfp::{ - ext::FromValue, latch::LatchLayer, AppIcon, Brightness, ClockDivision, Color, Config, MidiCc, - MidiChannel, MidiNote, MidiOut, Param, Range, Value, APP_MAX_PARAMS, + ext::FromValue, + latch::LatchLayer, + utils::attenuate_bipolar, + AppIcon, Brightness, ClockDivision, Color, Config, MidiCc, MidiChannel, MidiNote, MidiOut, + Param, Range, Value, APP_MAX_PARAMS, }; use crate::app::{ @@ -16,7 +19,7 @@ use crate::app::{ }; pub const CHANNELS: usize = 1; -pub const PARAMS: usize = 7; +pub const PARAMS: usize = 11; const LED_BRIGHTNESS: Brightness = Brightness::Mid; /// Reverse gesture LED feedback length (white↔off fade), same as Heat Pump invert. @@ -39,6 +42,22 @@ const MODE_PITCH_PHI: u8 = 3; /// ≈1200·log₂(φ) cents — one golden-ratio frequency step. const PHI_CENTS: u32 = 833; +const CV_JACK_OUT: usize = 0; +const CV_JACK_IN: usize = 1; +const DEST_DEPTH: usize = 0; +const DEST_CYCLE: usize = 1; +const DEST_RESET: usize = 2; +const DEST_COUNT: usize = 3; +const TRIG_HIGH: u16 = 2458; + +fn att_from_pct(pct: i32) -> u16 { + ((pct.clamp(0, 100) as u32 * 4095) / 100) as u16 +} + +fn mod_u16(base: u16, in_val: u16) -> u16 { + (base as i32 + in_val as i32 - 2047).clamp(0, 4095) as u16 +} + pub static CONFIG: Config = Config::new( "Golden Gate", "Fibonacci-spaced gates — successive ratios approach φ", @@ -72,7 +91,24 @@ pub static CONFIG: Config = Config::new( Color::Yellow, ], }) -.add_param(Param::MidiOut); +.add_param(Param::MidiOut) +.add_param(Param::Enum { + name: "Jack", + variants: &["CV Out", "CV In"], +}) +.add_param(Param::Range { + name: "Range", + variants: &[Range::_0_10V, Range::_Neg5_5V], +}) +.add_param(Param::Enum { + name: "CV Dest", + variants: &["Depth", "Cycle", "Reset"], +}) +.add_param(Param::i32 { + name: "CV Att", + min: 0, + max: 100, +}); pub struct Params { midi_channel: MidiChannel, @@ -82,13 +118,27 @@ pub struct Params { gatel: i32, speed: usize, color: Color, + cv_jack: usize, + range: Range, + cv_dest: usize, + cv_att: i32, } impl AppParams for Params { fn from_values(values: &[Value]) -> Option { - if values.len() < PARAMS { + if values.len() < 7 { return None; } + let (cv_jack, range, cv_dest, cv_att) = if values.len() >= PARAMS { + ( + usize::from_value(values[7]).min(1), + Range::from_value(values[8]), + usize::from_value(values[9]).min(DEST_COUNT - 1), + i32::from_value(values[10]).clamp(0, 100), + ) + } else { + (CV_JACK_OUT, Range::_0_10V, DEST_DEPTH, 100) + }; Some(Self { midi_channel: MidiChannel::from_value(values[0]), note: MidiNote::from_value(values[1]), @@ -97,6 +147,10 @@ impl AppParams for Params { speed: usize::from_value(values[4]), color: Color::from_value(values[5]), midi_out: MidiOut::from_value(values[6]), + cv_jack, + range, + cv_dest, + cv_att, }) } @@ -109,6 +163,10 @@ impl AppParams for Params { vec.push(self.speed.into()).unwrap(); vec.push(self.color.into()).unwrap(); vec.push(self.midi_out.into()).unwrap(); + vec.push(self.cv_jack.into()).unwrap(); + vec.push(self.range.into()).unwrap(); + vec.push(self.cv_dest.into()).unwrap(); + vec.push(self.cv_att.into()).unwrap(); vec } } @@ -261,6 +319,10 @@ pub async fn wrapper(app: App, exit_signal: &'static Signal::new(app.app_id, app.layout_id); @@ -287,17 +349,22 @@ pub async fn run( params: &ParamStore, storage: &ManagedStorage, ) { - let (midi_out, midi_chan, note, cc, gatel, param_speed, led_color) = params.query(|p| { - ( - p.midi_out, - p.midi_channel, - p.note, - p.cc, - p.gatel as u32, - p.speed, - p.color, - ) - }); + let (midi_out, midi_chan, note, cc, gatel, param_speed, led_color, cv_jack, range, cv_dest, cv_att) = + params.query(|p| { + ( + p.midi_out, + p.midi_channel, + p.note, + p.cc, + p.gatel as u32, + p.speed, + p.color, + p.cv_jack.min(1), + p.range, + p.cv_dest.min(DEST_COUNT - 1), + att_from_pct(p.cv_att), + ) + }); let mut clock = app.use_clock(); let ticks = clock.get_ticker(); @@ -335,13 +402,34 @@ pub async fn run( ) }); + let glob_fader_raw = app.make_global(fader_saved); + let glob_cycle_raw = app.make_global(shift_fader_saved); + let glob_cv_val = app.make_global(2047u16); + // Both handles address the same port; the hardware mode (GPO vs DAC) // follows whichever make_* call ran last. Reconfigured on mode toggle. - let cv_jack = app.make_out_jack(0, Range::_0_10V).await; - let gate_jack = app.make_gate_jack(0, 4095).await; - if is_pitch_mode(out_mode) { - app.make_out_jack(0, Range::_0_10V).await; - cv_jack.set_value(0); + // When Jack=CV In, skip outs entirely (MIDI-first). + let cv_out = if cv_jack == CV_JACK_OUT { + let j = app.make_out_jack(0, range).await; + Some(j) + } else { + None + }; + let gate_out = if cv_jack == CV_JACK_OUT { + Some(app.make_gate_jack(0, 4095).await) + } else { + None + }; + let cv_in = if cv_jack == CV_JACK_IN { + Some(app.make_in_jack(0, Range::_Neg5_5V).await) + } else { + None + }; + if cv_jack == CV_JACK_OUT && is_pitch_mode(out_mode) { + app.make_out_jack(0, range).await; + if let Some(ref j) = cv_out { + j.set_value(0); + } } // make_gate_jack drives the port high on configure; force a known-off @@ -351,8 +439,10 @@ pub async fn run( if is_pitch_mode(out_mode) { midi.send_pitch_bend(8192).await; } - if !is_pitch_mode(out_mode) { - gate_jack.set_low().await; + if cv_jack == CV_JACK_OUT && !is_pitch_mode(out_mode) { + if let Some(ref gate_jack) = gate_out { + gate_jack.set_low().await; + } } glob_muted.set(muted); @@ -407,7 +497,7 @@ pub async fn run( step = 0; glob_reset.set(false); if !is_pitch_mode(cached_mode) { - gate_jack.set_low().await; + if let Some(ref gate_jack) = gate_out { gate_jack.set_low().await; } } leds.unset(0, Led::Top); leds.unset(0, Led::Bottom); @@ -418,12 +508,18 @@ pub async fn run( // Mode changed on the device: reconfigure the jack. let mode = glob_mode.get(); if mode != cached_mode { - if is_pitch_mode(mode) { - app.make_out_jack(0, Range::_0_10V).await; - cv_jack.set_value(0); - } else { - app.make_gate_jack(0, 4095).await; - gate_jack.set_low().await; + if cv_jack == CV_JACK_OUT { + if is_pitch_mode(mode) { + app.make_out_jack(0, range).await; + if let Some(ref j) = cv_out { + j.set_value(0); + } + } else { + app.make_gate_jack(0, 4095).await; + if let Some(ref gate_jack) = gate_out { + gate_jack.set_low().await; + } + } } // Leaving φ mode: recenter any pitch bend left behind. if cached_mode == MODE_PITCH_PHI { @@ -454,7 +550,7 @@ pub async fn run( if hit && !glob_muted.get() { match cached_mode { MODE_GATE_CC => { - gate_jack.set_high().await; + if let Some(ref gate_jack) = gate_out { gate_jack.set_high().await; } midi.send_cc(cc, 4095).await; cc_on = true; } @@ -465,7 +561,7 @@ pub async fn run( glob_depth.get(), glob_reversed.get(), ); - cv_jack.set_value(semis_to_counts(semis)); + if let Some(ref cv_jack) = cv_out { cv_jack.set_value(semis_to_counts(semis)); } let n = { note }.transpose(semis as i8); midi.send_note_on(n, 4095).await; note_on = Some(n); @@ -478,14 +574,14 @@ pub async fn run( glob_reversed.get(), ); let cents = (gap as u32 * PHI_CENTS).min(2400); - cv_jack.set_value(cents_to_counts(cents)); + if let Some(ref cv_jack) = cv_out { cv_jack.set_value(cents_to_counts(cents)); } let (n, bend) = note_and_bend(note, cents); midi.send_pitch_bend(bend).await; midi.send_note_on(n, 4095).await; note_on = Some(n); } _ => { - gate_jack.set_high().await; + if let Some(ref gate_jack) = gate_out { gate_jack.set_high().await; } midi.send_note_on(note, 4095).await; note_on = Some(note); } @@ -508,7 +604,7 @@ pub async fn run( cc_on = false; } if !is_pitch_mode(cached_mode) { - gate_jack.set_low().await; + if let Some(ref gate_jack) = gate_out { gate_jack.set_low().await; } } leds.set(0, Led::Bottom, led_color, Brightness::Off); } @@ -580,7 +676,7 @@ pub async fn run( midi.send_note_off(note).await; midi.send_cc(cc, 0).await; if !is_pitch_mode(glob_mode.get()) { - gate_jack.set_low().await; + if let Some(ref gate_jack) = gate_out { gate_jack.set_low().await; } } leds.unset(0, Led::Button); leds.unset(0, Led::Bottom); @@ -636,6 +732,7 @@ pub async fn run( if let Some(new_value) = latch.update(faders.get_value(), latch_layer, target_value) { match latch_layer { LatchLayer::Main => { + glob_fader_raw.set(new_value); glob_depth.set(depth_from_value(new_value)); glob_mask.set(build_mask( glob_cycle.get(), @@ -645,6 +742,7 @@ pub async fn run( storage.modify_and_save(|s| s.fader_saved = new_value); } LatchLayer::Alt => { + glob_cycle_raw.set(new_value); glob_cycle.set(cycle_from_value(new_value)); glob_mask.set(build_mask( glob_cycle.get(), @@ -690,6 +788,8 @@ pub async fn run( if speed_saved <= 2 { glob_speed.set(speed_saved); } + glob_fader_raw.set(fader_saved); + glob_cycle_raw.set(shift_fader_saved); glob_depth.set(depth_from_value(fader_saved)); glob_cycle.set(cycle_from_value(shift_fader_saved)); glob_mask.set(build_mask( @@ -719,8 +819,53 @@ pub async fn run( }; let shift = async { + let mut prev_gate_high = false; + let mut last_depth = glob_depth.get(); + let mut last_cycle = glob_cycle.get(); loop { app.delay_millis(1).await; + if let Some(ref input) = cv_in { + let in_val = attenuate_bipolar(input.get_value(), cv_att); + glob_cv_val.set(in_val); + match cv_dest { + DEST_DEPTH => { + let d = depth_from_value(mod_u16(glob_fader_raw.get(), in_val)); + if d != last_depth { + last_depth = d; + glob_depth.set(d); + glob_mask.set(build_mask( + glob_cycle.get(), + d, + glob_reversed.get(), + )); + } + prev_gate_high = false; + } + DEST_CYCLE => { + let c = cycle_from_value(mod_u16(glob_cycle_raw.get(), in_val)); + if c != last_cycle { + last_cycle = c; + glob_cycle.set(c); + glob_mask.set(build_mask( + c, + glob_depth.get(), + glob_reversed.get(), + )); + } + prev_gate_high = false; + } + DEST_RESET => { + let high = in_val >= TRIG_HIGH; + if high && !prev_gate_high { + glob_reset.set(true); + } + prev_gate_high = high; + } + _ => { + prev_gate_high = false; + } + } + } let latch_active_layer = if buttons.is_shift_pressed() && !buttons.is_button_pressed(0) { LatchLayer::Alt From b86432b334f5f4dd3e449093c08951a2023171c6 Mon Sep 17 00:00:00 2001 From: kosmar Date: Fri, 24 Jul 2026 22:13:44 +0200 Subject: [PATCH 03/11] feat(golden-gate): Heat Pump speed grid and cycle up to 128 Co-authored-by: Cursor --- configurator/src/components/ManualTab.tsx | 29 ++++---- configurator/src/components/manual/Md.tsx | 8 +-- faderpunk/src/apps/fibonacci_gate.rs | 84 +++++++++++++++-------- faderpunk/src/tasks/midi.rs | 2 +- 4 files changed, 72 insertions(+), 51 deletions(-) diff --git a/configurator/src/components/ManualTab.tsx b/configurator/src/components/ManualTab.tsx index 7d9d0d2b8..e33d35dd9 100644 --- a/configurator/src/components/ManualTab.tsx +++ b/configurator/src/components/ManualTab.tsx @@ -1608,8 +1608,8 @@ On load, both registers are restored at the next phrase boundary so the recalled "MIDI Out", ], storage: [ - "Cycle length", "Fibonacci depth", + "Cycle length", "Muted", "Reversed", "Output mode", @@ -1625,9 +1625,9 @@ At shallow depth the hits are denser and more regular; deeper Fibonacci values o | Control | Action | | --- | --- | -| **Fader** | Cycle length N in steps (bar-aligned; Fibonacci gaps fill, then reset) | -| **Shift + Fader** | Fibonacci depth — how many sequence values rotate | -| **Button + Fader** | Speed — up = 16th, middle = 8th, down = quarter (overrides Configurator **Speed**) | +| **Fader** | Fibonacci depth — how many sequence values rotate | +| **Shift + Fader** | Cycle length N in steps (bar-aligned; Fibonacci gaps fill, then reset) | +| **Button + Fader** | Speed — Heat Pump grid (1/1 … 1/32, incl. dotted & triplets); overrides Configurator **Speed** | | **Short press** | Reset to downbeat | | **Long press** | Mute (no fader move) | | **Shift + short** | Reverse this cycle’s gap order (Button LED: white↔off fade) | @@ -1637,24 +1637,24 @@ At shallow depth the hits are denser and more regular; deeper Fibonacci values o | Control | Edits | Visual feedback | | --- | --- | --- | -| **Fader** | Cycle length | **Top LED** = cycle progress in app color. **Bottom LED** flashes on each hit | -| **Shift + Fader** | Depth | No dedicated meter — Top stays on cycle progress while you edit | -| **Button + Fader** | Speed | No dedicated meter — Top stays on cycle progress while you edit | +| **Fader** | Depth | **Top LED** = cycle progress in app color. **Bottom LED** flashes on each hit | +| **Shift + Fader** | Cycle length | **Top LED** = cycle length (red) while Shift is held | +| **Button + Fader** | Speed | **Top LED** = division type (cyan straight / yellow dotted / orange triplet) | | **Button LED** | — | Output mode color (app color / orange / red / pink); white↔off on reverse; off when muted |`, channels: [ { jackTitle: "Gate / Pitch Out", jackDescription: "Note and CC modes: gate pulse. Pitch modes: 1V/oct CV (MIDI note in parallel).", - faderTitle: "Cycle length", + faderTitle: "Fibonacci depth", faderDescription: - "How many steps before the pattern wraps (about 8–32).", - faderPlusShiftTitle: "Fibonacci depth", + "How far into 1, 1, 2, 3, 5 … 89 the gaps go — shallow = denser, deep = sparser.", + faderPlusShiftTitle: "Cycle length", faderPlusShiftDescription: - "How far into 1, 1, 2, 3, 5 and beyond the gaps go — shallow = denser, deep = sparser.", + "How many steps before the pattern wraps (about 8–128).", faderPlusFnTitle: "Speed", faderPlusFnDescription: - "Upper third: 16th · Middle: 8th · Lower third: quarter. Overrides the Speed parameter.", + "Clock division (same as Heat Pump): 1/1, 1/2, 1/4., 1/4, 1/8., 1/4T, 1/8, 1/8T, 1/16, 1/32. Up = faster. Overrides the Speed parameter.", fnTitle: "Reset / Mute", fnDescription: "Short press: restart at the downbeat. Long press: mute (do not move the fader).", @@ -1662,10 +1662,9 @@ At shallow depth the hits are denser and more regular; deeper Fibonacci values o fnPlusShiftDescription: "Short: reverse this cycle’s gaps. Long: cycle Note → CC → 12-TET pitch → φ pitch.", ledTop: "Progress through the cycle (app color)", - ledTopPlusShift: - "No depth meter — stays on cycle progress while Shift is held", + ledTopPlusShift: "Cycle length (red) while Shift is held", ledTopPlusFn: - "No speed meter — stays on cycle progress while the button is held", + "Speed division: cyan = straight, yellow = dotted, orange = triplet", ledBottom: "Flash on each hit", }, ], diff --git a/configurator/src/components/manual/Md.tsx b/configurator/src/components/manual/Md.tsx index 78d7d6093..628120b23 100644 --- a/configurator/src/components/manual/Md.tsx +++ b/configurator/src/components/manual/Md.tsx @@ -35,11 +35,7 @@ function splitMarkdownTables(src: string): Segment[] { let i = 0; while (i < lines.length) { - if ( - isRow(lines[i]) && - i + 1 < lines.length && - isSep(lines[i + 1]) - ) { + if (isRow(lines[i]) && i + 1 < lines.length && isSep(lines[i + 1])) { flushMd(); const headers = cells(lines[i]); i += 2; @@ -116,7 +112,7 @@ const ManualTable = ({ {headers.map((h) => ( diff --git a/faderpunk/src/apps/fibonacci_gate.rs b/faderpunk/src/apps/fibonacci_gate.rs index c080d2931..29bad1b0a 100644 --- a/faderpunk/src/apps/fibonacci_gate.rs +++ b/faderpunk/src/apps/fibonacci_gate.rs @@ -26,12 +26,19 @@ const LED_BRIGHTNESS: Brightness = Brightness::Mid; const REVERSE_FADE_MS: u16 = 500; /// Fibonacci gaps (in steps) between consecutive hits. -const FIB: [u16; 10] = [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]; +const FIB: [u16; 11] = [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]; const MIN_CYCLE: u32 = 8; -const MAX_CYCLE: u32 = 32; +/// Max phrase length in steps. Hit mask is `u128`, so this must be ≤ 128. +const MAX_CYCLE: u32 = 128; /// Pitch mode: cap intervals to two octaves. const MAX_SEMIS: u16 = 24; +/// Same grid as Heat Pump: tick spacing at 24 PPQN, slow → fast. +const SPEED_DIVS: [u32; 10] = [96, 48, 36, 24, 18, 16, 12, 8, 6, 3]; +const SPEED_COUNT: u8 = SPEED_DIVS.len() as u8; +/// Default Speed enum index = 16th (div 6). +const SPEED_DEFAULT: u8 = 8; + /// Output modes, cycled on the device via shift + long press. const MODE_GATE_NOTE: u8 = 0; const MODE_GATE_CC: u8 = 1; @@ -76,7 +83,10 @@ pub static CONFIG: Config = Config::new( }) .add_param(Param::Enum { name: "Speed", - variants: &["16th", "8th", "Quarter"], + // Heat Pump divisions (24 PPQN), slow → fast. + variants: &[ + "1/1", "1/2", "1/4.", "1/4", "1/8.", "1/4T", "1/8", "1/8T", "1/16", "1/32", + ], }) .add_param(Param::Color { name: "Color", @@ -181,7 +191,7 @@ pub struct Storage { reversed: bool, /// 0 = gate+note, 1 = gate+CC, 2 = pitch 12-TET, 3 = pitch φ. out_mode: u8, - /// 0 = 16th, 1 = 8th, 2 = quarter; 255 = follow the Speed param. + /// Speed index into `SPEED_DIVS` (0..=9); 255 = follow the Speed param. speed_saved: u8, } @@ -209,7 +219,7 @@ fn depth_from_value(value: u16) -> u32 { } /// Gaps used to fill one cycle of length `cycle` (forward Fibonacci order). -fn cycle_gaps(cycle: u32, depth: u32) -> heapless::Vec { +fn cycle_gaps(cycle: u32, depth: u32) -> heapless::Vec { let mut gaps = heapless::Vec::new(); let depth = depth.max(1) as usize; let mut pos = 0u32; @@ -228,18 +238,18 @@ fn cycle_gaps(cycle: u32, depth: u32) -> heapless::Vec { /// Precompute the hit mask for one cycle. Forward: cumulative Fibonacci gaps /// until ≥ N. Reverse: the *same* gaps that filled this cycle, in reverse /// order (not FIB[depth-1]…0 from the depth window — that ignored N). -fn build_mask(cycle: u32, depth: u32, reversed: bool) -> u32 { +fn build_mask(cycle: u32, depth: u32, reversed: bool) -> u128 { let mut gaps = cycle_gaps(cycle, depth); if reversed { gaps.reverse(); } - let mut mask = 0u32; + let mut mask = 0u128; let mut pos = 0u32; for &g in gaps.iter() { if pos >= cycle { break; } - mask |= 1 << pos; + mask |= 1u128 << pos; pos += g as u32; } mask @@ -288,12 +298,27 @@ fn is_pitch_mode(mode: u8) -> bool { mode == MODE_PITCH || mode == MODE_PITCH_PHI } -/// Ticks per step at 24 PPQN: 16th / 8th / quarter. fn div_for_speed(speed: u8) -> u32 { - match speed { - 1 => 12, - 2 => 24, - _ => 6, + SPEED_DIVS[(speed.min(SPEED_COUNT - 1)) as usize] +} + +/// Map a fader value to a speed index. Top of travel = fastest (Heat Pump order). +fn speed_from_fader(value: u16) -> u8 { + ((value as u32 * SPEED_COUNT as u32) / 4096).min(SPEED_COUNT as u32 - 1) as u8 +} + +/// Fader latch target: center of the zone for `speed`. +fn fader_for_speed(speed: u8) -> u16 { + let i = speed.min(SPEED_COUNT - 1) as u32; + ((i * 2 + 1) * 4096 / (SPEED_COUNT as u32 * 2)) as u16 +} + +/// Top LED while editing speed: orange = triplet, yellow = dotted, cyan = straight. +fn speed_led_color(speed: u8) -> Color { + match div_for_speed(speed) { + 8 | 16 => Color::Orange, // 1/8T, 1/4T + 18 | 36 => Color::Yellow, // dotted + _ => Color::Cyan, } } @@ -317,7 +342,7 @@ pub async fn wrapper(app: App, exit_signal: &'static Signal { @@ -632,12 +657,12 @@ pub async fn run( leds.set(0, Led::Top, Color::Red, Brightness::Custom(norm)); } LatchLayer::Third => { - let speed_color = match glob_speed.get() { - 0 => Color::Cyan, // 16th - 1 => Color::Yellow, // 8th - _ => Color::Orange, // quarter - }; - leds.set(0, Led::Top, speed_color, LED_BRIGHTNESS); + leds.set( + 0, + Led::Top, + speed_led_color(glob_speed.get()), + LED_BRIGHTNESS, + ); } } } @@ -725,8 +750,7 @@ pub async fn run( let target_value = match latch_layer { LatchLayer::Main => storage.query(|s| s.fader_saved), LatchLayer::Alt => storage.query(|s| s.shift_fader_saved), - // Center of the current speed's fader zone (up = 16th, down = quarter) - LatchLayer::Third => (2 - glob_speed.get()) as u16 * 1365 + 683, + LatchLayer::Third => fader_for_speed(glob_speed.get()), }; if let Some(new_value) = latch.update(faders.get_value(), latch_layer, target_value) { @@ -752,9 +776,9 @@ pub async fn run( storage.modify_and_save(|s| s.shift_fader_saved = new_value); } LatchLayer::Third => { - // Button held + fader: pick speed (up = 16th, down = quarter). + // Button held + fader: pick speed (up = fast, Heat Pump grid). glob_fader_moved.set(true); - let speed = (2u8).saturating_sub((new_value / 1366).min(2) as u8); + let speed = speed_from_fader(new_value); if speed != glob_speed.get() { glob_speed.set(speed); storage.modify_and_save(|s| s.speed_saved = speed); @@ -785,8 +809,10 @@ pub async fn run( glob_muted.set(muted); glob_reversed.set(reversed); glob_mode.set(out_mode); - if speed_saved <= 2 { + if speed_saved < SPEED_COUNT { glob_speed.set(speed_saved); + } else { + glob_speed.set((param_speed as u8).min(SPEED_COUNT - 1)); } glob_fader_raw.set(fader_saved); glob_cycle_raw.set(shift_fader_saved); diff --git a/faderpunk/src/tasks/midi.rs b/faderpunk/src/tasks/midi.rs index a16ebff17..638b66e97 100644 --- a/faderpunk/src/tasks/midi.rs +++ b/faderpunk/src/tasks/midi.rs @@ -513,7 +513,7 @@ pub async fn midi_in_task<'a>( if len == 0 { continue; } - let packets = usb_rx_buf[..len].chunks_exact(4); + let (packets, _remainder) = usb_rx_buf[..len].as_chunks::<4>(); for packet in packets { let cable = packet[0] >> 4; let msg_len = len_from_cin(packet[0]); From 66701a395f7e9597956290020816eabce88ad054 Mon Sep 17 00:00:00 2001 From: kosmar Date: Fri, 24 Jul 2026 22:49:36 +0200 Subject: [PATCH 04/11] feat(golden-gate): add Mode config param (Note/CC/Pitch/Phi) Co-authored-by: Cursor --- configurator/src/components/ManualTab.tsx | 7 ++++- faderpunk/src/apps/fibonacci_gate.rs | 37 ++++++++++++++++++++--- faderpunk/src/storage.rs | 14 +++++++++ 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/configurator/src/components/ManualTab.tsx b/configurator/src/components/ManualTab.tsx index e33d35dd9..f48f0728a 100644 --- a/configurator/src/components/ManualTab.tsx +++ b/configurator/src/components/ManualTab.tsx @@ -1606,6 +1606,11 @@ On load, both registers are restored at the next phrase boundary so the recalled "Speed", "Color", "MIDI Out", + "Jack", + "Range", + "CV Dest", + "CV Att", + "Mode", ], storage: [ "Fibonacci depth", @@ -1631,7 +1636,7 @@ At shallow depth the hits are denser and more regular; deeper Fibonacci values o | **Short press** | Reset to downbeat | | **Long press** | Mute (no fader move) | | **Shift + short** | Reverse this cycle’s gap order (Button LED: white↔off fade) | -| **Shift + long** | Cycle output mode: gate+note (app color) → gate+CC (orange) → 12-TET pitch (red) → φ pitch (pink) | +| **Shift + long** | Cycle output mode: Note → CC → 12-TET pitch → φ pitch (also a **Mode** parameter) | #### Faders & LEDs diff --git a/faderpunk/src/apps/fibonacci_gate.rs b/faderpunk/src/apps/fibonacci_gate.rs index 29bad1b0a..e2aca0742 100644 --- a/faderpunk/src/apps/fibonacci_gate.rs +++ b/faderpunk/src/apps/fibonacci_gate.rs @@ -19,7 +19,7 @@ use crate::app::{ }; pub const CHANNELS: usize = 1; -pub const PARAMS: usize = 11; +pub const PARAMS: usize = 12; const LED_BRIGHTNESS: Brightness = Brightness::Mid; /// Reverse gesture LED feedback length (white↔off fade), same as Heat Pump invert. @@ -118,6 +118,10 @@ pub static CONFIG: Config = Config::new( name: "CV Att", min: 0, max: 100, +}) +.add_param(Param::Enum { + name: "Mode", + variants: &["Note", "CC", "Pitch", "Phi"], }); pub struct Params { @@ -132,6 +136,8 @@ pub struct Params { range: Range, cv_dest: usize, cv_att: i32, + /// 0 = Note, 1 = CC, 2 = Pitch 12-TET, 3 = φ pitch. + mode: usize, } impl AppParams for Params { @@ -139,7 +145,8 @@ impl AppParams for Params { if values.len() < 7 { return None; } - let (cv_jack, range, cv_dest, cv_att) = if values.len() >= PARAMS { + // Indices 7..=10 added with CV jack; 11 = Mode. Accept older layouts. + let (cv_jack, range, cv_dest, cv_att) = if values.len() >= 11 { ( usize::from_value(values[7]).min(1), Range::from_value(values[8]), @@ -149,6 +156,11 @@ impl AppParams for Params { } else { (CV_JACK_OUT, Range::_0_10V, DEST_DEPTH, 100) }; + let mode = if values.len() >= PARAMS { + usize::from_value(values[11]).min(3) + } else { + MODE_GATE_NOTE as usize + }; Some(Self { midi_channel: MidiChannel::from_value(values[0]), note: MidiNote::from_value(values[1]), @@ -161,6 +173,7 @@ impl AppParams for Params { range, cv_dest, cv_att, + mode, }) } @@ -177,6 +190,7 @@ impl AppParams for Params { vec.push(self.range.into()).unwrap(); vec.push(self.cv_dest.into()).unwrap(); vec.push(self.cv_att.into()).unwrap(); + vec.push(self.mode.into()).unwrap(); vec } } @@ -348,6 +362,7 @@ pub async fn wrapper(app: App, exit_signal: &'static Signal::new(app.app_id, app.layout_id); @@ -374,7 +389,7 @@ pub async fn run( params: &ParamStore, storage: &ManagedStorage, ) { - let (midi_out, midi_chan, note, cc, gatel, param_speed, led_color, cv_jack, range, cv_dest, cv_att) = + let (midi_out, midi_chan, note, cc, gatel, param_speed, led_color, cv_jack, range, cv_dest, cv_att, param_mode) = params.query(|p| { ( p.midi_out, @@ -388,6 +403,7 @@ pub async fn run( p.range, p.cv_dest.min(DEST_COUNT - 1), att_from_pct(p.cv_att), + p.mode.min(3) as u8, ) }); @@ -415,7 +431,7 @@ pub async fn run( // true = none→white, false = white→none. let glob_reverse_fade_up = app.make_global(false); - let (fader_saved, shift_fader_saved, muted, reversed, out_mode, speed_saved) = + let (fader_saved, shift_fader_saved, muted, reversed, _stored_mode, speed_saved) = storage.query(|s| { ( s.fader_saved, @@ -450,6 +466,11 @@ pub async fn run( } else { None }; + // Mode param is source of truth on spawn (configurator / editor / Shift+long + // all write it). Keep scene storage in sync so LoadScene stays coherent. + let out_mode = param_mode; + storage.modify_and_save(|s| s.out_mode = out_mode); + if cv_jack == CV_JACK_OUT && is_pitch_mode(out_mode) { app.make_out_jack(0, range).await; if let Some(ref j) = cv_out { @@ -724,10 +745,13 @@ pub async fn run( long_press_fired.set(true); if buttons.is_shift_pressed() { - // Shift + long press: cycle output mode (Note / CC / Pitch). + // Shift + long press: cycle output mode (Note / CC / Pitch / Phi). let mode = (glob_mode.get() + 1) % 4; glob_mode.set(mode); storage.modify_and_save(|s| s.out_mode = mode); + params + .modify_and_save(|p| p.mode = mode as usize) + .await; if !glob_muted.get() { leds.set(0, Led::Button, mode_color(mode, led_color), LED_BRIGHTNESS); } @@ -809,6 +833,9 @@ pub async fn run( glob_muted.set(muted); glob_reversed.set(reversed); glob_mode.set(out_mode); + params + .modify_and_save(|p| p.mode = out_mode as usize) + .await; if speed_saved < SPEED_COUNT { glob_speed.set(speed_saved); } else { diff --git a/faderpunk/src/storage.rs b/faderpunk/src/storage.rs index d9c1ff898..81611db56 100644 --- a/faderpunk/src/storage.rs +++ b/faderpunk/src/storage.rs @@ -707,6 +707,20 @@ impl ParamStore

    { accessor(&*guard) } + /// Mutate params from the device (e.g. gesture shortcuts), persist, and + /// notify the configurator — without respawning the app. + pub async fn modify_and_save(&self, modifier: F) + where + F: FnOnce(&mut P), + { + { + let mut inner = self.inner.borrow_mut(); + modifier(&mut *inner); + } + self.save().await; + self.send_values().await; + } + pub async fn param_handler(&self) { APP_PARAM_SIGNALS[self.layout_id as usize].reset(); loop { From a938feebba53b7ae5d70ba688b9ef01f839715d1 Mon Sep 17 00:00:00 2001 From: kosmar Date: Sun, 26 Jul 2026 22:23:37 +0200 Subject: [PATCH 05/11] fix(golden-gate): absolute pitch CV and jack bring-up Authored by an AI coding agent on behalf of kosmar. --- faderpunk/src/apps/fibonacci_gate.rs | 89 ++++++++++++++++------------ 1 file changed, 51 insertions(+), 38 deletions(-) diff --git a/faderpunk/src/apps/fibonacci_gate.rs b/faderpunk/src/apps/fibonacci_gate.rs index e2aca0742..b1eb8318d 100644 --- a/faderpunk/src/apps/fibonacci_gate.rs +++ b/faderpunk/src/apps/fibonacci_gate.rs @@ -13,6 +13,7 @@ use libfp::{ AppIcon, Brightness, ClockDivision, Color, Config, MidiCc, MidiChannel, MidiNote, MidiOut, Param, Range, Value, APP_MAX_PARAMS, }; +use midly::num::u7; use crate::app::{ App, AppParams, AppStorage, ClockEvent, Led, ManagedStorage, ParamStore, SceneEvent, @@ -297,6 +298,21 @@ fn cents_to_counts(cents: u32) -> u16 { (cents * 4095 / 12_000).min(4095) as u16 } +fn midi_note_num(n: MidiNote) -> u8 { + u7::from(n).as_int() +} + +/// Absolute 1V/oct CV for a MIDI note (0V ≈ MIDI 0 / C-1). +fn midi_note_to_counts(n: MidiNote) -> u16 { + semis_to_counts(u16::from(midi_note_num(n)).min(120)) +} + +/// Absolute 1V/oct CV for base MIDI note + cent offset (φ mode). +fn midi_cents_to_counts(base: MidiNote, cents_offset: u32) -> u16 { + let abs = u32::from(midi_note_num(base)) * 100 + cents_offset; + cents_to_counts(abs.min(12_000)) +} + /// Nearest MIDI note + pitch bend (±2 semitone range assumed) for a /// cent offset above `base`. fn note_and_bend(base: MidiNote, cents_offset: u32) -> (MidiNote, u16) { @@ -447,49 +463,41 @@ pub async fn run( let glob_cycle_raw = app.make_global(shift_fader_saved); let glob_cv_val = app.make_global(2047u16); - // Both handles address the same port; the hardware mode (GPO vs DAC) - // follows whichever make_* call ran last. Reconfigured on mode toggle. + // Mode param is source of truth on spawn (configurator / editor / Shift+long + // all write it). Keep scene storage in sync so LoadScene stays coherent. + let out_mode = param_mode; + storage.modify_and_save(|s| s.out_mode = out_mode); + + // One jack, one role: Note/CC → gate GPO; Pitch/Phi → pitch DAC; In → CV mod. + // Do not configure DAC then GPO on the same port (that left Note mode silent + // when the final GPO bring-up raced the DAC setup on some boots). // When Jack=CV In, skip outs entirely (MIDI-first). - let cv_out = if cv_jack == CV_JACK_OUT { - let j = app.make_out_jack(0, range).await; - Some(j) - } else { - None - }; - let gate_out = if cv_jack == CV_JACK_OUT { - Some(app.make_gate_jack(0, 4095).await) + let (cv_out, gate_out) = if cv_jack == CV_JACK_OUT { + if is_pitch_mode(out_mode) { + let j = app.make_out_jack(0, range).await; + j.set_value(0); + (Some(j), None) + } else { + let g = app.make_gate_jack(0, 4095).await; + // make_gate_jack drives the port high on configure; force known-off + // (also clears any note left sounding by a prior run() dropped + // mid-gate, e.g. on a param change respawn). + g.set_low().await; + (None, Some(g)) + } } else { - None + (None, None) }; let cv_in = if cv_jack == CV_JACK_IN { Some(app.make_in_jack(0, Range::_Neg5_5V).await) } else { None }; - // Mode param is source of truth on spawn (configurator / editor / Shift+long - // all write it). Keep scene storage in sync so LoadScene stays coherent. - let out_mode = param_mode; - storage.modify_and_save(|s| s.out_mode = out_mode); - if cv_jack == CV_JACK_OUT && is_pitch_mode(out_mode) { - app.make_out_jack(0, range).await; - if let Some(ref j) = cv_out { - j.set_value(0); - } - } - - // make_gate_jack drives the port high on configure; force a known-off - // state (also clears any note left sounding by a prior run() that was - // dropped mid-gate, e.g. on a param change respawn). midi.send_note_off(note).await; if is_pitch_mode(out_mode) { midi.send_pitch_bend(8192).await; } - if cv_jack == CV_JACK_OUT && !is_pitch_mode(out_mode) { - if let Some(ref gate_jack) = gate_out { - gate_jack.set_low().await; - } - } glob_muted.set(muted); glob_reversed.set(reversed); @@ -552,6 +560,8 @@ pub async fn run( let clkn = ticks() as u32; // Mode changed on the device: reconfigure the jack. + // Param save respawns run() shortly after; this covers the + // window until then (and keeps hardware in the right mode). let mode = glob_mode.get(); if mode != cached_mode { if cv_jack == CV_JACK_OUT { @@ -607,8 +617,12 @@ pub async fn run( glob_depth.get(), glob_reversed.get(), ); - if let Some(ref cv_jack) = cv_out { cv_jack.set_value(semis_to_counts(semis)); } let n = { note }.transpose(semis as i8); + // Absolute 1V/oct for the sounded note (not the + // tiny interval-only voltage, which read as "no signal"). + if let Some(ref cv_jack) = cv_out { + cv_jack.set_value(midi_note_to_counts(n)); + } midi.send_note_on(n, 4095).await; note_on = Some(n); } @@ -620,8 +634,10 @@ pub async fn run( glob_reversed.get(), ); let cents = (gap as u32 * PHI_CENTS).min(2400); - if let Some(ref cv_jack) = cv_out { cv_jack.set_value(cents_to_counts(cents)); } let (n, bend) = note_and_bend(note, cents); + if let Some(ref cv_jack) = cv_out { + cv_jack.set_value(midi_cents_to_counts(note, cents)); + } midi.send_pitch_bend(bend).await; midi.send_note_on(n, 4095).await; note_on = Some(n); @@ -749,9 +765,8 @@ pub async fn run( let mode = (glob_mode.get() + 1) % 4; glob_mode.set(mode); storage.modify_and_save(|s| s.out_mode = mode); - params - .modify_and_save(|p| p.mode = mode as usize) - .await; + // Playground ParamStore: update() (main renamed this to modify_and_save). + params.update(|p| p.mode = mode as usize).await; if !glob_muted.get() { leds.set(0, Led::Button, mode_color(mode, led_color), LED_BRIGHTNESS); } @@ -833,9 +848,7 @@ pub async fn run( glob_muted.set(muted); glob_reversed.set(reversed); glob_mode.set(out_mode); - params - .modify_and_save(|p| p.mode = out_mode as usize) - .await; + params.update(|p| p.mode = out_mode as usize).await; if speed_saved < SPEED_COUNT { glob_speed.set(speed_saved); } else { From 89a2a94aaa13cf06584deefd6213f4f3c42e21a1 Mon Sep 17 00:00:00 2001 From: kosmar Date: Wed, 5 Aug 2026 00:16:51 +0200 Subject: [PATCH 06/11] fix(golden-gate): exclusive jack bring-up for Note vs Pitch modes --- faderpunk/src/apps/fibonacci_gate.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/faderpunk/src/apps/fibonacci_gate.rs b/faderpunk/src/apps/fibonacci_gate.rs index b1eb8318d..240ac49ab 100644 --- a/faderpunk/src/apps/fibonacci_gate.rs +++ b/faderpunk/src/apps/fibonacci_gate.rs @@ -405,6 +405,7 @@ pub async fn run( params: &ParamStore, storage: &ManagedStorage, ) { + let (midi_out, midi_chan, note, cc, gatel, param_speed, led_color, cv_jack, range, cv_dest, cv_att, param_mode) = params.query(|p| { ( From 3c494479d74784821b34e4dc3970a82b923ebeaa Mon Sep 17 00:00:00 2001 From: kosmar Date: Sun, 9 Aug 2026 00:39:16 +0200 Subject: [PATCH 07/11] chore(golden-gate): reduce embassy task pool_size to 4 Co-authored-by: Cursor --- faderpunk/src/apps/fibonacci_gate.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/faderpunk/src/apps/fibonacci_gate.rs b/faderpunk/src/apps/fibonacci_gate.rs index 240ac49ab..8f50c2866 100644 --- a/faderpunk/src/apps/fibonacci_gate.rs +++ b/faderpunk/src/apps/fibonacci_gate.rs @@ -361,7 +361,7 @@ fn mode_color(mode: u8, led_color: Color) -> Color { } } -#[embassy_executor::task(pool_size = 16 / CHANNELS)] +#[embassy_executor::task(pool_size = 4)] pub async fn wrapper(app: App, exit_signal: &'static Signal) { let param_store = ParamStore::::new( app.app_id, From 4a9f8301f100207221560265bc42c24d02e28b37 Mon Sep 17 00:00:00 2001 From: kosmar Date: Wed, 12 Aug 2026 12:26:26 +0200 Subject: [PATCH 08/11] fix(fibonacci_gate): claim registry ID 31 instead of 30 The branch still used the ID it was first drafted with. ID 30 now belongs to another app in docs/wip-app-ids.md, and 28 is reserved for Sift, so two of these app branches would have collided on merge. Authored by an AI coding agent on behalf of kosmar. --- faderpunk/src/apps/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/faderpunk/src/apps/mod.rs b/faderpunk/src/apps/mod.rs index 6b0e709d1..e3ad7013e 100644 --- a/faderpunk/src/apps/mod.rs +++ b/faderpunk/src/apps/mod.rs @@ -26,5 +26,5 @@ register_apps!( 25 => automator, 26 => genseq, 27 => bernoulli, - 30 => fibonacci_gate, + 31 => fibonacci_gate, ); From be2933d6f2fc48548911e68d3958643038b53203 Mon Sep 17 00:00:00 2001 From: kosmar Date: Thu, 13 Aug 2026 21:09:12 +0200 Subject: [PATCH 09/11] fix(golden-gate): keep CONFIG description ASCII-only Upstream PR #601 asserts at compile time that CONFIG strings are pure ASCII, because postcard-bindgen JavaScript codec cannot round-trip anything else. Doc and inline comments keep their typography. Authored by an AI coding agent on behalf of kosmar. --- faderpunk/src/apps/fibonacci_gate.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/faderpunk/src/apps/fibonacci_gate.rs b/faderpunk/src/apps/fibonacci_gate.rs index 8f50c2866..0fdc6f66f 100644 --- a/faderpunk/src/apps/fibonacci_gate.rs +++ b/faderpunk/src/apps/fibonacci_gate.rs @@ -68,7 +68,7 @@ fn mod_u16(base: u16, in_val: u16) -> u16 { pub static CONFIG: Config = Config::new( "Golden Gate", - "Fibonacci-spaced gates — successive ratios approach φ", + "Fibonacci-spaced gates - successive ratios approach phi", Color::Violet, AppIcon::SequenceSquare, ) From baa6331b350f9782739f659b4004da32a1e15404 Mon Sep 17 00:00:00 2001 From: kosmar Date: Fri, 14 Aug 2026 19:18:53 +0200 Subject: [PATCH 10/11] feat(storage): ParamStore::update polyfill without host push Same mutate+save API as #640 so WIP apps can call params.update now. Host AppState push stays out until the real writeback lands. Authored by an AI coding agent on behalf of kosmar. Co-authored-by: Cursor --- faderpunk/src/storage.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/faderpunk/src/storage.rs b/faderpunk/src/storage.rs index 9cff6da63..fcac3c2b7 100644 --- a/faderpunk/src/storage.rs +++ b/faderpunk/src/storage.rs @@ -709,6 +709,25 @@ impl ParamStore

    { accessor(&*guard) } + /// Change a param from inside the app and persist it to FRAM. + /// + /// Same call shape as the full AppParams writeback (#640), but without the + /// host push: the configurator stays stale until that lands. Apps can call + /// this now; swapping in the real #640 implementation later is a drop-in. + #[allow(dead_code)] + pub async fn update(&self, modifier: F) + where + F: FnOnce(&mut P), + { + { + let mut inner = self.inner.borrow_mut(); + modifier(&mut inner); + } + self.save().await; + // #640 adds: try_send AppState on APP_PARAM_PUSH_CHANNEL so the host + // tracks on-device edits live. + } + pub async fn param_handler(&self) { APP_PARAM_SIGNALS[self.layout_id as usize].reset(); loop { From 4da74cf29a008a206759c998e5cd4a7191fac03b Mon Sep 17 00:00:00 2001 From: kosmar Date: Sat, 15 Aug 2026 17:12:27 +0200 Subject: [PATCH 11/11] feat(golden-gate): raise CV Att ceiling from 100% to 400% A modulator that never reaches its rails could only nudge the target a little, since CV Att could at most pass the input through unchanged. Allowing gain up to 4x lets a tame LFO drive the destination across the full range, with the existing clamp in attenuate_bipolar turning the excess into a hold at the end stops. Stored presets keep their behaviour: the scale is linear and 100 still means unity. Co-authored-by: Cursor --- faderpunk/src/apps/fibonacci_gate.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/faderpunk/src/apps/fibonacci_gate.rs b/faderpunk/src/apps/fibonacci_gate.rs index 0dd971265..e0c222035 100644 --- a/faderpunk/src/apps/fibonacci_gate.rs +++ b/faderpunk/src/apps/fibonacci_gate.rs @@ -59,7 +59,7 @@ const DEST_COUNT: usize = 3; const TRIG_HIGH: u16 = 2458; fn att_from_pct(pct: i32) -> u16 { - ((pct.clamp(0, 100) as u32 * 4095) / 100) as u16 + ((pct.clamp(0, 400) as u32 * 4095) / 100) as u16 } fn mod_u16(base: u16, in_val: u16) -> u16 { @@ -118,7 +118,7 @@ pub static CONFIG: Config = Config::new( .add_param(Param::i32 { name: "CV Att", min: 0, - max: 100, + max: 400, }) .add_param(Param::Enum { name: "Mode", @@ -152,7 +152,7 @@ impl AppParams for Params { usize::from_value(values[7]).min(1), Range::from_value(values[8]), usize::from_value(values[9]).min(DEST_COUNT - 1), - i32::from_value(values[10]).clamp(0, 100), + i32::from_value(values[10]).clamp(0, 400), ) } else { (CV_JACK_OUT, Range::_0_10V, DEST_DEPTH, 100)