diff --git a/configurator/src/components/ManualTab.tsx b/configurator/src/components/ManualTab.tsx
index 375715377..57f8b51ae 100644
--- a/configurator/src/components/ManualTab.tsx
+++ b/configurator/src/components/ManualTab.tsx
@@ -1595,6 +1595,88 @@ 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",
+ "Jack",
+ "Range",
+ "CV Dest",
+ "CV Att",
+ "Mode",
+ ],
+ storage: [
+ "Fibonacci depth",
+ "Cycle length",
+ "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** | 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) |
+| **Shift + long** | Cycle output mode: Note → CC → 12-TET pitch → φ pitch (also a **Mode** parameter) |
+
+#### Faders & LEDs
+
+| Control | Edits | Visual feedback |
+| --- | --- | --- |
+| **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: "Fibonacci depth",
+ faderDescription:
+ "How far into 1, 1, 2, 3, 5 … 89 the gaps go — shallow = denser, deep = sparser.",
+ faderPlusShiftTitle: "Cycle length",
+ faderPlusShiftDescription:
+ "How many steps before the pattern wraps (about 8–128).",
+ faderPlusFnTitle: "Speed",
+ faderPlusFnDescription:
+ "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).",
+ 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: "Cycle length (red) while Shift is held",
+ ledTopPlusFn:
+ "Speed division: cyan = straight, yellow = dotted, orange = triplet",
+ 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 655f11d05..2e7481a2e 100644
--- a/configurator/src/components/manual/Apps.tsx
+++ b/configurator/src/components/manual/Apps.tsx
@@ -68,7 +68,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 }) => (
+
+ ),
+ 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 }) => (
-
- ),
- 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..e0c222035
--- /dev/null
+++ b/faderpunk/src/apps/fibonacci_gate.rs
@@ -0,0 +1,977 @@
+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,
+ utils::attenuate_bipolar,
+ 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,
+};
+
+pub const CHANNELS: usize = 1;
+pub const PARAMS: usize = 12;
+
+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; 11] = [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89];
+const MIN_CYCLE: u32 = 8;
+/// 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;
+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;
+
+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, 400) 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 phi",
+ 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",
+ // 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",
+ variants: &[
+ Color::Blue,
+ Color::Green,
+ Color::Rose,
+ Color::Orange,
+ Color::Cyan,
+ Color::Pink,
+ Color::Violet,
+ Color::Yellow,
+ ],
+})
+.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: 400,
+})
+.add_param(Param::Enum {
+ name: "Mode",
+ variants: &["Note", "CC", "Pitch", "Phi"],
+});
+
+pub struct Params {
+ midi_channel: MidiChannel,
+ midi_out: MidiOut,
+ note: MidiNote,
+ cc: MidiCc,
+ gatel: i32,
+ speed: usize,
+ color: Color,
+ cv_jack: usize,
+ range: Range,
+ cv_dest: usize,
+ cv_att: i32,
+ /// 0 = Note, 1 = CC, 2 = Pitch 12-TET, 3 = φ pitch.
+ mode: usize,
+}
+
+impl AppParams for Params {
+ fn from_values(values: &[Value]) -> Option {
+ if values.len() < 7 {
+ return None;
+ }
+ // 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]),
+ usize::from_value(values[9]).min(DEST_COUNT - 1),
+ i32::from_value(values[10]).clamp(0, 400),
+ )
+ } 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]),
+ 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]),
+ cv_jack,
+ range,
+ cv_dest,
+ cv_att,
+ mode,
+ })
+ }
+
+ 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.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.push(self.mode.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,
+ /// Speed index into `SPEED_DIVS` (0..=9); 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) -> u128 {
+ let mut gaps = cycle_gaps(cycle, depth);
+ if reversed {
+ gaps.reverse();
+ }
+ let mut mask = 0u128;
+ let mut pos = 0u32;
+ for &g in gaps.iter() {
+ if pos >= cycle {
+ break;
+ }
+ mask |= 1u128 << 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
+}
+
+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) {
+ 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
+}
+
+fn div_for_speed(speed: u8) -> u32 {
+ 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,
+ }
+}
+
+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 = 4)]
+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: SPEED_DEFAULT as usize,
+ color: Color::Violet,
+ cv_jack: CV_JACK_OUT,
+ range: Range::_0_10V,
+ cv_dest: DEST_DEPTH,
+ cv_att: 100,
+ mode: MODE_GATE_NOTE as usize,
+ },
+ );
+ 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, cv_jack, range, cv_dest, cv_att, param_mode) =
+ 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),
+ p.mode.min(3) as u8,
+ )
+ });
+
+ let mut clock = app.use_clock();
+ 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_u128);
+ 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, _stored_mode, speed_saved) =
+ storage.query(|s| {
+ (
+ s.fader_saved,
+ s.shift_fader_saved,
+ s.muted,
+ s.reversed,
+ s.out_mode,
+ s.speed_saved,
+ )
+ });
+
+ 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);
+
+ // 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, 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)
+ };
+ let cv_in = if cv_jack == CV_JACK_IN {
+ Some(app.make_in_jack(0, Range::_Neg5_5V).await)
+ } else {
+ None
+ };
+
+ midi.send_note_off(note).await;
+ if is_pitch_mode(out_mode) {
+ midi.send_pitch_bend(8192).await;
+ }
+
+ glob_muted.set(muted);
+ glob_reversed.set(reversed);
+ glob_mode.set(out_mode);
+ glob_speed.set(if speed_saved < SPEED_COUNT {
+ speed_saved
+ } else {
+ (param_speed as u8).min(SPEED_COUNT - 1)
+ });
+ 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) {
+ if let Some(ref gate_jack) = gate_out { gate_jack.set_low().await; }
+ }
+ leds.unset(0, Led::Top);
+ leds.unset(0, Led::Bottom);
+ }
+ ClockEvent::Tick(tick) => {
+ let clkn = tick 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 {
+ 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 {
+ 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() & (1u128 << step) != 0;
+ if hit && !glob_muted.get() {
+ match cached_mode {
+ MODE_GATE_CC => {
+ if let Some(ref gate_jack) = gate_out { 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(),
+ );
+ 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);
+ }
+ 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);
+ 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);
+ }
+ _ => {
+ if let Some(ref gate_jack) = gate_out { 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) {
+ if let Some(ref gate_jack) = gate_out { 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 => {
+ leds.set(
+ 0,
+ Led::Top,
+ speed_led_color(glob_speed.get()),
+ 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()) {
+ if let Some(ref gate_jack) = gate_out { 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 / Phi).
+ let mode = (glob_mode.get() + 1) % 4;
+ glob_mode.set(mode);
+ storage.modify_and_save(|s| s.out_mode = mode);
+ // Polyfill today (mutate+FRAM); #640 adds the host AppState push.
+ 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);
+ }
+ }
+ }
+ };
+
+ 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),
+ LatchLayer::Third => fader_for_speed(glob_speed.get()),
+ };
+
+ 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(),
+ glob_depth.get(),
+ glob_reversed.get(),
+ ));
+ 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(),
+ 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 = fast, Heat Pump grid).
+ glob_fader_moved.set(true);
+ 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);
+ }
+ }
+ }
+ }
+ }
+ };
+
+ 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);
+ // Polyfill today (mutate+FRAM); #640 adds the host AppState push.
+ params.update(|p| p.mode = out_mode as usize).await;
+ 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);
+ 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 {
+ 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
+ } 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..e3ad7013e 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,
+ 31 => fibonacci_gate,
);
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 {