Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4c023ad
feat: add Arp de Lévy app
kosmar Jul 22, 2026
9e4ab4b
feat(arp-de-levy): sync app with playground CV jack / UX state
kosmar Jul 24, 2026
41ce39f
feat(arp-de-levy): reroll expression depth for vel/gate jitter
kosmar Jul 25, 2026
bd279cf
fix(arp-de-levy): isolate clock watch from MIDI voice engine
kosmar Jul 25, 2026
a45abaa
fix(arp-de-levy): push Lévy alpha min/max extremes
kosmar Jul 25, 2026
18c301b
fix(arp): drive Button LED from Color param
kosmar Jul 25, 2026
8816410
feat(arp): CV Out Pitch/Gate/Velocity and LED feedback polish
kosmar Jul 26, 2026
0c57407
feat(arp): Mid→Low button duck on each note
kosmar Jul 31, 2026
a56d64d
fix(arp): non-blocking MIDI voice sends + USB-only MidiOut default
kosmar Aug 8, 2026
f05cfcc
feat(arp): scale-degree Lévy mutations and phrase cadence
kosmar Aug 10, 2026
b6459f7
fix(arp_de_levy): claim registry ID 34 instead of 32
kosmar Aug 12, 2026
7ea8639
fix(arp_de_levy): drop the perf-mute wait until the mechanism is upst…
kosmar Aug 12, 2026
b022bdc
feat(apps): add follow_key helper for the device tonality
kosmar Aug 12, 2026
710d9fe
feat(arp): follow the device tonic by shifting the pool, not rerollin…
kosmar Aug 12, 2026
9b30432
Merge remote-tracking branch 'origin/main' into feat/arp-de-levy
kosmar Aug 12, 2026
2d6e41e
fix(clock): never block the gatekeeper on a full pubsub
kosmar Aug 10, 2026
93ec934
fix(clock): keep the metronome off the clock pubsub
kosmar Aug 10, 2026
bddba8d
feat(app): add clock_ticker for apps that only need the tick count
kosmar Aug 10, 2026
ef1c2e8
feat(storage): let apps write back their own params and notify the host
kosmar Aug 10, 2026
b964def
fix(arp_de_levy): read the tick count through App::clock_ticker
kosmar Aug 12, 2026
c23484f
fix(arp): send NoteOff through the blocking sender
kosmar Aug 13, 2026
64399a4
fix(arp): rename to Arp de Levy and keep CONFIG ASCII-only
kosmar Aug 13, 2026
dd822e2
Merge remote-tracking branch 'origin/main' into feat/arp-de-levy
kosmar Aug 14, 2026
baa6331
feat(storage): ParamStore::update polyfill without host push
kosmar Aug 14, 2026
ebf7a63
Merge branch 'feat/param-update-polyfill'
kosmar Aug 14, 2026
7f0d229
feat(arp-de-levy): raise CV Att ceiling from 100% to 400%
kosmar Aug 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions configurator/src/components/ManualTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1595,6 +1595,95 @@ On load, both registers are restored at the next phrase boundary so the recalled
},
],
},
{
appId: 32,
title: "Arp de Lévy",
description:
"Lévy-flight generative arpeggiator — evolve, texture, and expression",
color: "Rose",
icon: "soft-random",
params: [
"MIDI Channel",
"Base Note",
"Color",
"MIDI Out",
"V/Oct",
"Bypass quantizer",
"Jack",
"Range",
"CV Dest",
"CV Att",
],
storage: [
"Mutation rate",
"Texture",
"Lévy α",
"Expression",
"Octave span",
"Muted",
"Reversed",
"Note pool",
"Phrase length",
],
text: `Arp de Lévy is a one-channel generative arpeggiator. It keeps a persistent note pool and walks it with **Lévy-flight** steps — mostly small melodic moves (1–3 semitones), occasionally a larger jump — so the phrase evolves without turning into white-noise pitch. CV (1V/oct, quantized unless **Bypass quantizer**) and MIDI note fire together on each hit. **V/Oct** selects Eurorack 1V/oct or Buchla 1.2V/oct scaling.

#### Mutation, texture, and flight

Mutation rate freezes the pool at the bottom and applies more Lévy edits at each phrase boundary as you raise it. Texture jointly moves density (rests), phrase length (from 16 steps down to 4), and swing — sparse/long/straight at the bottom, dense/short/swung toward the top. **Button + Fader** sets Lévy **α** (local ↔ wild jump character).

#### Expression

There is no dedicated fader for expression. **Short press** (and CV Dest = Reroll) re-rolls the note pool **and** picks a new **expression depth**. That depth controls how much each subsequent note jitters in **velocity** and **gate length** around a fixed base (half-step gate, ~78% velocity). Depth 0 = fixed notes; high depth = more random long/loud variation per hit.

#### Gestures

| Control | Action |
| --- | --- |
| **Fader** | Mutation rate (0 = freeze loop) |
| **Shift + Fader** | Texture macro (density + phrase length + swing) |
| **Button + Fader** | Lévy α (local ↔ wild) |
| **Short press** | Reroll pool + expression depth; reset phrase |
| **Long press** | Mute (no fader move) |
| **Shift + short** | Reverse playback |
| **Shift + long** | Cycle octave span 1→2→3→4 |

#### Faders & LEDs

| Control | Edits | Visual feedback |
| --- | --- | --- |
| **Fader** | Mutation | **Top LED** = phrase progress in app color. **Bottom LED** flashes on each hit |
| **Shift + Fader** | Texture | **Top LED** = texture in **orange** (brighter = denser/shorter) |
| **Button + Fader** | Lévy α | **Top LED** = **violet** (brighter = wilder) |
| **Button LED** | — | Octave color (blue/cyan/yellow/red); white↔off on reverse; off when muted |

Scenes store the live note pool, phrase length, and expression depth.`,
channels: [
{
jackTitle: "Pitch CV Out / CV In",
jackDescription:
"Jack mode selects Pitch CV out or CV in (Evolve / Texture / Reroll). MIDI note fires in parallel unless muted.",
faderTitle: "Mutation rate",
faderDescription:
"How strongly the note pool Lévy-walks at each phrase boundary. Bottom = frozen loop.",
faderPlusShiftTitle: "Texture",
faderPlusShiftDescription:
"One macro for density (rests), phrase length (from 16 steps down to 4), and swing.",
faderPlusFnTitle: "Lévy α",
faderPlusFnDescription:
"Flight character — low = local steps, high = wild jumps.",
fnTitle: "Reroll / Mute",
fnDescription:
"Short press: new note pool, new expression depth (vel/gate jitter), restart phrase. Long press: mute.",
fnPlusShiftTitle: "Reverse / Octaves",
fnPlusShiftDescription:
"Short: reverse playback. Long: cycle octave span 1–4.",
ledTop: "Phrase position (app color)",
ledTopPlusShift: "Texture amount (orange, brighter = denser/shorter)",
ledTopPlusFn: "Lévy α (violet, brighter = wilder)",
ledBottom: "Flash on each hit",
},
],
},
];

export const ManualTab = () => {
Expand Down
3 changes: 2 additions & 1 deletion configurator/src/components/manual/Apps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ export const Apps = ({ apps }: Props) => (
button 2 mutes Output B)
</li>
<li>
<strong>Long press (no shift)</strong> — AD Envelope, LFO, LFO+
<strong>Long press (no shift)</strong> — AD Envelope, LFO, LFO+, Arp de
Lévy
</li>
<li>
<strong>Shift + long press on button 0 / 2 / 4 / 6</strong> — Sequencer
Expand Down
4 changes: 2 additions & 2 deletions configurator/src/components/manual/ManualApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -295,9 +295,9 @@ export const ManualApp = ({ app }: Props) => {
</List>
</div>
) : null}
<p className="mb-4">
<div className="mb-4">
<Md>{app.text}</Md>
</p>
</div>
<div
className="inline-grid"
style={{
Expand Down
178 changes: 144 additions & 34 deletions configurator/src/components/manual/Md.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,44 +5,154 @@ interface MdProps {
children: ReactNode;
}

type Segment =
| { kind: "md"; text: string }
| { kind: "table"; headers: string[]; rows: string[][] };

/** Split GFM pipe-tables out so we can render them without remark-gfm. */
function splitMarkdownTables(src: string): Segment[] {
const lines = src.split("\n");
const out: Segment[] = [];
let buf: string[] = [];

const flushMd = () => {
if (!buf.length) return;
out.push({ kind: "md", text: buf.join("\n") });
buf = [];
};

const isRow = (line: string) => /^\s*\|.*\|\s*$/.test(line);
const isSep = (line: string) =>
/^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line);

const cells = (line: string) =>
line
.trim()
.replace(/^\|/, "")
.replace(/\|$/, "")
.split("|")
.map((c) => c.trim());

let i = 0;
while (i < lines.length) {
if (
isRow(lines[i]) &&
i + 1 < lines.length &&
isSep(lines[i + 1])
) {
flushMd();
const headers = cells(lines[i]);
i += 2;
const rows: string[][] = [];
while (i < lines.length && isRow(lines[i]) && !isSep(lines[i])) {
rows.push(cells(lines[i]));
i++;
}
out.push({ kind: "table", headers, rows });
continue;
}
buf.push(lines[i]);
i++;
}
flushMd();
return out;
}

const mdComponents = {
p: ({ children }: { children?: ReactNode }) => (
<p className="mb-2 last:mb-0">{children}</p>
),
h4: ({ children }: { children?: ReactNode }) => (
<h4 className="mt-6 mb-2 font-bold">{children}</h4>
),
strong: ({ children }: { children?: ReactNode }) => (
<strong className="font-semibold">{children}</strong>
),
em: ({ children }: { children?: ReactNode }) => <em>{children}</em>,
code: ({ children }: { children?: ReactNode }) => (
<code className="rounded bg-white/10 px-1 py-0.5 text-sm">{children}</code>
),
ul: ({ children }: { children?: ReactNode }) => (
<ul className="my-1 ml-3 list-inside list-disc">{children}</ul>
),
ol: ({ children }: { children?: ReactNode }) => (
<ol className="my-1 ml-3 list-inside list-decimal">{children}</ol>
),
a: ({ href, children }: { href?: string; children?: ReactNode }) => (
<a
className="font-semibold underline"
href={href}
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
};

const InlineMd = ({ text }: { text: string }) => (
<ReactMarkdown
components={{
...mdComponents,
// Table cells: keep inline — no wrapping <p>
p: ({ children }) => <>{children}</>,
}}
>
{text}
</ReactMarkdown>
);

const ManualTable = ({
headers,
rows,
}: {
headers: string[];
rows: string[][];
}) => (
<div className="my-3 overflow-x-auto">
<table className="w-full min-w-[20rem] border-collapse text-left text-sm">
<thead>
<tr className="border-b border-white/20">
{headers.map((h) => (
<th
key={h}
className="px-2 py-1.5 font-vox font-semibold whitespace-nowrap"
>
<InlineMd text={h} />
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri} className="border-b border-white/10 align-top">
{headers.map((_, ci) => (
<td key={ci} className="px-2 py-1.5">
<InlineMd text={row[ci] ?? ""} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);

export const Md = ({ children }: MdProps) => {
if (typeof children !== "string") return <>{children}</>;

const segments = splitMarkdownTables(children);
return (
<ReactMarkdown
components={{
p: ({ children }) => <>{children}</>,
h4: ({ children }) => (
<h4 className="mt-6 mb-2 font-bold">{children}</h4>
),
strong: ({ children }) => (
<strong className="font-semibold">{children}</strong>
),
em: ({ children }) => <em>{children}</em>,
code: ({ children }) => (
<code className="rounded bg-white/10 px-1 py-0.5 text-sm">
{children}
</code>
<div className="manual-md">
{segments.map((seg, i) =>
seg.kind === "table" ? (
<ManualTable key={i} headers={seg.headers} rows={seg.rows} />
) : (
<ReactMarkdown key={i} components={mdComponents}>
{seg.text}
</ReactMarkdown>
),
ul: ({ children }) => (
<ul className="my-1 ml-3 list-inside list-disc">{children}</ul>
),
ol: ({ children }) => (
<ol className="my-1 ml-3 list-inside list-decimal">{children}</ol>
),
a: ({ href, children }) => (
<a
className="font-semibold underline"
href={href}
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
}}
>
{children}
</ReactMarkdown>
)}
</div>
);
};
40 changes: 39 additions & 1 deletion faderpunk/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use crate::{
events::{EventPubSubChannel, InputEvent},
tasks::{
buttons::{is_channel_button_pressed, is_shift_button_pressed},
clock::{ClockSubscriber, CLOCK_PUBSUB},
clock::{ClockSubscriber, CLOCK_PUBSUB, TICK_COUNTER},
global_config::get_global_config,
i2c::{I2cLeaderMessage, I2cLeaderSender},
leds::{set_led_mode, LedMode, LedMsg},
Expand Down Expand Up @@ -437,6 +437,27 @@ impl MidiOutput {
self.send_midi_msg(msg).await;
}

/// Non-blocking NoteOn — drops if `APP_MIDI_CHANNEL` is full instead of
/// stalling Core 1 (dense melodic apps + busy playground).
///
/// There is deliberately no NoteOff counterpart: a dropped NoteOn is
/// silence, but a dropped NoteOff hangs the note with no later message to
/// correct it, and `APP_MIDI_CHANNEL` is shared by all 16 channels, so any
/// busy neighbour can cause the drop. Send the matching NoteOff with
/// [`Self::send_note_off`] from an async voice task.
pub fn try_send_note_on(&self, note_number: MidiNote, velocity: u16) {
let message = MidiMessage::NoteOn {
key: note_number.into(),
vel: scale_bits_12_7(velocity),
};
let event = LiveEvent::Midi {
channel: self.midi_channel,
message,
};
let msg = MidiMsg::new(event, self.midi_out, MidiEventSource::Local);
let _ = self.midi_sender.try_send((self.start_channel, msg));
}

/// Sends a MIDI Aftertouch message.
/// velocity is normalized to a range of 0-4095
#[allow(dead_code)]
Expand Down Expand Up @@ -795,6 +816,18 @@ impl<const N: usize> App<N> {
Clock::new()
}

/// Poll the current tick instead of subscribing to the clock.
///
/// For apps that derive everything from the tick number and never react to
/// Start/Stop/Reset. Unlike [`Self::use_clock`] this costs no subscriber
/// slot, and an app that falls behind cannot back up the shared clock
/// queue. Returns `u64::MAX` before the first tick; the counter going
/// backwards means the clock was restarted or reset.
#[allow(dead_code)] // first users are the WIP app branches
pub fn clock_ticker(&self) -> fn() -> u64 {
ticks
}

pub fn use_quantizer(&self, range: Range, vpo: VoltPerOct, bypass: bool) -> Quantizer {
Quantizer::new(range, vpo, bypass)
}
Expand Down Expand Up @@ -870,3 +903,8 @@ pub fn pitch_as_counts(pitch: Pitch, range: Range, vpo: VoltPerOct) -> u16 {
pub fn vpo_counts_per_oct(vpo: VoltPerOct) -> i16 {
get_global_config().vpo_counts_per_oct(vpo)
}

#[allow(dead_code)] // see App::clock_ticker
fn ticks() -> u64 {
TICK_COUNTER.load(Ordering::Relaxed)
}
Loading