diff --git a/docs/apps/48-maniac-bounce/manual.json b/docs/apps/48-maniac-bounce/manual.json
new file mode 100644
index 000000000..4e76b1ce9
--- /dev/null
+++ b/docs/apps/48-maniac-bounce/manual.json
@@ -0,0 +1,86 @@
+{
+ "id": 48,
+ "slug": "maniac_bounce",
+ "name": "Maniac Bounce",
+ "status": "wip",
+ "tag": "draft",
+ "description": "Delay with bounce pitch and velocity LFO",
+ "color": "Pink",
+ "icon": "Sine",
+ "channels_count": 1,
+ "cv_jack": true,
+ "params": [
+ "I/O",
+ "Max delay (ms)",
+ "Signal",
+ "Range",
+ "Color",
+ "MIDI In",
+ "MIDI In CH",
+ "MIDI Out",
+ "MIDI Out",
+ "MIDI CC",
+ "MIDI Note"
+ ],
+ "storage": [
+ "Delay / decay",
+ "Bounce interval",
+ "Vel LFO",
+ "Muted"
+ ],
+ "columns": [
+ {
+ "sections": [
+ {
+ "heading": "Faders",
+ "items": [
+ "Main — Delay (up = shorter) plus coupled decay (short trails die faster)",
+ "Alt — Bounce interval bipolar ±24 st (default ~+6)",
+ "Third — Velocity LFO rate + depth (up = faster and deeper)"
+ ]
+ }
+ ]
+ },
+ {
+ "sections": [
+ {
+ "heading": "Buttons",
+ "items": [
+ "Short — Mute / ring-out (block new input; trail plays out)",
+ "Long — Panic (clear queue + NoteOffs); skipped if Third was used this hold"
+ ]
+ }
+ ]
+ },
+ {
+ "sections": [
+ {
+ "heading": "Params / LEDs",
+ "items": [
+ "I/O: MIDI→MIDI · MIDI→CV · CV→MIDI; Signal Pitch / Gate / CV→CC / Gate→Note",
+ "Free-running ms delay only — no clock sync, no ping-pong",
+ "Poly with same-pitch kill; note length ≈ 50% of frozen delay",
+ "MIDI→CV: Vel LFO shapes gate pulse width; Pitch CV follows bounce",
+ "White flash on input (~20% when muted)"
+ ]
+ }
+ ]
+ }
+ ],
+ "channels": [
+ {
+ "jackTitle": "Delay I/O",
+ "jackDescription": "MIDI or CV in, MIDI or CV out per I/O mode. Dry through plus bouncing repeats.",
+ "faderTitle": "Delay / decay",
+ "faderDescription": "Repeat spacing. Fader up is shorter and decays faster; down is slower with more hops.",
+ "faderPlusShiftTitle": "Bounce interval",
+ "faderPlusShiftDescription": "Each repeat alternates +interval / −interval, ±24 semitones.",
+ "faderPlusFnTitle": "Velocity LFO",
+ "faderPlusFnDescription": "Rate and depth together on dry hits and repeats. MIDI→CV Gate uses the same LFO for pulse width.",
+ "fnTitle": "Mute / panic",
+ "fnDescription": "Short mute (ring-out). Long panic unless you were editing Third.",
+ "ledTop": "Activity / bounce interval",
+ "ledBottom": "Activity / bounce interval"
+ }
+ ]
+}
diff --git a/docs/apps/48-maniac-bounce/manual.md b/docs/apps/48-maniac-bounce/manual.md
new file mode 100644
index 000000000..2bc4c6146
--- /dev/null
+++ b/docs/apps/48-maniac-bounce/manual.md
@@ -0,0 +1,3 @@
+Maniac Bounce is a one-channel MIDI/CV delay with dry-through, bounce pitch, and a velocity LFO on dry notes and repeats. Main sets delay time (fader up = shorter) and echo retention together — short delays fade quickly, long delays keep bouncing. Shift + fader sets the bounce interval bipolar ±24 semitones (default about +6 st). Button + fader sets Vel LFO rate and depth coupled (up = faster and deeper); the LFO modulates velocity on every note including the immediate dry hit. In MIDI→CV Gate mode the same LFO shapes gate pulse width instead.
+
+Each note auto-gates to about half the frozen delay period. Bounce alternates +interval / −interval per repeat. A new NoteOn on the same pitch kills the prior chain for that pitch (poly with same-pitch kill). NoteOff does not kill the trail. Short button tap mutes new input while the trail rings out; long press panics unless you are holding Button+fader for Vel LFO. White button flash on input. Free-running ms delay only — no clock sync.
diff --git a/faderpunk/src/apps/maniac_bounce.rs b/faderpunk/src/apps/maniac_bounce.rs
new file mode 100644
index 000000000..9fcf098a3
--- /dev/null
+++ b/faderpunk/src/apps/maniac_bounce.rs
@@ -0,0 +1,1275 @@
+use embassy_futures::{
+ join::{join, join4},
+ select::{select, select3, Either},
+};
+use embassy_sync::{blocking_mutex::raw::NoopRawMutex, signal::Signal};
+use embassy_time::Instant;
+use heapless::Vec;
+use libm;
+use midly::{num::u7, MidiMessage};
+use serde::{Deserialize, Serialize};
+
+use libfp::{
+ ext::FromValue,
+ latch::LatchLayer,
+ utils::{bits_7_16, midi_gate, scale_bits_7_12, split_unsigned_value},
+ AppIcon, Brightness, Color, Config, MidiCc, MidiChannel, MidiIn, MidiNote, MidiOut, Param,
+ Range, Value, APP_MAX_PARAMS,
+};
+
+use crate::app::{App, AppParams, AppStorage, Led, ManagedStorage, ParamStore, SceneEvent};
+
+pub const CHANNELS: usize = 1;
+pub const PARAMS: usize = 11;
+
+const LED_BRIGHTNESS: Brightness = Brightness::Mid;
+const BUTTON_DUCK_MS: u16 = 25;
+const MAX_REPEATS: u8 = 16;
+const VELOCITY_FLOOR: u16 = 200;
+const QUEUE_CAP: usize = 48;
+const QUEUE_FEEDBACK_RESERVE: usize = 4;
+const SOUNDING_CAP: usize = 32;
+const OPEN_NOTES_CAP: usize = 16;
+const GATE_THRESH: u16 = 406;
+const INPUT_FLASH_PEAK: u8 = 255;
+const INPUT_FLASH_MUTED_SCALE: u16 = 51;
+const LOOPBACK_IGNORE_MS: u64 = 40;
+const RECENT_EMIT_CAP: usize = 24;
+const MIN_DELAY_MS: u64 = 1;
+const MAX_DELAY_MS_CAP: i32 = 8000;
+const INTERVAL_ST_MAX: i32 = 24;
+/// Default Alt fader: ~+6 semitones (nonzero bounce interval).
+const DEFAULT_INTERVAL_SAVED: u16 = 2560;
+/// Default Third fader: mid-high Vel LFO.
+const DEFAULT_VEL_LFO_SAVED: u16 = 3072;
+
+const IO_MIDI_MIDI: usize = 0;
+const IO_MIDI_CV: usize = 1;
+const IO_CV_MIDI: usize = 2;
+
+const SIG_PITCH: usize = 0;
+const SIG_GATE: usize = 1;
+const SIG_CV_CC: usize = 2;
+const SIG_GATE_NOTE: usize = 3;
+
+pub static CONFIG: Config = Config::new(
+ "Maniac Bounce",
+ "Delay with bounce pitch and velocity LFO",
+ Color::Pink,
+ AppIcon::Sine,
+)
+.add_param(Param::Enum {
+ name: "I/O",
+ variants: &["MIDI->MIDI", "MIDI->CV", "CV->MIDI"],
+})
+.add_param(Param::i32 {
+ name: "Max delay (ms)",
+ min: 10,
+ max: 8000,
+})
+.add_param(Param::Enum {
+ name: "Signal",
+ variants: &["Pitch", "Gate", "CV->CC", "Gate->Note"],
+})
+.add_param(Param::Range {
+ name: "Range",
+ variants: &[Range::_0_10V, Range::_Neg5_5V],
+})
+.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::MidiIn)
+.add_param(Param::MidiChannel { name: "MIDI In CH" })
+.add_param(Param::MidiOut)
+.add_param(Param::MidiChannel { name: "MIDI Out" })
+.add_param(Param::MidiCc { name: "MIDI CC" })
+.add_param(Param::MidiNote { name: "MIDI Note" });
+
+pub struct Params {
+ io_mode: usize,
+ max_delay_ms: i32,
+ signal: usize,
+ range: Range,
+ color: Color,
+ midi_in: MidiIn,
+ midi_in_ch: MidiChannel,
+ midi_out: MidiOut,
+ midi_out_ch: MidiChannel,
+ midi_cc: MidiCc,
+ midi_note: MidiNote,
+}
+
+impl AppParams for Params {
+ fn from_values(values: &[Value]) -> Option {
+ if values.len() < PARAMS {
+ return None;
+ }
+ Some(Self {
+ io_mode: usize::from_value(values[0]),
+ max_delay_ms: i32::from_value(values[1]),
+ signal: usize::from_value(values[2]),
+ range: Range::from_value(values[3]),
+ color: Color::from_value(values[4]),
+ midi_in: MidiIn::from_value(values[5]),
+ midi_in_ch: MidiChannel::from_value(values[6]),
+ midi_out: MidiOut::from_value(values[7]),
+ midi_out_ch: MidiChannel::from_value(values[8]),
+ midi_cc: MidiCc::from_value(values[9]),
+ midi_note: MidiNote::from_value(values[10]),
+ })
+ }
+
+ fn to_values(&self) -> Vec {
+ let mut vec = Vec::new();
+ vec.push(self.io_mode.into()).unwrap();
+ vec.push(self.max_delay_ms.into()).unwrap();
+ vec.push(self.signal.into()).unwrap();
+ vec.push(self.range.into()).unwrap();
+ vec.push(self.color.into()).unwrap();
+ vec.push(self.midi_in.into()).unwrap();
+ vec.push(self.midi_in_ch.into()).unwrap();
+ vec.push(self.midi_out.into()).unwrap();
+ vec.push(self.midi_out_ch.into()).unwrap();
+ vec.push(self.midi_cc.into()).unwrap();
+ vec.push(self.midi_note.into()).unwrap();
+ vec
+ }
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct Storage {
+ delay_saved: u16,
+ interval_saved: u16,
+ vel_lfo_saved: u16,
+ muted: bool,
+}
+
+impl Default for Storage {
+ fn default() -> Self {
+ Self {
+ delay_saved: 2048,
+ interval_saved: DEFAULT_INTERVAL_SAVED,
+ vel_lfo_saved: DEFAULT_VEL_LFO_SAVED,
+ muted: false,
+ }
+ }
+}
+
+impl AppStorage for Storage {}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum EventKind {
+ NoteOn,
+ NoteOff,
+ CvValue,
+ GateHigh,
+ GateLow,
+}
+
+#[derive(Clone, Copy)]
+struct PendingEvent {
+ kind: EventKind,
+ base_note: u8,
+ velocity: u16,
+ cv_value: u16,
+ interval: i8,
+ due_ms: u64,
+ generation: u8,
+ delay_ms: u64,
+ base_interval: i8,
+ is_dry: bool,
+}
+
+fn fader_to_delay_ms(fader: u16, max_ms: i32) -> u64 {
+ let max = max_ms.clamp(10, MAX_DELAY_MS_CAP) as u32;
+ let inverted = 4095u32.saturating_sub(fader as u32);
+ ((inverted * max / 4095) as u64).max(MIN_DELAY_MS)
+}
+
+fn feedback_retention(fader: u16) -> u16 {
+ if fader == 0 {
+ return 0;
+ }
+ let x = fader as u32;
+ let inv = 4095u32.saturating_sub(x);
+ (4095u32.saturating_sub((inv * inv) / 4095)) as u16
+}
+
+/// Main fader couples delay length with echo retention (short delay -> fewer hops).
+fn main_coupled_retention(delay_fader: u16) -> u16 {
+ let inverted = 4095u16.saturating_sub(delay_fader);
+ feedback_retention(inverted)
+}
+
+fn next_repeat_velocity(velocity: u16, delay_fader: u16) -> Option {
+ let retention = main_coupled_retention(delay_fader);
+ if retention == 0 {
+ return None;
+ }
+ let next = ((velocity as u32 * retention as u32) / 4095) as u16;
+ (next >= VELOCITY_FLOOR).then_some(next)
+}
+
+fn ports_can_loop(midi_in: MidiIn, midi_out: MidiOut) -> bool {
+ let MidiIn([usb_in, din_in]) = midi_in;
+ let MidiOut([usb_out, din1_out, din2_out]) = midi_out;
+ (usb_in && usb_out) || (din_in && (din1_out || din2_out))
+}
+
+fn same_channel_loop_risk(midi_in_ch: MidiChannel, midi_out_ch: MidiChannel) -> bool {
+ midi_in_ch == midi_out_ch
+}
+
+#[derive(Clone, Copy)]
+struct RecentEmit {
+ note: u8,
+ is_on: bool,
+ at_ms: u64,
+}
+
+fn record_emit(recent: &mut Vec, note: u8, is_on: bool, now_ms: u64) {
+ let mut i = 0;
+ while i < recent.len() {
+ if now_ms.saturating_sub(recent[i].at_ms) >= LOOPBACK_IGNORE_MS {
+ recent.swap_remove(i);
+ } else {
+ i += 1;
+ }
+ }
+ if recent.is_full() {
+ let _ = recent.remove(0);
+ }
+ let _ = recent.push(RecentEmit {
+ note,
+ is_on,
+ at_ms: now_ms,
+ });
+}
+
+fn is_own_echo(recent: &[RecentEmit], note: u8, is_on: bool, now_ms: u64) -> bool {
+ recent.iter().any(|e| {
+ e.note == note && e.is_on == is_on && now_ms.saturating_sub(e.at_ms) < LOOPBACK_IGNORE_MS
+ })
+}
+
+fn fader_to_interval(fader: u16) -> i8 {
+ let centered = fader as i32 - 2048;
+ ((centered * INTERVAL_ST_MAX) / 2048).clamp(-INTERVAL_ST_MAX, INTERVAL_ST_MAX) as i8
+}
+
+/// Bounce only: even generation = +interval, odd = -interval.
+fn bounce_interval(base: i8, generation: u8) -> i8 {
+ if generation.is_multiple_of(2) {
+ base
+ } else {
+ base.saturating_neg()
+ }
+}
+
+fn note_num(base_note: u8, interval: i8) -> u8 {
+ (base_note as i16 + interval as i16).clamp(0, 127) as u8
+}
+
+fn note_to_cv(note: u8) -> u16 {
+ let note_in = bits_7_16(u7::new(note.min(127)));
+ ((note_in as u32 * 410) / 12).min(4095) as u16
+}
+
+fn midi_note_u8(note: MidiNote) -> u8 {
+ u7::from(note).as_int()
+}
+
+fn split_semitone_leds(interval: i32) -> [u8; 2] {
+ if interval >= 0 {
+ let pos = ((interval * 255) / INTERVAL_ST_MAX).clamp(0, 255) as u8;
+ [pos, 0]
+ } else {
+ let neg = (((-interval) * 255) / INTERVAL_ST_MAX).clamp(0, 255) as u8;
+ [0, neg]
+ }
+}
+
+fn vel_lfo_params(fader: u16) -> (f32, f32) {
+ let t = fader as f32 / 4095.0;
+ let rate = 0.5 + t * 11.5;
+ let depth = t * t;
+ (rate, depth)
+}
+
+fn apply_vel_lfo(base: u16, phase: f32, depth: f32) -> u16 {
+ if depth <= 0.0 {
+ return base;
+ }
+ let lfo = (libm::sinf(phase) + 1.0) * 0.5;
+ let scale = 1.0 - depth + depth * lfo;
+ ((base as f32 * scale).max(VELOCITY_FLOOR as f32)) as u16
+}
+
+fn gate_pulse_factor(phase: f32, depth: f32) -> f32 {
+ if depth <= 0.0 {
+ return 1.0;
+ }
+ let lfo = (libm::sinf(phase) + 1.0) * 0.5;
+ 0.2 + 0.8 * (1.0 - depth + depth * lfo)
+}
+
+fn pulse_from_interval(interval: i8) -> u8 {
+ const MIN: u32 = 26;
+ const MAX: u32 = 255;
+ let mag = interval.unsigned_abs() as u32;
+ (MIN + (mag * (MAX - MIN) / INTERVAL_ST_MAX as u32)) as u8
+}
+
+fn pulse_from_vel_lfo(fader: u16) -> u8 {
+ const MIN: u32 = 26;
+ const MAX: u32 = 255;
+ (MIN + (fader as u32 * (MAX - MIN) / 4095)) as u8
+}
+
+fn effective_signal(io_mode: usize, signal: usize) -> usize {
+ match io_mode {
+ IO_MIDI_CV => {
+ if signal == SIG_GATE {
+ SIG_GATE
+ } else {
+ SIG_PITCH
+ }
+ }
+ IO_CV_MIDI => {
+ if signal == SIG_CV_CC {
+ SIG_CV_CC
+ } else {
+ SIG_GATE_NOTE
+ }
+ }
+ _ => SIG_PITCH,
+ }
+}
+
+fn note_length_ms(delay_ms: u64) -> u64 {
+ (delay_ms / 2).max(1)
+}
+
+#[embassy_executor::task(pool_size = 16 / CHANNELS)]
+pub async fn wrapper(app: App, exit_signal: &'static Signal) {
+ let ch = app.start_channel as u8;
+ let param_store = ParamStore::::new(
+ app.app_id,
+ app.layout_id,
+ Params {
+ io_mode: IO_MIDI_MIDI,
+ max_delay_ms: 2000,
+ signal: SIG_PITCH,
+ range: Range::_0_10V,
+ color: Color::Pink,
+ midi_in: MidiIn::default(),
+ midi_in_ch: MidiChannel::default(),
+ midi_out: MidiOut::default(),
+ midi_out_ch: MidiChannel::from(2),
+ midi_cc: MidiCc::from(32u8.saturating_add(ch)),
+ midi_note: MidiNote::from(60),
+ },
+ );
+ 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 (
+ io_mode,
+ max_delay_ms,
+ signal,
+ range,
+ led_color,
+ midi_in_cfg,
+ midi_in_ch,
+ midi_out_cfg,
+ midi_out_ch,
+ midi_cc,
+ midi_note,
+ ) = params.query(|p| {
+ (
+ p.io_mode,
+ p.max_delay_ms,
+ p.signal,
+ p.range,
+ p.color,
+ p.midi_in,
+ p.midi_in_ch,
+ p.midi_out,
+ p.midi_out_ch,
+ p.midi_cc,
+ p.midi_note,
+ )
+ });
+
+ let sig = effective_signal(io_mode, signal);
+ let base_note_cfg = midi_note_u8(midi_note);
+ let loop_guard = io_mode == IO_MIDI_MIDI
+ && ports_can_loop(midi_in_cfg, midi_out_cfg)
+ && same_channel_loop_risk(midi_in_ch, midi_out_ch);
+
+ let fader = app.use_faders();
+ let buttons = app.use_buttons();
+ let leds = app.use_leds();
+
+ let mut midi_in = app.use_midi_input(midi_in_cfg, midi_in_ch);
+ let midi_out = app.use_midi_output(midi_out_cfg, midi_out_ch, false);
+
+ let out_jack = if io_mode == IO_MIDI_CV {
+ Some(app.make_out_jack(0, range).await)
+ } else {
+ None
+ };
+ let in_jack = if io_mode == IO_CV_MIDI {
+ Some(app.make_in_jack(0, range).await)
+ } else {
+ None
+ };
+
+ let glob_muted = app.make_global(false);
+ let long_press_fired = app.make_global(false);
+ let third_layer_used = app.make_global(false);
+ let panic_flag = app.make_global(false);
+ let glob_latch_layer = app.make_global(LatchLayer::Main);
+ let delay_saved_glob = app.make_global(2048u16);
+ let interval_glob = app.make_global(0i8);
+ let vel_lfo_glob = app.make_global(DEFAULT_VEL_LFO_SAVED);
+ let activity_glob = app.make_global(0u8);
+ let button_duck_glob = app.make_global(0u16);
+ let input_flash_glob = app.make_global(0u8);
+ let queue_depth_glob = app.make_global(0u8);
+ let lfo_phase_glob = app.make_global(0.0f32);
+
+ let (delay_saved, interval_saved, vel_lfo_saved, muted) =
+ storage.query(|s| (s.delay_saved, s.interval_saved, s.vel_lfo_saved, s.muted));
+ delay_saved_glob.set(delay_saved);
+ interval_glob.set(fader_to_interval(interval_saved));
+ vel_lfo_glob.set(vel_lfo_saved);
+ glob_muted.set(muted);
+
+ if muted {
+ leds.unset(0, Led::Button);
+ leds.unset(0, Led::Top);
+ leds.unset(0, Led::Bottom);
+ } else {
+ leds.set(0, Led::Button, led_color, LED_BRIGHTNESS);
+ }
+
+ let engine = async {
+ let mut queue: Vec = Vec::new();
+ let mut sounding: Vec<(u8, u8), SOUNDING_CAP> = Vec::new();
+ let mut open_notes: Vec<(u8, u64, i8), OPEN_NOTES_CAP> = Vec::new();
+ let mut open_gate_delay: Option<(u64, i8)> = None;
+ let mut recent_emit: Vec = Vec::new();
+ let mut prev_gate = false;
+ let mut last_cc_gate: u16 = u16::MAX;
+ let mut lfo_phase = 0.0f32;
+
+ let enqueue = |queue: &mut Vec,
+ kind: EventKind,
+ base_note: u8,
+ velocity: u16,
+ cv_value: u16,
+ generation: u8,
+ base_interval: i8,
+ delay_ms: u64,
+ now_ms: u64,
+ is_dry: bool|
+ -> bool {
+ let is_release = matches!(kind, EventKind::NoteOff | EventKind::GateLow);
+ if generation > 0 && !is_release && queue.len() + QUEUE_FEEDBACK_RESERVE >= QUEUE_CAP {
+ return false;
+ }
+ if queue.is_full() {
+ if is_release {
+ if let Some(pos) = queue.iter().position(|e| {
+ e.generation > 0
+ && matches!(e.kind, EventKind::NoteOn | EventKind::GateHigh)
+ }) {
+ queue.swap_remove(pos);
+ } else if let Some(pos) = queue
+ .iter()
+ .position(|e| matches!(e.kind, EventKind::NoteOn | EventKind::GateHigh))
+ {
+ queue.swap_remove(pos);
+ } else {
+ return false;
+ }
+ } else {
+ return false;
+ }
+ }
+ queue
+ .push(PendingEvent {
+ kind,
+ base_note,
+ velocity,
+ cv_value,
+ interval: bounce_interval(base_interval, generation),
+ due_ms: now_ms.saturating_add(delay_ms),
+ generation,
+ delay_ms,
+ base_interval,
+ is_dry,
+ })
+ .is_ok()
+ };
+
+ let schedule_note_off = |queue: &mut Vec,
+ base_note: u8,
+ _interval: i8,
+ delay_ms: u64,
+ due_ms: u64,
+ generation: u8,
+ base_interval: i8| {
+ let off_ms = note_length_ms(delay_ms);
+ let _ = enqueue(
+ queue,
+ EventKind::NoteOff,
+ base_note,
+ 0,
+ 0,
+ generation,
+ base_interval,
+ off_ms,
+ due_ms,
+ false,
+ );
+ };
+
+ loop {
+ let midi_msg = if io_mode == IO_CV_MIDI {
+ app.delay_millis(1).await;
+ None
+ } else {
+ match select(midi_in.wait_for_message(), app.delay_millis(1)).await {
+ Either::First(msg) => Some(msg),
+ Either::Second(_) => None,
+ }
+ };
+
+ let delay_fader = delay_saved_glob.get();
+ let base_interval = interval_glob.get();
+ let vel_lfo_fader = vel_lfo_glob.get();
+ let now_ms = Instant::now().as_millis();
+ let delay_ms = fader_to_delay_ms(delay_fader, max_delay_ms);
+ let (lfo_rate, lfo_depth) = vel_lfo_params(vel_lfo_fader);
+ lfo_phase += lfo_rate * 0.001 * core::f32::consts::TAU;
+ lfo_phase_glob.set(lfo_phase);
+ let pulse = pulse_from_vel_lfo(vel_lfo_fader);
+
+ let kill_pitch = |queue: &mut Vec,
+ sounding: &mut Vec<(u8, u8), SOUNDING_CAP>,
+ base_note: u8| {
+ queue.retain(|e| e.base_note != base_note);
+ sounding.retain(|(_, b)| *b != base_note);
+ };
+
+ if let Some(msg) = midi_msg {
+ let accept_new = !glob_muted.get();
+ match (io_mode, sig, msg) {
+ (IO_MIDI_MIDI, _, MidiMessage::NoteOn { key, vel }) if vel > 0 => {
+ let key_n = key.as_int();
+ if loop_guard && is_own_echo(&recent_emit, key_n, true, now_ms) {
+ input_flash_glob.set(INPUT_FLASH_PEAK);
+ } else {
+ input_flash_glob.set(INPUT_FLASH_PEAK);
+ if accept_new {
+ for (note, base) in sounding.iter().copied().collect::>()
+ {
+ if base == key_n {
+ midi_out.send_note_off(MidiNote::from(note)).await;
+ }
+ }
+ kill_pitch(&mut queue, &mut sounding, key_n);
+
+ if let Some(slot) =
+ open_notes.iter_mut().find(|(n, _, _)| *n == key_n)
+ {
+ *slot = (key_n, delay_ms, base_interval);
+ } else {
+ let _ = open_notes.push((key_n, delay_ms, base_interval));
+ }
+
+ let vel12 = scale_bits_7_12(vel);
+ let mod_vel = apply_vel_lfo(vel12, lfo_phase, lfo_depth);
+ midi_out.send_note_on(MidiNote::from(key_n), mod_vel).await;
+ record_emit(&mut recent_emit, key_n, true, now_ms);
+ let _ = sounding.push((key_n, key_n));
+ activity_glob.set(pulse);
+ button_duck_glob.set(BUTTON_DUCK_MS);
+
+ let dry_len = note_length_ms(delay_ms);
+ let _ = enqueue(
+ &mut queue,
+ EventKind::NoteOff,
+ key_n,
+ 0,
+ 0,
+ 0,
+ base_interval,
+ dry_len,
+ now_ms,
+ true,
+ );
+
+ let _ = enqueue(
+ &mut queue,
+ EventKind::NoteOn,
+ key_n,
+ mod_vel,
+ 0,
+ 0,
+ base_interval,
+ delay_ms,
+ now_ms,
+ false,
+ );
+ schedule_note_off(
+ &mut queue,
+ key_n,
+ bounce_interval(base_interval, 0),
+ delay_ms,
+ now_ms.saturating_add(delay_ms),
+ 0,
+ base_interval,
+ );
+ }
+ }
+ }
+ (IO_MIDI_MIDI, _, MidiMessage::NoteOn { key, .. })
+ | (IO_MIDI_MIDI, _, MidiMessage::NoteOff { key, .. }) => {
+ let key_n = key.as_int();
+ if !(loop_guard && is_own_echo(&recent_emit, key_n, false, now_ms)) {
+ if let Some(pos) = open_notes.iter().position(|(n, _, _)| *n == key_n)
+ {
+ open_notes.swap_remove(pos);
+ }
+ // End dry only; bounce trail keeps auto-gating.
+ if let Some(pos) = sounding.iter().position(|(n, b)| *n == key_n && *b == key_n)
+ {
+ midi_out.send_note_off(MidiNote::from(key_n)).await;
+ sounding.swap_remove(pos);
+ record_emit(&mut recent_emit, key_n, false, now_ms);
+ }
+ }
+ }
+
+ (IO_MIDI_CV, SIG_PITCH, MidiMessage::NoteOn { key, vel }) if vel > 0 => {
+ input_flash_glob.set(INPUT_FLASH_PEAK);
+ if accept_new {
+ let key_n = key.as_int();
+ if let Some(jack) = out_jack.as_ref() {
+ jack.set_value(note_to_cv(key_n));
+ }
+ activity_glob.set(pulse);
+ let n = note_num(key_n, bounce_interval(base_interval, 0));
+ let _ = enqueue(
+ &mut queue,
+ EventKind::CvValue,
+ key_n,
+ scale_bits_7_12(vel),
+ note_to_cv(n),
+ 0,
+ base_interval,
+ delay_ms,
+ now_ms,
+ false,
+ );
+ }
+ }
+
+ (IO_MIDI_CV, SIG_GATE, MidiMessage::NoteOn { key, vel }) if vel > 0 => {
+ input_flash_glob.set(INPUT_FLASH_PEAK);
+ if accept_new {
+ let key_n = key.as_int();
+ kill_pitch(&mut queue, &mut sounding, key_n);
+ if let Some(slot) = open_notes.iter_mut().find(|(n, _, _)| *n == key_n)
+ {
+ *slot = (key_n, delay_ms, base_interval);
+ } else {
+ let _ = open_notes.push((key_n, delay_ms, base_interval));
+ }
+ if let Some(jack) = out_jack.as_ref() {
+ jack.set_value(4095);
+ }
+ activity_glob.set(pulse);
+ let pulse_ms = ((note_length_ms(delay_ms) as f32)
+ * gate_pulse_factor(lfo_phase, lfo_depth))
+ as u64;
+ let _ = enqueue(
+ &mut queue,
+ EventKind::GateLow,
+ key_n,
+ 0,
+ 0,
+ 0,
+ base_interval,
+ pulse_ms.max(1),
+ now_ms,
+ true,
+ );
+ let _ = enqueue(
+ &mut queue,
+ EventKind::GateHigh,
+ key_n,
+ scale_bits_7_12(vel),
+ 0,
+ 0,
+ base_interval,
+ delay_ms,
+ now_ms,
+ false,
+ );
+ }
+ }
+ (IO_MIDI_CV, SIG_GATE, MidiMessage::NoteOn { key, .. })
+ | (IO_MIDI_CV, SIG_GATE, MidiMessage::NoteOff { key, .. }) => {
+ let key_n = key.as_int();
+ if let Some(pos) = open_notes.iter().position(|(n, _, _)| *n == key_n) {
+ open_notes.swap_remove(pos);
+ }
+ if let Some(jack) = out_jack.as_ref() {
+ jack.set_value(0);
+ }
+ }
+ _ => {}
+ }
+ }
+
+ if io_mode == IO_CV_MIDI {
+ if let Some(jack) = in_jack.as_ref() {
+ let inval = jack.get_value();
+ let accept_new = !glob_muted.get();
+ if sig == SIG_GATE_NOTE {
+ let high = inval >= GATE_THRESH;
+ if high && !prev_gate {
+ input_flash_glob.set(INPUT_FLASH_PEAK);
+ if accept_new {
+ for (note, base) in sounding.iter().copied().collect::>()
+ {
+ if base == base_note_cfg {
+ midi_out.send_note_off(MidiNote::from(note)).await;
+ }
+ }
+ kill_pitch(&mut queue, &mut sounding, base_note_cfg);
+ open_gate_delay = Some((delay_ms, base_interval));
+
+ let mod_vel = apply_vel_lfo(4095, lfo_phase, lfo_depth);
+ midi_out
+ .send_note_on(MidiNote::from(base_note_cfg), mod_vel)
+ .await;
+ record_emit(&mut recent_emit, base_note_cfg, true, now_ms);
+ let _ = sounding.push((base_note_cfg, base_note_cfg));
+ activity_glob.set(pulse);
+
+ let dry_len = note_length_ms(delay_ms);
+ let _ = enqueue(
+ &mut queue,
+ EventKind::NoteOff,
+ base_note_cfg,
+ 0,
+ 0,
+ 0,
+ base_interval,
+ dry_len,
+ now_ms,
+ true,
+ );
+ let _ = enqueue(
+ &mut queue,
+ EventKind::NoteOn,
+ base_note_cfg,
+ mod_vel,
+ 0,
+ 0,
+ base_interval,
+ delay_ms,
+ now_ms,
+ false,
+ );
+ schedule_note_off(
+ &mut queue,
+ base_note_cfg,
+ bounce_interval(base_interval, 0),
+ delay_ms,
+ now_ms.saturating_add(delay_ms),
+ 0,
+ base_interval,
+ );
+ }
+ } else if !high && prev_gate {
+ let _ = open_gate_delay.take();
+ if let Some(pos) = sounding
+ .iter()
+ .position(|(n, b)| *n == base_note_cfg && *b == base_note_cfg)
+ {
+ midi_out.send_note_off(MidiNote::from(base_note_cfg)).await;
+ sounding.swap_remove(pos);
+ record_emit(&mut recent_emit, base_note_cfg, false, now_ms);
+ }
+ }
+ prev_gate = high;
+ } else if sig == SIG_CV_CC && accept_new {
+ let g = midi_gate(inval, false);
+ if g != last_cc_gate {
+ last_cc_gate = g;
+ let _ = enqueue(
+ &mut queue,
+ EventKind::CvValue,
+ 0,
+ 0,
+ inval,
+ 0,
+ 0,
+ delay_ms,
+ now_ms,
+ false,
+ );
+ }
+ }
+ }
+ }
+
+ if panic_flag.get() {
+ for (note, _) in sounding.iter() {
+ midi_out.send_note_off(MidiNote::from(*note)).await;
+ }
+ for event in queue.iter() {
+ if matches!(event.kind, EventKind::NoteOn) {
+ let n = note_num(event.base_note, event.interval);
+ midi_out.send_note_off(MidiNote::from(n)).await;
+ }
+ }
+ const ALL_SOUND_OFF: u8 = 120;
+ const ALL_NOTES_OFF: u8 = 123;
+ midi_out.send_cc(MidiCc::from(ALL_SOUND_OFF), 0).await;
+ midi_out.send_cc(MidiCc::from(ALL_NOTES_OFF), 0).await;
+
+ sounding.clear();
+ queue.clear();
+ open_notes.clear();
+ open_gate_delay = None;
+ recent_emit.clear();
+ if let Some(jack) = out_jack.as_ref() {
+ jack.set_value(0);
+ }
+ prev_gate = false;
+ last_cc_gate = u16::MAX;
+ panic_flag.set(false);
+ activity_glob.set(0);
+ queue_depth_glob.set(0);
+ continue;
+ }
+
+ let repeat_ok = matches!(
+ (io_mode, sig),
+ (IO_MIDI_MIDI, _) | (IO_MIDI_CV, SIG_GATE) | (IO_CV_MIDI, SIG_GATE_NOTE)
+ );
+
+ loop {
+ let mut best: Option = None;
+ for i in 0..queue.len() {
+ if now_ms < queue[i].due_ms {
+ continue;
+ }
+ best = Some(match best {
+ None => i,
+ Some(j) => {
+ let earlier = match queue[i].due_ms.cmp(&queue[j].due_ms) {
+ core::cmp::Ordering::Less => true,
+ core::cmp::Ordering::Greater => false,
+ core::cmp::Ordering::Equal => matches!(
+ (queue[i].kind, queue[j].kind),
+ (EventKind::NoteOff, EventKind::NoteOn)
+ | (EventKind::GateLow, EventKind::GateHigh)
+ ),
+ };
+ if earlier {
+ i
+ } else {
+ j
+ }
+ }
+ });
+ }
+ let Some(idx) = best else {
+ break;
+ };
+ let event = queue.swap_remove(idx);
+ let n = note_num(event.base_note, event.interval);
+ let note = MidiNote::from(n);
+ let mod_vel = apply_vel_lfo(event.velocity, lfo_phase, lfo_depth);
+
+ match event.kind {
+ EventKind::NoteOn => {
+ if sounding.iter().any(|(sn, _)| *sn == n) {
+ midi_out.send_note_off(note).await;
+ if let Some(pos) = sounding.iter().position(|(sn, _)| *sn == n) {
+ sounding.swap_remove(pos);
+ }
+ }
+ midi_out.send_note_on(note, mod_vel).await;
+ record_emit(&mut recent_emit, n, true, now_ms);
+ let _ = sounding.push((n, event.base_note));
+ activity_glob.set(pulse);
+ button_duck_glob.set(BUTTON_DUCK_MS);
+ schedule_note_off(
+ &mut queue,
+ event.base_note,
+ event.interval,
+ event.delay_ms,
+ event.due_ms,
+ event.generation,
+ event.base_interval,
+ );
+ if repeat_ok && !event.is_dry && event.generation < MAX_REPEATS {
+ if let Some(next_vel) = next_repeat_velocity(mod_vel, delay_fader) {
+ let next_gen = event.generation.saturating_add(1);
+ enqueue(
+ &mut queue,
+ EventKind::NoteOn,
+ event.base_note,
+ next_vel,
+ 0,
+ next_gen,
+ event.base_interval,
+ event.delay_ms,
+ event.due_ms,
+ false,
+ );
+ schedule_note_off(
+ &mut queue,
+ event.base_note,
+ bounce_interval(event.base_interval, next_gen),
+ event.delay_ms,
+ event.due_ms.saturating_add(event.delay_ms),
+ next_gen,
+ event.base_interval,
+ );
+ }
+ }
+ }
+ EventKind::NoteOff => {
+ midi_out.send_note_off(note).await;
+ record_emit(&mut recent_emit, n, false, now_ms);
+ if let Some(pos) = sounding.iter().position(|(sn, _)| *sn == n) {
+ sounding.swap_remove(pos);
+ }
+ }
+ EventKind::CvValue => {
+ if io_mode == IO_MIDI_CV {
+ if let Some(jack) = out_jack.as_ref() {
+ jack.set_value(event.cv_value);
+ }
+ } else if io_mode == IO_CV_MIDI {
+ midi_out.send_cc(midi_cc, event.cv_value).await;
+ }
+ activity_glob.set(pulse);
+ button_duck_glob.set(BUTTON_DUCK_MS);
+ if io_mode == IO_MIDI_CV
+ && !event.is_dry
+ && event.generation < MAX_REPEATS
+ {
+ if let Some(next_vel) =
+ next_repeat_velocity(event.velocity, delay_fader)
+ {
+ let next_gen = event.generation.saturating_add(1);
+ let bounced = note_num(
+ event.base_note,
+ bounce_interval(event.base_interval, next_gen),
+ );
+ enqueue(
+ &mut queue,
+ EventKind::CvValue,
+ event.base_note,
+ next_vel,
+ note_to_cv(bounced),
+ next_gen,
+ event.base_interval,
+ event.delay_ms,
+ event.due_ms,
+ false,
+ );
+ }
+ }
+ }
+ EventKind::GateHigh => {
+ if let Some(jack) = out_jack.as_ref() {
+ jack.set_value(4095);
+ }
+ activity_glob.set(pulse);
+ button_duck_glob.set(BUTTON_DUCK_MS);
+ let pulse_ms = ((note_length_ms(event.delay_ms) as f32)
+ * gate_pulse_factor(lfo_phase, lfo_depth))
+ as u64;
+ let _ = enqueue(
+ &mut queue,
+ EventKind::GateLow,
+ event.base_note,
+ 0,
+ 0,
+ event.generation,
+ event.base_interval,
+ pulse_ms.max(1),
+ event.due_ms,
+ false,
+ );
+ if repeat_ok && event.generation < MAX_REPEATS {
+ if let Some(next_vel) =
+ next_repeat_velocity(event.velocity, delay_fader)
+ {
+ let next_gen = event.generation.saturating_add(1);
+ enqueue(
+ &mut queue,
+ EventKind::GateHigh,
+ event.base_note,
+ next_vel,
+ 0,
+ next_gen,
+ event.base_interval,
+ event.delay_ms,
+ event.due_ms,
+ false,
+ );
+ }
+ }
+ }
+ EventKind::GateLow => {
+ if let Some(jack) = out_jack.as_ref() {
+ jack.set_value(0);
+ }
+ }
+ }
+ }
+
+ queue_depth_glob.set(((queue.len() as u32 * 255) / QUEUE_CAP as u32).min(255) as u8);
+
+ if activity_glob.get() > 0 {
+ activity_glob.set(activity_glob.get().saturating_sub(8));
+ }
+ }
+ };
+
+ let button_handler = async {
+ loop {
+ buttons.wait_for_any_down().await;
+ if !buttons.is_shift_pressed() {
+ long_press_fired.set(false);
+ third_layer_used.set(false);
+ buttons.wait_for_up(0).await;
+ if long_press_fired.get() {
+ if !third_layer_used.get() {
+ panic_flag.set(true);
+ }
+ } else if !third_layer_used.get() {
+ let muted = glob_muted.toggle();
+ storage.modify_and_save(|s| {
+ s.muted = muted;
+ });
+ if muted {
+ leds.unset(0, Led::Button);
+ } else {
+ leds.set(0, Led::Button, led_color, LED_BRIGHTNESS);
+ }
+ }
+ }
+ }
+ };
+
+ let long_press = async {
+ loop {
+ let _ = buttons.wait_for_any_long_press().await;
+ long_press_fired.set(true);
+ }
+ };
+
+ let fader_handler = async {
+ let mut latch = app.make_latch(fader.get_value());
+ loop {
+ fader.wait_for_change().await;
+ let latch_layer = glob_latch_layer.get();
+ let target_value = match latch_layer {
+ LatchLayer::Main => storage.query(|s| s.delay_saved),
+ LatchLayer::Alt => storage.query(|s| s.interval_saved),
+ LatchLayer::Third => storage.query(|s| s.vel_lfo_saved),
+ };
+ if let Some(new_value) = latch.update(fader.get_value(), latch_layer, target_value) {
+ if latch_layer == LatchLayer::Third {
+ third_layer_used.set(true);
+ }
+ match latch_layer {
+ LatchLayer::Main => {
+ delay_saved_glob.set(new_value);
+ storage.modify_and_save(|s| s.delay_saved = new_value);
+ }
+ LatchLayer::Alt => {
+ interval_glob.set(fader_to_interval(new_value));
+ storage.modify_and_save(|s| s.interval_saved = new_value);
+ }
+ LatchLayer::Third => {
+ vel_lfo_glob.set(new_value);
+ storage.modify_and_save(|s| s.vel_lfo_saved = new_value);
+ }
+ }
+ }
+ }
+ };
+
+ let led_handler = async {
+ loop {
+ app.delay_millis(1).await;
+ let latch_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_layer);
+
+ let input_flash = input_flash_glob.get();
+ if input_flash > 0 {
+ let shown = if glob_muted.get() {
+ ((input_flash as u16 * INPUT_FLASH_MUTED_SCALE) / 255) as u8
+ } else {
+ input_flash
+ };
+ leds.set(
+ 0,
+ Led::Button,
+ Color::White,
+ Brightness::Custom(shown.max(1)),
+ );
+ input_flash_glob.set(input_flash.saturating_sub(10));
+ }
+
+ let duck = button_duck_glob.get();
+ if duck > 0 {
+ button_duck_glob.set(duck.saturating_sub(1));
+ }
+
+ if glob_muted.get() {
+ if input_flash == 0 {
+ leds.unset(0, Led::Button);
+ leds.unset(0, Led::Top);
+ leds.unset(0, Led::Bottom);
+ }
+ continue;
+ }
+
+ match latch_layer {
+ LatchLayer::Main => {
+ let val = delay_saved_glob.get();
+ let led = split_unsigned_value(val);
+ let pulse = activity_glob.get();
+ let depth = queue_depth_glob.get();
+ leds.set(
+ 0,
+ Led::Top,
+ led_color,
+ Brightness::Custom(led[0].max(pulse).max(depth / 4)),
+ );
+ leds.set(
+ 0,
+ Led::Bottom,
+ led_color,
+ Brightness::Custom(led[1].max(pulse / 2).max(depth / 4)),
+ );
+ if input_flash == 0 {
+ let bright = if duck > 0 {
+ Brightness::Low
+ } else {
+ LED_BRIGHTNESS
+ };
+ leds.set(0, Led::Button, led_color, bright);
+ }
+ }
+ LatchLayer::Alt => {
+ let interval = interval_glob.get();
+ let led = split_semitone_leds(interval as i32);
+ let btn = pulse_from_interval(interval);
+ leds.set(0, Led::Top, Color::Orange, Brightness::Custom(led[0]));
+ leds.set(0, Led::Bottom, Color::Orange, Brightness::Custom(led[1]));
+ leds.set(0, Led::Button, Color::Orange, Brightness::Custom(btn));
+ }
+ LatchLayer::Third => {
+ let val = vel_lfo_glob.get();
+ let led = split_unsigned_value(val);
+ let btn = pulse_from_vel_lfo(val);
+ leds.set(0, Led::Top, Color::Violet, Brightness::Custom(led[0]));
+ leds.set(0, Led::Bottom, Color::Violet, Brightness::Custom(led[1]));
+ leds.set(0, Led::Button, Color::Violet, Brightness::Custom(btn));
+ }
+ }
+ }
+ };
+
+ let scene_handler = async {
+ loop {
+ match app.wait_for_scene_event().await {
+ SceneEvent::LoadScene(scene) => {
+ storage.load_from_scene(scene).await;
+ let (delay_saved, interval_saved, vel_lfo_saved, muted) = storage
+ .query(|s| (s.delay_saved, s.interval_saved, s.vel_lfo_saved, s.muted));
+ delay_saved_glob.set(delay_saved);
+ interval_glob.set(fader_to_interval(interval_saved));
+ vel_lfo_glob.set(vel_lfo_saved);
+ glob_muted.set(muted);
+ if muted {
+ leds.unset(0, Led::Button);
+ leds.unset(0, Led::Top);
+ leds.unset(0, Led::Bottom);
+ panic_flag.set(true);
+ } else {
+ leds.set(0, Led::Button, led_color, LED_BRIGHTNESS);
+ }
+ }
+ SceneEvent::SaveScene(scene) => {
+ storage.save_to_scene(scene).await;
+ }
+ }
+ }
+ };
+
+ join(
+ long_press,
+ join4(
+ engine,
+ button_handler,
+ fader_handler,
+ join(led_handler, scene_handler),
+ ),
+ )
+ .await;
+}
diff --git a/faderpunk/src/apps/mod.rs b/faderpunk/src/apps/mod.rs
index 0bfbba74b..4205eef43 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,
+ 48 => maniac_bounce,
);