Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
2c4a7f3
feat: add Grooves app
kosmar Jul 22, 2026
7c3a83a
feat(grooves): sync app with playground CV jack / UX state
kosmar Jul 24, 2026
f516040
feat(grooves): Shift+Fader genre scrub via shared palette
kosmar Jul 26, 2026
6a4c10d
feat(grooves): morph-axis genres with Jungle and Feel helpers
kosmar Jul 28, 2026
be9b1f6
checkpoint before checking out feat/midi-diagnostics
kosmar Jul 30, 2026
a9ed460
refactor(grooves): drop Color param; LEDs follow genre spectrum
kosmar Jul 31, 2026
8b40300
fix(grooves): isolate MIDI voice from clock against storms
kosmar Aug 3, 2026
8afbbac
fix(grooves): paint Alt spectrum LEDs on 1ms loop
kosmar Aug 3, 2026
c53d120
fix(grooves): non-blocking MIDI voice sends + USB-only MidiOut default
kosmar Aug 8, 2026
7e3739e
fix(clock): keep the metronome off the clock pubsub
kosmar Aug 10, 2026
15ed983
feat(app): add clock_ticker for apps that only need the tick count
kosmar Aug 10, 2026
33e3694
feat(app): add non-blocking note and CC senders for voice engines
kosmar Aug 10, 2026
b018343
feat(storage): let apps write back their own params and notify the host
kosmar Aug 10, 2026
cd2bbd9
Merge remote-tracking branch 'origin/main' into feat/grooves
kosmar Aug 10, 2026
38fcf8f
Merge remote-tracking branch 'fork/feat/clock-ticker' into feat/grooves
kosmar Aug 10, 2026
8d7cfb9
Merge remote-tracking branch 'fork/feat/nonblocking-notes' into feat/…
kosmar Aug 10, 2026
6c36ee4
Merge remote-tracking branch 'fork/feat/app-param-writeback' into fea…
kosmar Aug 10, 2026
e7bca6d
feat(grooves): genre fill on Shift+Tap and four-bar drum solo on Shif…
kosmar Aug 11, 2026
3de55d7
fix(gen-bindings): resolve CONFIG consts and signed param bounds
kosmar Aug 11, 2026
8ea33aa
feat(grooves): optional drum voices on a packed channel map
kosmar Aug 11, 2026
b4d4713
feat(grooves): ms-domain pocket, per-voice gates and humanized dynamics
kosmar Aug 11, 2026
52cae76
fix(grooves): claim registry ID 30 instead of 29
kosmar Aug 12, 2026
cc6ad26
Merge remote-tracking branch 'fork/feat/grooves' into feat/grooves
kosmar Aug 13, 2026
72952e7
fix(grooves): send NoteOff through the blocking sender
kosmar Aug 13, 2026
d6c4e58
Merge remote-tracking branch 'origin/main' into feat/grooves
kosmar Aug 14, 2026
baa6331
feat(storage): ParamStore::update polyfill without host push
kosmar Aug 14, 2026
b85d432
Merge branch 'feat/param-update-polyfill'
kosmar Aug 14, 2026
ddce354
feat(grooves): adopt the shared ornament engine from the integration …
kosmar Aug 15, 2026
839784e
feat(grooves): drummer personas on top of the genre DNA
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
96 changes: 96 additions & 0 deletions configurator/src/components/ManualTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1595,6 +1595,102 @@ On load, both registers are restored at the next phrase boundary so the recalled
},
],
},
{
appId: 29,
title: "Grooves",
description: "Multi-genre MIDI drum grooves with feel",
color: "Orange",
icon: "die",
params: [
"MIDI Note Kick",
"MIDI Channel Kick",
"MIDI Note Snare",
"MIDI Channel Snare",
"MIDI Note Hats",
"MIDI Channel Hats",
"Groove",
"Swing max %",
"GATE %",
"Color",
"MIDI Out",
"Jack",
"Range",
"CV Dest",
"CV Att",
],
storage: ["Feel", "Density", "Jack mode", "Reversed", "Muted"],
text: `Grooves is a one-channel multi-genre drum helper. It always sends three MIDI voices — **Kick**, **Snare**, and **Hats** — clock-synced to a 4/4 bar. Genre DNA includes accents, microtiming, and swing bias; **Feel** (Button + Fader) attenuates that character from flat/quantized to full groove.

#### Groove (genre)

Configurator **Groove** or **Shift + Fader** on device selects the pattern family (Shift+Fader scrub persists the param).

What changes by genre (core hits before Density fills):
- **Dub** — sparse kick on 1 & 3, snare 2 & 4, thin offbeat hats; heavy 1 accent
- **Disco** — four-on-the-floor, clap/snare 2 & 4, offbeat hats
- **House** — classic four-on-the-floor + offbeat hats
- **Techno** — four-on-the-floor, denser hats (Feel mainly via hat dynamics), snare mostly on 4
- **Trip-Hop** — laid-back sparse kick/hats, late snare pocket
- **Hip-Hop** — syncopated boom-bap kicks with strong 1+3 accents, snare 2 & 4
- **Jungle** — amen-ish breakbeat; busy kick, snare 2 & 4, rapid hats
- **UK Garage** — skippy kick/hat syncopation, snare 2 & 4
- **Dubstep** — half-time: kick on 1, snare on 3

#### Density, Feel, jack

**Density** reveals extra kick/snare/hat fills as quiet ghosts that get louder past their reveal point.

**Feel** (0 = flat equal velocities + straight grid; max = full genre accents, microtiming, and swing bias). Swing amount is genre bias × Feel, capped by **Swing max %**. Ghost drag also scales with Feel.

**CV Dest** can modulate Density, Feel, or Reset. Jack **Any** / **Stacked** toggled with Shift + long.

#### Gestures

| Control | Action |
| --- | --- |
| **Fader** | Density — reveal / loudness of fill hits |
| **Shift + Fader** | Genre scrub (LED shows genre color) |
| **Button + Fader** | Feel — attenuate groove character (accents, timing, swing) |
| **Short press** | Reset to downbeat |
| **Long press** | Mute all MIDI and jack output (no fader move) |
| **Shift + short** | Reverse swing (delays onbeats instead) |
| **Shift + long** | Toggle jack Any ↔ Stacked |

#### Faders & LEDs

| Control | Edits | Visual feedback |
| --- | --- | --- |
| **Fader** | Density | **Top LED** = bar progress in app color. **Bottom LED** flashes on each hit |
| **Shift + Fader** | Genre | **Top/Bottom LEDs** = genre color split |
| **Button + Fader** | Feel | **Top LED** = Feel amount in **red** (brighter = more) |
| **Button LED** | — | app color while playing; white↔off on reverse; brief yellow/violet on jack-mode toggle; off when muted |`,
channels: [
{
jackTitle: "Jack output",
jackDescription:
"Any mode: one velocity-weighted pulse per hit. Stacked mode: 1 V = kick, 2 V = snare, 4 V = hats.",
faderTitle: "Density",
faderDescription:
"Groove macro — raises the fader to reveal extra kick, snare and hat hits as quiet ghosts that get louder the further you go.",
faderPlusShiftTitle: "Genre",
faderPlusShiftDescription:
"Scrub across Dub → … → Dubstep pattern families (same order as the Groove param).",
faderPlusFnTitle: "Feel",
faderPlusFnDescription:
"Attenuates groove character: bottom = flat/quantized; top = full accents, microtiming, and genre swing (capped by Swing max %).",
fnTitle: "Reset / Mute",
fnDescription:
"Short press: jump to the downbeat. Long press: mute MIDI and jack (do not move the fader).",
fnPlusShiftTitle: "Reverse / Jack mode",
fnPlusShiftDescription:
"Short: reverse swing (delay onbeats). Long: toggle jack Any ↔ Stacked.",
ledTop: "Bar position (app color)",
ledTopPlusShift: "Genre color",
ledTopPlusFn: "Feel amount (red, brighter = more)",
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+,
Grooves
</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>
);
};
56 changes: 56 additions & 0 deletions docs/cheatsheet/legacy/30-grooves.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<!-- GROOVES -->
<section class="app">
<div class="app-head">
<span class="id">ID 30</span>
<h2>Grooves <span class="tag ready">ready</span></h2>
<span class="desc">Multi-genre MIDI drum grooves with swing</span>
</div>
<div class="app-body">
<div class="cols">
<div>
<h3>Faders</h3>
<ul>
<li><strong>Main</strong> — Density (extra hits)</li>
<li><strong>Alt</strong> — Genre scrub (shared axis with Chord Vamp)</li>
<li><strong>Third</strong> — Swing (capped by Swing max %)</li>
</ul>
<h3>Genres</h3>
<ul>
<li>Dub → Disco → House → Techno → Trip-Hop → Hip-Hop → Jungle → UK Garage → Dubstep</li>
<li>LEDs: continuous open red→yellow→green→cyan→blue spectrum (same as Chord Vamp)</li>
<li>Sound morphs continuously between neighboring genres along the axis</li>
</ul>
</div>
<div>
<h3>Buttons</h3>
<ul>
<li><strong>Short</strong> — Mute (notes off)</li>
<li><strong>Shift+Tap</strong> — Genre fill until next downbeat (break at high density)</li>
<li><strong>Shift+Hold</strong> — Drum solo while held: 4-bar phrase (build → busier → peak → turnaround); resolves on release</li>
<li><strong>Long</strong> — Reset to downbeat</li>
</ul>
<h3>Jack params</h3>
<ul>
<li>Jack: CV Out Any · CV Out Stacked · CV In Density · CV In Feel · CV In Reset</li>
<li>Stacked sums families — hats 1, snare 2, kick 4 of 10 units; Open Hat counts as hats, Clap as snare, toms as kick</li>
<li>The jack follows the groove, so it still pulses with every note switched off</li>
<li>Range, CV Att</li>
</ul>
</div>
<div>
<h3>Params / LEDs</h3>
<ul>
<li>Notes: Kick, Snare, Hats, Open Hat, Low Tom, High Tom, Clap — note 0 switches a voice off</li>
<li>MIDI Ch is the base for all seven; Ch Map overrides it per voice (7 packed nibbles, 0 = follow base)</li>
<li>Groove, Swing max % (negative = reverse swing), GATE %, MIDI Out</li>
<li><strong>Button</strong> Main: genre hue sat half→full (density) + bar lightness; hit dips kick −50% / snare −30% / hats −10%</li>
<li><strong>Button</strong> Third: White→Color→Red (swing); same voice-weighted hit dips</li>
<li><strong>Button</strong> Alt: committed genre hue (Top/Bottom = live fader meters; color changes only after latch pickup)</li>
</ul>
</div>
</div>
<div class="manual">
<p><strong>Manual.</strong> Genre MIDI drums on the global clock (9 shared genres with Chord Vamp, including Jungle). Density reveals hits; Shift+Fader scrubs genre on the shared red→blue spectrum — button hue follows the latched genre (meters show live fader until pickup) while neighboring genres morph continuously; Button+Fader = Swing (capped by Swing max %). Shift+Tap plays a dedicated genre fill (or break at high density) that resolves on the next downbeat — Techno machine-guns, Hip-Hop and Jungle roll the snare, Dub and Dubstep answer sparsely — with velocity crescendoing toward the bar line, independent of the Density fader. Shift+Hold escalates into a drum solo that plays a four-bar phrase rather than looping the fill: an open build, a busier bar, a peak, then a turnaround that drops back to the sparse end-weighted figure and breathes. Each solo bar carries its own accent mask — the weight sits on the one and the backbeat, Feel only adds human jitter around it instead of randomising the dynamics — and every figure keeps rests and trades kick against snare rather than stacking them. Feel also opens ghost drags on the step right after a hit, so the fill-ins read as flams off the figure. Genre character stays intact: Techno rolls 16th kick bursts against clap stabs, Jungle chops the amen skeleton (kick on 1 and 3e, snare answering on the e), Dubstep keeps its half-time anchor, Dub and Trip-Hop stay wide open. Letting go drops straight into the end-weighted fill for the rest of the bar, so the solo always lands on the one. Releasing keeps either gesture running to the next downbeat so it always resolves. Above roughly 60 % density the same gesture reads subtractively and plays a break instead — the floor drops out (Techno keeps the kick, House the hats, Dub goes near-silent) and a late snare pickup hands the groove back on the one. Each genre carries three fill, three break, and three solo figures; low Feel locks to one, higher Feel rolls between them (same Die mix as Bassment). The button shows the gesture: bright white while filling, dim white while breaking. Watch the button: Main density ramps genre hue from half→full saturation with bar lightness; hits dim by voice (kick −50%, snare −30%, hats −10%) — not a clock blink. MIDI fires outside the clock subscriber (pending_* isolate). Third swing blends White→app→Red. Long resets to the downbeat. Four optional voices sit on top of the three written ones and cost no pattern data — each is derived from a hit the groove already plays, and each stays silent until you give it a note (note 0 = off, which is how they ship). Clap layers under the accented snare the way house and disco stack the two. Open Hat takes over an accented hat off the beat, muting the closed hat on that step rather than smearing both transients. The toms only appear inside a fill, break or solo, and only in the back half of the bar, where they lift the snare figure into the descending run that makes a fill read as a fill — High Tom through the third beat, Low Tom to land it. With all four off, Grooves plays exactly as it did before they existed. All seven voices share the base MIDI Ch unless Ch Map is non-zero; Ch Map packs one channel per voice into seven nibbles (voice order Kick, Snare, Hats, Open Hat, Low Tom, High Tom, Clap; nibble + 1 = channel), so drum machines that expect a channel per voice can be addressed without spending a param each. Swing max % carries the direction in its sign — negative swings the offbeats early. Jack folds the old Jack, Jack Mode and CV Dest into one enum, since a CV In destination and a CV Out level shape were never live at the same time. CV In modulates Density/Feel or triggers Reset; CV Att scales input.</p>
</div>
</div>
</section>
Loading