Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2f1989c
feat(echolot): add MIDI/CV delay app with feedback and interval
kosmar Jul 23, 2026
b2b5b93
fix(echolot): dim muted MIDI-in flash to ~20%
kosmar Jul 23, 2026
68c6fa6
feat(echolot): sync app with playground CV jack / UX state
kosmar Jul 24, 2026
b48c81d
fix(echolot): restore app synced from playground
kosmar Jul 24, 2026
5e13e8f
fix(echolot): curb loopback feedback collapse and rename Bounce/Out
kosmar Jul 24, 2026
96c22ca
feat(echolot): enable Ping-Pong routing for CV→MIDI
kosmar Jul 25, 2026
7c5cc9e
fix(echolot): alternate CV→CC Ping-Pong across A/B outs
kosmar Jul 25, 2026
60cbee3
fix(echolot): show Routing/Out Pong for CV→MIDI in configurator
kosmar Jul 25, 2026
6b34761
fix(echolot): offset CV→CC Ping-Pong by delay period
kosmar Jul 25, 2026
7801f28
feat(echolot): widen delay, clock, interval, and feedback ranges
kosmar Jul 25, 2026
4370b06
fix(echolot): show i32 param range and default Max delay to 8000
kosmar Jul 25, 2026
be17144
fix(echolot): alternate MIDI Ping-Pong notes across A/B outs
kosmar Jul 25, 2026
857ab27
fix(echolot): Ping-Pong as In→A→B delay chain, not parallel outs
kosmar Jul 25, 2026
609685d
checkpoint before checking out feat/midi-diagnostics
kosmar Jul 26, 2026
fd8f920
fix(echolot): freeze delay/interval for held notes
kosmar Jul 26, 2026
66d5e70
fix(echolot): seed Ping-Pong Out B for Gate→Note
kosmar Jul 31, 2026
6792a37
fix(echolot): Mid→Low button duck on delay activity
kosmar Jul 31, 2026
a3295ab
fix(echolot): keep feedback NoteOffs under queue pressure
kosmar Jul 31, 2026
7152770
fix(echolot): idle metro ducks button only, not output LEDs
kosmar Jul 31, 2026
3790096
fix(echolot): keep CONFIG enum labels ASCII-only
kosmar Aug 13, 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
50 changes: 40 additions & 10 deletions configurator/src/components/ActiveApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -25,16 +26,35 @@ 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<boolean>(false);
const {
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<string, string | boolean | boolean[]>,
) => {
Expand Down Expand Up @@ -122,16 +142,26 @@ export const ActiveApp = ({ app, layoutId, params, startChannel }: Props) => {
Parameters
</h2>
<div className="grid grid-cols-4 gap-x-8 gap-y-8 px-4">
{app.params.map((param, idx) => (
<AppParam
key={`param-${startChannel}-${idx}`}
param={param}
paramIndex={idx}
register={register}
control={control}
defaultValue={getDefaultValue(params[idx])}
/>
))}
{app.params.map((param, idx) => {
const visible =
app.name !== "Echolot" ||
isEcholotParamVisible(param, ioMode);
return (
<div
key={`param-${startChannel}-${idx}`}
className={visible ? undefined : "hidden"}
aria-hidden={!visible}
>
<AppParam
param={param}
paramIndex={idx}
register={register}
control={control}
defaultValue={getDefaultValue(params[idx])}
/>
</div>
);
})}
</div>
</div>
<div className="flex justify-end p-4">
Expand Down
137 changes: 137 additions & 0 deletions configurator/src/components/ManualTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1592,6 +1592,143 @@ 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** (−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 &lt; ~7% or **16** 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 at ~20%** (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 **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):

- **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 / 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

| Delay mode | Fader meaning |
| --- | --- |
| **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.

#### 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 (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: [
{
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, up to 16 taps). Also sets delay-cycle LED blink brightness (10–100%).",
faderPlusFnTitle: "Interval",
faderPlusFnDescription:
"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.",
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 = () => {
Expand Down
1 change: 1 addition & 0 deletions configurator/src/components/input/ParamI32.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,6 @@ export const ParamI32 = ({
max={max}
type="number"
label={name}
description={`${min}–${max}`}
/>
);
2 changes: 1 addition & 1 deletion configurator/src/components/manual/Apps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
</li>
<li>
<strong>Long press (no shift)</strong> — AD Envelope, LFO, LFO+
Expand Down
40 changes: 40 additions & 0 deletions configurator/src/utils/echolot-params.ts
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 2 additions & 1 deletion configurator/src/utils/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] });
}
Expand Down
Loading