From 2f1989ccc39d559d6f7dd7fc178f74eba813a941 Mon Sep 17 00:00:00 2001 From: kosmar Date: Thu, 23 Jul 2026 10:01:33 +0200 Subject: [PATCH 01/20] feat(echolot): add MIDI/CV delay app with feedback and interval --- configurator/src/components/ManualTab.tsx | 136 +++ configurator/src/components/manual/Apps.tsx | 2 +- configurator/src/utils/validators.ts | 3 +- faderpunk/src/apps/echolot.rs | 1127 +++++++++++++++++++ faderpunk/src/apps/mod.rs | 1 + faderpunk/src/tasks/midi.rs | 2 +- gen-bindings/Cargo.lock | 2 +- 7 files changed, 1269 insertions(+), 4 deletions(-) create mode 100644 faderpunk/src/apps/echolot.rs diff --git a/configurator/src/components/ManualTab.tsx b/configurator/src/components/ManualTab.tsx index 72abb5d91..cdba2f09f 100644 --- a/configurator/src/components/ManualTab.tsx +++ b/configurator/src/components/ManualTab.tsx @@ -1592,6 +1592,142 @@ On load, both registers are restored at the next phrase boundary so the recalled }, ], }, + { + appId: 33, + title: "Echolot", + description: "MIDI/CV delay with feedback and pitch shift", + color: "Cyan", + icon: "sine", + params: [ + "I/O", + "Delay mode", + "Max delay (ms)", + "Interval mode", + "Routing", + "Signal", + "Range", + "Color", + "MIDI In", + "MIDI In CH", + "MIDI Out", + "MIDI Out A", + "MIDI Out B", + "MIDI CC", + "MIDI Note", + ], + storage: ["Delay", "Feedback", "Interval", "Muted"], + text: `Echolot is a one-channel **delay / echo / harmonizer** for MIDI and CV. Incoming events are delayed, optionally transposed, and can regenerate with decaying velocity (feedback). Use it to double a voice on another MIDI channel, slapback-echo notes, or delay gates and CCs into the modular domain. + +#### Hardware UX + +| Layer | How | Fader controls | +| --- | --- | --- | +| **Main** | Fader alone | **Delay time** — fader up = faster (shorter delay); free ms or clock divisions when Delay mode = Clock | +| **Alt** | **Shift + Fader** | **Feedback** (0–100%) — how loud / strong each regenerated repeat is | +| **Third** | **Button held + Fader** | **Interval** (−12…+12 semitones) for note/gate pitch shift | + +| Gesture | Action | +| --- | --- | +| **Short press** | **Mute / ring-out** — block new inputs; queued echoes keep playing (and feedback can finish). Note-offs still accepted. | +| **Long press** | **Panic / hard kill** (on release, only if you didn't move Interval) — notes off, CC 120/123, empty queue, CV → 0 | + +Feedback is **regenerative** (classic delay): the queue usually holds only the *next* tap. When it fires, another is scheduled with velocity × feedback, until velocity < ~7% or 8 repeats. Shift+Fader / Button+Fader do not clear the trail. + +| LED (channel) | Main | Shift (Alt) | Button held (Third) | +| --- | --- | --- | --- | +| **Top / Bottom** | Delay amount (app color); **delay-cycle blink** at feedback brightness (10–100%); Ping-Pong: Top = Out A, Bottom = Out B; faint **queue depth** when idle | Feedback (**green**): low = Bottom, high = Top | Interval (**red**): down = Bottom, up = Top | +| **Button** | Idle Mid; **white flash = MIDI/CV note in**; app-color blink = delay fire (feedback 10–100%) | **Green**, brightness = feedback | **Red**, brightness = |interval| | +| **Muted** | Top/Bottom off; **white input flash still works** (so you can verify MIDI In while muted) | | | + +#### Feedback (sound + LEDs) + +**Shift + Fader** sets feedback 0–100%: + +- **0%** — one delayed hit only (doubler / slapback); no regenerated repeats +- **>0%** — each fired note/gate schedules another generation with velocity × feedback +- Stops at max **8** repeats or when velocity falls below ~**7%** (internal floor) +- Applies to **note/gate** streams (MIDI→MIDI, MIDI→CV Gate, CV→MIDI Gate→Note) — not pitch-hold or CV→CC + +Every time a delayed event **fires**, and also on a **free-running delay metronome** (same period as Delay — works with no input): + +- **Button** blinks at brightness mapped from feedback: **10%…100%** of full LED scale (low feedback = soft tick, high = bright flash), then decays +- **Top / Bottom** also pulse at that brightness; with **Ping-Pong** routing, Top = Out A fires, Bottom = Out B fires +- A faint residual glow shows **queue depth** (how much is still waiting) when idle + +While you hold **Shift**, Top/Bottom show feedback in **green** (low at Bottom, high at Top) and the button brightness tracks feedback. Hold the **channel button** for interval in **red** (down at Bottom, up at Top); button brightness tracks |semitones|. + +#### I/O modes + +Configurator **I/O** picks the path. The jack is only used when CV is involved (one jack = in **or** out). + +| I/O | Jack | What is delayed | +| --- | --- | --- | +| **MIDI→MIDI** | unused | MIDI notes (in CH → Out A / B) | +| **MIDI→CV** | **Out** | Notes become Pitch CV or Gate (see Signal) | +| **CV→MIDI** | **In** | Gate→Note or continuous CV→CC (see Signal) | + +#### Signal (context-dependent) + +| Signal | Meaningful when | Behavior | +| --- | --- | --- | +| **Pitch** | MIDI→CV | Delayed note-on sets pitch CV (holds last pitch; note-off does not zero) | +| **Gate** | MIDI→CV | Delayed note-on → high, note-off → low; feedback can echo gates | +| **CV→CC** | CV→MIDI | CV changes are delayed and sent as MIDI CC | +| **Gate→Note** | CV→MIDI | Rising gate → delayed note-on (MIDI Note + Interval); falling → note-off | + +Wrong Signal choices for the current I/O are ignored (Pitch/Gate for MIDI→CV; CV→CC / Gate→Note for CV→MIDI). + +#### Interval & routing + +| Param | Options | Notes | +| --- | --- | --- | +| **Interval mode** | Fixed / Stack / Pong | Fixed = same shift every generation; Stack = +N, +2N, …; Pong = +N, −N, +N… | +| **Routing** | Single / Ping-Pong | MIDI→MIDI only: repeats alternate **Out A** ↔ **Out B** (see LED Top/Bottom) | + +#### Delay timing + +| Delay mode | Fader meaning | +| --- | --- | +| **ms** | Fader up = shorter delay (0 at top … **Max delay** at bottom) | +| **Clock** | Musical divisions (straight note-length table, same idea as Clock Divider “Straight”) | + +Clock **Stop** / **Reset** clears the queue when in Clock mode. + +#### Typical patches + +- **Harmony doubler:** MIDI→MIDI, Interval = +7 (Fifth), Feedback low or 0, Out A = synth B. +- **Slapback:** MIDI→MIDI, short Delay, Feedback 0, Interval 0 — soft 10% button ticks on each delay. +- **Echo trail:** Feedback up (brighter blinks); Interval Fixed or Pong for melodic cascades. +- **Stereo MIDI ping-pong:** Routing = Ping-Pong, two Out channels → two devices (watch Top vs Bottom). +- **Delayed gate into modular:** MIDI→CV + Gate, or CV→MIDI + Gate→Note the other way.`, + channels: [ + { + jackTitle: "CV jack (mode-dependent)", + jackDescription: + "Unused in MIDI→MIDI. **Out** for MIDI→CV (Pitch or Gate). **In** for CV→MIDI (Gate→Note or CV→CC).", + faderTitle: "Delay", + faderDescription: + "Delay time: fader up = faster/shorter (ms capped by Max delay, or clock division).", + faderPlusShiftTitle: "Feedback", + faderPlusShiftDescription: + "0–100% regeneration (velocity × feedback per repeat). Also sets delay-cycle LED blink brightness (10–100%).", + faderPlusFnTitle: "Interval", + faderPlusFnDescription: + "Pitch shift −12…+12 semitones (note/gate paths).", + fnTitle: "Mute / Panic", + fnDescription: + "Short: mute (ring-out — no new input, queue finishes). Long: hard kill — notes off + CC 120/123 + empty queue.", + ledTop: + "Delay amount; delay-cycle blink (feedback 10–100%); Ping-Pong Out A; queue depth when idle", + ledTopPlusShift: "Feedback level (green)", + ledTopPlusFn: "Positive interval (red)", + ledBottom: + "Delay amount; delay-cycle blink / Ping-Pong Out B; queue depth when idle", + ledBottomPlusShift: "Feedback level (green)", + ledBottomPlusFn: "Negative interval (red)", + }, + ], + }, ]; export const ManualTab = () => { diff --git a/configurator/src/components/manual/Apps.tsx b/configurator/src/components/manual/Apps.tsx index f6d471c26..e0fceeeb5 100644 --- a/configurator/src/components/manual/Apps.tsx +++ b/configurator/src/components/manual/Apps.tsx @@ -24,7 +24,7 @@ export const Apps = ({ apps }: Props) => ( channel), Random Trigger, Euclid, Envelope Follower, Turing, Turing+, MIDI to CV, CV2MIDI, CV/OCT to MIDI, Panner, FP-Grids (per-channel trigger mutes), TB-3PO, GenSeq, Bernoulli Gate (button 1 mutes Output A, - button 2 mutes Output B) + button 2 mutes Output B), Echolot
  • Long press (no shift) — AD Envelope, LFO, LFO+ diff --git a/configurator/src/utils/validators.ts b/configurator/src/utils/validators.ts index 9b88c655c..6f85458c6 100644 --- a/configurator/src/utils/validators.ts +++ b/configurator/src/utils/validators.ts @@ -86,10 +86,11 @@ export const getParamSchema = (param: Param) => { .default({ tag: "MidiCc", value: [32] }); } case "MidiChannel": { + // Firmware MidiChannel is 1–16 (human CH); converts to 0–15 u4 when sending. return z .object({ tag: z.literal("MidiChannel"), - value: z.tuple([z.number().int().min(0).max(15)]), + value: z.tuple([z.number().int().min(1).max(16)]), }) .default({ tag: "MidiChannel", value: [1] }); } diff --git a/faderpunk/src/apps/echolot.rs b/faderpunk/src/apps/echolot.rs new file mode 100644 index 000000000..481200960 --- /dev/null +++ b/faderpunk/src/apps/echolot.rs @@ -0,0 +1,1127 @@ +use embassy_futures::{ + join::{join, join5}, + select::{select, select3, Either}, +}; +use embassy_sync::{blocking_mutex::raw::NoopRawMutex, signal::Signal}; +use embassy_time::Instant; +use heapless::Vec; +use midly::{num::u7, MidiMessage}; +use serde::{Deserialize, Serialize}; + +use libfp::{ + ext::FromValue, + latch::LatchLayer, + utils::{ + bits_7_16, midi_gate, resolution_for_mode, scale_bits_7_12, split_unsigned_value, + value_to_resolution, + }, + AppIcon, Brightness, ClockDivision, Color, Config, MidiCc, MidiChannel, MidiIn, MidiNote, + MidiOut, Param, Range, Value, APP_MAX_PARAMS, +}; + +use crate::app::{ + App, AppParams, AppStorage, ClockEvent, Led, ManagedStorage, ParamStore, SceneEvent, +}; + +pub const CHANNELS: usize = 1; +pub const PARAMS: usize = 15; + +const LED_BRIGHTNESS: Brightness = Brightness::Mid; +const MAX_REPEATS: u8 = 8; +const VELOCITY_FLOOR: u16 = 300; +const QUEUE_CAP: usize = 32; +const SOUNDING_CAP: usize = 32; +const GATE_THRESH: u16 = 406; +/// Straight note-length table for clock-synced delay (same as clk_div "Straight"). +const CLOCK_DIVISION_MODE: usize = 0; + +/// I/O routing mode. +const IO_MIDI_MIDI: usize = 0; +const IO_MIDI_CV: usize = 1; +const IO_CV_MIDI: usize = 2; + +/// Signal role (interpreted in context of I/O mode). +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( + "Echolot", + "MIDI/CV delay with feedback and pitch shift", + Color::Cyan, + AppIcon::Sine, +) +.add_param(Param::Enum { + name: "I/O", + variants: &["MIDI→MIDI", "MIDI→CV", "CV→MIDI"], +}) +.add_param(Param::Enum { + name: "Delay mode", + variants: &["ms", "Clock"], +}) +.add_param(Param::i32 { + name: "Max delay (ms)", + min: 10, + max: 2000, +}) +.add_param(Param::Enum { + name: "Interval mode", + variants: &["Fixed", "Stack", "Pong"], +}) +.add_param(Param::Enum { + name: "Routing", + variants: &["Single", "Ping-Pong"], +}) +.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 A", +}) +.add_param(Param::MidiChannel { + name: "MIDI Out B", +}) +.add_param(Param::MidiCc { name: "MIDI CC" }) +.add_param(Param::MidiNote { name: "MIDI Note" }); + +pub struct Params { + io_mode: usize, + delay_mode: usize, + max_delay_ms: i32, + interval_mode: usize, + routing: usize, + signal: usize, + range: Range, + color: Color, + midi_in: MidiIn, + midi_in_ch: MidiChannel, + midi_out: MidiOut, + midi_out_a: MidiChannel, + midi_out_b: 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]), + delay_mode: usize::from_value(values[1]), + max_delay_ms: i32::from_value(values[2]), + interval_mode: usize::from_value(values[3]), + routing: usize::from_value(values[4]), + signal: usize::from_value(values[5]), + range: Range::from_value(values[6]), + color: Color::from_value(values[7]), + midi_in: MidiIn::from_value(values[8]), + midi_in_ch: MidiChannel::from_value(values[9]), + midi_out: MidiOut::from_value(values[10]), + midi_out_a: MidiChannel::from_value(values[11]), + midi_out_b: MidiChannel::from_value(values[12]), + midi_cc: MidiCc::from_value(values[13]), + midi_note: MidiNote::from_value(values[14]), + }) + } + + fn to_values(&self) -> Vec { + let mut vec = Vec::new(); + vec.push(self.io_mode.into()).unwrap(); + vec.push(self.delay_mode.into()).unwrap(); + vec.push(self.max_delay_ms.into()).unwrap(); + vec.push(self.interval_mode.into()).unwrap(); + vec.push(self.routing.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_a.into()).unwrap(); + vec.push(self.midi_out_b.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, + feedback_saved: u16, + interval_saved: u16, + muted: bool, +} + +impl Default for Storage { + fn default() -> Self { + Self { + delay_saved: 2048, + feedback_saved: 0, + interval_saved: 2048, + muted: false, + } + } +} + +impl AppStorage for Storage {} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum EventKind { + NoteOn, + NoteOff, + /// Delayed CV/CC level (MIDI→CV pitch or CV→MIDI CC). + CvValue, + GateHigh, + GateLow, +} + +#[derive(Clone, Copy)] +struct PendingEvent { + kind: EventKind, + base_note: u8, + velocity: u16, + cv_value: u16, + interval: i8, + out_target: u8, + due_ms: u64, + due_tick: u32, + generation: u8, +} + +fn fader_to_delay_ms(fader: u16, max_ms: i32) -> u64 { + let max = max_ms.clamp(10, 2000) as u32; + // Match clock-mode / rate UX: fader up = faster (shorter delay). + let inverted = 4095u32.saturating_sub(fader as u32); + (inverted * max / 4095) as u64 +} + +fn fader_to_interval(fader: u16) -> i8 { + let centered = fader as i32 - 2048; + ((centered * 12) / 2048).clamp(-12, 12) as i8 +} + +fn interval_for_gen(base: i8, generation: u8, mode: usize) -> i8 { + match mode { + 1 => base.saturating_mul((generation as i8).saturating_add(1)), + 2 => { + if generation.is_multiple_of(2) { + base + } else { + base.saturating_neg() + } + } + _ => base, + } +} + +fn out_target_for_gen(generation: u8, ping_pong: bool) -> u8 { + if ping_pong && !generation.is_multiple_of(2) { + 1 + } else { + 0 + } +} + +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) / 12).clamp(0, 255) as u8; + [pos, 0] + } else { + let neg = (((-interval) * 255) / 12).clamp(0, 255) as u8; + [0, neg] + } +} + +/// Button/activity pulse brightness from feedback fader: 10%…100% of full scale. +fn pulse_from_feedback(feedback: u16) -> u8 { + const MIN: u32 = 26; // ≈10% of 255 + const MAX: u32 = 255; + (MIN + (feedback as u32 * (MAX - MIN) / 4095)) as u8 +} + +/// Button brightness from |interval|: 10% at unison … 100% at ±12 st. +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) / 12)) as u8 +} + +/// Resolve Signal enum in context of I/O mode. +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, // unused in MIDI→MIDI + } +} + +#[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, + delay_mode: 0, + max_delay_ms: 1000, + interval_mode: 0, + routing: 0, + signal: SIG_PITCH, + range: Range::_0_10V, + color: Color::Cyan, + midi_in: MidiIn::default(), + midi_in_ch: MidiChannel::default(), + midi_out: MidiOut::default(), + midi_out_a: MidiChannel::default(), + midi_out_b: 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, + delay_mode, + max_delay_ms, + interval_mode, + routing, + signal, + range, + led_color, + midi_in_cfg, + midi_in_ch, + midi_out_cfg, + midi_out_a, + midi_out_b, + midi_cc, + midi_note, + ) = params.query(|p| { + ( + p.io_mode, + p.delay_mode, + p.max_delay_ms, + p.interval_mode, + p.routing, + p.signal, + p.range, + p.color, + p.midi_in, + p.midi_in_ch, + p.midi_out, + p.midi_out_a, + p.midi_out_b, + p.midi_cc, + p.midi_note, + ) + }); + + let clocked = delay_mode == 1; + let ping_pong = routing == 1 && io_mode == IO_MIDI_MIDI; + let sig = effective_signal(io_mode, signal); + let resolution = resolution_for_mode(CLOCK_DIVISION_MODE); + let base_note_cfg = midi_note_u8(midi_note); + + let fader = app.use_faders(); + let buttons = app.use_buttons(); + let leds = app.use_leds(); + let mut clock = app.use_clock(); + let ticks = clock.get_ticker(); + + let mut midi_in = app.use_midi_input(midi_in_cfg, midi_in_ch); + let midi_a = app.use_midi_output(midi_out_cfg, midi_out_a, false); + let midi_b = app.use_midi_output(midi_out_cfg, midi_out_b, false); + + // One jack: out for MIDI→CV, in for CV→MIDI, unused for MIDI→MIDI. + 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 feedback_glob = app.make_global(0u16); + let interval_glob = app.make_global(0i8); + let activity_glob = app.make_global(0u8); + // White button flash on MIDI/CV note/gate input (proves listen path). + let input_flash_glob = app.make_global(0u8); + // 0 = Out A / top flash, 1 = Out B / bottom flash (ping-pong cue). + let pong_side_glob = app.make_global(0u8); + let queue_depth_glob = app.make_global(0u8); + + let (delay_saved, feedback_saved, interval_saved, muted) = storage.query(|s| { + ( + s.delay_saved, + s.feedback_saved, + s.interval_saved, + s.muted, + ) + }); + delay_saved_glob.set(delay_saved); + feedback_glob.set(feedback_saved); + interval_glob.set(fader_to_interval(interval_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 prev_gate = false; + let mut last_cc_gate: u16 = u16::MAX; + // Free-running delay-period metronome (blinks even with empty queue). + let mut next_metro_ms = Instant::now().as_millis(); + let mut next_metro_tick = ticks() as u32; + + let enqueue = |queue: &mut Vec, + kind: EventKind, + base_note: u8, + velocity: u16, + cv_value: u16, + generation: u8, + base_interval: i8, + delay_ms: u64, + delay_ticks: u32, + now_ms: u64, + now_tick: u32| { + let _ = queue.push(PendingEvent { + kind, + base_note, + velocity, + cv_value, + interval: interval_for_gen(base_interval, generation, interval_mode), + out_target: out_target_for_gen(generation, ping_pong), + due_ms: now_ms.saturating_add(delay_ms), + due_tick: now_tick.wrapping_add(delay_ticks), + generation, + }); + }; + + loop { + // MIDI→MIDI / MIDI→CV: wait on MIDI or 1ms tick. + // CV→MIDI: poll only (no MIDI input needed). + 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 now_ms = Instant::now().as_millis(); + let now_tick = ticks() as u32; + let delay_ms = fader_to_delay_ms(delay_fader, max_delay_ms); + let delay_ticks = value_to_resolution(delay_fader, resolution).max(1); + let feedback = feedback_glob.get(); + let pulse = pulse_from_feedback(feedback); + + // Idle delay metronome: one blink per delay period, no input required. + // Floor period so near-zero delay still reads as a pulse (~25 Hz max). + const MIN_METRO_MS: u64 = 40; + if !glob_muted.get() { + if clocked { + if now_tick.wrapping_sub(next_metro_tick) < (u32::MAX / 2) { + activity_glob.set(pulse); + next_metro_tick = now_tick.wrapping_add(delay_ticks); + } + } else if now_ms >= next_metro_ms { + activity_glob.set(pulse); + next_metro_ms = now_ms.saturating_add(delay_ms.max(MIN_METRO_MS)); + } + } + + if let Some(msg) = midi_msg { + let accept_new = !glob_muted.get(); + match (io_mode, sig, msg) { + // ── MIDI → MIDI notes ────────────────────────────────── + ( + IO_MIDI_MIDI, + _, + MidiMessage::NoteOn { key, vel }, + ) if vel > 0 => { + // Always flash on NoteOn so MIDI In is verifiable (even when muted). + input_flash_glob.set(255); + if accept_new { + enqueue( + &mut queue, + EventKind::NoteOn, + key.as_int(), + scale_bits_7_12(vel), + 0, + 0, + base_interval, + delay_ms, + delay_ticks, + now_ms, + now_tick, + ); + } + } + (IO_MIDI_MIDI, _, MidiMessage::NoteOn { key, .. }) + | (IO_MIDI_MIDI, _, MidiMessage::NoteOff { key, .. }) => { + // Note-offs always accepted so held notes can release during ring-out. + enqueue( + &mut queue, + EventKind::NoteOff, + key.as_int(), + 0, + 0, + 0, + base_interval, + delay_ms, + delay_ticks, + now_ms, + now_tick, + ); + } + + // ── MIDI → CV Pitch (hold last delayed note) ─────────── + ( + IO_MIDI_CV, + SIG_PITCH, + MidiMessage::NoteOn { key, vel }, + ) if vel > 0 => { + input_flash_glob.set(255); + if accept_new { + let n = note_num( + key.as_int(), + interval_for_gen(base_interval, 0, interval_mode), + ); + enqueue( + &mut queue, + EventKind::CvValue, + key.as_int(), + scale_bits_7_12(vel), + note_to_cv(n), + 0, + base_interval, + delay_ms, + delay_ticks, + now_ms, + now_tick, + ); + } + } + + // ── MIDI → CV Gate ───────────────────────────────────── + ( + IO_MIDI_CV, + SIG_GATE, + MidiMessage::NoteOn { key, vel }, + ) if vel > 0 => { + input_flash_glob.set(255); + if accept_new { + enqueue( + &mut queue, + EventKind::GateHigh, + key.as_int(), + scale_bits_7_12(vel), + 4095, + 0, + base_interval, + delay_ms, + delay_ticks, + now_ms, + now_tick, + ); + } + } + (IO_MIDI_CV, SIG_GATE, MidiMessage::NoteOn { key, .. }) + | (IO_MIDI_CV, SIG_GATE, MidiMessage::NoteOff { key, .. }) => { + enqueue( + &mut queue, + EventKind::GateLow, + key.as_int(), + 0, + 0, + 0, + base_interval, + delay_ms, + delay_ticks, + now_ms, + now_tick, + ); + } + _ => {} + } + } + + // ── CV → MIDI input sampling ─────────────────────────────────── + 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(255); + if accept_new { + enqueue( + &mut queue, + EventKind::NoteOn, + base_note_cfg, + 4095, + 0, + 0, + base_interval, + delay_ms, + delay_ticks, + now_ms, + now_tick, + ); + } + } else if !high && prev_gate { + enqueue( + &mut queue, + EventKind::NoteOff, + base_note_cfg, + 0, + 0, + 0, + base_interval, + delay_ms, + delay_ticks, + now_ms, + now_tick, + ); + } + 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; + enqueue( + &mut queue, + EventKind::CvValue, + 0, + 0, + inval, + 0, + 0, + delay_ms, + delay_ticks, + now_ms, + now_tick, + ); + } + } + } + } + + // ── Panic (hard kill) ────────────────────────────────────────── + if panic_flag.get() { + for (out, n) in sounding.iter() { + let note = MidiNote::from(*n); + if *out == 0 { + midi_a.send_note_off(note).await; + } else { + midi_b.send_note_off(note).await; + } + } + // Catch delayed NoteOns that never reached sounding yet. + for event in queue.iter() { + if matches!(event.kind, EventKind::NoteOn) { + let n = note_num(event.base_note, event.interval); + let note = MidiNote::from(n); + if event.out_target == 0 { + midi_a.send_note_off(note).await; + } else { + midi_b.send_note_off(note).await; + } + } + } + // Channel-wide MIDI panic on both outs. + const ALL_SOUND_OFF: u8 = 120; + const ALL_NOTES_OFF: u8 = 123; + midi_a + .send_cc(MidiCc::from(ALL_SOUND_OFF), 0) + .await; + midi_a + .send_cc(MidiCc::from(ALL_NOTES_OFF), 0) + .await; + midi_b + .send_cc(MidiCc::from(ALL_SOUND_OFF), 0) + .await; + midi_b + .send_cc(MidiCc::from(ALL_NOTES_OFF), 0) + .await; + + sounding.clear(); + queue.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; + } + + // ── Due event processing (plays during mute = ring-out) ──────── + // Feedback only for note/gate event streams (not continuous CC/pitch holds). + let feedback_ok = matches!( + (io_mode, sig), + (IO_MIDI_MIDI, _) | (IO_MIDI_CV, SIG_GATE) | (IO_CV_MIDI, SIG_GATE_NOTE) + ); + + let mut i = 0; + while i < queue.len() { + let due = if clocked { + now_tick.wrapping_sub(queue[i].due_tick) < (u32::MAX / 2) + } else { + now_ms >= queue[i].due_ms + }; + if !due { + i += 1; + continue; + } + + let event = queue.swap_remove(i); + let n = note_num(event.base_note, event.interval); + let note = MidiNote::from(n); + + match event.kind { + EventKind::NoteOn => { + if event.out_target == 0 { + midi_a.send_note_on(note, event.velocity).await; + } else { + midi_b.send_note_on(note, event.velocity).await; + } + let _ = sounding.push((event.out_target, n)); + activity_glob.set(pulse); + pong_side_glob.set(event.out_target); + if feedback_ok && feedback > 0 && event.generation < MAX_REPEATS { + let next_vel = + ((event.velocity as u32 * feedback as u32) / 4095) as u16; + if next_vel >= VELOCITY_FLOOR { + let next_gen = event.generation.saturating_add(1); + enqueue( + &mut queue, + EventKind::NoteOn, + event.base_note, + next_vel, + 0, + next_gen, + base_interval, + delay_ms, + delay_ticks, + event.due_ms, + event.due_tick, + ); + } + } + } + EventKind::NoteOff => { + if event.out_target == 0 { + midi_a.send_note_off(note).await; + } else { + midi_b.send_note_off(note).await; + } + if let Some(pos) = sounding + .iter() + .position(|(o, sn)| *o == event.out_target && *sn == n) + { + sounding.swap_remove(pos); + } + if feedback_ok && feedback > 0 && event.generation < MAX_REPEATS { + let next_gen = event.generation.saturating_add(1); + enqueue( + &mut queue, + EventKind::NoteOff, + event.base_note, + 0, + 0, + next_gen, + base_interval, + delay_ms, + delay_ticks, + event.due_ms, + event.due_tick, + ); + } + } + 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_a.send_cc(midi_cc, event.cv_value).await; + } + activity_glob.set(pulse); + pong_side_glob.set(0); + } + EventKind::GateHigh => { + if let Some(jack) = out_jack.as_ref() { + jack.set_value(4095); + } + activity_glob.set(pulse); + pong_side_glob.set(0); + if feedback_ok && feedback > 0 && event.generation < MAX_REPEATS { + let next_vel = + ((event.velocity as u32 * feedback as u32) / 4095) as u16; + if next_vel >= VELOCITY_FLOOR { + let next_gen = event.generation.saturating_add(1); + enqueue( + &mut queue, + EventKind::GateHigh, + event.base_note, + next_vel, + 4095, + next_gen, + base_interval, + delay_ms, + delay_ticks, + event.due_ms, + event.due_tick, + ); + } + } + } + EventKind::GateLow => { + if let Some(jack) = out_jack.as_ref() { + jack.set_value(0); + } + if feedback_ok && feedback > 0 && event.generation < MAX_REPEATS { + let next_gen = event.generation.saturating_add(1); + enqueue( + &mut queue, + EventKind::GateLow, + event.base_note, + 0, + 0, + next_gen, + base_interval, + delay_ms, + delay_ticks, + event.due_ms, + event.due_tick, + ); + } + } + } + } + + queue_depth_glob.set( + ((queue.len() as u32 * 255) / QUEUE_CAP as u32).min(255) as u8, + ); + + if activity_glob.get() > 0 { + // Decay ~32ms to black from full so each delay tick reads as a blink. + 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() { + // Long-press panic only if this hold wasn't Interval (Button+Fader). + if !third_layer_used.get() { + panic_flag.set(true); + } + } else if !third_layer_used.get() { + // Short press: mute / ring-out (block new input; queue keeps playing). + 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 { + // Only marks the gesture; panic runs on release (see button_handler) + // so Button+Fader Interval edits never wipe the echo trail mid-hold. + 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.feedback_saved), + LatchLayer::Third => storage.query(|s| s.interval_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 => { + feedback_glob.set(new_value); + storage.modify_and_save(|s| s.feedback_saved = new_value); + } + LatchLayer::Third => { + interval_glob.set(fader_to_interval(new_value)); + storage.modify_and_save(|s| s.interval_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); + + // Incoming-note flash (white) — shown even when muted, to diagnose MIDI In. + let input_flash = input_flash_glob.get(); + if input_flash > 0 { + leds.set( + 0, + Led::Button, + Color::White, + Brightness::Custom(input_flash), + ); + input_flash_glob.set(input_flash.saturating_sub(10)); + } + + 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 pong = pong_side_glob.get(); + let depth = queue_depth_glob.get(); + + // Delay meter as baseline; delay-cycle pulse overlays. + // Ping-Pong: pulse hits Top for Out A, Bottom for Out B. + let (top_b, bot_b) = if pulse > 0 && ping_pong { + if pong == 0 { + (pulse, led[1].max(depth / 4)) + } else { + (led[0].max(depth / 4), pulse) + } + } else { + ( + led[0].max(pulse).max(depth / 4), + led[1] + .max(if pulse > 0 { pulse / 2 } else { 0 }) + .max(depth / 4), + ) + }; + leds.set(0, Led::Top, led_color, Brightness::Custom(top_b)); + leds.set(0, Led::Bottom, led_color, Brightness::Custom(bot_b)); + // White input flash wins over delay pulse on the button. + if input_flash == 0 { + if pulse > 0 { + leds.set(0, Led::Button, led_color, Brightness::Custom(pulse)); + } else { + leds.set(0, Led::Button, led_color, LED_BRIGHTNESS); + } + } + } + LatchLayer::Alt => { + // Feedback: low = Bottom, high = Top (same split as delay meter). + let led = split_unsigned_value(feedback_glob.get()); + let btn = pulse_from_feedback(feedback_glob.get()); + leds.set(0, Led::Top, Color::Green, Brightness::Custom(led[0])); + leds.set(0, Led::Bottom, Color::Green, Brightness::Custom(led[1])); + leds.set(0, Led::Button, Color::Green, Brightness::Custom(btn)); + } + LatchLayer::Third => { + // Interval: down = Bottom, up = Top; button tracks |st|. + 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::Red, Brightness::Custom(led[0])); + leds.set(0, Led::Bottom, Color::Red, Brightness::Custom(led[1])); + leds.set(0, Led::Button, Color::Red, 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, feedback_saved, interval_saved, muted) = storage.query(|s| { + ( + s.delay_saved, + s.feedback_saved, + s.interval_saved, + s.muted, + ) + }); + delay_saved_glob.set(delay_saved); + feedback_glob.set(feedback_saved); + interval_glob.set(fader_to_interval(interval_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; + } + } + } + }; + + let clock_watch = async { + loop { + match clock.wait_for_event(ClockDivision::_1).await { + ClockEvent::Stop | ClockEvent::Reset if clocked => { + panic_flag.set(true); + } + _ => {} + } + } + }; + + join( + long_press, + join5( + engine, + button_handler, + fader_handler, + join(led_handler, scene_handler), + clock_watch, + ), + ) + .await; +} diff --git a/faderpunk/src/apps/mod.rs b/faderpunk/src/apps/mod.rs index 0bfbba74b..6eaeff8dc 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, + 33 => echolot, ); diff --git a/faderpunk/src/tasks/midi.rs b/faderpunk/src/tasks/midi.rs index a16ebff17..0a5eac3b1 100644 --- a/faderpunk/src/tasks/midi.rs +++ b/faderpunk/src/tasks/midi.rs @@ -513,7 +513,7 @@ pub async fn midi_in_task<'a>( if len == 0 { continue; } - let packets = usb_rx_buf[..len].chunks_exact(4); + let (packets, _) = usb_rx_buf[..len].as_chunks::<4>(); for packet in packets { let cable = packet[0] >> 4; let msg_len = len_from_cin(packet[0]); diff --git a/gen-bindings/Cargo.lock b/gen-bindings/Cargo.lock index 09524fbb4..b4b26888e 100644 --- a/gen-bindings/Cargo.lock +++ b/gen-bindings/Cargo.lock @@ -323,7 +323,7 @@ checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "libfp" -version = "0.10.4" +version = "0.10.5-beta.0" dependencies = [ "embassy-time", "enum-ordinalize", From b2b5b933e15f6051cab2fa6bdaeb0306dbb73238 Mon Sep 17 00:00:00 2001 From: kosmar Date: Thu, 23 Jul 2026 18:48:26 +0200 Subject: [PATCH 02/20] fix(echolot): dim muted MIDI-in flash to ~20% --- configurator/src/components/ManualTab.tsx | 2 +- faderpunk/src/apps/echolot.rs | 20 +++++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/configurator/src/components/ManualTab.tsx b/configurator/src/components/ManualTab.tsx index cdba2f09f..994c6313d 100644 --- a/configurator/src/components/ManualTab.tsx +++ b/configurator/src/components/ManualTab.tsx @@ -1637,7 +1637,7 @@ Feedback is **regenerative** (classic delay): the queue usually holds only the * | --- | --- | --- | --- | | **Top / Bottom** | Delay amount (app color); **delay-cycle blink** at feedback brightness (10–100%); Ping-Pong: Top = Out A, Bottom = Out B; faint **queue depth** when idle | Feedback (**green**): low = Bottom, high = Top | Interval (**red**): down = Bottom, up = Top | | **Button** | Idle Mid; **white flash = MIDI/CV note in**; app-color blink = delay fire (feedback 10–100%) | **Green**, brightness = feedback | **Red**, brightness = |interval| | -| **Muted** | Top/Bottom off; **white input flash still works** (so you can verify MIDI In while muted) | | | +| **Muted** | Top/Bottom off; **white input flash still works at ~20%** (verify MIDI In while muted) | | | #### Feedback (sound + LEDs) diff --git a/faderpunk/src/apps/echolot.rs b/faderpunk/src/apps/echolot.rs index 481200960..a757a4353 100644 --- a/faderpunk/src/apps/echolot.rs +++ b/faderpunk/src/apps/echolot.rs @@ -32,6 +32,10 @@ const VELOCITY_FLOOR: u16 = 300; const QUEUE_CAP: usize = 32; const SOUNDING_CAP: usize = 32; const GATE_THRESH: u16 = 406; +/// Peak white button flash on note/gate in (full scale). +const INPUT_FLASH_PEAK: u8 = 255; +/// While muted, show the same flash at ~20% so MIDI In stays visible without looking “live”. +const INPUT_FLASH_MUTED_SCALE: u16 = 51; // 51/255 ≈ 20% /// Straight note-length table for clock-synced delay (same as clk_div "Straight"). const CLOCK_DIVISION_MODE: usize = 0; @@ -536,7 +540,7 @@ pub async fn run( MidiMessage::NoteOn { key, vel }, ) if vel > 0 => { // Always flash on NoteOn so MIDI In is verifiable (even when muted). - input_flash_glob.set(255); + input_flash_glob.set(INPUT_FLASH_PEAK); if accept_new { enqueue( &mut queue, @@ -577,7 +581,7 @@ pub async fn run( SIG_PITCH, MidiMessage::NoteOn { key, vel }, ) if vel > 0 => { - input_flash_glob.set(255); + input_flash_glob.set(INPUT_FLASH_PEAK); if accept_new { let n = note_num( key.as_int(), @@ -605,7 +609,7 @@ pub async fn run( SIG_GATE, MidiMessage::NoteOn { key, vel }, ) if vel > 0 => { - input_flash_glob.set(255); + input_flash_glob.set(INPUT_FLASH_PEAK); if accept_new { enqueue( &mut queue, @@ -650,7 +654,7 @@ pub async fn run( if sig == SIG_GATE_NOTE { let high = inval >= GATE_THRESH; if high && !prev_gate { - input_flash_glob.set(255); + input_flash_glob.set(INPUT_FLASH_PEAK); if accept_new { enqueue( &mut queue, @@ -993,13 +997,19 @@ pub async fn run( glob_latch_layer.set(latch_layer); // Incoming-note flash (white) — shown even when muted, to diagnose MIDI In. + // Muted: same decay envelope, peak shown at ~20% so mute still reads as muted. 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(input_flash), + Brightness::Custom(shown.max(1)), ); input_flash_glob.set(input_flash.saturating_sub(10)); } From 68c6fa6752f8f95a80775022da2694a76a466d4c Mon Sep 17 00:00:00 2001 From: kosmar Date: Fri, 24 Jul 2026 12:37:14 +0200 Subject: [PATCH 03/20] feat(echolot): sync app with playground CV jack / UX state --- faderpunk/src/apps/echolot.rs | 1137 --------------------------------- 1 file changed, 1137 deletions(-) diff --git a/faderpunk/src/apps/echolot.rs b/faderpunk/src/apps/echolot.rs index a757a4353..e69de29bb 100644 --- a/faderpunk/src/apps/echolot.rs +++ b/faderpunk/src/apps/echolot.rs @@ -1,1137 +0,0 @@ -use embassy_futures::{ - join::{join, join5}, - select::{select, select3, Either}, -}; -use embassy_sync::{blocking_mutex::raw::NoopRawMutex, signal::Signal}; -use embassy_time::Instant; -use heapless::Vec; -use midly::{num::u7, MidiMessage}; -use serde::{Deserialize, Serialize}; - -use libfp::{ - ext::FromValue, - latch::LatchLayer, - utils::{ - bits_7_16, midi_gate, resolution_for_mode, scale_bits_7_12, split_unsigned_value, - value_to_resolution, - }, - AppIcon, Brightness, ClockDivision, Color, Config, MidiCc, MidiChannel, MidiIn, MidiNote, - MidiOut, Param, Range, Value, APP_MAX_PARAMS, -}; - -use crate::app::{ - App, AppParams, AppStorage, ClockEvent, Led, ManagedStorage, ParamStore, SceneEvent, -}; - -pub const CHANNELS: usize = 1; -pub const PARAMS: usize = 15; - -const LED_BRIGHTNESS: Brightness = Brightness::Mid; -const MAX_REPEATS: u8 = 8; -const VELOCITY_FLOOR: u16 = 300; -const QUEUE_CAP: usize = 32; -const SOUNDING_CAP: usize = 32; -const GATE_THRESH: u16 = 406; -/// Peak white button flash on note/gate in (full scale). -const INPUT_FLASH_PEAK: u8 = 255; -/// While muted, show the same flash at ~20% so MIDI In stays visible without looking “live”. -const INPUT_FLASH_MUTED_SCALE: u16 = 51; // 51/255 ≈ 20% -/// Straight note-length table for clock-synced delay (same as clk_div "Straight"). -const CLOCK_DIVISION_MODE: usize = 0; - -/// I/O routing mode. -const IO_MIDI_MIDI: usize = 0; -const IO_MIDI_CV: usize = 1; -const IO_CV_MIDI: usize = 2; - -/// Signal role (interpreted in context of I/O mode). -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( - "Echolot", - "MIDI/CV delay with feedback and pitch shift", - Color::Cyan, - AppIcon::Sine, -) -.add_param(Param::Enum { - name: "I/O", - variants: &["MIDI→MIDI", "MIDI→CV", "CV→MIDI"], -}) -.add_param(Param::Enum { - name: "Delay mode", - variants: &["ms", "Clock"], -}) -.add_param(Param::i32 { - name: "Max delay (ms)", - min: 10, - max: 2000, -}) -.add_param(Param::Enum { - name: "Interval mode", - variants: &["Fixed", "Stack", "Pong"], -}) -.add_param(Param::Enum { - name: "Routing", - variants: &["Single", "Ping-Pong"], -}) -.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 A", -}) -.add_param(Param::MidiChannel { - name: "MIDI Out B", -}) -.add_param(Param::MidiCc { name: "MIDI CC" }) -.add_param(Param::MidiNote { name: "MIDI Note" }); - -pub struct Params { - io_mode: usize, - delay_mode: usize, - max_delay_ms: i32, - interval_mode: usize, - routing: usize, - signal: usize, - range: Range, - color: Color, - midi_in: MidiIn, - midi_in_ch: MidiChannel, - midi_out: MidiOut, - midi_out_a: MidiChannel, - midi_out_b: 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]), - delay_mode: usize::from_value(values[1]), - max_delay_ms: i32::from_value(values[2]), - interval_mode: usize::from_value(values[3]), - routing: usize::from_value(values[4]), - signal: usize::from_value(values[5]), - range: Range::from_value(values[6]), - color: Color::from_value(values[7]), - midi_in: MidiIn::from_value(values[8]), - midi_in_ch: MidiChannel::from_value(values[9]), - midi_out: MidiOut::from_value(values[10]), - midi_out_a: MidiChannel::from_value(values[11]), - midi_out_b: MidiChannel::from_value(values[12]), - midi_cc: MidiCc::from_value(values[13]), - midi_note: MidiNote::from_value(values[14]), - }) - } - - fn to_values(&self) -> Vec { - let mut vec = Vec::new(); - vec.push(self.io_mode.into()).unwrap(); - vec.push(self.delay_mode.into()).unwrap(); - vec.push(self.max_delay_ms.into()).unwrap(); - vec.push(self.interval_mode.into()).unwrap(); - vec.push(self.routing.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_a.into()).unwrap(); - vec.push(self.midi_out_b.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, - feedback_saved: u16, - interval_saved: u16, - muted: bool, -} - -impl Default for Storage { - fn default() -> Self { - Self { - delay_saved: 2048, - feedback_saved: 0, - interval_saved: 2048, - muted: false, - } - } -} - -impl AppStorage for Storage {} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum EventKind { - NoteOn, - NoteOff, - /// Delayed CV/CC level (MIDI→CV pitch or CV→MIDI CC). - CvValue, - GateHigh, - GateLow, -} - -#[derive(Clone, Copy)] -struct PendingEvent { - kind: EventKind, - base_note: u8, - velocity: u16, - cv_value: u16, - interval: i8, - out_target: u8, - due_ms: u64, - due_tick: u32, - generation: u8, -} - -fn fader_to_delay_ms(fader: u16, max_ms: i32) -> u64 { - let max = max_ms.clamp(10, 2000) as u32; - // Match clock-mode / rate UX: fader up = faster (shorter delay). - let inverted = 4095u32.saturating_sub(fader as u32); - (inverted * max / 4095) as u64 -} - -fn fader_to_interval(fader: u16) -> i8 { - let centered = fader as i32 - 2048; - ((centered * 12) / 2048).clamp(-12, 12) as i8 -} - -fn interval_for_gen(base: i8, generation: u8, mode: usize) -> i8 { - match mode { - 1 => base.saturating_mul((generation as i8).saturating_add(1)), - 2 => { - if generation.is_multiple_of(2) { - base - } else { - base.saturating_neg() - } - } - _ => base, - } -} - -fn out_target_for_gen(generation: u8, ping_pong: bool) -> u8 { - if ping_pong && !generation.is_multiple_of(2) { - 1 - } else { - 0 - } -} - -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) / 12).clamp(0, 255) as u8; - [pos, 0] - } else { - let neg = (((-interval) * 255) / 12).clamp(0, 255) as u8; - [0, neg] - } -} - -/// Button/activity pulse brightness from feedback fader: 10%…100% of full scale. -fn pulse_from_feedback(feedback: u16) -> u8 { - const MIN: u32 = 26; // ≈10% of 255 - const MAX: u32 = 255; - (MIN + (feedback as u32 * (MAX - MIN) / 4095)) as u8 -} - -/// Button brightness from |interval|: 10% at unison … 100% at ±12 st. -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) / 12)) as u8 -} - -/// Resolve Signal enum in context of I/O mode. -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, // unused in MIDI→MIDI - } -} - -#[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, - delay_mode: 0, - max_delay_ms: 1000, - interval_mode: 0, - routing: 0, - signal: SIG_PITCH, - range: Range::_0_10V, - color: Color::Cyan, - midi_in: MidiIn::default(), - midi_in_ch: MidiChannel::default(), - midi_out: MidiOut::default(), - midi_out_a: MidiChannel::default(), - midi_out_b: 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, - delay_mode, - max_delay_ms, - interval_mode, - routing, - signal, - range, - led_color, - midi_in_cfg, - midi_in_ch, - midi_out_cfg, - midi_out_a, - midi_out_b, - midi_cc, - midi_note, - ) = params.query(|p| { - ( - p.io_mode, - p.delay_mode, - p.max_delay_ms, - p.interval_mode, - p.routing, - p.signal, - p.range, - p.color, - p.midi_in, - p.midi_in_ch, - p.midi_out, - p.midi_out_a, - p.midi_out_b, - p.midi_cc, - p.midi_note, - ) - }); - - let clocked = delay_mode == 1; - let ping_pong = routing == 1 && io_mode == IO_MIDI_MIDI; - let sig = effective_signal(io_mode, signal); - let resolution = resolution_for_mode(CLOCK_DIVISION_MODE); - let base_note_cfg = midi_note_u8(midi_note); - - let fader = app.use_faders(); - let buttons = app.use_buttons(); - let leds = app.use_leds(); - let mut clock = app.use_clock(); - let ticks = clock.get_ticker(); - - let mut midi_in = app.use_midi_input(midi_in_cfg, midi_in_ch); - let midi_a = app.use_midi_output(midi_out_cfg, midi_out_a, false); - let midi_b = app.use_midi_output(midi_out_cfg, midi_out_b, false); - - // One jack: out for MIDI→CV, in for CV→MIDI, unused for MIDI→MIDI. - 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 feedback_glob = app.make_global(0u16); - let interval_glob = app.make_global(0i8); - let activity_glob = app.make_global(0u8); - // White button flash on MIDI/CV note/gate input (proves listen path). - let input_flash_glob = app.make_global(0u8); - // 0 = Out A / top flash, 1 = Out B / bottom flash (ping-pong cue). - let pong_side_glob = app.make_global(0u8); - let queue_depth_glob = app.make_global(0u8); - - let (delay_saved, feedback_saved, interval_saved, muted) = storage.query(|s| { - ( - s.delay_saved, - s.feedback_saved, - s.interval_saved, - s.muted, - ) - }); - delay_saved_glob.set(delay_saved); - feedback_glob.set(feedback_saved); - interval_glob.set(fader_to_interval(interval_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 prev_gate = false; - let mut last_cc_gate: u16 = u16::MAX; - // Free-running delay-period metronome (blinks even with empty queue). - let mut next_metro_ms = Instant::now().as_millis(); - let mut next_metro_tick = ticks() as u32; - - let enqueue = |queue: &mut Vec, - kind: EventKind, - base_note: u8, - velocity: u16, - cv_value: u16, - generation: u8, - base_interval: i8, - delay_ms: u64, - delay_ticks: u32, - now_ms: u64, - now_tick: u32| { - let _ = queue.push(PendingEvent { - kind, - base_note, - velocity, - cv_value, - interval: interval_for_gen(base_interval, generation, interval_mode), - out_target: out_target_for_gen(generation, ping_pong), - due_ms: now_ms.saturating_add(delay_ms), - due_tick: now_tick.wrapping_add(delay_ticks), - generation, - }); - }; - - loop { - // MIDI→MIDI / MIDI→CV: wait on MIDI or 1ms tick. - // CV→MIDI: poll only (no MIDI input needed). - 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 now_ms = Instant::now().as_millis(); - let now_tick = ticks() as u32; - let delay_ms = fader_to_delay_ms(delay_fader, max_delay_ms); - let delay_ticks = value_to_resolution(delay_fader, resolution).max(1); - let feedback = feedback_glob.get(); - let pulse = pulse_from_feedback(feedback); - - // Idle delay metronome: one blink per delay period, no input required. - // Floor period so near-zero delay still reads as a pulse (~25 Hz max). - const MIN_METRO_MS: u64 = 40; - if !glob_muted.get() { - if clocked { - if now_tick.wrapping_sub(next_metro_tick) < (u32::MAX / 2) { - activity_glob.set(pulse); - next_metro_tick = now_tick.wrapping_add(delay_ticks); - } - } else if now_ms >= next_metro_ms { - activity_glob.set(pulse); - next_metro_ms = now_ms.saturating_add(delay_ms.max(MIN_METRO_MS)); - } - } - - if let Some(msg) = midi_msg { - let accept_new = !glob_muted.get(); - match (io_mode, sig, msg) { - // ── MIDI → MIDI notes ────────────────────────────────── - ( - IO_MIDI_MIDI, - _, - MidiMessage::NoteOn { key, vel }, - ) if vel > 0 => { - // Always flash on NoteOn so MIDI In is verifiable (even when muted). - input_flash_glob.set(INPUT_FLASH_PEAK); - if accept_new { - enqueue( - &mut queue, - EventKind::NoteOn, - key.as_int(), - scale_bits_7_12(vel), - 0, - 0, - base_interval, - delay_ms, - delay_ticks, - now_ms, - now_tick, - ); - } - } - (IO_MIDI_MIDI, _, MidiMessage::NoteOn { key, .. }) - | (IO_MIDI_MIDI, _, MidiMessage::NoteOff { key, .. }) => { - // Note-offs always accepted so held notes can release during ring-out. - enqueue( - &mut queue, - EventKind::NoteOff, - key.as_int(), - 0, - 0, - 0, - base_interval, - delay_ms, - delay_ticks, - now_ms, - now_tick, - ); - } - - // ── MIDI → CV Pitch (hold last delayed note) ─────────── - ( - IO_MIDI_CV, - SIG_PITCH, - MidiMessage::NoteOn { key, vel }, - ) if vel > 0 => { - input_flash_glob.set(INPUT_FLASH_PEAK); - if accept_new { - let n = note_num( - key.as_int(), - interval_for_gen(base_interval, 0, interval_mode), - ); - enqueue( - &mut queue, - EventKind::CvValue, - key.as_int(), - scale_bits_7_12(vel), - note_to_cv(n), - 0, - base_interval, - delay_ms, - delay_ticks, - now_ms, - now_tick, - ); - } - } - - // ── MIDI → CV Gate ───────────────────────────────────── - ( - IO_MIDI_CV, - SIG_GATE, - MidiMessage::NoteOn { key, vel }, - ) if vel > 0 => { - input_flash_glob.set(INPUT_FLASH_PEAK); - if accept_new { - enqueue( - &mut queue, - EventKind::GateHigh, - key.as_int(), - scale_bits_7_12(vel), - 4095, - 0, - base_interval, - delay_ms, - delay_ticks, - now_ms, - now_tick, - ); - } - } - (IO_MIDI_CV, SIG_GATE, MidiMessage::NoteOn { key, .. }) - | (IO_MIDI_CV, SIG_GATE, MidiMessage::NoteOff { key, .. }) => { - enqueue( - &mut queue, - EventKind::GateLow, - key.as_int(), - 0, - 0, - 0, - base_interval, - delay_ms, - delay_ticks, - now_ms, - now_tick, - ); - } - _ => {} - } - } - - // ── CV → MIDI input sampling ─────────────────────────────────── - 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 { - enqueue( - &mut queue, - EventKind::NoteOn, - base_note_cfg, - 4095, - 0, - 0, - base_interval, - delay_ms, - delay_ticks, - now_ms, - now_tick, - ); - } - } else if !high && prev_gate { - enqueue( - &mut queue, - EventKind::NoteOff, - base_note_cfg, - 0, - 0, - 0, - base_interval, - delay_ms, - delay_ticks, - now_ms, - now_tick, - ); - } - 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; - enqueue( - &mut queue, - EventKind::CvValue, - 0, - 0, - inval, - 0, - 0, - delay_ms, - delay_ticks, - now_ms, - now_tick, - ); - } - } - } - } - - // ── Panic (hard kill) ────────────────────────────────────────── - if panic_flag.get() { - for (out, n) in sounding.iter() { - let note = MidiNote::from(*n); - if *out == 0 { - midi_a.send_note_off(note).await; - } else { - midi_b.send_note_off(note).await; - } - } - // Catch delayed NoteOns that never reached sounding yet. - for event in queue.iter() { - if matches!(event.kind, EventKind::NoteOn) { - let n = note_num(event.base_note, event.interval); - let note = MidiNote::from(n); - if event.out_target == 0 { - midi_a.send_note_off(note).await; - } else { - midi_b.send_note_off(note).await; - } - } - } - // Channel-wide MIDI panic on both outs. - const ALL_SOUND_OFF: u8 = 120; - const ALL_NOTES_OFF: u8 = 123; - midi_a - .send_cc(MidiCc::from(ALL_SOUND_OFF), 0) - .await; - midi_a - .send_cc(MidiCc::from(ALL_NOTES_OFF), 0) - .await; - midi_b - .send_cc(MidiCc::from(ALL_SOUND_OFF), 0) - .await; - midi_b - .send_cc(MidiCc::from(ALL_NOTES_OFF), 0) - .await; - - sounding.clear(); - queue.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; - } - - // ── Due event processing (plays during mute = ring-out) ──────── - // Feedback only for note/gate event streams (not continuous CC/pitch holds). - let feedback_ok = matches!( - (io_mode, sig), - (IO_MIDI_MIDI, _) | (IO_MIDI_CV, SIG_GATE) | (IO_CV_MIDI, SIG_GATE_NOTE) - ); - - let mut i = 0; - while i < queue.len() { - let due = if clocked { - now_tick.wrapping_sub(queue[i].due_tick) < (u32::MAX / 2) - } else { - now_ms >= queue[i].due_ms - }; - if !due { - i += 1; - continue; - } - - let event = queue.swap_remove(i); - let n = note_num(event.base_note, event.interval); - let note = MidiNote::from(n); - - match event.kind { - EventKind::NoteOn => { - if event.out_target == 0 { - midi_a.send_note_on(note, event.velocity).await; - } else { - midi_b.send_note_on(note, event.velocity).await; - } - let _ = sounding.push((event.out_target, n)); - activity_glob.set(pulse); - pong_side_glob.set(event.out_target); - if feedback_ok && feedback > 0 && event.generation < MAX_REPEATS { - let next_vel = - ((event.velocity as u32 * feedback as u32) / 4095) as u16; - if next_vel >= VELOCITY_FLOOR { - let next_gen = event.generation.saturating_add(1); - enqueue( - &mut queue, - EventKind::NoteOn, - event.base_note, - next_vel, - 0, - next_gen, - base_interval, - delay_ms, - delay_ticks, - event.due_ms, - event.due_tick, - ); - } - } - } - EventKind::NoteOff => { - if event.out_target == 0 { - midi_a.send_note_off(note).await; - } else { - midi_b.send_note_off(note).await; - } - if let Some(pos) = sounding - .iter() - .position(|(o, sn)| *o == event.out_target && *sn == n) - { - sounding.swap_remove(pos); - } - if feedback_ok && feedback > 0 && event.generation < MAX_REPEATS { - let next_gen = event.generation.saturating_add(1); - enqueue( - &mut queue, - EventKind::NoteOff, - event.base_note, - 0, - 0, - next_gen, - base_interval, - delay_ms, - delay_ticks, - event.due_ms, - event.due_tick, - ); - } - } - 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_a.send_cc(midi_cc, event.cv_value).await; - } - activity_glob.set(pulse); - pong_side_glob.set(0); - } - EventKind::GateHigh => { - if let Some(jack) = out_jack.as_ref() { - jack.set_value(4095); - } - activity_glob.set(pulse); - pong_side_glob.set(0); - if feedback_ok && feedback > 0 && event.generation < MAX_REPEATS { - let next_vel = - ((event.velocity as u32 * feedback as u32) / 4095) as u16; - if next_vel >= VELOCITY_FLOOR { - let next_gen = event.generation.saturating_add(1); - enqueue( - &mut queue, - EventKind::GateHigh, - event.base_note, - next_vel, - 4095, - next_gen, - base_interval, - delay_ms, - delay_ticks, - event.due_ms, - event.due_tick, - ); - } - } - } - EventKind::GateLow => { - if let Some(jack) = out_jack.as_ref() { - jack.set_value(0); - } - if feedback_ok && feedback > 0 && event.generation < MAX_REPEATS { - let next_gen = event.generation.saturating_add(1); - enqueue( - &mut queue, - EventKind::GateLow, - event.base_note, - 0, - 0, - next_gen, - base_interval, - delay_ms, - delay_ticks, - event.due_ms, - event.due_tick, - ); - } - } - } - } - - queue_depth_glob.set( - ((queue.len() as u32 * 255) / QUEUE_CAP as u32).min(255) as u8, - ); - - if activity_glob.get() > 0 { - // Decay ~32ms to black from full so each delay tick reads as a blink. - 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() { - // Long-press panic only if this hold wasn't Interval (Button+Fader). - if !third_layer_used.get() { - panic_flag.set(true); - } - } else if !third_layer_used.get() { - // Short press: mute / ring-out (block new input; queue keeps playing). - 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 { - // Only marks the gesture; panic runs on release (see button_handler) - // so Button+Fader Interval edits never wipe the echo trail mid-hold. - 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.feedback_saved), - LatchLayer::Third => storage.query(|s| s.interval_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 => { - feedback_glob.set(new_value); - storage.modify_and_save(|s| s.feedback_saved = new_value); - } - LatchLayer::Third => { - interval_glob.set(fader_to_interval(new_value)); - storage.modify_and_save(|s| s.interval_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); - - // Incoming-note flash (white) — shown even when muted, to diagnose MIDI In. - // Muted: same decay envelope, peak shown at ~20% so mute still reads as muted. - 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)); - } - - 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 pong = pong_side_glob.get(); - let depth = queue_depth_glob.get(); - - // Delay meter as baseline; delay-cycle pulse overlays. - // Ping-Pong: pulse hits Top for Out A, Bottom for Out B. - let (top_b, bot_b) = if pulse > 0 && ping_pong { - if pong == 0 { - (pulse, led[1].max(depth / 4)) - } else { - (led[0].max(depth / 4), pulse) - } - } else { - ( - led[0].max(pulse).max(depth / 4), - led[1] - .max(if pulse > 0 { pulse / 2 } else { 0 }) - .max(depth / 4), - ) - }; - leds.set(0, Led::Top, led_color, Brightness::Custom(top_b)); - leds.set(0, Led::Bottom, led_color, Brightness::Custom(bot_b)); - // White input flash wins over delay pulse on the button. - if input_flash == 0 { - if pulse > 0 { - leds.set(0, Led::Button, led_color, Brightness::Custom(pulse)); - } else { - leds.set(0, Led::Button, led_color, LED_BRIGHTNESS); - } - } - } - LatchLayer::Alt => { - // Feedback: low = Bottom, high = Top (same split as delay meter). - let led = split_unsigned_value(feedback_glob.get()); - let btn = pulse_from_feedback(feedback_glob.get()); - leds.set(0, Led::Top, Color::Green, Brightness::Custom(led[0])); - leds.set(0, Led::Bottom, Color::Green, Brightness::Custom(led[1])); - leds.set(0, Led::Button, Color::Green, Brightness::Custom(btn)); - } - LatchLayer::Third => { - // Interval: down = Bottom, up = Top; button tracks |st|. - 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::Red, Brightness::Custom(led[0])); - leds.set(0, Led::Bottom, Color::Red, Brightness::Custom(led[1])); - leds.set(0, Led::Button, Color::Red, 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, feedback_saved, interval_saved, muted) = storage.query(|s| { - ( - s.delay_saved, - s.feedback_saved, - s.interval_saved, - s.muted, - ) - }); - delay_saved_glob.set(delay_saved); - feedback_glob.set(feedback_saved); - interval_glob.set(fader_to_interval(interval_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; - } - } - } - }; - - let clock_watch = async { - loop { - match clock.wait_for_event(ClockDivision::_1).await { - ClockEvent::Stop | ClockEvent::Reset if clocked => { - panic_flag.set(true); - } - _ => {} - } - } - }; - - join( - long_press, - join5( - engine, - button_handler, - fader_handler, - join(led_handler, scene_handler), - clock_watch, - ), - ) - .await; -} From b48c81d3b558a4988500bf40269bd0a6ac5bdaeb Mon Sep 17 00:00:00 2001 From: kosmar Date: Fri, 24 Jul 2026 12:37:36 +0200 Subject: [PATCH 04/20] fix(echolot): restore app synced from playground --- faderpunk/src/apps/echolot.rs | 1137 +++++++++++++++++++++++++++++++++ 1 file changed, 1137 insertions(+) diff --git a/faderpunk/src/apps/echolot.rs b/faderpunk/src/apps/echolot.rs index e69de29bb..d47511c23 100644 --- a/faderpunk/src/apps/echolot.rs +++ b/faderpunk/src/apps/echolot.rs @@ -0,0 +1,1137 @@ +use embassy_futures::{ + join::{join, join5}, + select::{select, select3, Either}, +}; +use embassy_sync::{blocking_mutex::raw::NoopRawMutex, signal::Signal}; +use embassy_time::Instant; +use heapless::Vec; +use midly::{num::u7, MidiMessage}; +use serde::{Deserialize, Serialize}; + +use libfp::{ + ext::FromValue, + latch::LatchLayer, + utils::{ + bits_7_16, midi_gate, resolution_for_mode, scale_bits_7_12, split_unsigned_value, + value_to_resolution, + }, + AppIcon, Brightness, ClockDivision, Color, Config, MidiCc, MidiChannel, MidiIn, MidiNote, + MidiOut, Param, Range, Value, APP_MAX_PARAMS, +}; + +use crate::app::{ + App, AppParams, AppStorage, ClockEvent, Led, ManagedStorage, ParamStore, SceneEvent, +}; + +pub const CHANNELS: usize = 1; +pub const PARAMS: usize = 15; + +const LED_BRIGHTNESS: Brightness = Brightness::Mid; +const MAX_REPEATS: u8 = 8; +const VELOCITY_FLOOR: u16 = 300; +const QUEUE_CAP: usize = 32; +const SOUNDING_CAP: usize = 32; +const GATE_THRESH: u16 = 406; +/// Peak white button flash on note/gate in (full scale). +const INPUT_FLASH_PEAK: u8 = 255; +/// While muted, show the same flash at ~20% so MIDI In stays visible without looking “live”. +const INPUT_FLASH_MUTED_SCALE: u16 = 51; // 51/255 ≈ 20% +/// Straight note-length table for clock-synced delay (same as clk_div "Straight"). +const CLOCK_DIVISION_MODE: usize = 0; + +/// I/O routing mode. +const IO_MIDI_MIDI: usize = 0; +const IO_MIDI_CV: usize = 1; +const IO_CV_MIDI: usize = 2; + +/// Signal role (interpreted in context of I/O mode). +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( + "Echolot", + "MIDI/CV delay with feedback and pitch shift", + Color::Cyan, + AppIcon::Sine, +) +.add_param(Param::Enum { + name: "I/O", + variants: &["MIDI→MIDI", "MIDI→CV", "CV→MIDI"], +}) +.add_param(Param::Enum { + name: "Delay mode", + variants: &["ms", "Clock"], +}) +.add_param(Param::i32 { + name: "Max delay (ms)", + min: 10, + max: 2000, +}) +.add_param(Param::Enum { + name: "Interval mode", + variants: &["Fixed", "Stack", "Pong"], +}) +.add_param(Param::Enum { + name: "Routing", + variants: &["Single", "Ping-Pong"], +}) +.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 A", +}) +.add_param(Param::MidiChannel { + name: "MIDI Out B", +}) +.add_param(Param::MidiCc { name: "MIDI CC" }) +.add_param(Param::MidiNote { name: "MIDI Note" }); + +pub struct Params { + io_mode: usize, + delay_mode: usize, + max_delay_ms: i32, + interval_mode: usize, + routing: usize, + signal: usize, + range: Range, + color: Color, + midi_in: MidiIn, + midi_in_ch: MidiChannel, + midi_out: MidiOut, + midi_out_a: MidiChannel, + midi_out_b: 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]), + delay_mode: usize::from_value(values[1]), + max_delay_ms: i32::from_value(values[2]), + interval_mode: usize::from_value(values[3]), + routing: usize::from_value(values[4]), + signal: usize::from_value(values[5]), + range: Range::from_value(values[6]), + color: Color::from_value(values[7]), + midi_in: MidiIn::from_value(values[8]), + midi_in_ch: MidiChannel::from_value(values[9]), + midi_out: MidiOut::from_value(values[10]), + midi_out_a: MidiChannel::from_value(values[11]), + midi_out_b: MidiChannel::from_value(values[12]), + midi_cc: MidiCc::from_value(values[13]), + midi_note: MidiNote::from_value(values[14]), + }) + } + + fn to_values(&self) -> Vec { + let mut vec = Vec::new(); + vec.push(self.io_mode.into()).unwrap(); + vec.push(self.delay_mode.into()).unwrap(); + vec.push(self.max_delay_ms.into()).unwrap(); + vec.push(self.interval_mode.into()).unwrap(); + vec.push(self.routing.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_a.into()).unwrap(); + vec.push(self.midi_out_b.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, + feedback_saved: u16, + interval_saved: u16, + muted: bool, +} + +impl Default for Storage { + fn default() -> Self { + Self { + delay_saved: 2048, + feedback_saved: 0, + interval_saved: 2048, + muted: false, + } + } +} + +impl AppStorage for Storage {} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum EventKind { + NoteOn, + NoteOff, + /// Delayed CV/CC level (MIDI→CV pitch or CV→MIDI CC). + CvValue, + GateHigh, + GateLow, +} + +#[derive(Clone, Copy)] +struct PendingEvent { + kind: EventKind, + base_note: u8, + velocity: u16, + cv_value: u16, + interval: i8, + out_target: u8, + due_ms: u64, + due_tick: u32, + generation: u8, +} + +fn fader_to_delay_ms(fader: u16, max_ms: i32) -> u64 { + let max = max_ms.clamp(10, 2000) as u32; + // Match clock-mode / rate UX: fader up = faster (shorter delay). + let inverted = 4095u32.saturating_sub(fader as u32); + (inverted * max / 4095) as u64 +} + +fn fader_to_interval(fader: u16) -> i8 { + let centered = fader as i32 - 2048; + ((centered * 12) / 2048).clamp(-12, 12) as i8 +} + +fn interval_for_gen(base: i8, generation: u8, mode: usize) -> i8 { + match mode { + 1 => base.saturating_mul((generation as i8).saturating_add(1)), + 2 => { + if generation.is_multiple_of(2) { + base + } else { + base.saturating_neg() + } + } + _ => base, + } +} + +fn out_target_for_gen(generation: u8, ping_pong: bool) -> u8 { + if ping_pong && !generation.is_multiple_of(2) { + 1 + } else { + 0 + } +} + +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) / 12).clamp(0, 255) as u8; + [pos, 0] + } else { + let neg = (((-interval) * 255) / 12).clamp(0, 255) as u8; + [0, neg] + } +} + +/// Button/activity pulse brightness from feedback fader: 10%…100% of full scale. +fn pulse_from_feedback(feedback: u16) -> u8 { + const MIN: u32 = 26; // ≈10% of 255 + const MAX: u32 = 255; + (MIN + (feedback as u32 * (MAX - MIN) / 4095)) as u8 +} + +/// Button brightness from |interval|: 10% at unison … 100% at ±12 st. +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) / 12)) as u8 +} + +/// Resolve Signal enum in context of I/O mode. +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, // unused in MIDI→MIDI + } +} + +#[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, + delay_mode: 0, + max_delay_ms: 1000, + interval_mode: 0, + routing: 0, + signal: SIG_PITCH, + range: Range::_0_10V, + color: Color::Cyan, + midi_in: MidiIn::default(), + midi_in_ch: MidiChannel::default(), + midi_out: MidiOut::default(), + midi_out_a: MidiChannel::default(), + midi_out_b: 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, + delay_mode, + max_delay_ms, + interval_mode, + routing, + signal, + range, + led_color, + midi_in_cfg, + midi_in_ch, + midi_out_cfg, + midi_out_a, + midi_out_b, + midi_cc, + midi_note, + ) = params.query(|p| { + ( + p.io_mode, + p.delay_mode, + p.max_delay_ms, + p.interval_mode, + p.routing, + p.signal, + p.range, + p.color, + p.midi_in, + p.midi_in_ch, + p.midi_out, + p.midi_out_a, + p.midi_out_b, + p.midi_cc, + p.midi_note, + ) + }); + + let clocked = delay_mode == 1; + let ping_pong = routing == 1 && io_mode == IO_MIDI_MIDI; + let sig = effective_signal(io_mode, signal); + let resolution = resolution_for_mode(CLOCK_DIVISION_MODE); + let base_note_cfg = midi_note_u8(midi_note); + + let fader = app.use_faders(); + let buttons = app.use_buttons(); + let leds = app.use_leds(); + let mut clock = app.use_clock(); + let ticks = clock.get_ticker(); + + let mut midi_in = app.use_midi_input(midi_in_cfg, midi_in_ch); + let midi_a = app.use_midi_output(midi_out_cfg, midi_out_a, false); + let midi_b = app.use_midi_output(midi_out_cfg, midi_out_b, false); + + // One jack: out for MIDI→CV, in for CV→MIDI, unused for MIDI→MIDI. + 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 feedback_glob = app.make_global(0u16); + let interval_glob = app.make_global(0i8); + let activity_glob = app.make_global(0u8); + // White button flash on MIDI/CV note/gate input (proves listen path). + let input_flash_glob = app.make_global(0u8); + // 0 = Out A / top flash, 1 = Out B / bottom flash (ping-pong cue). + let pong_side_glob = app.make_global(0u8); + let queue_depth_glob = app.make_global(0u8); + + let (delay_saved, feedback_saved, interval_saved, muted) = storage.query(|s| { + ( + s.delay_saved, + s.feedback_saved, + s.interval_saved, + s.muted, + ) + }); + delay_saved_glob.set(delay_saved); + feedback_glob.set(feedback_saved); + interval_glob.set(fader_to_interval(interval_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 prev_gate = false; + let mut last_cc_gate: u16 = u16::MAX; + // Free-running delay-period metronome (blinks even with empty queue). + let mut next_metro_ms = Instant::now().as_millis(); + let mut next_metro_tick = ticks() as u32; + + let enqueue = |queue: &mut Vec, + kind: EventKind, + base_note: u8, + velocity: u16, + cv_value: u16, + generation: u8, + base_interval: i8, + delay_ms: u64, + delay_ticks: u32, + now_ms: u64, + now_tick: u32| { + let _ = queue.push(PendingEvent { + kind, + base_note, + velocity, + cv_value, + interval: interval_for_gen(base_interval, generation, interval_mode), + out_target: out_target_for_gen(generation, ping_pong), + due_ms: now_ms.saturating_add(delay_ms), + due_tick: now_tick.wrapping_add(delay_ticks), + generation, + }); + }; + + loop { + // MIDI→MIDI / MIDI→CV: wait on MIDI or 1ms tick. + // CV→MIDI: poll only (no MIDI input needed). + 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 now_ms = Instant::now().as_millis(); + let now_tick = ticks() as u32; + let delay_ms = fader_to_delay_ms(delay_fader, max_delay_ms); + let delay_ticks = value_to_resolution(delay_fader, resolution).max(1); + let feedback = feedback_glob.get(); + let pulse = pulse_from_feedback(feedback); + + // Idle delay metronome: one blink per delay period, no input required. + // Floor period so near-zero delay still reads as a pulse (~25 Hz max). + const MIN_METRO_MS: u64 = 40; + if !glob_muted.get() { + if clocked { + if now_tick.wrapping_sub(next_metro_tick) < (u32::MAX / 2) { + activity_glob.set(pulse); + next_metro_tick = now_tick.wrapping_add(delay_ticks); + } + } else if now_ms >= next_metro_ms { + activity_glob.set(pulse); + next_metro_ms = now_ms.saturating_add(delay_ms.max(MIN_METRO_MS)); + } + } + + if let Some(msg) = midi_msg { + let accept_new = !glob_muted.get(); + match (io_mode, sig, msg) { + // ── MIDI → MIDI notes ────────────────────────────────── + ( + IO_MIDI_MIDI, + _, + MidiMessage::NoteOn { key, vel }, + ) if vel > 0 => { + // Always flash on NoteOn so MIDI In is verifiable (even when muted). + input_flash_glob.set(INPUT_FLASH_PEAK); + if accept_new { + enqueue( + &mut queue, + EventKind::NoteOn, + key.as_int(), + scale_bits_7_12(vel), + 0, + 0, + base_interval, + delay_ms, + delay_ticks, + now_ms, + now_tick, + ); + } + } + (IO_MIDI_MIDI, _, MidiMessage::NoteOn { key, .. }) + | (IO_MIDI_MIDI, _, MidiMessage::NoteOff { key, .. }) => { + // Note-offs always accepted so held notes can release during ring-out. + enqueue( + &mut queue, + EventKind::NoteOff, + key.as_int(), + 0, + 0, + 0, + base_interval, + delay_ms, + delay_ticks, + now_ms, + now_tick, + ); + } + + // ── MIDI → CV Pitch (hold last delayed note) ─────────── + ( + IO_MIDI_CV, + SIG_PITCH, + MidiMessage::NoteOn { key, vel }, + ) if vel > 0 => { + input_flash_glob.set(INPUT_FLASH_PEAK); + if accept_new { + let n = note_num( + key.as_int(), + interval_for_gen(base_interval, 0, interval_mode), + ); + enqueue( + &mut queue, + EventKind::CvValue, + key.as_int(), + scale_bits_7_12(vel), + note_to_cv(n), + 0, + base_interval, + delay_ms, + delay_ticks, + now_ms, + now_tick, + ); + } + } + + // ── MIDI → CV Gate ───────────────────────────────────── + ( + IO_MIDI_CV, + SIG_GATE, + MidiMessage::NoteOn { key, vel }, + ) if vel > 0 => { + input_flash_glob.set(INPUT_FLASH_PEAK); + if accept_new { + enqueue( + &mut queue, + EventKind::GateHigh, + key.as_int(), + scale_bits_7_12(vel), + 4095, + 0, + base_interval, + delay_ms, + delay_ticks, + now_ms, + now_tick, + ); + } + } + (IO_MIDI_CV, SIG_GATE, MidiMessage::NoteOn { key, .. }) + | (IO_MIDI_CV, SIG_GATE, MidiMessage::NoteOff { key, .. }) => { + enqueue( + &mut queue, + EventKind::GateLow, + key.as_int(), + 0, + 0, + 0, + base_interval, + delay_ms, + delay_ticks, + now_ms, + now_tick, + ); + } + _ => {} + } + } + + // ── CV → MIDI input sampling ─────────────────────────────────── + 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 { + enqueue( + &mut queue, + EventKind::NoteOn, + base_note_cfg, + 4095, + 0, + 0, + base_interval, + delay_ms, + delay_ticks, + now_ms, + now_tick, + ); + } + } else if !high && prev_gate { + enqueue( + &mut queue, + EventKind::NoteOff, + base_note_cfg, + 0, + 0, + 0, + base_interval, + delay_ms, + delay_ticks, + now_ms, + now_tick, + ); + } + 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; + enqueue( + &mut queue, + EventKind::CvValue, + 0, + 0, + inval, + 0, + 0, + delay_ms, + delay_ticks, + now_ms, + now_tick, + ); + } + } + } + } + + // ── Panic (hard kill) ────────────────────────────────────────── + if panic_flag.get() { + for (out, n) in sounding.iter() { + let note = MidiNote::from(*n); + if *out == 0 { + midi_a.send_note_off(note).await; + } else { + midi_b.send_note_off(note).await; + } + } + // Catch delayed NoteOns that never reached sounding yet. + for event in queue.iter() { + if matches!(event.kind, EventKind::NoteOn) { + let n = note_num(event.base_note, event.interval); + let note = MidiNote::from(n); + if event.out_target == 0 { + midi_a.send_note_off(note).await; + } else { + midi_b.send_note_off(note).await; + } + } + } + // Channel-wide MIDI panic on both outs. + const ALL_SOUND_OFF: u8 = 120; + const ALL_NOTES_OFF: u8 = 123; + midi_a + .send_cc(MidiCc::from(ALL_SOUND_OFF), 0) + .await; + midi_a + .send_cc(MidiCc::from(ALL_NOTES_OFF), 0) + .await; + midi_b + .send_cc(MidiCc::from(ALL_SOUND_OFF), 0) + .await; + midi_b + .send_cc(MidiCc::from(ALL_NOTES_OFF), 0) + .await; + + sounding.clear(); + queue.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; + } + + // ── Due event processing (plays during mute = ring-out) ──────── + // Feedback only for note/gate event streams (not continuous CC/pitch holds). + let feedback_ok = matches!( + (io_mode, sig), + (IO_MIDI_MIDI, _) | (IO_MIDI_CV, SIG_GATE) | (IO_CV_MIDI, SIG_GATE_NOTE) + ); + + let mut i = 0; + while i < queue.len() { + let due = if clocked { + now_tick.wrapping_sub(queue[i].due_tick) < (u32::MAX / 2) + } else { + now_ms >= queue[i].due_ms + }; + if !due { + i += 1; + continue; + } + + let event = queue.swap_remove(i); + let n = note_num(event.base_note, event.interval); + let note = MidiNote::from(n); + + match event.kind { + EventKind::NoteOn => { + if event.out_target == 0 { + midi_a.send_note_on(note, event.velocity).await; + } else { + midi_b.send_note_on(note, event.velocity).await; + } + let _ = sounding.push((event.out_target, n)); + activity_glob.set(pulse); + pong_side_glob.set(event.out_target); + if feedback_ok && feedback > 0 && event.generation < MAX_REPEATS { + let next_vel = + ((event.velocity as u32 * feedback as u32) / 4095) as u16; + if next_vel >= VELOCITY_FLOOR { + let next_gen = event.generation.saturating_add(1); + enqueue( + &mut queue, + EventKind::NoteOn, + event.base_note, + next_vel, + 0, + next_gen, + base_interval, + delay_ms, + delay_ticks, + event.due_ms, + event.due_tick, + ); + } + } + } + EventKind::NoteOff => { + if event.out_target == 0 { + midi_a.send_note_off(note).await; + } else { + midi_b.send_note_off(note).await; + } + if let Some(pos) = sounding + .iter() + .position(|(o, sn)| *o == event.out_target && *sn == n) + { + sounding.swap_remove(pos); + } + if feedback_ok && feedback > 0 && event.generation < MAX_REPEATS { + let next_gen = event.generation.saturating_add(1); + enqueue( + &mut queue, + EventKind::NoteOff, + event.base_note, + 0, + 0, + next_gen, + base_interval, + delay_ms, + delay_ticks, + event.due_ms, + event.due_tick, + ); + } + } + 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_a.send_cc(midi_cc, event.cv_value).await; + } + activity_glob.set(pulse); + pong_side_glob.set(0); + } + EventKind::GateHigh => { + if let Some(jack) = out_jack.as_ref() { + jack.set_value(4095); + } + activity_glob.set(pulse); + pong_side_glob.set(0); + if feedback_ok && feedback > 0 && event.generation < MAX_REPEATS { + let next_vel = + ((event.velocity as u32 * feedback as u32) / 4095) as u16; + if next_vel >= VELOCITY_FLOOR { + let next_gen = event.generation.saturating_add(1); + enqueue( + &mut queue, + EventKind::GateHigh, + event.base_note, + next_vel, + 4095, + next_gen, + base_interval, + delay_ms, + delay_ticks, + event.due_ms, + event.due_tick, + ); + } + } + } + EventKind::GateLow => { + if let Some(jack) = out_jack.as_ref() { + jack.set_value(0); + } + if feedback_ok && feedback > 0 && event.generation < MAX_REPEATS { + let next_gen = event.generation.saturating_add(1); + enqueue( + &mut queue, + EventKind::GateLow, + event.base_note, + 0, + 0, + next_gen, + base_interval, + delay_ms, + delay_ticks, + event.due_ms, + event.due_tick, + ); + } + } + } + } + + queue_depth_glob.set( + ((queue.len() as u32 * 255) / QUEUE_CAP as u32).min(255) as u8, + ); + + if activity_glob.get() > 0 { + // Decay ~32ms to black from full so each delay tick reads as a blink. + 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() { + // Long-press panic only if this hold wasn't Interval (Button+Fader). + if !third_layer_used.get() { + panic_flag.set(true); + } + } else if !third_layer_used.get() { + // Short press: mute / ring-out (block new input; queue keeps playing). + 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 { + // Only marks the gesture; panic runs on release (see button_handler) + // so Button+Fader Interval edits never wipe the echo trail mid-hold. + 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.feedback_saved), + LatchLayer::Third => storage.query(|s| s.interval_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 => { + feedback_glob.set(new_value); + storage.modify_and_save(|s| s.feedback_saved = new_value); + } + LatchLayer::Third => { + interval_glob.set(fader_to_interval(new_value)); + storage.modify_and_save(|s| s.interval_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); + + // Incoming-note flash (white) — shown even when muted, to diagnose MIDI In. + // Muted: same decay envelope, peak shown at ~20% so mute still reads as muted. + 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)); + } + + 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 pong = pong_side_glob.get(); + let depth = queue_depth_glob.get(); + + // Delay meter as baseline; delay-cycle pulse overlays. + // Ping-Pong: pulse hits Top for Out A, Bottom for Out B. + let (top_b, bot_b) = if pulse > 0 && ping_pong { + if pong == 0 { + (pulse, led[1].max(depth / 4)) + } else { + (led[0].max(depth / 4), pulse) + } + } else { + ( + led[0].max(pulse).max(depth / 4), + led[1] + .max(if pulse > 0 { pulse / 2 } else { 0 }) + .max(depth / 4), + ) + }; + leds.set(0, Led::Top, led_color, Brightness::Custom(top_b)); + leds.set(0, Led::Bottom, led_color, Brightness::Custom(bot_b)); + // White input flash wins over delay pulse on the button. + if input_flash == 0 { + if pulse > 0 { + leds.set(0, Led::Button, led_color, Brightness::Custom(pulse)); + } else { + leds.set(0, Led::Button, led_color, LED_BRIGHTNESS); + } + } + } + LatchLayer::Alt => { + // Feedback: low = Bottom, high = Top (same split as delay meter). + let led = split_unsigned_value(feedback_glob.get()); + let btn = pulse_from_feedback(feedback_glob.get()); + leds.set(0, Led::Top, Color::Green, Brightness::Custom(led[0])); + leds.set(0, Led::Bottom, Color::Green, Brightness::Custom(led[1])); + leds.set(0, Led::Button, Color::Green, Brightness::Custom(btn)); + } + LatchLayer::Third => { + // Interval: down = Bottom, up = Top; button tracks |st|. + 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::Red, Brightness::Custom(led[0])); + leds.set(0, Led::Bottom, Color::Red, Brightness::Custom(led[1])); + leds.set(0, Led::Button, Color::Red, 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, feedback_saved, interval_saved, muted) = storage.query(|s| { + ( + s.delay_saved, + s.feedback_saved, + s.interval_saved, + s.muted, + ) + }); + delay_saved_glob.set(delay_saved); + feedback_glob.set(feedback_saved); + interval_glob.set(fader_to_interval(interval_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; + } + } + } + }; + + let clock_watch = async { + loop { + match clock.wait_for_event(ClockDivision::_1).await { + ClockEvent::Stop | ClockEvent::Reset if clocked => { + panic_flag.set(true); + } + _ => {} + } + } + }; + + join( + long_press, + join5( + engine, + button_handler, + fader_handler, + join(led_handler, scene_handler), + clock_watch, + ), + ) + .await; +} From 5e13e8f59e1b90266c2b120b0c58cf44d3799345 Mon Sep 17 00:00:00 2001 From: kosmar Date: Fri, 24 Jul 2026 20:31:08 +0200 Subject: [PATCH 05/20] fix(echolot): curb loopback feedback collapse and rename Bounce/Out --- faderpunk/src/apps/echolot.rs | 243 ++++++++++++++++++++++++++-------- 1 file changed, 191 insertions(+), 52 deletions(-) diff --git a/faderpunk/src/apps/echolot.rs b/faderpunk/src/apps/echolot.rs index d47511c23..49845e318 100644 --- a/faderpunk/src/apps/echolot.rs +++ b/faderpunk/src/apps/echolot.rs @@ -28,8 +28,11 @@ pub const PARAMS: usize = 15; const LED_BRIGHTNESS: Brightness = Brightness::Mid; const MAX_REPEATS: u8 = 8; -const VELOCITY_FLOOR: u16 = 300; +/// Stop re-queueing once delayed velocity falls below this (≈ MIDI vel 6). +const VELOCITY_FLOOR: u16 = 200; const QUEUE_CAP: usize = 32; +/// Leave headroom so NoteOffs can still land when feedback floods the queue. +const QUEUE_FEEDBACK_RESERVE: usize = 4; const SOUNDING_CAP: usize = 32; const GATE_THRESH: u16 = 406; /// Peak white button flash on note/gate in (full scale). @@ -38,6 +41,11 @@ const INPUT_FLASH_PEAK: u8 = 255; const INPUT_FLASH_MUTED_SCALE: u16 = 51; // 51/255 ≈ 20% /// Straight note-length table for clock-synced delay (same as clk_div "Straight"). const CLOCK_DIVISION_MODE: usize = 0; +/// Ignore own delayed MIDI coming back via host/DIN loopback (In CH == Out CH). +const LOOPBACK_IGNORE_MS: u64 = 40; +const RECENT_EMIT_CAP: usize = 24; +/// Floor free-running delay so feedback can't collapse into a solid tone. +const MIN_DELAY_MS: u64 = 1; /// I/O routing mode. const IO_MIDI_MIDI: usize = 0; @@ -71,7 +79,7 @@ pub static CONFIG: Config = Config::new( }) .add_param(Param::Enum { name: "Interval mode", - variants: &["Fixed", "Stack", "Pong"], + variants: &["Fixed", "Stack", "Bounce"], }) .add_param(Param::Enum { name: "Routing", @@ -104,10 +112,10 @@ pub static CONFIG: Config = Config::new( }) .add_param(Param::MidiOut) .add_param(Param::MidiChannel { - name: "MIDI Out A", + name: "MIDI Out", }) .add_param(Param::MidiChannel { - name: "MIDI Out B", + name: "MIDI Out Pong", }) .add_param(Param::MidiCc { name: "MIDI CC" }) .add_param(Param::MidiNote { name: "MIDI Note" }); @@ -223,7 +231,99 @@ fn fader_to_delay_ms(fader: u16, max_ms: i32) -> u64 { let max = max_ms.clamp(10, 2000) as u32; // Match clock-mode / rate UX: fader up = faster (shorter delay). let inverted = 4095u32.saturating_sub(fader as u32); - (inverted * max / 4095) as u64 + ((inverted * max / 4095) as u64).max(MIN_DELAY_MS) +} + +/// Shift+Fader → per-repeat velocity retention (0 = single delay only, 4095 ≈ hold). +/// +/// Quadratic ease toward unity so the middle of the fader yields several audible +/// diminishing taps instead of dying after the first echo. +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 +} + +/// Next-echo velocity after applying feedback retention; None = trail is done. +fn next_feedback_velocity(velocity: u16, feedback_fader: u16) -> Option { + let retention = feedback_retention(feedback_fader); + if retention == 0 { + return None; + } + let next = ((velocity as u32 * retention as u32) / 4095) as u16; + (next >= VELOCITY_FLOOR).then_some(next) +} + +/// How many feedback repeats a full-scale note can produce (caps NoteOff trail). +fn max_feedback_repeats(feedback_fader: u16) -> u8 { + let mut vel = 4095u16; + let mut n = 0u8; + while n < MAX_REPEATS { + match next_feedback_velocity(vel, feedback_fader) { + Some(next) => { + vel = next; + n = n.saturating_add(1); + } + None => break, + } + } + n +} + +/// True when MIDI In shares a port with MIDI Out (USB↔USB or DIN↔DIN). +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)) +} + +/// In CH matches an active Out CH — host/DIN thru will re-inject our own echoes. +fn same_channel_loop_risk( + midi_in_ch: MidiChannel, + midi_out_a: MidiChannel, + midi_out_b: MidiChannel, + ping_pong: bool, +) -> bool { + midi_in_ch == midi_out_a || (ping_pong && midi_in_ch == midi_out_b) +} + +#[derive(Clone, Copy)] +struct RecentEmit { + note: u8, + /// true = NoteOn / GateHigh, false = NoteOff / GateLow + is_on: bool, + at_ms: u64, +} + +fn record_emit(recent: &mut Vec, note: u8, is_on: bool, now_ms: u64) { + // Drop stale entries first so capacity stays available for fresh sends. + 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 { @@ -330,8 +430,9 @@ pub async fn wrapper(app: App, exit_signal: &'static Signal = Vec::new(); let mut sounding: Vec<(u8, u8), SOUNDING_CAP> = Vec::new(); + let mut recent_emit: Vec = Vec::new(); let mut prev_gate = false; let mut last_cc_gate: u16 = u16::MAX; // Free-running delay-period metronome (blinks even with empty queue). @@ -479,18 +584,40 @@ pub async fn run( delay_ms: u64, delay_ticks: u32, now_ms: u64, - now_tick: u32| { - let _ = queue.push(PendingEvent { - kind, - base_note, - velocity, - cv_value, - interval: interval_for_gen(base_interval, generation, interval_mode), - out_target: out_target_for_gen(generation, ping_pong), - due_ms: now_ms.saturating_add(delay_ms), - due_tick: now_tick.wrapping_add(delay_ticks), - generation, - }); + now_tick: u32| + -> bool { + // Feedback gens yield to NoteOff / fresh input when the queue is tight. + if generation > 0 && queue.len() + QUEUE_FEEDBACK_RESERVE >= QUEUE_CAP { + return false; + } + // Prefer landing NoteOffs: drop oldest feedback NoteOn if full. + if queue.is_full() { + if matches!(kind, EventKind::NoteOff | EventKind::GateLow) { + if let Some(pos) = queue + .iter() + .position(|e| e.generation > 0 && 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: interval_for_gen(base_interval, generation, interval_mode), + out_target: out_target_for_gen(generation, ping_pong), + due_ms: now_ms.saturating_add(delay_ms), + due_tick: now_tick.wrapping_add(delay_ticks), + generation, + }) + .is_ok() }; loop { @@ -539,14 +666,41 @@ pub async fn run( _, MidiMessage::NoteOn { key, vel }, ) if vel > 0 => { - // Always flash on NoteOn so MIDI In is verifiable (even when muted). - input_flash_glob.set(INPUT_FLASH_PEAK); - if accept_new { + let key_n = key.as_int(); + // Host/DIN loopback of our own Out on the same CH — don't re-arm. + if loop_guard && is_own_echo(&recent_emit, key_n, true, now_ms) { + // still flash so the loop is visible as activity + input_flash_glob.set(INPUT_FLASH_PEAK); + } else { + // Always flash on NoteOn so MIDI In is verifiable (even when muted). + input_flash_glob.set(INPUT_FLASH_PEAK); + if accept_new { + enqueue( + &mut queue, + EventKind::NoteOn, + key_n, + scale_bits_7_12(vel), + 0, + 0, + base_interval, + delay_ms, + delay_ticks, + now_ms, + now_tick, + ); + } + } + } + (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)) { + // Note-offs always accepted so held notes can release during ring-out. enqueue( &mut queue, - EventKind::NoteOn, - key.as_int(), - scale_bits_7_12(vel), + EventKind::NoteOff, + key_n, + 0, 0, 0, base_interval, @@ -557,23 +711,6 @@ pub async fn run( ); } } - (IO_MIDI_MIDI, _, MidiMessage::NoteOn { key, .. }) - | (IO_MIDI_MIDI, _, MidiMessage::NoteOff { key, .. }) => { - // Note-offs always accepted so held notes can release during ring-out. - enqueue( - &mut queue, - EventKind::NoteOff, - key.as_int(), - 0, - 0, - 0, - base_interval, - delay_ms, - delay_ticks, - now_ms, - now_tick, - ); - } // ── MIDI → CV Pitch (hold last delayed note) ─────────── ( @@ -748,6 +885,7 @@ pub async fn run( sounding.clear(); queue.clear(); + recent_emit.clear(); if let Some(jack) = out_jack.as_ref() { jack.set_value(0); } @@ -789,13 +927,13 @@ pub async fn run( } else { midi_b.send_note_on(note, event.velocity).await; } + record_emit(&mut recent_emit, n, true, now_ms); let _ = sounding.push((event.out_target, n)); activity_glob.set(pulse); pong_side_glob.set(event.out_target); - if feedback_ok && feedback > 0 && event.generation < MAX_REPEATS { - let next_vel = - ((event.velocity as u32 * feedback as u32) / 4095) as u16; - if next_vel >= VELOCITY_FLOOR { + if feedback_ok && event.generation < MAX_REPEATS { + if let Some(next_vel) = next_feedback_velocity(event.velocity, feedback) + { let next_gen = event.generation.saturating_add(1); enqueue( &mut queue, @@ -819,13 +957,15 @@ pub async fn run( } else { midi_b.send_note_off(note).await; } + record_emit(&mut recent_emit, n, false, now_ms); if let Some(pos) = sounding .iter() .position(|(o, sn)| *o == event.out_target && *sn == n) { sounding.swap_remove(pos); } - if feedback_ok && feedback > 0 && event.generation < MAX_REPEATS { + // Mirror NoteOn trail length so orphan NoteOffs don't cut a re-struck note. + if feedback_ok && event.generation < max_feedback_repeats(feedback) { let next_gen = event.generation.saturating_add(1); enqueue( &mut queue, @@ -859,10 +999,9 @@ pub async fn run( } activity_glob.set(pulse); pong_side_glob.set(0); - if feedback_ok && feedback > 0 && event.generation < MAX_REPEATS { - let next_vel = - ((event.velocity as u32 * feedback as u32) / 4095) as u16; - if next_vel >= VELOCITY_FLOOR { + if feedback_ok && event.generation < MAX_REPEATS { + if let Some(next_vel) = next_feedback_velocity(event.velocity, feedback) + { let next_gen = event.generation.saturating_add(1); enqueue( &mut queue, @@ -884,7 +1023,7 @@ pub async fn run( if let Some(jack) = out_jack.as_ref() { jack.set_value(0); } - if feedback_ok && feedback > 0 && event.generation < MAX_REPEATS { + if feedback_ok && event.generation < max_feedback_repeats(feedback) { let next_gen = event.generation.saturating_add(1); enqueue( &mut queue, From 96c22cae5f487f0b67d945b81d67026d969e18ad Mon Sep 17 00:00:00 2001 From: kosmar Date: Sat, 25 Jul 2026 21:13:26 +0200 Subject: [PATCH 06/20] =?UTF-8?q?feat(echolot):=20enable=20Ping-Pong=20rou?= =?UTF-8?q?ting=20for=20CV=E2=86=92MIDI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alternate delay repeats across MIDI Out A/B in CV→MIDI (Gate→Note and CC), matching MIDI→MIDI. MIDI→CV stays single-jack. Co-authored-by: Cursor --- faderpunk/src/apps/echolot.rs | 69 +++++++++++++++++++++++++---------- 1 file changed, 50 insertions(+), 19 deletions(-) diff --git a/faderpunk/src/apps/echolot.rs b/faderpunk/src/apps/echolot.rs index 49845e318..af33d5325 100644 --- a/faderpunk/src/apps/echolot.rs +++ b/faderpunk/src/apps/echolot.rs @@ -498,7 +498,8 @@ pub async fn run( }); let clocked = delay_mode == 1; - let ping_pong = routing == 1 && io_mode == IO_MIDI_MIDI; + // Ping-Pong: two MIDI outs. MIDI→MIDI and CV→MIDI (MIDI→CV has only one jack). + let ping_pong = routing == 1 && matches!(io_mode, IO_MIDI_MIDI | IO_CV_MIDI); let sig = effective_signal(io_mode, signal); let resolution = resolution_for_mode(CLOCK_DIVISION_MODE); let base_note_cfg = midi_note_u8(midi_note); @@ -932,9 +933,10 @@ pub async fn run( activity_glob.set(pulse); pong_side_glob.set(event.out_target); if feedback_ok && event.generation < MAX_REPEATS { - if let Some(next_vel) = next_feedback_velocity(event.velocity, feedback) + let next_gen = event.generation.saturating_add(1); + if let Some(next_vel) = + next_feedback_velocity(event.velocity, feedback) { - let next_gen = event.generation.saturating_add(1); enqueue( &mut queue, EventKind::NoteOn, @@ -948,6 +950,25 @@ pub async fn run( event.due_ms, event.due_tick, ); + } else if ping_pong && event.generation == 0 { + // Ping-Pong with Alt feedback at 0: still one cross + // to Out B so Pong is audible (classic stereo tap). + let seed = ((event.velocity as u32 * 3) / 4) + .max(VELOCITY_FLOOR as u32) + as u16; + enqueue( + &mut queue, + EventKind::NoteOn, + event.base_note, + seed, + 0, + next_gen, + base_interval, + delay_ms, + delay_ticks, + event.due_ms, + event.due_tick, + ); } } } @@ -965,21 +986,27 @@ pub async fn run( sounding.swap_remove(pos); } // Mirror NoteOn trail length so orphan NoteOffs don't cut a re-struck note. - if feedback_ok && event.generation < max_feedback_repeats(feedback) { + if feedback_ok { let next_gen = event.generation.saturating_add(1); - enqueue( - &mut queue, - EventKind::NoteOff, - event.base_note, - 0, - 0, - next_gen, - base_interval, - delay_ms, - delay_ticks, - event.due_ms, - event.due_tick, - ); + let trail = max_feedback_repeats(feedback); + let seed_pong = ping_pong + && event.generation == 0 + && trail == 0; + if event.generation < trail || seed_pong { + enqueue( + &mut queue, + EventKind::NoteOff, + event.base_note, + 0, + 0, + next_gen, + base_interval, + delay_ms, + delay_ticks, + event.due_ms, + event.due_tick, + ); + } } } EventKind::CvValue => { @@ -988,10 +1015,14 @@ pub async fn run( jack.set_value(event.cv_value); } } else if io_mode == IO_CV_MIDI { - midi_a.send_cc(midi_cc, event.cv_value).await; + if event.out_target == 0 { + midi_a.send_cc(midi_cc, event.cv_value).await; + } else { + midi_b.send_cc(midi_cc, event.cv_value).await; + } } activity_glob.set(pulse); - pong_side_glob.set(0); + pong_side_glob.set(event.out_target); } EventKind::GateHigh => { if let Some(jack) = out_jack.as_ref() { From 7c5cc9e5cf5ad40dc4696f7f26ed86a9c78585b0 Mon Sep 17 00:00:00 2001 From: kosmar Date: Sat, 25 Jul 2026 21:14:55 +0200 Subject: [PATCH 07/20] =?UTF-8?q?fix(echolot):=20alternate=20CV=E2=86=92CC?= =?UTF-8?q?=20Ping-Pong=20across=20A/B=20outs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- faderpunk/src/apps/echolot.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/faderpunk/src/apps/echolot.rs b/faderpunk/src/apps/echolot.rs index af33d5325..2797547f1 100644 --- a/faderpunk/src/apps/echolot.rs +++ b/faderpunk/src/apps/echolot.rs @@ -571,6 +571,9 @@ pub async fn run( let mut recent_emit: Vec = Vec::new(); let mut prev_gate = false; let mut last_cc_gate: u16 = u16::MAX; + // CV→CC Ping-Pong: alternate successive delayed CC values across A/B + // (unlike notes, CC events are gen-0 one-shots — no feedback trail). + let mut cc_pong_next = false; // Free-running delay-period metronome (blinks even with empty queue). let mut next_metro_ms = Instant::now().as_millis(); let mut next_metro_tick = ticks() as u32; @@ -828,13 +831,18 @@ pub async fn run( let g = midi_gate(inval, false); if g != last_cc_gate { last_cc_gate = g; + // Ping-Pong: odd updates → Out B (generation 1). + let gen = if ping_pong && cc_pong_next { 1 } else { 0 }; + if ping_pong { + cc_pong_next = !cc_pong_next; + } enqueue( &mut queue, EventKind::CvValue, 0, 0, inval, - 0, + gen, 0, delay_ms, delay_ticks, From 60cbee3d9f7e9f04008c5ebeeb8778baa1ae79a6 Mon Sep 17 00:00:00 2001 From: kosmar Date: Sat, 25 Jul 2026 21:18:13 +0200 Subject: [PATCH 08/20] =?UTF-8?q?fix(echolot):=20show=20Routing/Out=20Pong?= =?UTF-8?q?=20for=20CV=E2=86=92MIDI=20in=20configurator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hide Routing + MIDI Out Pong only for MIDI→CV; keep them for MIDI→MIDI and CV→MIDI. Update manual to match firmware Ping-Pong support. Co-authored-by: Cursor --- configurator/src/components/ActiveApp.tsx | 50 ++++++++++++++++++----- configurator/src/components/ManualTab.tsx | 7 ++-- configurator/src/utils/echolot-params.ts | 40 ++++++++++++++++++ 3 files changed, 84 insertions(+), 13 deletions(-) create mode 100644 configurator/src/utils/echolot-params.ts diff --git a/configurator/src/components/ActiveApp.tsx b/configurator/src/components/ActiveApp.tsx index 95dff73f7..fdbbef7e5 100644 --- a/configurator/src/components/ActiveApp.tsx +++ b/configurator/src/components/ActiveApp.tsx @@ -11,6 +11,7 @@ import { getSlots, transformParamFormValues, } from "../utils/utils"; +import { isEcholotParamVisible } from "../utils/echolot-params"; import { ButtonPrimary } from "./Button"; import { Icon } from "./Icon"; import type { App } from "../utils/types"; @@ -25,6 +26,15 @@ interface Props { params: Value[]; } +function enumFormIndex(raw: unknown, fallback: number): number { + if (raw === undefined || raw === null || raw === "") return fallback; + if (typeof raw === "object" && raw !== null && "currentKey" in raw) { + return Number((raw as { currentKey: unknown }).currentKey); + } + if (Array.isArray(raw)) return Number(raw[0]); + return Number(raw); +} + export const ActiveApp = ({ app, layoutId, params, startChannel }: Props) => { const { device, isSimulator, setParams } = useStore(); const [saved, setSaved] = useState(false); @@ -32,9 +42,19 @@ export const ActiveApp = ({ app, layoutId, params, startChannel }: Props) => { register, control, handleSubmit, + watch, formState: { isSubmitting }, } = useForm(); + const ioFallback = + params[0]?.tag === "Enum" ? Number(params[0].value) : 0; + // Always watch (hooks); only Echolot uses I/O for param visibility. + const ioWatched = watch("param-Enum-0"); + const ioMode = + app.name === "Echolot" + ? enumFormIndex(ioWatched, ioFallback) + : ioFallback; + const onSubmit = async ( data: Record, ) => { @@ -122,16 +142,26 @@ export const ActiveApp = ({ app, layoutId, params, startChannel }: Props) => { Parameters
    - {app.params.map((param, idx) => ( - - ))} + {app.params.map((param, idx) => { + const visible = + app.name !== "Echolot" || + isEcholotParamVisible(param, ioMode); + return ( +
    + +
    + ); + })}
    diff --git a/configurator/src/components/ManualTab.tsx b/configurator/src/components/ManualTab.tsx index 994c6313d..ffab21733 100644 --- a/configurator/src/components/ManualTab.tsx +++ b/configurator/src/components/ManualTab.tsx @@ -1681,8 +1681,8 @@ Wrong Signal choices for the current I/O are ignored (Pitch/Gate for MIDI→CV; | Param | Options | Notes | | --- | --- | --- | -| **Interval mode** | Fixed / Stack / Pong | Fixed = same shift every generation; Stack = +N, +2N, …; Pong = +N, −N, +N… | -| **Routing** | Single / Ping-Pong | MIDI→MIDI only: repeats alternate **Out A** ↔ **Out B** (see LED Top/Bottom) | +| **Interval mode** | Fixed / Stack / Bounce | Fixed = same shift every generation; Stack = +N, +2N, …; Bounce = +N, −N, +N… | +| **Routing** | Single / Ping-Pong | **MIDI→MIDI** and **CV→MIDI**: repeats alternate **MIDI Out** ↔ **MIDI Out Pong** (LED Top/Bottom). Hidden for MIDI→CV (single jack). | #### Delay timing @@ -1698,7 +1698,8 @@ Clock **Stop** / **Reset** clears the queue when in Clock mode. - **Harmony doubler:** MIDI→MIDI, Interval = +7 (Fifth), Feedback low or 0, Out A = synth B. - **Slapback:** MIDI→MIDI, short Delay, Feedback 0, Interval 0 — soft 10% button ticks on each delay. - **Echo trail:** Feedback up (brighter blinks); Interval Fixed or Pong for melodic cascades. -- **Stereo MIDI ping-pong:** Routing = Ping-Pong, two Out channels → two devices (watch Top vs Bottom). +- **Stereo MIDI ping-pong:** Routing = Ping-Pong, two Out channels → two devices (MIDI→MIDI or CV→MIDI; watch Top vs Bottom). +- **CV→MIDI ping-pong:** I/O = CV→MIDI, Routing = Ping-Pong, set **MIDI Out** + **MIDI Out Pong** — delayed Gate→Note / CV→CC alternates A/B. - **Delayed gate into modular:** MIDI→CV + Gate, or CV→MIDI + Gate→Note the other way.`, channels: [ { diff --git a/configurator/src/utils/echolot-params.ts b/configurator/src/utils/echolot-params.ts new file mode 100644 index 000000000..7ba0f5ae5 --- /dev/null +++ b/configurator/src/utils/echolot-params.ts @@ -0,0 +1,40 @@ +import type { Param } from "@atov/fp-config"; + +/** Echolot I/O enum indices (must match firmware CONFIG order). */ +export const ECHOLOT_IO_MIDI_MIDI = 0; +export const ECHOLOT_IO_MIDI_CV = 1; +export const ECHOLOT_IO_CV_MIDI = 2; + +function paramName(param: Param): string { + if (param.tag === "MidiIn") return "MIDI In"; + if (param.tag === "MidiOut") return "MIDI Out"; + if ("value" in param && param.value && typeof param.value === "object") { + const v = param.value as { name?: string }; + if (typeof v.name === "string") return v.name; + } + return param.tag; +} + +/** + * Which Echolot params matter for the selected I/O mode. + * Routing / MIDI Out Pong apply to MIDI→MIDI and CV→MIDI (two MIDI outs); + * MIDI→CV is single-jack and has no Ping-Pong path. + */ +export function isEcholotParamVisible(param: Param, ioMode: number): boolean { + const name = paramName(param); + const hasMidiOutPong = + ioMode === ECHOLOT_IO_MIDI_MIDI || ioMode === ECHOLOT_IO_CV_MIDI; + const hasMidiIn = ioMode !== ECHOLOT_IO_CV_MIDI; + + if (name === "Routing" || name === "MIDI Out Pong") { + return hasMidiOutPong; + } + if (param.tag === "MidiIn" || name === "MIDI In CH") { + return hasMidiIn; + } + if (name === "Range") { + // Jack range only when CV is involved. + return ioMode !== ECHOLOT_IO_MIDI_MIDI; + } + return true; +} From 6b34761db95d68af2594df00f1ade6a79e673bde Mon Sep 17 00:00:00 2001 From: kosmar Date: Sat, 25 Jul 2026 21:23:10 +0200 Subject: [PATCH 09/20] =?UTF-8?q?fix(echolot):=20offset=20CV=E2=86=92CC=20?= =?UTF-8?q?Ping-Pong=20by=20delay=20period?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- faderpunk/src/apps/echolot.rs | 38 ++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/faderpunk/src/apps/echolot.rs b/faderpunk/src/apps/echolot.rs index 2797547f1..8ec6b0fef 100644 --- a/faderpunk/src/apps/echolot.rs +++ b/faderpunk/src/apps/echolot.rs @@ -571,12 +571,11 @@ pub async fn run( let mut recent_emit: Vec = Vec::new(); let mut prev_gate = false; let mut last_cc_gate: u16 = u16::MAX; - // CV→CC Ping-Pong: alternate successive delayed CC values across A/B - // (unlike notes, CC events are gen-0 one-shots — no feedback trail). - let mut cc_pong_next = false; // Free-running delay-period metronome (blinks even with empty queue). let mut next_metro_ms = Instant::now().as_millis(); let mut next_metro_tick = ticks() as u32; + let mut last_metro_delay_ms = u64::MAX; + let mut last_metro_delay_ticks = u32::MAX; let enqueue = |queue: &mut Vec, kind: EventKind, @@ -649,6 +648,13 @@ pub async fn run( // Idle delay metronome: one blink per delay period, no input required. // Floor period so near-zero delay still reads as a pulse (~25 Hz max). const MIN_METRO_MS: u64 = 40; + // Retarget immediately when Main fader changes delay. + if delay_ms != last_metro_delay_ms || delay_ticks != last_metro_delay_ticks { + last_metro_delay_ms = delay_ms; + last_metro_delay_ticks = delay_ticks; + next_metro_ms = now_ms.saturating_add(delay_ms.max(MIN_METRO_MS)); + next_metro_tick = now_tick.wrapping_add(delay_ticks); + } if !glob_muted.get() { if clocked { if now_tick.wrapping_sub(next_metro_tick) < (u32::MAX / 2) { @@ -831,18 +837,15 @@ pub async fn run( let g = midi_gate(inval, false); if g != last_cc_gate { last_cc_gate = g; - // Ping-Pong: odd updates → Out B (generation 1). - let gen = if ping_pong && cc_pong_next { 1 } else { 0 }; - if ping_pong { - cc_pong_next = !cc_pong_next; - } + // Always Out A (gen 0). Ping-Pong seeds Out B on fire + // so B lags A by one delay period (not simultaneous). enqueue( &mut queue, EventKind::CvValue, 0, 0, inval, - gen, + 0, 0, delay_ms, delay_ticks, @@ -1028,6 +1031,23 @@ pub async fn run( } else { midi_b.send_cc(midi_cc, event.cv_value).await; } + // Ping-Pong: one delayed cross to the other out so A/B + // are offset by Delay (Main fader) — not same-time copies. + if ping_pong && event.generation == 0 { + enqueue( + &mut queue, + EventKind::CvValue, + 0, + 0, + event.cv_value, + 1, + 0, + delay_ms, + delay_ticks, + event.due_ms, + event.due_tick, + ); + } } activity_glob.set(pulse); pong_side_glob.set(event.out_target); From 7801f28154521a80f0ecf42fc57e7c1bbc2e0c11 Mon Sep 17 00:00:00 2001 From: kosmar Date: Sat, 25 Jul 2026 21:36:08 +0200 Subject: [PATCH 10/20] feat(echolot): widen delay, clock, interval, and feedback ranges Co-authored-by: Cursor --- configurator/src/components/ManualTab.tsx | 14 ++++----- faderpunk/src/apps/echolot.rs | 36 ++++++++++++----------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/configurator/src/components/ManualTab.tsx b/configurator/src/components/ManualTab.tsx index ffab21733..4eb4b29a8 100644 --- a/configurator/src/components/ManualTab.tsx +++ b/configurator/src/components/ManualTab.tsx @@ -1624,14 +1624,14 @@ On load, both registers are restored at the next phrase boundary so the recalled | --- | --- | --- | | **Main** | Fader alone | **Delay time** — fader up = faster (shorter delay); free ms or clock divisions when Delay mode = Clock | | **Alt** | **Shift + Fader** | **Feedback** (0–100%) — how loud / strong each regenerated repeat is | -| **Third** | **Button held + Fader** | **Interval** (−12…+12 semitones) for note/gate pitch shift | +| **Third** | **Button held + Fader** | **Interval** (−24…+24 semitones) for note/gate pitch shift | | Gesture | Action | | --- | --- | | **Short press** | **Mute / ring-out** — block new inputs; queued echoes keep playing (and feedback can finish). Note-offs still accepted. | | **Long press** | **Panic / hard kill** (on release, only if you didn't move Interval) — notes off, CC 120/123, empty queue, CV → 0 | -Feedback is **regenerative** (classic delay): the queue usually holds only the *next* tap. When it fires, another is scheduled with velocity × feedback, until velocity < ~7% or 8 repeats. Shift+Fader / Button+Fader do not clear the trail. +Feedback is **regenerative** (classic delay): the queue usually holds only the *next* tap. When it fires, another is scheduled with velocity × feedback, until velocity < ~7% or **16** repeats. Shift+Fader / Button+Fader do not clear the trail. | LED (channel) | Main | Shift (Alt) | Button held (Third) | | --- | --- | --- | --- | @@ -1645,7 +1645,7 @@ Feedback is **regenerative** (classic delay): the queue usually holds only the * - **0%** — one delayed hit only (doubler / slapback); no regenerated repeats - **>0%** — each fired note/gate schedules another generation with velocity × feedback -- Stops at max **8** repeats or when velocity falls below ~**7%** (internal floor) +- Stops at max **16** repeats or when velocity falls below ~**7%** (internal floor) - Applies to **note/gate** streams (MIDI→MIDI, MIDI→CV Gate, CV→MIDI Gate→Note) — not pitch-hold or CV→CC Every time a delayed event **fires**, and also on a **free-running delay metronome** (same period as Delay — works with no input): @@ -1688,8 +1688,8 @@ Wrong Signal choices for the current I/O are ignored (Pitch/Gate for MIDI→CV; | Delay mode | Fader meaning | | --- | --- | -| **ms** | Fader up = shorter delay (0 at top … **Max delay** at bottom) | -| **Clock** | Musical divisions (straight note-length table, same idea as Clock Divider “Straight”) | +| **ms** | Fader up = shorter delay (0 at top … **Max delay** at bottom; Max delay param up to **8000** ms) | +| **Clock** | Musical divisions at 24 PPQN: **16 bars → 1 tick** (fader up = shorter; ~64th at the top) | Clock **Stop** / **Reset** clears the queue when in Clock mode. @@ -1711,10 +1711,10 @@ Clock **Stop** / **Reset** clears the queue when in Clock mode. "Delay time: fader up = faster/shorter (ms capped by Max delay, or clock division).", faderPlusShiftTitle: "Feedback", faderPlusShiftDescription: - "0–100% regeneration (velocity × feedback per repeat). Also sets delay-cycle LED blink brightness (10–100%).", + "0–100% regeneration (velocity × feedback per repeat, up to 16 taps). Also sets delay-cycle LED blink brightness (10–100%).", faderPlusFnTitle: "Interval", faderPlusFnDescription: - "Pitch shift −12…+12 semitones (note/gate paths).", + "Pitch shift −24…+24 semitones (note/gate paths).", fnTitle: "Mute / Panic", fnDescription: "Short: mute (ring-out — no new input, queue finishes). Long: hard kill — notes off + CC 120/123 + empty queue.", diff --git a/faderpunk/src/apps/echolot.rs b/faderpunk/src/apps/echolot.rs index 8ec6b0fef..2c20412af 100644 --- a/faderpunk/src/apps/echolot.rs +++ b/faderpunk/src/apps/echolot.rs @@ -11,10 +11,7 @@ use serde::{Deserialize, Serialize}; use libfp::{ ext::FromValue, latch::LatchLayer, - utils::{ - bits_7_16, midi_gate, resolution_for_mode, scale_bits_7_12, split_unsigned_value, - value_to_resolution, - }, + utils::{bits_7_16, midi_gate, scale_bits_7_12, split_unsigned_value, value_to_resolution}, AppIcon, Brightness, ClockDivision, Color, Config, MidiCc, MidiChannel, MidiIn, MidiNote, MidiOut, Param, Range, Value, APP_MAX_PARAMS, }; @@ -27,10 +24,10 @@ pub const CHANNELS: usize = 1; pub const PARAMS: usize = 15; const LED_BRIGHTNESS: Brightness = Brightness::Mid; -const MAX_REPEATS: u8 = 8; +const MAX_REPEATS: u8 = 16; /// Stop re-queueing once delayed velocity falls below this (≈ MIDI vel 6). const VELOCITY_FLOOR: u16 = 200; -const QUEUE_CAP: usize = 32; +const QUEUE_CAP: usize = 48; /// Leave headroom so NoteOffs can still land when feedback floods the queue. const QUEUE_FEEDBACK_RESERVE: usize = 4; const SOUNDING_CAP: usize = 32; @@ -39,13 +36,18 @@ const GATE_THRESH: u16 = 406; const INPUT_FLASH_PEAK: u8 = 255; /// While muted, show the same flash at ~20% so MIDI In stays visible without looking “live”. const INPUT_FLASH_MUTED_SCALE: u16 = 51; // 51/255 ≈ 20% -/// Straight note-length table for clock-synced delay (same as clk_div "Straight"). -const CLOCK_DIVISION_MODE: usize = 0; /// Ignore own delayed MIDI coming back via host/DIN loopback (In CH == Out CH). const LOOPBACK_IGNORE_MS: u64 = 40; const RECENT_EMIT_CAP: usize = 24; /// Floor free-running delay so feedback can't collapse into a solid tone. const MIN_DELAY_MS: u64 = 1; +/// Configurator / clamp ceiling for Max delay (ms) param. +const MAX_DELAY_MS_CAP: i32 = 8000; +/// Button+Fader pitch shift range (semitones). +const INTERVAL_ST_MAX: i32 = 24; +/// Clock-mode delay lengths at 24 PPQN (fader up = shorter). +/// 1536 = 16 bars … 1 = 1 tick (~64th at 24 PPQN). +const CLOCK_DELAY_TICKS: &[u16] = &[1536, 768, 384, 192, 96, 48, 24, 12, 6, 3, 2, 1]; /// I/O routing mode. const IO_MIDI_MIDI: usize = 0; @@ -75,7 +77,7 @@ pub static CONFIG: Config = Config::new( .add_param(Param::i32 { name: "Max delay (ms)", min: 10, - max: 2000, + max: 8000, }) .add_param(Param::Enum { name: "Interval mode", @@ -228,7 +230,7 @@ struct PendingEvent { } fn fader_to_delay_ms(fader: u16, max_ms: i32) -> u64 { - let max = max_ms.clamp(10, 2000) as u32; + let max = max_ms.clamp(10, MAX_DELAY_MS_CAP) as u32; // Match clock-mode / rate UX: fader up = faster (shorter delay). let inverted = 4095u32.saturating_sub(fader as u32); ((inverted * max / 4095) as u64).max(MIN_DELAY_MS) @@ -328,7 +330,7 @@ fn is_own_echo(recent: &[RecentEmit], note: u8, is_on: bool, now_ms: u64) -> boo fn fader_to_interval(fader: u16) -> i8 { let centered = fader as i32 - 2048; - ((centered * 12) / 2048).clamp(-12, 12) as i8 + ((centered * INTERVAL_ST_MAX) / 2048).clamp(-INTERVAL_ST_MAX, INTERVAL_ST_MAX) as i8 } fn interval_for_gen(base: i8, generation: u8, mode: usize) -> i8 { @@ -368,10 +370,10 @@ fn midi_note_u8(note: MidiNote) -> u8 { fn split_semitone_leds(interval: i32) -> [u8; 2] { if interval >= 0 { - let pos = ((interval * 255) / 12).clamp(0, 255) as u8; + let pos = ((interval * 255) / INTERVAL_ST_MAX).clamp(0, 255) as u8; [pos, 0] } else { - let neg = (((-interval) * 255) / 12).clamp(0, 255) as u8; + let neg = (((-interval) * 255) / INTERVAL_ST_MAX).clamp(0, 255) as u8; [0, neg] } } @@ -383,12 +385,12 @@ fn pulse_from_feedback(feedback: u16) -> u8 { (MIN + (feedback as u32 * (MAX - MIN) / 4095)) as u8 } -/// Button brightness from |interval|: 10% at unison … 100% at ±12 st. +/// Button brightness from |interval|: 10% at unison … 100% at ±INTERVAL_ST_MAX. 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) / 12)) as u8 + (MIN + (mag * (MAX - MIN) / INTERVAL_ST_MAX as u32)) as u8 } /// Resolve Signal enum in context of I/O mode. @@ -421,7 +423,7 @@ pub async fn wrapper(app: App, exit_signal: &'static Signal Date: Sat, 25 Jul 2026 22:01:10 +0200 Subject: [PATCH 11/20] fix(echolot): show i32 param range and default Max delay to 8000 Co-authored-by: Cursor --- configurator/src/components/input/ParamI32.tsx | 1 + faderpunk/src/apps/echolot.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/configurator/src/components/input/ParamI32.tsx b/configurator/src/components/input/ParamI32.tsx index 10104d9a7..3d07c5d89 100644 --- a/configurator/src/components/input/ParamI32.tsx +++ b/configurator/src/components/input/ParamI32.tsx @@ -28,5 +28,6 @@ export const ParamI32 = ({ max={max} type="number" label={name} + description={`${min}–${max}`} /> ); diff --git a/faderpunk/src/apps/echolot.rs b/faderpunk/src/apps/echolot.rs index 2c20412af..acd7189d6 100644 --- a/faderpunk/src/apps/echolot.rs +++ b/faderpunk/src/apps/echolot.rs @@ -423,7 +423,7 @@ pub async fn wrapper(app: App, exit_signal: &'static Signal Date: Sat, 25 Jul 2026 23:23:12 +0200 Subject: [PATCH 12/20] fix(echolot): alternate MIDI Ping-Pong notes across A/B outs Co-authored-by: Cursor --- faderpunk/src/apps/echolot.rs | 57 ++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/faderpunk/src/apps/echolot.rs b/faderpunk/src/apps/echolot.rs index acd7189d6..f24e51aca 100644 --- a/faderpunk/src/apps/echolot.rs +++ b/faderpunk/src/apps/echolot.rs @@ -39,6 +39,8 @@ const INPUT_FLASH_MUTED_SCALE: u16 = 51; // 51/255 ≈ 20% /// Ignore own delayed MIDI coming back via host/DIN loopback (In CH == Out CH). const LOOPBACK_IGNORE_MS: u64 = 40; const RECENT_EMIT_CAP: usize = 24; +/// Track which ping-pong generation each live MIDI note used (NoteOff pairing). +const NOTE_GEN_CAP: usize = 16; /// Floor free-running delay so feedback can't collapse into a solid tone. const MIN_DELAY_MS: u64 = 1; /// Configurator / clamp ceiling for Max delay (ms) param. @@ -355,6 +357,34 @@ fn out_target_for_gen(generation: u8, ping_pong: bool) -> u8 { } } +/// Ping-Pong: alternate successive input events across Out A (gen 0) / Out B (gen 1). +fn next_input_gen(ping_pong: bool, input_pong_next: &mut bool) -> u8 { + if !ping_pong { + return 0; + } + let gen = if *input_pong_next { 1 } else { 0 }; + *input_pong_next = !*input_pong_next; + gen +} + +fn remember_note_gen(note_gens: &mut Vec<(u8, u8), NOTE_GEN_CAP>, key: u8, gen: u8) { + if let Some((_, g)) = note_gens.iter_mut().find(|(k, _)| *k == key) { + *g = gen; + } else { + let _ = note_gens.push((key, gen)); + } +} + +fn take_note_gen(note_gens: &mut Vec<(u8, u8), NOTE_GEN_CAP>, key: u8) -> u8 { + if let Some(i) = note_gens.iter().position(|(k, _)| *k == key) { + let g = note_gens[i].1; + note_gens.swap_remove(i); + g + } else { + 0 + } +} + fn note_num(base_note: u8, interval: i8) -> u8 { (base_note as i16 + interval as i16).clamp(0, 127) as u8 } @@ -573,6 +603,9 @@ pub async fn run( let mut recent_emit: Vec = Vec::new(); let mut prev_gate = false; let mut last_cc_gate: u16 = u16::MAX; + // Ping-Pong: alternate successive input notes across Out A/B (gen 0/1). + let mut input_pong_next = false; + let mut note_gens: Vec<(u8, u8), NOTE_GEN_CAP> = Vec::new(); // Free-running delay-period metronome (blinks even with empty queue). let mut next_metro_ms = Instant::now().as_millis(); let mut next_metro_tick = ticks() as u32; @@ -687,13 +720,15 @@ pub async fn run( // Always flash on NoteOn so MIDI In is verifiable (even when muted). input_flash_glob.set(INPUT_FLASH_PEAK); if accept_new { + let gen = next_input_gen(ping_pong, &mut input_pong_next); + remember_note_gen(&mut note_gens, key_n, gen); enqueue( &mut queue, EventKind::NoteOn, key_n, scale_bits_7_12(vel), 0, - 0, + gen, base_interval, delay_ms, delay_ticks, @@ -708,13 +743,15 @@ pub async fn run( let key_n = key.as_int(); if !(loop_guard && is_own_echo(&recent_emit, key_n, false, now_ms)) { // Note-offs always accepted so held notes can release during ring-out. + // Pair with the gen used for this key's NoteOn (same Out A/B). + let gen = take_note_gen(&mut note_gens, key_n); enqueue( &mut queue, EventKind::NoteOff, key_n, 0, 0, - 0, + gen, base_interval, delay_ms, delay_ticks, @@ -805,13 +842,15 @@ pub async fn run( if high && !prev_gate { input_flash_glob.set(INPUT_FLASH_PEAK); if accept_new { + let gen = next_input_gen(ping_pong, &mut input_pong_next); + remember_note_gen(&mut note_gens, base_note_cfg, gen); enqueue( &mut queue, EventKind::NoteOn, base_note_cfg, 4095, 0, - 0, + gen, base_interval, delay_ms, delay_ticks, @@ -820,13 +859,14 @@ pub async fn run( ); } } else if !high && prev_gate { + let gen = take_note_gen(&mut note_gens, base_note_cfg); enqueue( &mut queue, EventKind::NoteOff, base_note_cfg, 0, 0, - 0, + gen, base_interval, delay_ms, delay_ticks, @@ -900,6 +940,8 @@ pub async fn run( sounding.clear(); queue.clear(); recent_emit.clear(); + note_gens.clear(); + input_pong_next = false; if let Some(jack) = out_jack.as_ref() { jack.set_value(0); } @@ -963,9 +1005,10 @@ pub async fn run( event.due_ms, event.due_tick, ); - } else if ping_pong && event.generation == 0 { + } else if ping_pong && event.generation < 2 { // Ping-Pong with Alt feedback at 0: still one cross - // to Out B so Pong is audible (classic stereo tap). + // to the other out so Pong is audible (classic stereo tap). + // gen 0→1 (A→B) and gen 1→2 (B→A) when inputs already alternate. let seed = ((event.velocity as u32 * 3) / 4) .max(VELOCITY_FLOOR as u32) as u16; @@ -1003,7 +1046,7 @@ pub async fn run( let next_gen = event.generation.saturating_add(1); let trail = max_feedback_repeats(feedback); let seed_pong = ping_pong - && event.generation == 0 + && event.generation < 2 && trail == 0; if event.generation < trail || seed_pong { enqueue( From 857ab27f05190d324c65eee32794b178cc863f6b Mon Sep 17 00:00:00 2001 From: kosmar Date: Sat, 25 Jul 2026 23:29:17 +0200 Subject: [PATCH 13/20] =?UTF-8?q?fix(echolot):=20Ping-Pong=20as=20In?= =?UTF-8?q?=E2=86=92A=E2=86=92B=20delay=20chain,=20not=20parallel=20outs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- faderpunk/src/apps/echolot.rs | 61 ++++++----------------------------- 1 file changed, 10 insertions(+), 51 deletions(-) diff --git a/faderpunk/src/apps/echolot.rs b/faderpunk/src/apps/echolot.rs index f24e51aca..fed838ece 100644 --- a/faderpunk/src/apps/echolot.rs +++ b/faderpunk/src/apps/echolot.rs @@ -39,8 +39,6 @@ const INPUT_FLASH_MUTED_SCALE: u16 = 51; // 51/255 ≈ 20% /// Ignore own delayed MIDI coming back via host/DIN loopback (In CH == Out CH). const LOOPBACK_IGNORE_MS: u64 = 40; const RECENT_EMIT_CAP: usize = 24; -/// Track which ping-pong generation each live MIDI note used (NoteOff pairing). -const NOTE_GEN_CAP: usize = 16; /// Floor free-running delay so feedback can't collapse into a solid tone. const MIN_DELAY_MS: u64 = 1; /// Configurator / clamp ceiling for Max delay (ms) param. @@ -357,34 +355,6 @@ fn out_target_for_gen(generation: u8, ping_pong: bool) -> u8 { } } -/// Ping-Pong: alternate successive input events across Out A (gen 0) / Out B (gen 1). -fn next_input_gen(ping_pong: bool, input_pong_next: &mut bool) -> u8 { - if !ping_pong { - return 0; - } - let gen = if *input_pong_next { 1 } else { 0 }; - *input_pong_next = !*input_pong_next; - gen -} - -fn remember_note_gen(note_gens: &mut Vec<(u8, u8), NOTE_GEN_CAP>, key: u8, gen: u8) { - if let Some((_, g)) = note_gens.iter_mut().find(|(k, _)| *k == key) { - *g = gen; - } else { - let _ = note_gens.push((key, gen)); - } -} - -fn take_note_gen(note_gens: &mut Vec<(u8, u8), NOTE_GEN_CAP>, key: u8) -> u8 { - if let Some(i) = note_gens.iter().position(|(k, _)| *k == key) { - let g = note_gens[i].1; - note_gens.swap_remove(i); - g - } else { - 0 - } -} - fn note_num(base_note: u8, interval: i8) -> u8 { (base_note as i16 + interval as i16).clamp(0, 127) as u8 } @@ -603,9 +573,6 @@ pub async fn run( let mut recent_emit: Vec = Vec::new(); let mut prev_gate = false; let mut last_cc_gate: u16 = u16::MAX; - // Ping-Pong: alternate successive input notes across Out A/B (gen 0/1). - let mut input_pong_next = false; - let mut note_gens: Vec<(u8, u8), NOTE_GEN_CAP> = Vec::new(); // Free-running delay-period metronome (blinks even with empty queue). let mut next_metro_ms = Instant::now().as_millis(); let mut next_metro_tick = ticks() as u32; @@ -720,15 +687,15 @@ pub async fn run( // Always flash on NoteOn so MIDI In is verifiable (even when muted). input_flash_glob.set(INPUT_FLASH_PEAK); if accept_new { - let gen = next_input_gen(ping_pong, &mut input_pong_next); - remember_note_gen(&mut note_gens, key_n, gen); + // Always Out A (gen 0). Ping-Pong seeds Out B one + // delay later on fire — In→A→B, not simultaneous. enqueue( &mut queue, EventKind::NoteOn, key_n, scale_bits_7_12(vel), 0, - gen, + 0, base_interval, delay_ms, delay_ticks, @@ -743,15 +710,13 @@ pub async fn run( let key_n = key.as_int(); if !(loop_guard && is_own_echo(&recent_emit, key_n, false, now_ms)) { // Note-offs always accepted so held notes can release during ring-out. - // Pair with the gen used for this key's NoteOn (same Out A/B). - let gen = take_note_gen(&mut note_gens, key_n); enqueue( &mut queue, EventKind::NoteOff, key_n, 0, 0, - gen, + 0, base_interval, delay_ms, delay_ticks, @@ -842,15 +807,13 @@ pub async fn run( if high && !prev_gate { input_flash_glob.set(INPUT_FLASH_PEAK); if accept_new { - let gen = next_input_gen(ping_pong, &mut input_pong_next); - remember_note_gen(&mut note_gens, base_note_cfg, gen); enqueue( &mut queue, EventKind::NoteOn, base_note_cfg, 4095, 0, - gen, + 0, base_interval, delay_ms, delay_ticks, @@ -859,14 +822,13 @@ pub async fn run( ); } } else if !high && prev_gate { - let gen = take_note_gen(&mut note_gens, base_note_cfg); enqueue( &mut queue, EventKind::NoteOff, base_note_cfg, 0, 0, - gen, + 0, base_interval, delay_ms, delay_ticks, @@ -940,8 +902,6 @@ pub async fn run( sounding.clear(); queue.clear(); recent_emit.clear(); - note_gens.clear(); - input_pong_next = false; if let Some(jack) = out_jack.as_ref() { jack.set_value(0); } @@ -1005,10 +965,9 @@ pub async fn run( event.due_ms, event.due_tick, ); - } else if ping_pong && event.generation < 2 { - // Ping-Pong with Alt feedback at 0: still one cross - // to the other out so Pong is audible (classic stereo tap). - // gen 0→1 (A→B) and gen 1→2 (B→A) when inputs already alternate. + } else if ping_pong && event.generation == 0 { + // Ping-Pong with Alt feedback at 0: one delayed cross + // In→A→B (Out B lags Out A by Delay, not simultaneous). let seed = ((event.velocity as u32 * 3) / 4) .max(VELOCITY_FLOOR as u32) as u16; @@ -1046,7 +1005,7 @@ pub async fn run( let next_gen = event.generation.saturating_add(1); let trail = max_feedback_repeats(feedback); let seed_pong = ping_pong - && event.generation < 2 + && event.generation == 0 && trail == 0; if event.generation < trail || seed_pong { enqueue( From 609685dd40d7dfa6bfa74a6ed5527149e3c65956 Mon Sep 17 00:00:00 2001 From: kosmar Date: Sun, 26 Jul 2026 22:31:46 +0200 Subject: [PATCH 14/20] checkpoint before checking out feat/midi-diagnostics --- faderpunk/src/apps/echolot.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/faderpunk/src/apps/echolot.rs b/faderpunk/src/apps/echolot.rs index fed838ece..13b40b767 100644 --- a/faderpunk/src/apps/echolot.rs +++ b/faderpunk/src/apps/echolot.rs @@ -31,6 +31,8 @@ const QUEUE_CAP: usize = 48; /// Leave headroom so NoteOffs can still land when feedback floods the queue. const QUEUE_FEEDBACK_RESERVE: usize = 4; const SOUNDING_CAP: usize = 32; +/// Held input notes whose delay is frozen until matching NoteOff (polyphony headroom). +const OPEN_NOTES_CAP: usize = 16; const GATE_THRESH: u16 = 406; /// Peak white button flash on note/gate in (full scale). const INPUT_FLASH_PEAK: u8 = 255; @@ -227,6 +229,10 @@ struct PendingEvent { due_ms: u64, due_tick: u32, generation: u8, + /// Delay frozen at enqueue — NoteOff / feedback children must reuse this so + /// scrubbing Main delay cannot stretch a held note into a constant beep. + delay_ms: u64, + delay_ticks: u32, } fn fader_to_delay_ms(fader: u16, max_ms: i32) -> u64 { @@ -570,6 +576,9 @@ pub async fn run( let engine = async { let mut queue: Vec = Vec::new(); let mut sounding: Vec<(u8, u8), SOUNDING_CAP> = Vec::new(); + // Input NoteOn → freeze delay until matching NoteOff (Main fader scrub safe). + let mut open_notes: Vec<(u8, u64, u32), OPEN_NOTES_CAP> = Vec::new(); + let mut open_gate_delay: Option<(u64, u32)> = None; let mut recent_emit: Vec = Vec::new(); let mut prev_gate = false; let mut last_cc_gate: u16 = u16::MAX; @@ -621,6 +630,8 @@ pub async fn run( due_ms: now_ms.saturating_add(delay_ms), due_tick: now_tick.wrapping_add(delay_ticks), generation, + delay_ms, + delay_ticks, }) .is_ok() }; From fd8f9203020c38bcf2976f3bc85e0eeece4993e5 Mon Sep 17 00:00:00 2001 From: kosmar Date: Sun, 26 Jul 2026 23:02:05 +0200 Subject: [PATCH 15/20] fix(echolot): freeze delay/interval for held notes Authored by an AI coding agent on behalf of kosmar. Co-authored-by: Cursor --- faderpunk/src/apps/echolot.rs | 291 ++++++++++++++++++++++++---------- 1 file changed, 209 insertions(+), 82 deletions(-) diff --git a/faderpunk/src/apps/echolot.rs b/faderpunk/src/apps/echolot.rs index 13b40b767..25cc55071 100644 --- a/faderpunk/src/apps/echolot.rs +++ b/faderpunk/src/apps/echolot.rs @@ -233,6 +233,8 @@ struct PendingEvent { /// scrubbing Main delay cannot stretch a held note into a constant beep. delay_ms: u64, delay_ticks: u32, + /// Interval base frozen at enqueue — Button+Fader scrub must not retarget Offs. + base_interval: i8, } fn fader_to_delay_ms(fader: u16, max_ms: i32) -> u64 { @@ -576,9 +578,9 @@ pub async fn run( let engine = async { let mut queue: Vec = Vec::new(); let mut sounding: Vec<(u8, u8), SOUNDING_CAP> = Vec::new(); - // Input NoteOn → freeze delay until matching NoteOff (Main fader scrub safe). - let mut open_notes: Vec<(u8, u64, u32), OPEN_NOTES_CAP> = Vec::new(); - let mut open_gate_delay: Option<(u64, u32)> = None; + // Input NoteOn → freeze delay + interval until matching NoteOff. + let mut open_notes: Vec<(u8, u64, u32, i8), OPEN_NOTES_CAP> = Vec::new(); + let mut open_gate_delay: Option<(u64, u32, i8)> = None; let mut recent_emit: Vec = Vec::new(); let mut prev_gate = false; let mut last_cc_gate: u16 = u16::MAX; @@ -604,7 +606,7 @@ pub async fn run( if generation > 0 && queue.len() + QUEUE_FEEDBACK_RESERVE >= QUEUE_CAP { return false; } - // Prefer landing NoteOffs: drop oldest feedback NoteOn if full. + // Prefer landing NoteOffs: drop oldest feedback NoteOn if full, else any NoteOn. if queue.is_full() { if matches!(kind, EventKind::NoteOff | EventKind::GateLow) { if let Some(pos) = queue @@ -612,6 +614,11 @@ pub async fn run( .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; } @@ -632,6 +639,7 @@ pub async fn run( generation, delay_ms, delay_ticks, + base_interval, }) .is_ok() }; @@ -698,13 +706,23 @@ pub async fn run( // Always flash on NoteOn so MIDI In is verifiable (even when muted). input_flash_glob.set(INPUT_FLASH_PEAK); if accept_new { - // Always Out A (gen 0). Ping-Pong seeds Out B one - // delay later on fire — In→A→B, not simultaneous. + // Freeze delay + interval so Main / Button+Fader scrub + // cannot leave a NoteOn hanging without a matching Off. + if let Some(slot) = + open_notes.iter_mut().find(|(n, _, _, _)| *n == key_n) + { + *slot = (key_n, delay_ms, delay_ticks, base_interval); + } else { + let _ = open_notes + .push((key_n, delay_ms, delay_ticks, base_interval)); + } + let vel12 = scale_bits_7_12(vel); + // Out A (gen 0) at +1 delay. enqueue( &mut queue, EventKind::NoteOn, key_n, - scale_bits_7_12(vel), + vel12, 0, 0, base_interval, @@ -713,6 +731,43 @@ pub async fn run( now_ms, now_tick, ); + // Ping-Pong: queue Out B (gen 1) at +2 delay here so the + // matching NoteOff can be paired at release. Seeding B only + // when A fires left B hanging whenever Offs were dropped. + if ping_pong { + if let Some(b_vel) = next_feedback_velocity(vel12, feedback) { + let _ = enqueue( + &mut queue, + EventKind::NoteOn, + key_n, + b_vel, + 0, + 1, + base_interval, + delay_ms, + delay_ticks, + now_ms.saturating_add(delay_ms), + now_tick.wrapping_add(delay_ticks), + ); + } else if feedback == 0 { + let seed = ((vel12 as u32 * 3) / 4) + .max(VELOCITY_FLOOR as u32) + as u16; + let _ = enqueue( + &mut queue, + EventKind::NoteOn, + key_n, + seed, + 0, + 1, + base_interval, + delay_ms, + delay_ticks, + now_ms.saturating_add(delay_ms), + now_tick.wrapping_add(delay_ticks), + ); + } + } } } } @@ -720,6 +775,16 @@ pub async fn run( | (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)) { + // Reuse NoteOn delay + interval so scrubbing Main / pitch + // cannot retarget the Off onto a different MIDI note. + let (off_delay_ms, off_delay_ticks, off_interval) = if let Some(pos) = + open_notes.iter().position(|(n, _, _, _)| *n == key_n) + { + let (_, d, t, iv) = open_notes.swap_remove(pos); + (d, t, iv) + } else { + (delay_ms, delay_ticks, base_interval) + }; // Note-offs always accepted so held notes can release during ring-out. enqueue( &mut queue, @@ -728,12 +793,28 @@ pub async fn run( 0, 0, 0, - base_interval, - delay_ms, - delay_ticks, + off_interval, + off_delay_ms, + off_delay_ticks, now_ms, now_tick, ); + // Pair Ping-Pong Out B off at +2 delay (same hold as A). + if ping_pong { + let _ = enqueue( + &mut queue, + EventKind::NoteOff, + key_n, + 0, + 0, + 1, + off_interval, + off_delay_ms, + off_delay_ticks, + now_ms.saturating_add(off_delay_ms), + now_tick.wrapping_add(off_delay_ticks), + ); + } } } @@ -773,10 +854,19 @@ pub async fn run( ) if vel > 0 => { input_flash_glob.set(INPUT_FLASH_PEAK); if accept_new { + let key_n = key.as_int(); + if let Some(slot) = + open_notes.iter_mut().find(|(n, _, _, _)| *n == key_n) + { + *slot = (key_n, delay_ms, delay_ticks, base_interval); + } else { + let _ = open_notes + .push((key_n, delay_ms, delay_ticks, base_interval)); + } enqueue( &mut queue, EventKind::GateHigh, - key.as_int(), + key_n, scale_bits_7_12(vel), 4095, 0, @@ -790,16 +880,25 @@ pub async fn run( } (IO_MIDI_CV, SIG_GATE, MidiMessage::NoteOn { key, .. }) | (IO_MIDI_CV, SIG_GATE, MidiMessage::NoteOff { key, .. }) => { + let key_n = key.as_int(); + let (off_delay_ms, off_delay_ticks, off_interval) = if let Some(pos) = + open_notes.iter().position(|(n, _, _, _)| *n == key_n) + { + let (_, d, t, iv) = open_notes.swap_remove(pos); + (d, t, iv) + } else { + (delay_ms, delay_ticks, base_interval) + }; enqueue( &mut queue, EventKind::GateLow, - key.as_int(), + key_n, 0, 0, 0, - base_interval, - delay_ms, - delay_ticks, + off_interval, + off_delay_ms, + off_delay_ticks, now_ms, now_tick, ); @@ -818,6 +917,7 @@ pub async fn run( if high && !prev_gate { input_flash_glob.set(INPUT_FLASH_PEAK); if accept_new { + open_gate_delay = Some((delay_ms, delay_ticks, base_interval)); enqueue( &mut queue, EventKind::NoteOn, @@ -833,6 +933,9 @@ pub async fn run( ); } } else if !high && prev_gate { + let (off_delay_ms, off_delay_ticks, off_interval) = open_gate_delay + .take() + .unwrap_or((delay_ms, delay_ticks, base_interval)); enqueue( &mut queue, EventKind::NoteOff, @@ -840,9 +943,9 @@ pub async fn run( 0, 0, 0, - base_interval, - delay_ms, - delay_ticks, + off_interval, + off_delay_ms, + off_delay_ticks, now_ms, now_tick, ); @@ -912,6 +1015,8 @@ pub async fn run( 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); @@ -931,24 +1036,65 @@ pub async fn run( (IO_MIDI_MIDI, _) | (IO_MIDI_CV, SIG_GATE) | (IO_CV_MIDI, SIG_GATE_NOTE) ); - let mut i = 0; - while i < queue.len() { - let due = if clocked { - now_tick.wrapping_sub(queue[i].due_tick) < (u32::MAX / 2) - } else { - now_ms >= queue[i].due_ms - }; - if !due { - i += 1; - continue; + loop { + let mut best: Option = None; + for i in 0..queue.len() { + let due = if clocked { + now_tick.wrapping_sub(queue[i].due_tick) < (u32::MAX / 2) + } else { + now_ms >= queue[i].due_ms + }; + if !due { + 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::NoteOn, EventKind::NoteOff) + | (EventKind::GateHigh, EventKind::GateLow) + ) + } + }; + if earlier { + i + } else { + j + } + } + }); } - - let event = queue.swap_remove(i); + 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); match event.kind { EventKind::NoteOn => { + // Retrigger hygiene: never stack a stuck note on the same out. + if sounding + .iter() + .any(|(o, sn)| *o == event.out_target && *sn == n) + { + if event.out_target == 0 { + midi_a.send_note_off(note).await; + } else { + midi_b.send_note_off(note).await; + } + if let Some(pos) = sounding + .iter() + .position(|(o, sn)| *o == event.out_target && *sn == n) + { + sounding.swap_remove(pos); + } + } if event.out_target == 0 { midi_a.send_note_on(note, event.velocity).await; } else { @@ -960,41 +1106,25 @@ pub async fn run( pong_side_glob.set(event.out_target); if feedback_ok && event.generation < MAX_REPEATS { let next_gen = event.generation.saturating_add(1); - if let Some(next_vel) = - next_feedback_velocity(event.velocity, feedback) - { - enqueue( - &mut queue, - EventKind::NoteOn, - event.base_note, - next_vel, - 0, - next_gen, - base_interval, - delay_ms, - delay_ticks, - event.due_ms, - event.due_tick, - ); - } else if ping_pong && event.generation == 0 { - // Ping-Pong with Alt feedback at 0: one delayed cross - // In→A→B (Out B lags Out A by Delay, not simultaneous). - let seed = ((event.velocity as u32 * 3) / 4) - .max(VELOCITY_FLOOR as u32) - as u16; - enqueue( - &mut queue, - EventKind::NoteOn, - event.base_note, - seed, - 0, - next_gen, - base_interval, - delay_ms, - delay_ticks, - event.due_ms, - event.due_tick, - ); + // Ping-Pong gen0→gen1 already queued at input; continue from gen1+. + if !(ping_pong && event.generation == 0) { + if let Some(next_vel) = + next_feedback_velocity(event.velocity, feedback) + { + enqueue( + &mut queue, + EventKind::NoteOn, + event.base_note, + next_vel, + 0, + next_gen, + event.base_interval, + event.delay_ms, + event.delay_ticks, + event.due_ms, + event.due_tick, + ); + } } } } @@ -1011,14 +1141,11 @@ pub async fn run( { sounding.swap_remove(pos); } - // Mirror NoteOn trail length so orphan NoteOffs don't cut a re-struck note. - if feedback_ok { + // Mirror NoteOn trail for gen1+ (gen0→gen1 Off already at input). + if feedback_ok && !(ping_pong && event.generation == 0) { let next_gen = event.generation.saturating_add(1); let trail = max_feedback_repeats(feedback); - let seed_pong = ping_pong - && event.generation == 0 - && trail == 0; - if event.generation < trail || seed_pong { + if event.generation < trail { enqueue( &mut queue, EventKind::NoteOff, @@ -1026,9 +1153,9 @@ pub async fn run( 0, 0, next_gen, - base_interval, - delay_ms, - delay_ticks, + event.base_interval, + event.delay_ms, + event.delay_ticks, event.due_ms, event.due_tick, ); @@ -1057,8 +1184,8 @@ pub async fn run( event.cv_value, 1, 0, - delay_ms, - delay_ticks, + event.delay_ms, + event.delay_ticks, event.due_ms, event.due_tick, ); @@ -1084,9 +1211,9 @@ pub async fn run( next_vel, 4095, next_gen, - base_interval, - delay_ms, - delay_ticks, + event.base_interval, + event.delay_ms, + event.delay_ticks, event.due_ms, event.due_tick, ); @@ -1106,9 +1233,9 @@ pub async fn run( 0, 0, next_gen, - base_interval, - delay_ms, - delay_ticks, + event.base_interval, + event.delay_ms, + event.delay_ticks, event.due_ms, event.due_tick, ); From 66d5e70b7a5985b9c37b24ed0cb3523dc3187866 Mon Sep 17 00:00:00 2001 From: kosmar Date: Fri, 31 Jul 2026 16:08:44 +0200 Subject: [PATCH 16/20] =?UTF-8?q?fix(echolot):=20seed=20Ping-Pong=20Out=20?= =?UTF-8?q?B=20for=20Gate=E2=86=92Note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- faderpunk/src/apps/echolot.rs | 57 ++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/faderpunk/src/apps/echolot.rs b/faderpunk/src/apps/echolot.rs index 25cc55071..c5996a8ea 100644 --- a/faderpunk/src/apps/echolot.rs +++ b/faderpunk/src/apps/echolot.rs @@ -918,11 +918,14 @@ pub async fn run( input_flash_glob.set(INPUT_FLASH_PEAK); if accept_new { open_gate_delay = Some((delay_ms, delay_ticks, base_interval)); + // Out A (gen 0) at +1 delay — same seed pattern as MIDI→MIDI + // so Ping-Pong Out B is paired at input (fire-path skips gen0→1). + const GATE_NOTE_VEL: u16 = 4095; enqueue( &mut queue, EventKind::NoteOn, base_note_cfg, - 4095, + GATE_NOTE_VEL, 0, 0, base_interval, @@ -931,6 +934,42 @@ pub async fn run( now_ms, now_tick, ); + if ping_pong { + if let Some(b_vel) = + next_feedback_velocity(GATE_NOTE_VEL, feedback) + { + let _ = enqueue( + &mut queue, + EventKind::NoteOn, + base_note_cfg, + b_vel, + 0, + 1, + base_interval, + delay_ms, + delay_ticks, + now_ms.saturating_add(delay_ms), + now_tick.wrapping_add(delay_ticks), + ); + } else if feedback == 0 { + let seed = ((GATE_NOTE_VEL as u32 * 3) / 4) + .max(VELOCITY_FLOOR as u32) + as u16; + let _ = enqueue( + &mut queue, + EventKind::NoteOn, + base_note_cfg, + seed, + 0, + 1, + base_interval, + delay_ms, + delay_ticks, + now_ms.saturating_add(delay_ms), + now_tick.wrapping_add(delay_ticks), + ); + } + } } } else if !high && prev_gate { let (off_delay_ms, off_delay_ticks, off_interval) = open_gate_delay @@ -949,6 +988,22 @@ pub async fn run( now_ms, now_tick, ); + // Pair Ping-Pong Out B off at +2 delay (same hold as A). + if ping_pong { + let _ = enqueue( + &mut queue, + EventKind::NoteOff, + base_note_cfg, + 0, + 0, + 1, + off_interval, + off_delay_ms, + off_delay_ticks, + now_ms.saturating_add(off_delay_ms), + now_tick.wrapping_add(off_delay_ticks), + ); + } } prev_gate = high; } else if sig == SIG_CV_CC && accept_new { From 6792a3764411d2a7eb75dd87a4939dee93c05909 Mon Sep 17 00:00:00 2001 From: kosmar Date: Fri, 31 Jul 2026 16:16:50 +0200 Subject: [PATCH 17/20] =?UTF-8?q?fix(echolot):=20Mid=E2=86=92Low=20button?= =?UTF-8?q?=20duck=20on=20delay=20activity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- faderpunk/src/apps/echolot.rs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/faderpunk/src/apps/echolot.rs b/faderpunk/src/apps/echolot.rs index c5996a8ea..22017ad8a 100644 --- a/faderpunk/src/apps/echolot.rs +++ b/faderpunk/src/apps/echolot.rs @@ -24,6 +24,8 @@ pub const CHANNELS: usize = 1; pub const PARAMS: usize = 15; const LED_BRIGHTNESS: Brightness = Brightness::Mid; +/// Mid→Low button duck on delay activity — same length as Heat Pump metronome duck. +const BUTTON_DUCK_MS: u16 = 25; const MAX_REPEATS: u8 = 16; /// Stop re-queueing once delayed velocity falls below this (≈ MIDI vel 6). const VELOCITY_FLOOR: u16 = 200; @@ -548,6 +550,7 @@ pub async fn run( let feedback_glob = app.make_global(0u16); let interval_glob = app.make_global(0i8); let activity_glob = app.make_global(0u8); + let button_duck_glob = app.make_global(0u16); // White button flash on MIDI/CV note/gate input (proves listen path). let input_flash_glob = app.make_global(0u8); // 0 = Out A / top flash, 1 = Out B / bottom flash (ping-pong cue). @@ -680,10 +683,12 @@ pub async fn run( if clocked { if now_tick.wrapping_sub(next_metro_tick) < (u32::MAX / 2) { activity_glob.set(pulse); + button_duck_glob.set(BUTTON_DUCK_MS); next_metro_tick = now_tick.wrapping_add(delay_ticks); } } else if now_ms >= next_metro_ms { activity_glob.set(pulse); + button_duck_glob.set(BUTTON_DUCK_MS); next_metro_ms = now_ms.saturating_add(delay_ms.max(MIN_METRO_MS)); } } @@ -1158,6 +1163,7 @@ pub async fn run( record_emit(&mut recent_emit, n, true, now_ms); let _ = sounding.push((event.out_target, n)); activity_glob.set(pulse); + button_duck_glob.set(BUTTON_DUCK_MS); pong_side_glob.set(event.out_target); if feedback_ok && event.generation < MAX_REPEATS { let next_gen = event.generation.saturating_add(1); @@ -1247,6 +1253,7 @@ pub async fn run( } } activity_glob.set(pulse); + button_duck_glob.set(BUTTON_DUCK_MS); pong_side_glob.set(event.out_target); } EventKind::GateHigh => { @@ -1254,6 +1261,7 @@ pub async fn run( jack.set_value(4095); } activity_glob.set(pulse); + button_duck_glob.set(BUTTON_DUCK_MS); pong_side_glob.set(0); if feedback_ok && event.generation < MAX_REPEATS { if let Some(next_vel) = next_feedback_velocity(event.velocity, feedback) @@ -1409,6 +1417,11 @@ pub async fn run( 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); @@ -1444,13 +1457,14 @@ pub async fn run( }; leds.set(0, Led::Top, led_color, Brightness::Custom(top_b)); leds.set(0, Led::Bottom, led_color, Brightness::Custom(bot_b)); - // White input flash wins over delay pulse on the button. + // White input flash wins over Mid→Low out duck on the button. if input_flash == 0 { - if pulse > 0 { - leds.set(0, Led::Button, led_color, Brightness::Custom(pulse)); + let bright = if duck > 0 { + Brightness::Low } else { - leds.set(0, Led::Button, led_color, LED_BRIGHTNESS); - } + LED_BRIGHTNESS + }; + leds.set(0, Led::Button, led_color, bright); } } LatchLayer::Alt => { From a3295ab15233ae82304c0615ea79b96e6fa9e93e Mon Sep 17 00:00:00 2001 From: kosmar Date: Fri, 31 Jul 2026 17:16:03 +0200 Subject: [PATCH 18/20] fix(echolot): keep feedback NoteOffs under queue pressure Co-authored-by: Cursor --- faderpunk/src/apps/echolot.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/faderpunk/src/apps/echolot.rs b/faderpunk/src/apps/echolot.rs index 22017ad8a..fae4a4307 100644 --- a/faderpunk/src/apps/echolot.rs +++ b/faderpunk/src/apps/echolot.rs @@ -605,13 +605,19 @@ pub async fn run( now_ms: u64, now_tick: u32| -> bool { - // Feedback gens yield to NoteOff / fresh input when the queue is tight. - if generation > 0 && queue.len() + QUEUE_FEEDBACK_RESERVE >= QUEUE_CAP { + // Feedback NoteOns/GateHighs yield when the queue is tight so NoteOffs + // and fresh input still land. Never apply this reserve to Offs/Lows — + // refusing those leaves sounding notes beeping until panic. + let is_release = matches!(kind, EventKind::NoteOff | EventKind::GateLow); + if generation > 0 + && !is_release + && queue.len() + QUEUE_FEEDBACK_RESERVE >= QUEUE_CAP + { return false; } // Prefer landing NoteOffs: drop oldest feedback NoteOn if full, else any NoteOn. if queue.is_full() { - if matches!(kind, EventKind::NoteOff | EventKind::GateLow) { + if is_release { if let Some(pos) = queue .iter() .position(|e| e.generation > 0 && matches!(e.kind, EventKind::NoteOn | EventKind::GateHigh)) @@ -1113,11 +1119,14 @@ pub async fn run( let earlier = match queue[i].due_ms.cmp(&queue[j].due_ms) { core::cmp::Ordering::Less => true, core::cmp::Ordering::Greater => false, + // Same due: release before attack so a gate≈delay + // Off for gen N cannot kill gen N+1 after it starts, + // and zero-length ties still end cleanly. core::cmp::Ordering::Equal => { matches!( (queue[i].kind, queue[j].kind), - (EventKind::NoteOn, EventKind::NoteOff) - | (EventKind::GateHigh, EventKind::GateLow) + (EventKind::NoteOff, EventKind::NoteOn) + | (EventKind::GateLow, EventKind::GateHigh) ) } }; From 71527708ac67c54b26b86e112ae11dad4167408e Mon Sep 17 00:00:00 2001 From: kosmar Date: Fri, 31 Jul 2026 17:16:03 +0200 Subject: [PATCH 19/20] fix(echolot): idle metro ducks button only, not output LEDs Co-authored-by: Cursor --- faderpunk/src/apps/echolot.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/faderpunk/src/apps/echolot.rs b/faderpunk/src/apps/echolot.rs index fae4a4307..9ae9807a8 100644 --- a/faderpunk/src/apps/echolot.rs +++ b/faderpunk/src/apps/echolot.rs @@ -675,8 +675,9 @@ pub async fn run( let feedback = feedback_glob.get(); let pulse = pulse_from_feedback(feedback); - // Idle delay metronome: one blink per delay period, no input required. - // Floor period so near-zero delay still reads as a pulse (~25 Hz max). + // Idle delay metronome: tempo cue on the button only. Do NOT set + // activity_glob — that drives Top/Bottom "output" LEDs and made + // the app look alive when no MIDI/CV was actually leaving. const MIN_METRO_MS: u64 = 40; // Retarget immediately when Main fader changes delay. if delay_ms != last_metro_delay_ms || delay_ticks != last_metro_delay_ticks { @@ -688,12 +689,10 @@ pub async fn run( if !glob_muted.get() { if clocked { if now_tick.wrapping_sub(next_metro_tick) < (u32::MAX / 2) { - activity_glob.set(pulse); button_duck_glob.set(BUTTON_DUCK_MS); next_metro_tick = now_tick.wrapping_add(delay_ticks); } } else if now_ms >= next_metro_ms { - activity_glob.set(pulse); button_duck_glob.set(BUTTON_DUCK_MS); next_metro_ms = now_ms.saturating_add(delay_ms.max(MIN_METRO_MS)); } From 3790096773b028a4e55971a1d3624db07fdbb968 Mon Sep 17 00:00:00 2001 From: kosmar Date: Thu, 13 Aug 2026 21:09:12 +0200 Subject: [PATCH 20/20] fix(echolot): keep CONFIG enum labels ASCII-only Upstream PR #601 asserts at compile time that CONFIG strings are pure ASCII, because postcard-bindgen JavaScript codec cannot round-trip anything else. Doc and inline comments keep their typography. Authored by an AI coding agent on behalf of kosmar. --- faderpunk/src/apps/echolot.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/faderpunk/src/apps/echolot.rs b/faderpunk/src/apps/echolot.rs index 9ae9807a8..2067f1ee3 100644 --- a/faderpunk/src/apps/echolot.rs +++ b/faderpunk/src/apps/echolot.rs @@ -72,7 +72,7 @@ pub static CONFIG: Config = Config::new( ) .add_param(Param::Enum { name: "I/O", - variants: &["MIDI→MIDI", "MIDI→CV", "CV→MIDI"], + variants: &["MIDI->MIDI", "MIDI->CV", "CV->MIDI"], }) .add_param(Param::Enum { name: "Delay mode", @@ -93,7 +93,7 @@ pub static CONFIG: Config = Config::new( }) .add_param(Param::Enum { name: "Signal", - variants: &["Pitch", "Gate", "CV→CC", "Gate→Note"], + variants: &["Pitch", "Gate", "CV->CC", "Gate->Note"], }) .add_param(Param::Range { name: "Range",