diff --git a/configurator/src/components/ManualTab.tsx b/configurator/src/components/ManualTab.tsx index 375715377..7f7455d2f 100644 --- a/configurator/src/components/ManualTab.tsx +++ b/configurator/src/components/ManualTab.tsx @@ -1595,6 +1595,95 @@ On load, both registers are restored at the next phrase boundary so the recalled }, ], }, + { + appId: 32, + title: "Arp de Lévy", + description: + "Lévy-flight generative arpeggiator — evolve, texture, and expression", + color: "Rose", + icon: "soft-random", + params: [ + "MIDI Channel", + "Base Note", + "Color", + "MIDI Out", + "V/Oct", + "Bypass quantizer", + "Jack", + "Range", + "CV Dest", + "CV Att", + ], + storage: [ + "Mutation rate", + "Texture", + "Lévy α", + "Expression", + "Octave span", + "Muted", + "Reversed", + "Note pool", + "Phrase length", + ], + text: `Arp de Lévy is a one-channel generative arpeggiator. It keeps a persistent note pool and walks it with **Lévy-flight** steps — mostly small melodic moves (1–3 semitones), occasionally a larger jump — so the phrase evolves without turning into white-noise pitch. CV (1V/oct, quantized unless **Bypass quantizer**) and MIDI note fire together on each hit. **V/Oct** selects Eurorack 1V/oct or Buchla 1.2V/oct scaling. + +#### Mutation, texture, and flight + +Mutation rate freezes the pool at the bottom and applies more Lévy edits at each phrase boundary as you raise it. Texture jointly moves density (rests), phrase length (from 16 steps down to 4), and swing — sparse/long/straight at the bottom, dense/short/swung toward the top. **Button + Fader** sets Lévy **α** (local ↔ wild jump character). + +#### Expression + +There is no dedicated fader for expression. **Short press** (and CV Dest = Reroll) re-rolls the note pool **and** picks a new **expression depth**. That depth controls how much each subsequent note jitters in **velocity** and **gate length** around a fixed base (half-step gate, ~78% velocity). Depth 0 = fixed notes; high depth = more random long/loud variation per hit. + +#### Gestures + +| Control | Action | +| --- | --- | +| **Fader** | Mutation rate (0 = freeze loop) | +| **Shift + Fader** | Texture macro (density + phrase length + swing) | +| **Button + Fader** | Lévy α (local ↔ wild) | +| **Short press** | Reroll pool + expression depth; reset phrase | +| **Long press** | Mute (no fader move) | +| **Shift + short** | Reverse playback | +| **Shift + long** | Cycle octave span 1→2→3→4 | + +#### Faders & LEDs + +| Control | Edits | Visual feedback | +| --- | --- | --- | +| **Fader** | Mutation | **Top LED** = phrase progress in app color. **Bottom LED** flashes on each hit | +| **Shift + Fader** | Texture | **Top LED** = texture in **orange** (brighter = denser/shorter) | +| **Button + Fader** | Lévy α | **Top LED** = **violet** (brighter = wilder) | +| **Button LED** | — | Octave color (blue/cyan/yellow/red); white↔off on reverse; off when muted | + +Scenes store the live note pool, phrase length, and expression depth.`, + channels: [ + { + jackTitle: "Pitch CV Out / CV In", + jackDescription: + "Jack mode selects Pitch CV out or CV in (Evolve / Texture / Reroll). MIDI note fires in parallel unless muted.", + faderTitle: "Mutation rate", + faderDescription: + "How strongly the note pool Lévy-walks at each phrase boundary. Bottom = frozen loop.", + faderPlusShiftTitle: "Texture", + faderPlusShiftDescription: + "One macro for density (rests), phrase length (from 16 steps down to 4), and swing.", + faderPlusFnTitle: "Lévy α", + faderPlusFnDescription: + "Flight character — low = local steps, high = wild jumps.", + fnTitle: "Reroll / Mute", + fnDescription: + "Short press: new note pool, new expression depth (vel/gate jitter), restart phrase. Long press: mute.", + fnPlusShiftTitle: "Reverse / Octaves", + fnPlusShiftDescription: + "Short: reverse playback. Long: cycle octave span 1–4.", + ledTop: "Phrase position (app color)", + ledTopPlusShift: "Texture amount (orange, brighter = denser/shorter)", + ledTopPlusFn: "Lévy α (violet, brighter = wilder)", + 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..e9c25e57b 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+, Arp de + Lévy
  • 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 }) => ( -
      {children}
    - ), - ol: ({ children }) => ( -
      {children}
    - ), - a: ({ href, children }) => ( - - {children} - - ), - }} - > - {children} - + )} +
    ); }; diff --git a/faderpunk/src/app.rs b/faderpunk/src/app.rs index a7676a941..3cf5130db 100644 --- a/faderpunk/src/app.rs +++ b/faderpunk/src/app.rs @@ -23,7 +23,7 @@ use crate::{ events::{EventPubSubChannel, InputEvent}, tasks::{ buttons::{is_channel_button_pressed, is_shift_button_pressed}, - clock::{ClockSubscriber, CLOCK_PUBSUB}, + clock::{ClockSubscriber, CLOCK_PUBSUB, TICK_COUNTER}, global_config::get_global_config, i2c::{I2cLeaderMessage, I2cLeaderSender}, leds::{set_led_mode, LedMode, LedMsg}, @@ -437,6 +437,27 @@ impl MidiOutput { self.send_midi_msg(msg).await; } + /// Non-blocking NoteOn — drops if `APP_MIDI_CHANNEL` is full instead of + /// stalling Core 1 (dense melodic apps + busy playground). + /// + /// There is deliberately no NoteOff counterpart: a dropped NoteOn is + /// silence, but a dropped NoteOff hangs the note with no later message to + /// correct it, and `APP_MIDI_CHANNEL` is shared by all 16 channels, so any + /// busy neighbour can cause the drop. Send the matching NoteOff with + /// [`Self::send_note_off`] from an async voice task. + pub fn try_send_note_on(&self, note_number: MidiNote, velocity: u16) { + let message = MidiMessage::NoteOn { + key: note_number.into(), + vel: scale_bits_12_7(velocity), + }; + let event = LiveEvent::Midi { + channel: self.midi_channel, + message, + }; + let msg = MidiMsg::new(event, self.midi_out, MidiEventSource::Local); + let _ = self.midi_sender.try_send((self.start_channel, msg)); + } + /// Sends a MIDI Aftertouch message. /// velocity is normalized to a range of 0-4095 #[allow(dead_code)] @@ -795,6 +816,18 @@ impl App { Clock::new() } + /// Poll the current tick instead of subscribing to the clock. + /// + /// For apps that derive everything from the tick number and never react to + /// Start/Stop/Reset. Unlike [`Self::use_clock`] this costs no subscriber + /// slot, and an app that falls behind cannot back up the shared clock + /// queue. Returns `u64::MAX` before the first tick; the counter going + /// backwards means the clock was restarted or reset. + #[allow(dead_code)] // first users are the WIP app branches + pub fn clock_ticker(&self) -> fn() -> u64 { + ticks + } + pub fn use_quantizer(&self, range: Range, vpo: VoltPerOct, bypass: bool) -> Quantizer { Quantizer::new(range, vpo, bypass) } @@ -870,3 +903,8 @@ pub fn pitch_as_counts(pitch: Pitch, range: Range, vpo: VoltPerOct) -> u16 { pub fn vpo_counts_per_oct(vpo: VoltPerOct) -> i16 { get_global_config().vpo_counts_per_oct(vpo) } + +#[allow(dead_code)] // see App::clock_ticker +fn ticks() -> u64 { + TICK_COUNTER.load(Ordering::Relaxed) +} diff --git a/faderpunk/src/apps/arp_de_levy.rs b/faderpunk/src/apps/arp_de_levy.rs new file mode 100644 index 000000000..083c14152 --- /dev/null +++ b/faderpunk/src/apps/arp_de_levy.rs @@ -0,0 +1,1248 @@ +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, + quantizer::Pitch, + utils::attenuate_bipolar, + AppIcon, Brightness, ClockDivision, Color, Config, Key, MidiChannel, MidiNote, MidiOut, Note, + Param, Range, Value, VoltPerOct, APP_MAX_PARAMS, +}; +use midly::num::u7; + +use crate::{ + app::{ + App, AppParams, AppStorage, ClockEvent, Die, Led, ManagedStorage, ParamStore, SceneEvent, + }, + apps::follow_key, + tasks::global_config::get_global_config, + tasks::leds::LedMode, +}; + +pub const CHANNELS: usize = 1; +pub const PARAMS: usize = 12; + +/// Reverse gesture LED feedback length (white↔off fade), same as Golden Gate / Heat Pump. +const REVERSE_FADE_MS: u16 = 500; +/// Octave-span cue: one Button blink in the span color (Shift+long). +const OCTAVE_BLINK_MS: u16 = 250; +/// Hold off periodic button LED writes so LedMode::Flash can finish. +const BUTTON_FLASH_MS: u16 = 850; +/// Button latch level floor (Low-ish → High). Wide span so fader motion reads clearly. +const BTN_LEVEL_FLOOR: u8 = 100; +/// Top latch level: deeper dim→bright for amount. +const TOP_LEVEL_FLOOR: u8 = 40; +/// Bottom pitch height floor while gated. +const PITCH_LEVEL_FLOOR: u8 = 90; + +const POOL_CAP: usize = 16; +const MIN_PHRASE: usize = 4; +const MAX_PHRASE: usize = 16; +/// Ticks per 16th at 24 PPQN. +const STEP_DIV: u32 = 6; +/// Local Lévy table (**scale degrees**): sticky stepwise motion at α min. +const LEVY_LOCAL_DEG: [i8; 16] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 3]; +/// Wild Lévy table (**scale degrees**): larger leaps at α max (~2 octaves in heptatonic). +const LEVY_WILD_DEG: [i8; 16] = [3, 4, 5, 5, 7, 7, 8, 9, 10, 11, 12, 12, 14, 14, 16, 16]; +/// Chance (0..=4095) to snap a mutated slot toward the phrase tonic (Contura-ish). +const TONIC_PULL: u16 = 900; +/// Pull α toward 0 / 4095 so Third-layer extremes read clearly (< 1 = stronger). +const ALPHA_LEAN_CURVE: f32 = 0.5; + +const OCT_COLORS: [Color; 4] = [Color::Blue, Color::Cyan, Color::Yellow, Color::Red]; + +const CV_JACK_OUT: usize = 0; +const CV_JACK_IN: usize = 1; +const DEST_MUTATION: usize = 0; +const DEST_TEXTURE: usize = 1; +const DEST_REROLL: usize = 2; +const DEST_COUNT: usize = 3; +/// CV Out jack source (when Jack = CV Out). +const OUT_PITCH: usize = 0; +const OUT_GATE: usize = 1; +const OUT_VELOCITY: usize = 2; +const OUT_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( + "Arp de Levy", + "Levy-flight generative arpeggiator - evolve, texture, and flight character", + Color::Rose, + AppIcon::SoftRandom, +) +.add_param(Param::MidiChannel { + name: "MIDI Channel", +}) +.add_param(Param::MidiNote { name: "Base Note" }) +.add_param(Param::Color { + name: "Color", + variants: &[ + Color::Rose, + Color::Cyan, + Color::Blue, + Color::Green, + Color::Orange, + Color::Pink, + Color::Violet, + Color::Yellow, + ], +}) +.add_param(Param::MidiOut) +.add_param(Param::VoltPerOct) +.add_param(Param::bool { + name: "Bypass quantizer", +}) +.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 Out", + variants: &["Pitch", "Gate", "Velocity"], +}) +.add_param(Param::Enum { + name: "CV Dest", + variants: &["Evolve", "Texture", "Reroll"], +}) +.add_param(Param::i32 { + name: "CV Att", + min: 0, + max: 400, +}) +// Arp already follows the device scale for its degree math; this adds the +// tonic, so the global Tonic fader transposes it with the rest of the set. +.add_param(Param::bool { + name: "Follow device tonic", +}); + +pub struct Params { + midi_channel: MidiChannel, + note: MidiNote, + color: Color, + midi_out: MidiOut, + vpo: VoltPerOct, + bypass: bool, + cv_jack: usize, + range: Range, + cv_out: usize, + cv_dest: usize, + cv_att: i32, + follow_tonic: bool, +} + +impl Default for Params { + fn default() -> Self { + Self { + midi_channel: MidiChannel::default(), + note: MidiNote::from(48), + color: Color::Rose, + midi_out: MidiOut([true, false, false]), // USB only — all-ports floods cable + vpo: VoltPerOct::Standard, + bypass: false, + cv_jack: CV_JACK_OUT, + range: Range::_0_10V, + cv_out: OUT_PITCH, + cv_dest: DEST_MUTATION, + cv_att: 100, + follow_tonic: true, + } + } +} + +impl AppParams for Params { + fn from_values(values: &[Value]) -> Option { + if values.len() < 6 { + return None; + } + // Pinned to 11, not PARAMS: this slot layout is fixed history, and a + // `>= PARAMS` check would silently misread it the moment PARAMS grows. + let (cv_jack, range, cv_out, cv_dest, cv_att) = if values.len() >= 11 { + ( + usize::from_value(values[6]).min(1), + Range::from_value(values[7]), + usize::from_value(values[8]).min(OUT_COUNT - 1), + usize::from_value(values[9]).min(DEST_COUNT - 1), + i32::from_value(values[10]).clamp(0, 400), + ) + } else if values.len() >= 10 { + // Pre-CV-Out layout: Jack, Range, Dest, Att. + ( + usize::from_value(values[6]).min(1), + Range::from_value(values[7]), + OUT_PITCH, + usize::from_value(values[8]).min(DEST_COUNT - 1), + i32::from_value(values[9]).clamp(0, 400), + ) + } else { + (CV_JACK_OUT, Range::_0_10V, OUT_PITCH, DEST_MUTATION, 100) + }; + Some(Self { + midi_channel: MidiChannel::from_value(values[0]), + note: MidiNote::from_value(values[1]), + color: Color::from_value(values[2]), + midi_out: MidiOut::from_value(values[3]), + vpo: VoltPerOct::from_value(values[4]), + bypass: bool::from_value(values[5]), + cv_jack, + range, + cv_out, + cv_dest, + cv_att, + // Older blobs predate the flag; default it on like a fresh instance. + follow_tonic: values.len() < 12 || bool::from_value(values[11]), + }) + } + + 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.color.into()).unwrap(); + vec.push(self.midi_out.into()).unwrap(); + vec.push(self.vpo.into()).unwrap(); + vec.push(self.bypass.into()).unwrap(); + vec.push(self.cv_jack.into()).unwrap(); + vec.push(self.range.into()).unwrap(); + vec.push(self.cv_out.into()).unwrap(); + vec.push(self.cv_dest.into()).unwrap(); + vec.push(self.cv_att.into()).unwrap(); + vec + } +} + +#[derive(Serialize, Deserialize, Clone, Copy)] +pub struct Storage { + /// Main fader: evolve / mutation rate (raw 12-bit). + fader_saved: u16, + /// Shift fader: texture macro (raw 12-bit). + shift_fader_saved: u16, + /// Button+fader: Lévy α / flight character (raw 12-bit). + alpha_saved: u16, + /// Expression depth (0..=4095): how much per-note velocity/gate jitter. + /// Re-rolled on short-press / CV reroll. + expression: u16, + /// Octave span 1..=4 (cycled by Shift+long). + octaves: u8, + /// CV Out source 0..=2 (Pitch / Gate / Velocity); Shift+short when Jack=Out. + cv_out: u8, + muted: bool, + reversed: bool, + /// Persistent note pool as MIDI note numbers. + pool: [u8; POOL_CAP], + /// How many pool slots are live (mirrors texture-derived length; persisted). + phrase_len: u8, +} + +impl Default for Storage { + fn default() -> Self { + let mut pool = [48u8; POOL_CAP]; + // Seed a simple C-major-ish ascending seed before first Lévy walk. + for (i, n) in pool.iter_mut().enumerate() { + *n = 48 + [0, 2, 4, 5, 7, 9, 11, 12, 14, 16, 17, 19, 21, 23, 24, 26][i]; + } + Self { + fader_saved: 0, // frozen by default + shift_fader_saved: 2048, + alpha_saved: 2048, // balanced flight + expression: 0, // deterministic until first reroll + octaves: 2, + cv_out: OUT_PITCH as u8, + muted: false, + reversed: false, + pool, + phrase_len: 8, + } + } +} + +impl AppStorage for Storage {} + +fn clamp_octaves(o: u8) -> u8 { + o.clamp(1, 4) +} + +fn cycle_octaves(o: u8) -> u8 { + let o = clamp_octaves(o); + if o >= 4 { + 1 + } else { + o + 1 + } +} + +fn octave_color(octaves: u8) -> Color { + OCT_COLORS[(clamp_octaves(octaves) - 1) as usize] +} + +fn cv_out_color(mode: usize) -> Color { + match mode.min(OUT_COUNT - 1) { + OUT_GATE => Color::Yellow, + OUT_VELOCITY => Color::Orange, + _ => Color::Red, // Pitch + } +} + +/// Map a 12-bit level into [floor, 255] with sqrt ease-in so early fader travel +/// already brightens (Evolve leaving 0 feels alive; full throw still hits High). +fn level_bright(value: u16, floor: u8) -> Brightness { + let t = value as f32 / 4095.0; + let curved = libm::sqrtf(t.clamp(0.0, 1.0)); + let span = (255 - floor) as f32; + Brightness::Custom((floor as f32 + curved * span) as u8) +} + +/// Pitch height within the current octave span → Bottom brightness. +fn pitch_bright(raw: u8, lo: u8, hi: u8) -> Brightness { + let span = (hi.saturating_sub(lo)).max(1) as u16; + let pos = (raw.saturating_sub(lo) as u16).min(span); + Brightness::Custom( + (PITCH_LEVEL_FLOOR as u16 + pos * (255 - PITCH_LEVEL_FLOOR as u16) / span) as u8, + ) +} + +/// Latch layer → hue for Button/Top. +fn latch_hue(layer: LatchLayer, led_color: Color) -> Color { + match layer { + LatchLayer::Main => led_color, + LatchLayer::Alt => Color::Orange, + LatchLayer::Third => Color::Violet, + } +} + +fn cycle_cv_out(mode: u8) -> u8 { + ((mode as usize + 1) % OUT_COUNT) as u8 +} + +/// Texture → (density 0..=4095 hit threshold, phrase_len, swing_ticks). +/// Bottom: sparse / long phrase / no swing. Top: dense / short / swung. +fn texture_from_value(value: u16) -> (u16, usize, u32) { + let t = value as u32; + // Hit if die.roll() < density: bottom ≈820 (20%), top ≈4095 (100%). + let density = (820 + t * (4095 - 820) / 4095) as u16; + let phrase = MAX_PHRASE - (t as usize * (MAX_PHRASE - MIN_PHRASE) / 4095); + let phrase = phrase.clamp(MIN_PHRASE, MAX_PHRASE); + // Swing delay on odd steps: 0 .. ~40% of a step. + let swing = t * (STEP_DIV * 2 / 5) / 4095; + (density, phrase, swing) +} + +/// Expression depth (0..=4095) → per-note velocity. Base ~78%; jitter scales with depth. +fn express_velocity(die: &Die, expression: u16) -> u16 { + const BASE: u16 = 3200; + const MIN_V: u16 = 256; + if expression == 0 { + return BASE; + } + let delta = ((die.roll() as i32 - 2048) * expression as i32) / 2048; + (BASE as i32 + delta).clamp(MIN_V as i32, 4095) as u16 +} + +/// Expression depth → gate length in clock ticks (1..=STEP_DIV-1). Base = half step. +fn express_gate_ticks(die: &Die, expression: u16) -> u32 { + const BASE: u32 = STEP_DIV / 2; + if expression == 0 { + return BASE.max(1); + } + let max_j = ((expression as u32) * (STEP_DIV / 2 - 1) / 4095) as i32; + let delta = ((die.roll() as i32 - 2048) * max_j) / 2048; + (BASE as i32 + delta).clamp(1, (STEP_DIV - 1) as i32) as u32 +} + +fn base_midi(note: MidiNote) -> u8 { + u7::from(note).as_int() +} + +fn clamp_note(n: i16) -> u8 { + n.clamp(0, 127) as u8 +} + +/// α (0..=4095) blends Local→Wild degree leaps. +fn levy_degree_delta(die: &Die, alpha: u16) -> i8 { + let t = alpha as f32 / 4095.0; + let centered = (t - 0.5) * 2.0; + let lean = libm::copysignf(libm::powf(centered.abs(), ALPHA_LEAN_CURVE), centered); + let eff = ((0.5 + lean * 0.5) * 4095.0) as u16; + let mag = if die.roll() < eff { + LEVY_WILD_DEG[(die.roll() as usize) % LEVY_WILD_DEG.len()] + } else { + LEVY_LOCAL_DEG[(die.roll() as usize) % LEVY_LOCAL_DEG.len()] + }; + if die.roll() & 1 == 0 { + mag + } else { + -mag + } +} + +fn scale_pcs(key: Key) -> Vec { + let mask = if key == Key::Off { + Key::Chromatic.as_u16_key() + } else { + key.as_u16_key() + }; + let mut out = Vec::new(); + for i in 0..12u8 { + if (mask >> (11 - i)) & 1 != 0 { + let _ = out.push(i); + } + } + if out.is_empty() { + let _ = out.push(0); + } + out +} + +/// Nearest scale degree index for `note` relative to `tonic` pitch-class. +fn note_to_degree(note: u8, tonic: u8, pcs: &[u8]) -> i16 { + let n = pcs.len().max(1) as i16; + let pc = ((note as i16 - tonic as i16).rem_euclid(12)) as u8; + let mut best_i = 0i16; + let mut best_d = 12u8; + for (i, &p) in pcs.iter().enumerate() { + let d = (p as i16 - pc as i16).unsigned_abs() as u8; + let d = d.min(12 - d); + if d < best_d { + best_d = d; + best_i = i as i16; + } + } + let oct = (note as i16 - tonic as i16).div_euclid(12); + oct * n + best_i +} + +fn degree_to_note(degree: i16, tonic: u8, pcs: &[u8]) -> u8 { + let n = pcs.len().max(1) as i16; + let d = degree.rem_euclid(n * 16); // keep in a wide window before clamp + let oct = d.div_euclid(n); + let i = d.rem_euclid(n) as usize; + let semi = tonic as i16 + oct * 12 + pcs[i] as i16; + semi.clamp(0, 127) as u8 +} + +fn mutate_pool( + pool: &mut [u8; POOL_CAP], + phrase_len: usize, + lo: u8, + hi: u8, + alpha: u16, + die: &Die, + tonic_midi: u8, +) { + if phrase_len == 0 { + return; + } + let key = get_global_config().quantizer.key; + let pcs = scale_pcs(key); + let tonic = tonic_midi % 12; + let i = (die.roll() as usize) % phrase_len; + let deg = note_to_degree(pool[i], tonic, &pcs); + let delta = i16::from(levy_degree_delta(die, alpha)); + let mut next_deg = deg + delta; + // Mild Contura-style tonic pull: nudge toward base after leaps. + if delta.abs() >= 3 && die.roll() < TONIC_PULL { + let pull_deg = note_to_degree(tonic_midi.clamp(lo, hi), tonic, &pcs); + if next_deg > pull_deg { + next_deg -= 1; + } else if next_deg < pull_deg { + next_deg += 1; + } + } + pool[i] = degree_to_note(next_deg, tonic, &pcs).clamp(lo, hi); +} + +fn reroll_pool( + pool: &mut [u8; POOL_CAP], + phrase_len: usize, + lo: u8, + hi: u8, + die: &Die, + tonic_midi: u8, +) { + let key = get_global_config().quantizer.key; + let pcs = scale_pcs(key); + let tonic = tonic_midi % 12; + let base_deg = note_to_degree(tonic_midi.clamp(lo, hi), tonic, &pcs); + let span_deg = ((hi - lo) as i16 / 2).max(4); + for (i, slot) in pool.iter_mut().enumerate() { + if i < phrase_len { + let wobble = (die.roll() as i16 * span_deg / 4095) - span_deg / 2; + *slot = degree_to_note(base_deg + wobble, tonic, &pcs).clamp(lo, hi); + } else { + *slot = lo; + } + } +} + +/// At phrase cadence (last slot), bias playback toward tonic for resolution. +fn cadence_note(raw: u8, step: usize, phrase_len: usize, tonic_midi: u8, die: &Die) -> u8 { + if phrase_len == 0 { + return raw; + } + let last = phrase_len - 1; + if step % phrase_len == last && die.roll() < 2400 { + tonic_midi + } else { + raw + } +} + +/// Sequential walk through the pool; reverse flips direction. +fn pool_index(step: usize, len: usize, reversed: bool) -> usize { + if len == 0 { + return 0; + } + let i = step % len; + if reversed { + len - 1 - i + } else { + i + } +} + +fn note_to_pitch(note: u8) -> Pitch { + // MIDI note 0 = C-1 → octave -1; MIDI 60 = C4 → octave 4. + let octave = (note as i16 / 12) - 1; + let pc = note % 12; + Pitch { + octave: octave as i8, + note: Note::from(pc), + raw: None, + } +} + +#[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::default()); + 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, + base_note, + led_color, + vpo, + bypass, + cv_jack, + range, + param_cv_out, + cv_dest, + cv_att, + follow_tonic, + ) = params.query(|p| { + ( + p.midi_out, + p.midi_channel, + p.note, + p.color, + p.vpo, + p.bypass, + p.cv_jack.min(1), + p.range, + p.cv_out.min(OUT_COUNT - 1), + p.cv_dest.min(DEST_COUNT - 1), + att_from_pct(p.cv_att), + p.follow_tonic, + ) + }); + + let mut clock = app.use_clock(); + let ticks = app.clock_ticker(); + let faders = app.use_faders(); + let buttons = app.use_buttons(); + let leds = app.use_leds(); + let die = app.use_die(); + let quantizer = app.use_quantizer(range, vpo, bypass); + let midi = app.use_midi_output(midi_out, midi_chan, false); + + let out_jack = if cv_jack == CV_JACK_OUT { + Some(app.make_out_jack(0, range).await) + } else { + None + }; + let in_jack = if cv_jack == CV_JACK_IN { + Some(app.make_in_jack(0, Range::_Neg5_5V).await) + } else { + None + }; + let glob_cv_val = app.make_global(2047u16); + + // Configurator CV Out wins on spawn; keep storage / scenes in sync. + storage.modify_and_save(|s| s.cv_out = param_cv_out as u8); + let glob_cv_out = app.make_global(param_cv_out); + + let ( + fader_saved, + shift_fader_saved, + alpha_saved, + expression_saved, + octaves_saved, + muted, + reversed, + pool_saved, + phrase_saved, + ) = storage.query(|s| { + ( + s.fader_saved, + s.shift_fader_saved, + s.alpha_saved, + s.expression, + s.octaves, + s.muted, + s.reversed, + s.pool, + s.phrase_len, + ) + }); + + let glob_muted = app.make_global(muted); + let glob_reversed = app.make_global(reversed); + let glob_mutation = app.make_global(fader_saved); + let glob_texture = app.make_global(shift_fader_saved); + let glob_alpha = app.make_global(alpha_saved); + let glob_expression = app.make_global(expression_saved); + let glob_octaves = app.make_global(clamp_octaves(octaves_saved)); + let glob_phrase = + app.make_global(phrase_saved.clamp(MIN_PHRASE as u8, MAX_PHRASE as u8) as usize); + let glob_reset = app.make_global(false); + let glob_reroll = 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); + let glob_reverse_fade = app.make_global(0u16); + let glob_reverse_fade_up = app.make_global(false); + let glob_octave_blink = app.make_global(0u16); + let glob_btn_flash = app.make_global(0u16); + let glob_reload_pool = app.make_global(false); + // Root in effect right now — the param's own, or the device tonic when + // following. The clock future owns it; the voice future reads it. + let glob_base = app.make_global(base_midi(base_note)); + // Clock watch → voice engine (never await MIDI/quantizer inside the clock subscriber). + // Same isolation pattern as Chord Vamp — keeps USB MIDI from stalling the clock path. + let pending_fire = app.make_global(false); + let pending_raw = app.make_global(0u8); + let pending_vel = app.make_global(0u16); + let pending_note_off = app.make_global(false); + let pending_silence = app.make_global(false); + + // Clear any note left sounding by a prior run() that was dropped mid-gate + // (e.g. on a param-change respawn) — same MIDI hygiene as Golden Gate. + midi.send_note_off(base_note).await; + for n in pool_saved { + midi.send_note_off(MidiNote::from(n)).await; + } + if let Some(ref jack) = out_jack { + jack.set_value(0); + } + + if muted { + leds.unset(0, Led::Button); + } else { + let c = latch_hue(LatchLayer::Main, led_color); + leds.set( + 0, + Led::Button, + c, + level_bright(fader_saved, BTN_LEVEL_FLOOR), + ); + } + + let (density0, phrase0, _) = texture_from_value(shift_fader_saved); + let _ = density0; + glob_phrase.set(phrase0); + + let schedule_hit = |clkn: u32, + raw: u8, + swing: u32, + step: usize, + pending_on_at: &mut Option, + pending_note: &mut Option, + gate_off_at: &mut Option| { + if glob_muted.get() { + return; + } + if swing > 0 && (step % 2 == 1) { + *pending_on_at = Some(clkn + swing); + *pending_note = Some(raw); + } else { + let expression = glob_expression.get(); + pending_raw.set(raw); + pending_vel.set(express_velocity(&die, expression)); + pending_fire.set(true); + *gate_off_at = Some(clkn + express_gate_ticks(&die, expression)); + } + }; + + let fut_clock = async { + let mut pool = storage.query(|s| s.pool); + // The pool holds absolute MIDI notes, so a device transpose has to move + // it. Shifting keeps the melodic shape the pool has evolved into; + // rerolling would throw it away. Resolved at phrase starts only — + // reading the device tonic copies the whole GlobalConfig. + let shift_pool = |pool: &mut [u8; POOL_CAP], delta: i16| { + for n in pool.iter_mut() { + *n = clamp_note(i16::from(*n) + delta); + } + }; + let mut cur_base = base_midi(base_note); + if follow_tonic { + let want = follow_key::root(true, base_note); + if want != cur_base { + shift_pool(&mut pool, i16::from(want) - i16::from(cur_base)); + cur_base = want; + glob_base.set(cur_base); + } + } + let mut step: usize = 0; + let mut pending_on_at: Option = None; + let mut pending_note: Option = None; + let mut gate_off_at: Option = None; + + loop { + match clock.wait_for_event(ClockDivision::_1).await { + ClockEvent::Reset | ClockEvent::Stop => { + pending_on_at = None; + pending_note = None; + gate_off_at = None; + pending_fire.set(false); + pending_note_off.set(false); + pending_silence.set(true); + step = 0; + glob_reset.set(false); + leds.unset(0, Led::Top); + leds.unset(0, Led::Bottom); + } + ClockEvent::Tick(_) => { + let clkn = ticks() as u32; + let octaves = clamp_octaves(glob_octaves.get()); + let lo = cur_base; + let hi = clamp_note(lo as i16 + (octaves as i16) * 12); + let texture_val = if cv_jack == CV_JACK_IN && cv_dest == DEST_TEXTURE { + mod_u16(glob_texture.get(), glob_cv_val.get()) + } else { + glob_texture.get() + }; + let (density, phrase_len, swing) = texture_from_value(texture_val); + glob_phrase.set(phrase_len); + + if glob_reload_pool.get() { + glob_reload_pool.set(false); + pool = storage.query(|s| s.pool); + } + + if glob_reroll.get() { + glob_reroll.set(false); + reroll_pool(&mut pool, phrase_len, lo, hi, &die, lo); + // Short/CV reroll also picks a new expression depth. + let expression = die.roll(); + glob_expression.set(expression); + storage.modify_and_save(|s| { + s.pool = pool; + s.phrase_len = phrase_len as u8; + s.expression = expression; + }); + } + + // Gate off — flag only; voice engine owns MIDI. + if let Some(off_at) = gate_off_at { + if clkn >= off_at { + pending_note_off.set(true); + gate_off_at = None; + } + } + + // Delayed (swung) note-on + if let Some(on_at) = pending_on_at { + if clkn >= on_at { + if let Some(raw) = pending_note.take() { + if !glob_muted.get() { + let expression = glob_expression.get(); + pending_raw.set(raw); + pending_vel.set(express_velocity(&die, expression)); + pending_fire.set(true); + gate_off_at = + Some(on_at + express_gate_ticks(&die, expression)); + } + } + pending_on_at = None; + } + } + + if clkn.is_multiple_of(STEP_DIV) { + if glob_reset.get() { + glob_reset.set(false); + step = 0; + } + + // At phrase boundary: Lévy-mutate according to evolve rate + α. + if step == 0 { + let mut mutation = if cv_jack == CV_JACK_IN && cv_dest == DEST_MUTATION { + mod_u16(glob_mutation.get(), glob_cv_val.get()) + } else { + glob_mutation.get() + }; + let alpha = glob_alpha.get(); + let mut changed = false; + // Number of mutations scales with fader (0 = freeze). + while mutation > 0 { + if die.roll() < mutation { + mutate_pool(&mut pool, phrase_len, lo, hi, alpha, &die, lo); + changed = true; + } + mutation = mutation.saturating_sub(1024); + } + if changed { + storage.modify_and_save(|s| { + s.pool = pool; + s.phrase_len = phrase_len as u8; + }); + } + } + + let reversed = glob_reversed.get(); + let idx = pool_index(step, phrase_len, reversed); + let raw = cadence_note( + pool[idx].clamp(lo, hi), + step, + phrase_len, + lo, + &die, + ); + + // Density: rest if roll >= density threshold. + let hit = die.roll() < density && !glob_muted.get(); + + if hit { + schedule_hit( + clkn, + raw, + swing, + step, + &mut pending_on_at, + &mut pending_note, + &mut gate_off_at, + ); + } + + step = step.wrapping_add(1); + if phrase_len > 0 && step.is_multiple_of(phrase_len) { + step = 0; + if follow_tonic { + let want = follow_key::root(true, base_note); + if want != cur_base { + shift_pool( + &mut pool, + i16::from(want) - i16::from(cur_base), + ); + cur_base = want; + glob_base.set(cur_base); + } + } + } + } + } + _ => {} + } + } + }; + + let voice = async { + let mut note_on: Option = None; + loop { + app.delay_millis(1).await; + + if pending_silence.get() { + pending_silence.set(false); + pending_fire.set(false); + pending_note_off.set(false); + if let Some(n) = note_on.take() { + midi.send_note_off(n).await; + } + if let Some(ref jack) = out_jack { + jack.set_value(0); + } + leds.set(0, Led::Bottom, led_color, Brightness::Off); + continue; + } + + if pending_note_off.get() { + pending_note_off.set(false); + if let Some(n) = note_on.take() { + midi.send_note_off(n).await; + } + // Gate / Velocity drop with the note; Pitch holds until next hit / silence. + if glob_cv_out.get() != OUT_PITCH { + if let Some(ref jack) = out_jack { + jack.set_value(0); + } + } + leds.set(0, Led::Bottom, led_color, Brightness::Off); + } + + if pending_fire.get() { + pending_fire.set(false); + if !glob_muted.get() { + let octaves = clamp_octaves(glob_octaves.get()); + let lo = glob_base.get(); + let hi = clamp_note(lo as i16 + (octaves as i16) * 12); + let raw = pending_raw.get(); + fire_note( + &midi, + out_jack.as_ref(), + &quantizer, + &leds, + led_color, + vpo, + range, + glob_cv_out.get(), + raw, + pending_vel.get(), + pitch_bright(raw, lo, hi), + &mut note_on, + ) + .await; + } + } + } + }; + + 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() { + if cv_jack == CV_JACK_OUT { + // Shift + short (CV Out): cycle Pitch / Gate / Velocity + flash. + let next = storage.modify_and_save(|s| { + s.cv_out = cycle_cv_out(s.cv_out); + s.cv_out + }); + let next = next as usize; + glob_cv_out.set(next); + params.update(|p| p.cv_out = next).await; + glob_reverse_fade.set(0); + glob_octave_blink.set(0); + leds.set_mode( + 0, + Led::Button, + LedMode::Flash(cv_out_color(next), Some(3)), + ); + glob_btn_flash.set(BUTTON_FLASH_MS); + } else { + // Shift + short (CV In): reverse playback direction. + let reversed = glob_reversed.toggle(); + storage.modify_and_save(|s| s.reversed = reversed); + glob_octave_blink.set(0); + 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: reroll pool + expression depth (vel/gate jitter). + glob_reroll.set(true); + glob_reset.set(true); + } else if !glob_fader_moved.get() { + // Long press without fader move: mute. + let muted = glob_muted.toggle(); + storage.modify_and_save(|s| s.muted = muted); + if muted { + pending_silence.set(true); + leds.unset(0, Led::Button); + leds.unset(0, Led::Bottom); + } + // Unmute: Button restored by shift loop via latch_level. + } + } + } + }; + + let long_press = async { + loop { + buttons.wait_for_any_long_press().await; + long_press_fired.set(true); + + if buttons.is_shift_pressed() { + // Shift + long: cycle octave span 1→2→3→4. + let octaves = cycle_octaves(glob_octaves.get()); + glob_octaves.set(octaves); + storage.modify_and_save(|s| s.octaves = octaves); + // Top cue + one Button blink in the span color. + leds.set(0, Led::Top, octave_color(octaves), Brightness::High); + glob_reverse_fade.set(0); + glob_btn_flash.set(0); + glob_octave_blink.set(OCTAVE_BLINK_MS); + } + } + }; + + 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(); + + 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 => storage.query(|s| s.alpha_saved), + }; + + if let Some(new_value) = latch.update(faders.get_value(), latch_layer, target_value) { + match latch_layer { + LatchLayer::Main => { + glob_mutation.set(new_value); + storage.modify_and_save(|s| s.fader_saved = new_value); + } + LatchLayer::Alt => { + glob_texture.set(new_value); + let (_, phrase, _) = texture_from_value(new_value); + glob_phrase.set(phrase); + storage.modify_and_save(|s| { + s.shift_fader_saved = new_value; + s.phrase_len = phrase as u8; + }); + } + LatchLayer::Third => { + glob_fader_moved.set(true); + glob_alpha.set(new_value); + storage.modify_and_save(|s| s.alpha_saved = new_value); + } + } + } + } + }; + + 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, + alpha_saved, + expression, + octaves, + cv_out, + muted, + reversed, + ) = storage.query(|s| { + ( + s.fader_saved, + s.shift_fader_saved, + s.alpha_saved, + s.expression, + s.octaves, + s.cv_out, + s.muted, + s.reversed, + ) + }); + glob_mutation.set(fader_saved); + glob_texture.set(shift_fader_saved); + glob_alpha.set(alpha_saved); + glob_expression.set(expression); + glob_octaves.set(clamp_octaves(octaves)); + let cv_out = (cv_out as usize).min(OUT_COUNT - 1); + glob_cv_out.set(cv_out); + params.update(|p| p.cv_out = cv_out).await; + glob_muted.set(muted); + glob_reversed.set(reversed); + let (_, phrase, _) = texture_from_value(shift_fader_saved); + glob_phrase.set(phrase); + glob_reroll.set(false); + glob_reload_pool.set(true); + glob_reset.set(true); + + if muted { + midi.send_note_off(base_note).await; + leds.unset(0, Led::Button); + } + // Unmute / level: Button restored by shift loop via latch_level. + } + SceneEvent::SaveScene(scene) => { + storage.save_to_scene(scene).await; + } + } + } + }; + + let shift = async { + let mut prev_gate_high = false; + loop { + app.delay_millis(1).await; + if let Some(ref input) = in_jack { + let in_val = attenuate_bipolar(input.get_value(), cv_att); + glob_cv_val.set(in_val); + if cv_dest == DEST_REROLL { + let high = in_val >= TRIG_HIGH; + if high && !prev_gate_high { + glob_reroll.set(true); + glob_reset.set(true); + } + prev_gate_high = high; + } else { + 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); + + // Live fader → brightness so scrubbing shows a real gradient immediately + // (params still go through the latch in fut_faders). + let layer_color = latch_hue(latch_active_layer, led_color); + let fader_now = faders.get_value(); + let btn_bright = level_bright(fader_now, BTN_LEVEL_FLOOR); + let top_bright = level_bright(fader_now, TOP_LEVEL_FLOOR); + + // Top: deeper dim→bright. Button: Low→High with sqrt ease. + leds.set(0, Led::Top, layer_color, top_bright); + + // Reverse / octave / CV-flash cues own the Button briefly; else latch level. + let fade_left = glob_reverse_fade.get(); + let blink_left = glob_octave_blink.get(); + let flash_left = glob_btn_flash.get(); + if flash_left > 0 { + glob_btn_flash.set(flash_left.saturating_sub(1)); + } else 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)); + glob_reverse_fade.set(fade_left.saturating_sub(1)); + } else if blink_left > 0 { + // Octave-span cue: one Button blink in Blue/Cyan/Yellow/Red. + let bright = + ((blink_left as u32 * 255) / OCTAVE_BLINK_MS as u32).min(255) as u8; + leds.set( + 0, + Led::Button, + octave_color(glob_octaves.get()), + Brightness::Custom(bright), + ); + glob_octave_blink.set(blink_left.saturating_sub(1)); + } else if glob_muted.get() { + leds.unset(0, Led::Button); + } else { + leds.set(0, Led::Button, layer_color, btn_bright); + } + } + }; + + join( + long_press, + join5( + join(fut_clock, voice), + fut_buttons, + fut_faders, + scene_handler, + shift, + ), + ) + .await; +} + +#[allow(clippy::too_many_arguments)] +async fn fire_note( + midi: &crate::app::MidiOutput, + cv_jack: Option<&crate::app::OutJack>, + quantizer: &crate::app::Quantizer, + leds: &crate::app::Leds, + led_color: Color, + vpo: VoltPerOct, + out_range: Range, + cv_out: usize, + raw: u8, + velocity: u16, + bottom_bright: Brightness, + note_on: &mut Option, +) { + // Quantize via 1V/oct counts derived from the MIDI note, then emit MIDI + // (and CV according to CV Out mode — same dual-path idea as GenSeq). + let pitch = note_to_pitch(raw); + let counts = pitch.as_counts(out_range, vpo); + let q = quantizer.get_quantized_note(counts).await; + let out_counts = q.as_counts(out_range, vpo); + if let Some(jack) = cv_jack { + match cv_out.min(OUT_COUNT - 1) { + OUT_GATE => jack.set_value(4095), + OUT_VELOCITY => jack.set_value(velocity), + _ => jack.set_value(out_counts), // Pitch + } + } + + let midi_n = q.as_midi(); + // Prefer quantized pitch; fall back to raw if bypass leaves us at 0. + let n = if u7::from(midi_n).as_int() == 0 { + MidiNote::from(raw) + } else { + midi_n + }; + + if let Some(prev) = note_on.take() { + midi.send_note_off(prev).await; + } + midi.try_send_note_on(n, velocity); + *note_on = Some(n); + // Bottom: pitch height within octave span while the gate is open. + leds.set(0, Led::Bottom, led_color, bottom_bright); +} diff --git a/faderpunk/src/apps/follow_key.rs b/faderpunk/src/apps/follow_key.rs new file mode 100644 index 000000000..1da30c1a5 --- /dev/null +++ b/faderpunk/src/apps/follow_key.rs @@ -0,0 +1,104 @@ +//! Shared "follow the device tonality" helpers for note-generating apps. +//! +//! The device has one global Key + Tonic, reachable live by holding the Scene +//! button (Fader 4 = Scale, Fader 5 = Tonic). An app that follows the Tonic +//! turns that fader into a **transpose** — and every following app moves +//! together, which is what you want when a bass and a melody play the same +//! tune. Apps expose this as two `Param::bool`s named "Follow device tonic" +//! and "Follow device scale". +//! +//! Scale and Tonic are deliberately separate: an app's Scale is often part of +//! its own character (Contura's Folk set, Bassment's genre feel), so the usual +//! default is to follow the Tonic but keep the local Scale. +//! +//! **Cost:** every call here copies the whole `GlobalConfig`. Resolve once per +//! bar or per phrase and cache it — never per step and never per note. + +// Each app needs a different part of this surface, and the app feature branches +// carry one app each — so on those, some of it is unused by construction. +#![allow(dead_code)] + +use libfp::{Key, MidiNote}; +use midly::num::u7; + +use crate::tasks::global_config::get_global_config; + +/// The device Scale, normalized for note generators: a Key of `Off` means +/// "don't quantize" device-wide, but a generator has to pick notes from +/// *something*, so it reads as chromatic here. +pub fn device_key() -> Key { + match get_global_config().quantizer.key { + Key::Off => Key::Chromatic, + k => k, + } +} + +/// Pitch class (0–11) the app should anchor on: the device Tonic when +/// following, otherwise the pitch class of the app's own root. +pub fn tonic_pc(follow: bool, local_root: MidiNote) -> u8 { + if follow { + get_global_config().quantizer.tonic as u8 + } else { + midi_u8(local_root) % 12 + } +} + +/// The app's root, retuned onto the device Tonic when following. This is all an +/// app needs whose scale already comes from the global quantizer — there the +/// scale follows anyway and only the root runs loose. +pub fn root(follow: bool, local_root: MidiNote) -> u8 { + if follow { + retune(local_root, get_global_config().quantizer.tonic as u8) + } else { + midi_u8(local_root) + } +} + +/// Root *and* Scale at once, for apps whose scale is a plain [`Key`] — one +/// `GlobalConfig` copy instead of two. +/// +/// The returned root keeps the octave of `local_root` and only takes on the +/// new pitch class, so following the Tonic transposes within the register the +/// patch was written in rather than jumping the app an octave. +pub fn root_and_key( + follow_tonic: bool, + follow_scale: bool, + local_root: MidiNote, + local_key: Key, +) -> (u8, Key) { + if !follow_tonic && !follow_scale { + return (midi_u8(local_root), local_key); + } + let c = get_global_config(); + let pc = if follow_tonic { + c.quantizer.tonic as u8 + } else { + midi_u8(local_root) % 12 + }; + let key = if follow_scale { + match c.quantizer.key { + Key::Off => Key::Chromatic, + k => k, + } + } else { + local_key + }; + (retune(local_root, pc), key) +} + +/// Re-anchor an absolute root note onto pitch class `pc`, keeping its octave. +pub fn retune(root: MidiNote, pc: u8) -> u8 { + let n = midi_u8(root); + let target = (n - n % 12) + pc % 12; + // A root in the top octave can overshoot; drop an octave rather than clamp, + // so the pitch class stays the one that was asked for. + if target > 127 { + target - 12 + } else { + target + } +} + +fn midi_u8(note: MidiNote) -> u8 { + u7::from(note).as_int() +} diff --git a/faderpunk/src/apps/mod.rs b/faderpunk/src/apps/mod.rs index 0bfbba74b..4835bd39a 100644 --- a/faderpunk/src/apps/mod.rs +++ b/faderpunk/src/apps/mod.rs @@ -1,3 +1,5 @@ +mod follow_key; + register_apps!( 1 => control, 2 => lfo, @@ -26,4 +28,5 @@ register_apps!( 25 => automator, 26 => genseq, 27 => bernoulli, + 34 => arp_de_levy, ); 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 { diff --git a/faderpunk/src/tasks/clock.rs b/faderpunk/src/tasks/clock.rs index 40b0afc85..4b5346ee7 100644 --- a/faderpunk/src/tasks/clock.rs +++ b/faderpunk/src/tasks/clock.rs @@ -15,7 +15,7 @@ use embassy_sync::{ use embassy_time::{Duration, Instant, Timer}; use heapless::Deque; use midly::live::SystemRealtime; -use portable_atomic::{AtomicBool, Ordering}; +use portable_atomic::{AtomicBool, AtomicU64, Ordering}; use libfp::{ utils::bpm_to_clock_duration, AuxJackMode, ClockSrc, GlobalConfig, MidiOut, MidiOutConfig, @@ -34,7 +34,7 @@ use crate::{ const CLOCK_PUBSUB_SIZE: usize = 16; // 16 apps + 1 metronome -const CLOCK_PUBSUB_SUBSCRIBERS: usize = 17; +const CLOCK_PUBSUB_SUBSCRIBERS: usize = 16; // Only the gatekeeper publishes to CLOCK_PUBSUB const CLOCK_PUBSUB_PUBLISHERS: usize = 5; // Add a slight delay before the very first tick (to offset it to reset) @@ -46,6 +46,14 @@ const METRONOME_HIGH_MS: u64 = 25; pub static METRONOME_HIGH: AtomicBool = AtomicBool::new(true); +/// Current gatekeeper tick, mirrored for readers that only need the count. +/// +/// A [`CLOCK_PUBSUB`] subscription costs a queue slot and obliges the reader to +/// keep draining it; anything that only wants "which tick is it" can poll this +/// instead and never influence the gatekeeper. `u64::MAX` means "not started" — +/// the first tick after a Start/Reset is 0. +pub static TICK_COUNTER: AtomicU64 = AtomicU64::new(u64::MAX); + type AuxInputs = ( Peri<'static, PIN_1>, Peri<'static, PIN_2>, @@ -275,10 +283,11 @@ async fn send_analog_ticks(spawner: &Spawner, config: &GlobalConfig, counters: & } } if !ports.is_empty() { - MAX_CHANNEL + // try_send for the same reason: a full MAX queue must not stall the + // clock gatekeeper. A dropped analog tick is better than a dead clock. + let _ = MAX_CHANNEL .sender() - .send(MaxCmd::GpoSetHighMany(ports.clone())) - .await; + .try_send(MaxCmd::GpoSetHighMany(ports.clone())); spawner.spawn(analog_tick_release(ports, 5)).ok(); } } @@ -291,10 +300,11 @@ async fn send_analog_reset(spawner: &Spawner, config: &GlobalConfig) { } } if !ports.is_empty() { - MAX_CHANNEL + // try_send for the same reason: a full MAX queue must not stall the + // clock gatekeeper. A dropped analog tick is better than a dead clock. + let _ = MAX_CHANNEL .sender() - .send(MaxCmd::GpoSetHighMany(ports.clone())) - .await; + .try_send(MaxCmd::GpoSetHighMany(ports.clone())); spawner.spawn(analog_tick_release(ports, 10)).ok(); } } @@ -302,33 +312,49 @@ async fn send_analog_reset(spawner: &Spawner, config: &GlobalConfig) { #[embassy_executor::task(pool_size = 4)] async fn analog_tick_release(ports: heapless::Vec, trigger_len: u64) { Timer::after_millis(trigger_len).await; - MAX_CHANNEL - .sender() - .send(MaxCmd::GpoSetLowMany(ports)) - .await; + let _ = MAX_CHANNEL.sender().try_send(MaxCmd::GpoSetLowMany(ports)); } +/// Scene LED beat flash — polls [`TICK_COUNTER`] only. +/// +/// Must never subscribe to [`CLOCK_PUBSUB`]: awaiting a timer while holding a +/// subscriber slot lets the shared queue fill, and a gatekeeper that cannot +/// publish stops the whole device clock. #[embassy_executor::task] async fn metronome() { - let mut sub = CLOCK_PUBSUB.subscriber().unwrap(); + let mut last_seen = TICK_COUNTER.load(Ordering::Relaxed); + let mut last_beat = u64::MAX; + let mut high_left_ms: u16 = 0; loop { - match sub.next_message_pure().await { - ClockEvent::Tick(ticks) => { - // Fire on the first tick of each quarter note (every 24 ppqn ticks). - if ticks.is_multiple_of(24) { - METRONOME_HIGH.store(true, Ordering::Relaxed); - Timer::after_millis(METRONOME_HIGH_MS).await; - METRONOME_HIGH.store(false, Ordering::Relaxed); - } - } - ClockEvent::Start | ClockEvent::Reset => { - METRONOME_HIGH.store(true, Ordering::Relaxed); - } - ClockEvent::Stop => { + Timer::after_millis(1).await; + + if high_left_ms > 0 { + high_left_ms -= 1; + if high_left_ms == 0 { METRONOME_HIGH.store(false, Ordering::Relaxed); } } + + let t = TICK_COUNTER.load(Ordering::Relaxed); + if t == last_seen { + continue; + } + // Counter reset (Start/Reset stores u64::MAX, then ticks from 0). + if t < last_seen { + last_beat = u64::MAX; + METRONOME_HIGH.store(true, Ordering::Relaxed); + high_left_ms = METRONOME_HIGH_MS as u16; + } + last_seen = t; + + // First tick of each quarter note (every 24 PPQN ticks). + let beat = t / 24; + if beat != last_beat { + last_beat = beat; + METRONOME_HIGH.store(true, Ordering::Relaxed); + high_left_ms = METRONOME_HIGH_MS as u16; + } } } @@ -400,9 +426,15 @@ async fn run_clock_gatekeeper() { || matches!(source, ClockSrc::Atom | ClockSrc::Meteor | ClockSrc::Cube) { tick_counter = tick_counter.wrapping_add(1); - clock_publisher - .publish(ClockEvent::Tick(tick_counter)) - .await; + // Publish the count before the pubsub so a poller + // never reads a tick the subscribers already saw. + TICK_COUNTER.store(tick_counter, Ordering::Relaxed); + // Never await on the tick path: a subscriber that + // sleeps while holding its slot fills the queue, + // and a blocked gatekeeper stops the whole device + // clock. Overwriting the oldest tick only costs a + // lagged subscriber a beat. + clock_publisher.publish_immediate(ClockEvent::Tick(tick_counter)); send_analog_ticks(&spawner, &config, &mut analog_tick_counters).await; } } @@ -423,6 +455,7 @@ async fn run_clock_gatekeeper() { // (Re-)start the clock. Full phase reset ClockInEvent::Start(_) => { tick_counter = u64::MAX; + TICK_COUNTER.store(u64::MAX, Ordering::Relaxed); is_running = true; clock_publisher.publish(ClockEvent::Reset).await; clock_publisher.publish(ClockEvent::Start).await; @@ -439,6 +472,7 @@ async fn run_clock_gatekeeper() { // Reset the phase without affecting the run state ClockInEvent::Reset(_) => { tick_counter = u64::MAX; + TICK_COUNTER.store(u64::MAX, Ordering::Relaxed); clock_publisher.publish(ClockEvent::Reset).await; analog_tick_counters = [0; 3]; send_analog_reset(&spawner, &config).await;