Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
164 changes: 164 additions & 0 deletions releases/89_Lockstep/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Lockstep

**Dual quantized pitch-mover for the Music Thing Modular Workshop Computer.**

Two CV outputs wander together through a scale. You tune two oscillators by hand to
set the interval; Lockstep walks them in **parallel**, so the harmony you dialled in
moves as one — clocked, quantized, and always in key. One knob sets how *often* it
moves, another how *far*. The Main knob is a resonant low-pass filter that processes
the two oscillators returned through the audio inputs, so the card is a complete
generative two-voice instrument on its own.

---

## Why this card exists

It came out of a long dead-end: driving oscillators from another card's bassline that
output pitch CV in *negative* territory, which some oscillator inputs (a SineSquare's
Pitch jack, for one) simply won't track. Lockstep is built the opposite way — its walk
is **centered high (around MIDI 72) and floored so the CV never drops below ~0 V**. It
drives positive-only pitch inputs cleanly, and you tune the oscillators down to taste.

---

## I/O

| Jack | Direction | Function |
|------|-----------|----------|
| **Pulse In 1** | Input | Clock. Each rising edge can advance the walk (see X knob for division). Disconnected → internal free-running clock. |
| **Pulse In 2** | Input | Re-seed. A rising edge jumps the walk to a fresh pattern (and recenters it). |
| **CV Out 1** | Output | Voice 1 pitch CV. V/Oct, hardware-calibrated, quantized to the scale. |
| **CV Out 2** | Output | Voice 2 pitch CV. Follows CV Out 1 (parallel motion), except when it takes a mid-beat passing move (see **Doubling** below). |
| **Pulse Out 1** | Output | Gate (~10 ms) on every step where voice 1's quantized note changes. Drive an external envelope for voice 1. |
| **Pulse Out 2** | Output | Same gate for voice 2 — fires on the main step *and* on any mid-beat double. |
| **Audio In 1 / 2** | Input | The two oscillators, patched back in for processing. |
| **Audio Out 1 / 2** | Output | The oscillators through the resonant low-pass filter. |
| **CV In 1** | Input | Doubling probability (bipolar). Unpatched → the ~25% default. Patched: full negative = never, 0 V ≈ 50%, full positive = nearly every interval. Patch a slow LFO or random CV to make the ornamentation itself drift. |
| **CV In 2** | Input | Scale offset (bipolar). Shifts the knob-selected scale up or down the 12-scale table; a full swing reaches any scale. 0 V = no shift. Sweep it slowly to morph dark→bright, or step it to change key/mode per phrase. |

---

## Controls

### X — Movement rate

How often the pitch moves.

- **Clocked** (Pulse In 1 patched): X is a **clock divider** — fully CW = move on every clock pulse, fully CCW = hold for up to 60 pulses.
- **Free** (Pulse In 1 unpatched): X is the **internal rate** — fully CW ≈ fast (many moves/sec), fully CCW ≈ slow (~2 sec per move).

### Y — Movement range

How far it wanders from the center.

- Fully **CCW** = frozen on a single note (no movement).
- Fully **CW** = up to ±1 octave of quantized wander.

Everything in between scales the span; small settings drift gently around the center, large settings roam.

### Doubling — voice B passing tones

Both voices normally step together on every beat. But *sometimes* **voice B (CV Out 2)
takes one extra step at the midpoint of the interval** — a passing tone of ±1–2
scale steps — then re-locks with voice A on the next beat. Voice A holds the pulse; B
adds a little melodic flourish over it.

- Probability is set by **CV In 1** (unpatched ≈ 25% of intervals). See the I/O table.
- It only happens when there's room: the interval must be at least 2 clocks long (so
**not** at X fully CW / every-clock) and **Y** must be above zero.
- **Pulse Out 2** gates on the double too, so an external envelope re-articulates the
passing note.

### Main knob — Filter cutoff *(switch-dependent)*

- **Switch UP or MIDDLE:** resonant low-pass cutoff for the two oscillators returned on Audio In 1/2. Left = dark, right = bright and open. Resonance is fixed and moderately singing.
- **Switch DOWN (held):** selects the scale instead (see below). The cutoff is held at its last value while you do this.

### Switch

| Position | Function |
|----------|----------|
| **MIDDLE** | **Run** — normal generative movement. |
| **UP** | **Freeze** — hold the current pitches. The walk stops; CV outs stay put. Flip back to MIDDLE to resume. |
| **DOWN** *(momentary)* | **Scale select** — while held, the Main knob picks the scale. LEDs show the scale index in 6-bit binary. Release to return to Run/Freeze. |

---

## LEDs

| State | Display |
|-------|---------|
| **Run** | A single bright LED shows the walk's current position within its range (a bouncing bar). Dim flashes mark note changes. |
| **Freeze** | All six LEDs dim and static. |
| **Scale select** (switch DOWN) | Scale index (0–11) in 6-bit binary at half brightness. |

---

## Scales

Twelve scales, arranged CCW (dark/minor) → CW (bright/ambiguous), reused from the Markov
card: Phrygian, Hirajōshi, Harmonic Minor, Natural Minor *(default)*, Minor Pentatonic,
m7 Arpeggio, Dorian, Major Pentatonic, Ionian (Major), Maj7 Arpeggio, Whole Tone,
Chromatic. All scales are rooted at C — you set the actual key by tuning the oscillators.

Hold **switch DOWN** and turn the Main knob to pick the base scale. **CV In 2** then
offsets that choice up or down the table (see I/O), so you can modulate the scale under
CV while the knob sets home. Scale changes re-quantize the current notes immediately — a
slowly swept LFO on CV In 2 makes the held voices drift through modes and reharmonize.

---

## How to patch it

1. **CV Out 1 → Oscillator A Pitch (1V/Oct).** **CV Out 2 → Oscillator B Pitch.**
2. **Tune the two oscillators by ear** to the interval you want (a fifth, an octave, a
third — whatever). Both now move in parallel, preserving that interval.
3. Optional articulation: **Pulse Out 1 → envelope A**, **Pulse Out 2 → envelope B**, so
the notes re-trigger on each change instead of gliding continuously.
4. Optional processing: **Osc A → Audio In 1**, **Osc B → Audio In 2**, then
**Audio Out 1/2 → your mixer**. The Main knob now filters both voices.
5. **Clock:** patch your system clock to **Pulse In 1**, or leave it unpatched to
free-run at the rate set by X.
6. Set **Y** for how much the line should move, **X** for how fast, hold **switch DOWN**
and sweep **Main** to pick a scale.

**Freeze a phrase:** let it run until a nice shape appears, flip the switch **UP** to lock
the pitches, then keep playing over it. Flip back to **MIDDLE** to set it moving again.

---

## Technical notes

- Sample rate 48 kHz, system clock 192 MHz. Integer-only DSP in the audio callback.
- **Movement:** a reflected random walk in semitones, bounded by Y, quantized to the
selected scale. The output is octave-folded to stay ≥ MIDI 60 (~0 V) so it drives
positive-only pitch inputs.
- **Parallel motion:** CV Out 1 and CV Out 2 carry the same walk, so the interval between
your two voices is whatever you tuned into the oscillators. The exception is doubling —
voice B occasionally departs for a mid-beat passing tone, then re-locks (see above).
- **Filter:** a Chamberlin state-variable low-pass per channel, fixed-point Q12, with a
fixed moderate resonance.
- The pure movement engine (`src/lockstep_engine.h`) is unit-tested on the host —
see `sim/test_engine.cpp` (`./sim/build.sh`), which checks that every output note is a
scale tone, stays positive-CV, and respects the range.

---

## Build

Requires the Pico SDK at `PICO_SDK_PATH`.

```bash
cd src
cmake -S . -B build
cmake --build build -j$(sysctl -n hw.logicalcpu)
# Output: build/lockstep.uf2
```

Flash by holding BOOTSEL while connecting USB, then copy the `.uf2` to the mounted drive.

---

## Status

**Built and validated in offline simulation; not yet tested on hardware.**
95 changes: 95 additions & 0 deletions releases/89_Lockstep/info.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
draft: true
Name: Lockstep
short-description: Dual quantized pitch-mover — two CV outs walk together through a scale to drive two oscillators in parallel
Language: C++ (Pico SDK)
Creator: Jason Moore
Version: 1.0
Status: Draft
License: MIT
date: 2026-07-16

summary: |
Two CV outputs wander together through a scale (parallel motion): you tune two
oscillators by hand to set the interval and the card moves them in lockstep, clocked
and quantized. X sets how often it moves (clock division when clocked, internal rate
when free), Y sets how far (frozen single note up to +/- one octave). Switch DOWN
(momentary) selects the scale via the Main knob; UP freezes the current pitches;
MIDDLE runs. Main knob is a resonant low-pass filter processing the two oscillators
returned through Audio In 1/2, out Audio Out 1/2. Pulse In 1 clocks it, Pulse In 2
re-seeds. The walk is centered high and floored so the pitch CV never goes negative.

panel:
inputs:
- id: PulseIn1
name: Clock
description: Rising edge advances the walk (divided by X); internal clock when unpatched
- id: PulseIn2
name: Re-seed
description: Rising edge jumps the walk to a fresh pattern
- id: AudioIn1
name: Oscillator A Return
description: Oscillator A fed back in for filtering
- id: AudioIn2
name: Oscillator B Return
description: Oscillator B fed back in for filtering
- id: CVIn1
name: Unused
description: Not used in this version
- id: CVIn2
name: Unused
description: Not used in this version
outputs:
- id: CVOut1
name: Voice A Pitch
description: Quantized V/Oct pitch CV for oscillator A
- id: CVOut2
name: Voice B Pitch
description: Quantized V/Oct pitch CV for oscillator B (parallel, identical walk)
- id: PulseOut1
name: Voice A Gate
description: 10 ms gate on each note change for voice A
- id: PulseOut2
name: Voice B Gate
description: 10 ms gate on each note change for voice B
- id: AudioOut1
name: Voice A Filtered
description: Oscillator A through the resonant low-pass
- id: AudioOut2
name: Voice B Filtered
description: Oscillator B through the resonant low-pass

controls:
knobs:
- when: { z: any }
main:
name: Filter Cutoff / Scale Select
description: Resonant low-pass cutoff for the returned oscillators; selects the scale while switch is held DOWN
x:
name: Movement Rate
description: Clock division when clocked, internal rate when free-running
y:
name: Movement Range
description: How far the walk wanders, from a single held note to plus/minus one octave

leds:
- when: { z: any }
display: list
items:
- id: LED0
name: Position / Scale bit 0
description: Walk position bar in Run; scale-index bit while selecting
- id: LED1
name: Position / Scale bit 1
description: Walk position bar in Run; scale-index bit while selecting
- id: LED2
name: Position / Scale bit 2
description: Walk position bar in Run; scale-index bit while selecting
- id: LED3
name: Position / Scale bit 3
description: Walk position bar in Run; scale-index bit while selecting
- id: LED4
name: Position / Scale bit 4
description: Walk position bar in Run; scale-index bit while selecting
- id: LED5
name: Position / Scale bit 5
description: Walk position bar in Run; scale-index bit while selecting
Binary file added releases/89_Lockstep/lockstep.uf2
Binary file not shown.
2 changes: 2 additions & 0 deletions releases/89_Lockstep/sim/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
test_engine
build/
5 changes: 5 additions & 0 deletions releases/89_Lockstep/sim/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")"
c++ -std=c++17 -I../src test_engine.cpp -o test_engine
./test_engine
63 changes: 63 additions & 0 deletions releases/89_Lockstep/sim/test_engine.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Host self-check for the Lockstep movement engine. No hardware, no Pico SDK.
// ./build.sh (compiles and runs)
#include <cassert>
#include <cstdio>
#include "lockstep_engine.h"

static bool chromaInScale(uint8_t note, uint8_t scaleIdx) {
int c = note % 12;
const Scale& s = generative_scales[scaleIdx];
for (int i = 0; i < s.num_notes; i++) if (s.notes[i] == c) return true;
return false;
}

int main() {
// 1) Every output note is a scale tone AND positive-CV safe (>= MINNOTE),
// and the walk never escapes its range, across all scales and Y settings.
for (uint8_t sc = 0; sc < NUM_SCALES; sc++) {
Walk w; w.reseed(0xC0FFEEu + sc);
for (int i = 0; i < 20000; i++) {
int32_t range = i % (LOCKSTEP_HALFMAX + 1); // sweep Y: 0..12
w.step(range);
assert(w.pos >= -LOCKSTEP_HALFMAX && w.pos <= LOCKSTEP_HALFMAX);
uint8_t n = LockstepNote(w.pos, sc);
assert(n >= LOCKSTEP_MINNOTE); // never negative CV
assert(chromaInScale(n, sc)); // always on the scale
}
}

// 2) range == 0 pins to the center note (Y fully CCW = single held note).
{
Walk w; w.reseed(1);
for (int i = 0; i < 100; i++) { w.step(0); assert(w.pos == 0); }
}

// 3) range > 0 actually moves (not frozen).
{
Walk w; w.reseed(42);
int moved = 0;
for (int i = 0; i < 200; i++) { w.step(7); if (w.pos != 0) moved++; }
assert(moved > 0);
}

// 4) Ornament (voice B passing tone) stays in range, on-scale, and actually
// differs from the base at least sometimes (it's a *passing* tone).
for (uint8_t sc = 0; sc < NUM_SCALES; sc++) {
Walk w; w.reseed(0xBEEF + sc);
int differed = 0;
for (int i = 0; i < 5000; i++) {
int32_t range = 1 + (i % LOCKSTEP_HALFMAX); // 1..12 (range 0 is the frozen case)
w.step(range);
int32_t pb = LockstepOrnament(w.pos, range, w.next());
assert(pb >= -range && pb <= range);
uint8_t nb = LockstepNote(pb, sc);
assert(nb >= LOCKSTEP_MINNOTE);
assert(chromaInScale(nb, sc));
if (pb != w.pos) differed++;
}
assert(differed > 0);
}

printf("lockstep engine: all checks passed\n");
return 0;
}
1 change: 1 addition & 0 deletions releases/89_Lockstep/src/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
build/
23 changes: 23 additions & 0 deletions releases/89_Lockstep/src/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
cmake_minimum_required (VERSION 3.13)
include(pico_sdk_import.cmake)
project(computercard C CXX ASM)
set(CMAKE_CXX_STANDARD 17)
pico_sdk_init()


macro (add_program _name)
add_executable(${ARGV})
if (TARGET ${_name})
target_compile_definitions(${_name} PRIVATE PICO_XOSC_STARTUP_DELAY_MULTIPLIER=64)
target_include_directories(${_name} PUBLIC ${CMAKE_CURRENT_LIST_DIR})
target_link_libraries(${_name} pico_unique_id pico_stdlib hardware_dma hardware_i2c hardware_pwm hardware_adc hardware_spi)
pico_add_extra_outputs(${_name})
target_sources(${_name} PUBLIC ${_name}.cpp)
target_compile_definitions(${_name} PRIVATE PICO_XOSC_STARTUP_DELAY_MULTIPLIER=64)

pico_enable_stdio_usb(${_name} 0)
endif()
endmacro()


add_program(lockstep)
Loading