midi-cli 0.3.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- midi_cli-0.4.0/ARCHITECTURE.md +189 -0
- {midi_cli-0.3.0 → midi_cli-0.4.0}/PKG-INFO +1 -1
- midi_cli-0.4.0/midi_cli/app.py +680 -0
- midi_cli-0.4.0/midi_cli/audio.py +288 -0
- {midi_cli-0.3.0 → midi_cli-0.4.0}/midi_cli/config.py +44 -13
- {midi_cli-0.3.0 → midi_cli-0.4.0}/midi_cli/loop.py +21 -1
- midi_cli-0.4.0/midi_cli/midi.py +229 -0
- midi_cli-0.4.0/midi_cli/sampler.py +436 -0
- midi_cli-0.4.0/midi_cli/state.py +311 -0
- midi_cli-0.4.0/midi_cli/ui.py +622 -0
- {midi_cli-0.3.0 → midi_cli-0.4.0}/pyproject.toml +1 -1
- {midi_cli-0.3.0 → midi_cli-0.4.0}/uv.lock +1 -1
- midi_cli-0.3.0/ARCHITECTURE.md +0 -149
- midi_cli-0.3.0/midi_cli/app.py +0 -362
- midi_cli-0.3.0/midi_cli/audio.py +0 -326
- midi_cli-0.3.0/midi_cli/midi.py +0 -176
- midi_cli-0.3.0/midi_cli/sampler.py +0 -233
- midi_cli-0.3.0/midi_cli/state.py +0 -178
- midi_cli-0.3.0/midi_cli/ui.py +0 -487
- {midi_cli-0.3.0 → midi_cli-0.4.0}/.gitignore +0 -0
- {midi_cli-0.3.0 → midi_cli-0.4.0}/CLAUDE.md +0 -0
- {midi_cli-0.3.0 → midi_cli-0.4.0}/LICENSE +0 -0
- {midi_cli-0.3.0 → midi_cli-0.4.0}/README.md +0 -0
- {midi_cli-0.3.0 → midi_cli-0.4.0}/midi_cli/__init__.py +0 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
Developer reference for midi-cli: instruments, focus traversal, audio flow, and persistence.
|
|
4
|
+
|
|
5
|
+
## Module Overview
|
|
6
|
+
|
|
7
|
+
| File | Responsibility |
|
|
8
|
+
|------|---------------|
|
|
9
|
+
| `app.py` | Entry point, main loop, raw key input, focus traversal |
|
|
10
|
+
| `state.py` | All global state; instruments, focus, edit workflow state |
|
|
11
|
+
| `audio.py` | `sd.OutputStream` callback; synth + sample mixing; loop mixing; metronome |
|
|
12
|
+
| `midi.py` | MIDI input callback; keyboard-mock trigger; routes note-ons through the active instrument |
|
|
13
|
+
| `sampler.py` | Per-instrument sample I/O, pitch/volume offsets, chromatic pre-compute, copy |
|
|
14
|
+
| `loop.py` | Loop state machine (`cycle_loop_state`, `reset_loop_state`) |
|
|
15
|
+
| `ui.py` | Terminal rendering: submenu strip, header strip, waveform block, tune panel |
|
|
16
|
+
| `config.py` | Config load/save, device enumeration, instrument persistence |
|
|
17
|
+
|
|
18
|
+
## Startup Flow
|
|
19
|
+
|
|
20
|
+
1. `load_defaults()` reads `config.json` (devices, MIDI port, synesthesia, instruments, current instrument)
|
|
21
|
+
2. `migrate_legacy_samples()` moves any flat `samples/note_*.wav` into `samples/sampler-a/`
|
|
22
|
+
3. `init_instruments(cfg["instruments"])` builds the in-memory instrument list
|
|
23
|
+
4. `load_all_instruments()` loads each sampler's wav/pitch/volume files and (if chromatic was on) recomputes its chromatic set
|
|
24
|
+
5. `activate_instrument(idx)` rebinds `st.samples`, `st.pitch_offsets`, `st.volume_offsets`, `st.pitched_samples`, `st.chromatic_samples` to the live instrument's dicts
|
|
25
|
+
6. MIDI input opened; if unavailable, falls back to keyboard mock
|
|
26
|
+
7. `sd.OutputStream` started; main loop runs
|
|
27
|
+
|
|
28
|
+
## Three-Layer Model
|
|
29
|
+
|
|
30
|
+
The UI is organized in three layers:
|
|
31
|
+
|
|
32
|
+
- **Modes** (focus ring, ←→/Tab cycles): `instrument │ edit │ loop │ config`
|
|
33
|
+
- `st.focus` holds the active mode. `edit` is skipped when the current instrument is a synth.
|
|
34
|
+
- `←` / `→` (and `Tab` / `Shift-Tab`) change mode. If a submenu is open, it closes first.
|
|
35
|
+
|
|
36
|
+
- **Submodes** (submenu strip — the line above the header, ↑↓ navigate, Enter select):
|
|
37
|
+
- `instrument` → `synth / sampler-a / sampler-b`
|
|
38
|
+
- `edit` → `tune / trim / copy / split / record / chromatic: off/on` (Enter opens workflow)
|
|
39
|
+
- `loop` → `○ idle │ ● rec │ ▶ play │ ⊕ dub │ metro: on/off ♩bpm` (↑↓ navigate, Enter select/activate)
|
|
40
|
+
- `config` → device/port/synesthesia/volume fields (↑↓ navigate, Enter cycles value)
|
|
41
|
+
|
|
42
|
+
- **Toggles / sub-modes**:
|
|
43
|
+
- Metro+BPM row (row 4 in loop submenu): Enter toggles metronome on/off. While metro is on and row 4 is focused: ↑↓ adjust BPM ±1, ←→ adjust BPM ±5. Turning metro off closes the submenu; Escape closes submenu with metro state preserved.
|
|
44
|
+
- Chromatic on/off: Enter when `chromatic` row is focused in edit submenu.
|
|
45
|
+
|
|
46
|
+
## UI Layout
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
[submenu strip] ← submode options for the focused mode
|
|
50
|
+
[header strip] ← instrument │ edit │ loop │ config
|
|
51
|
+
[status line] ← note, sustain, loop position, chromatic state, etc.
|
|
52
|
+
[MIDI/keyboard hint]
|
|
53
|
+
[help line]
|
|
54
|
+
[blank]
|
|
55
|
+
[waveform block — 6 rows] ← pad strip / loop waveform / trim / split / config
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`NUM_UI_LINES = 5` (submenu + header + status + MIDI + help). `waveform_block = TRIM_WAVEFORM_HEIGHT + 1 = 7`.
|
|
59
|
+
|
|
60
|
+
## Key Map
|
|
61
|
+
|
|
62
|
+
| Key | Action |
|
|
63
|
+
|-----|--------|
|
|
64
|
+
| `←` `→` | **Always** cycle focus ring (close submenu if open). Exception: edit actions (tune/trim/split) retain fine-control behavior. Exception: when metro is on and loop row 4 is focused, adjust BPM ±5. |
|
|
65
|
+
| `↑` `↓` | Navigate submenu rows when a submenu is open. Exception: when metro is on and loop row 4 is focused, adjust BPM ±1 (navigation blocked). Inert at top level. |
|
|
66
|
+
| `Tab` / `Shift-Tab` | Equivalent to `→` / `←` (cycle focus ring, close submenu if open). Exception: swap tune param / switch board in copy. |
|
|
67
|
+
| `Space` | Cycle loop state (`idle → recording → playing → overdub`) globally, from any focus. |
|
|
68
|
+
| `Enter` | Open submenu / confirm step. In config submenu: cycle the focused field's value. In loop submenu on metro row: toggle metro on/off (turning off also closes submenu). |
|
|
69
|
+
| `Escape` | Back out / close overlay / clear loop. |
|
|
70
|
+
| `F1`–`F10` | Switch to instrument at index 0–9 (global, any focus). |
|
|
71
|
+
| `Ctrl-A` | Advance split-marker → assign phase |
|
|
72
|
+
| `Ctrl-C` | Quit |
|
|
73
|
+
| MIDI / `1`–`0` | Play notes (`1`=C4 … `0`=A4 in keyboard-mock mode) |
|
|
74
|
+
| `` ` `` | Toggle sustain (keyboard-mock mode) |
|
|
75
|
+
|
|
76
|
+
Single-letter verbs (`m`, `v`, `l`, `z`, `r`, `c`) have been removed — everything goes through focus traversal.
|
|
77
|
+
|
|
78
|
+
## Edit Submenu
|
|
79
|
+
|
|
80
|
+
Auto-opened when Tab moves focus to `edit`. Rows (all lowercase):
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
tune pitch + volume editor
|
|
84
|
+
trim start/end selection
|
|
85
|
+
copy source → dest (cross-board capable)
|
|
86
|
+
split markers → slice-assign
|
|
87
|
+
record arm/disarm recording
|
|
88
|
+
chromatic toggle + root (per-instrument)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Navigation inside the submenu:
|
|
92
|
+
|
|
93
|
+
- `←` / `→` / `↑` / `↓` all step the focused row (wrapping)
|
|
94
|
+
- `Enter` on a non-toggle row enters its workflow (sets `st.edit_action`)
|
|
95
|
+
- `Enter` on `chromatic` toggles it
|
|
96
|
+
|
|
97
|
+
## Edit Actions
|
|
98
|
+
|
|
99
|
+
Each edit action sets `st.edit_action` and hijacks key handling until `Escape` backs out.
|
|
100
|
+
|
|
101
|
+
### tune
|
|
102
|
+
|
|
103
|
+
- `pitch_selected_note` tracks the currently selected note.
|
|
104
|
+
- Play a key to select it (and preview its sound).
|
|
105
|
+
- `Tab` toggles `st.tune_edit_param` between `"pitch"` and `"volume"`.
|
|
106
|
+
- Arrows adjust whichever param is focused:
|
|
107
|
+
- pitch: `↑↓` ±1 semitone, `←→` ±10 cents
|
|
108
|
+
- volume: `↑↓` ±1 dB, `←→` ±0.5 dB (clamped to [-40, +12] dB)
|
|
109
|
+
- Changes persist to `samples/<inst_id>/pitch_offsets.json` and `volume_offsets.json`.
|
|
110
|
+
|
|
111
|
+
### trim
|
|
112
|
+
|
|
113
|
+
- Play a key with an existing sample to select.
|
|
114
|
+
- Step 0: adjust start (fine=0.1%, coarse=1%), `Enter` confirms.
|
|
115
|
+
- Step 1: adjust end, `Enter` saves the slice.
|
|
116
|
+
- `Escape` in step 1 returns to step 0; in step 0 deselects.
|
|
117
|
+
|
|
118
|
+
### copy
|
|
119
|
+
|
|
120
|
+
- Source: play any key on any instrument with an existing sample.
|
|
121
|
+
- `Tab` / `Shift-Tab` while in copy switches the *active instrument* so you can paste to a different board.
|
|
122
|
+
- Destination: play the target key. If it already has a sample, confirm with `Enter` or cancel with `Escape`.
|
|
123
|
+
|
|
124
|
+
### split
|
|
125
|
+
|
|
126
|
+
- Play a key with an existing sample to select.
|
|
127
|
+
- Markers phase: cursor arrow keys + `Enter` to place markers; `Ctrl-A` advances to assign.
|
|
128
|
+
- Assign phase: each note-on saves the current slice to that key and advances to the next.
|
|
129
|
+
- `Escape` steps back (slice → prior slice → markers phase → deselect).
|
|
130
|
+
|
|
131
|
+
### record
|
|
132
|
+
|
|
133
|
+
- All note-ons start recording on that key (via `start_recording`).
|
|
134
|
+
- All note-offs stop (via `stop_recording`).
|
|
135
|
+
- Escape stops any in-flight recording and exits the action.
|
|
136
|
+
|
|
137
|
+
### Chromatic (toggle)
|
|
138
|
+
|
|
139
|
+
- Toggles `inst["chromatic_on"]`.
|
|
140
|
+
- If the instrument has no `chromatic_root`, the next note-on with an existing sample sets the root and kicks off background pre-computation (`compute_chromatic_samples_async`).
|
|
141
|
+
- When on, `_audio_for_note` in `midi.py` returns the pre-computed chromatic sample for the played note.
|
|
142
|
+
|
|
143
|
+
## Audio Callback
|
|
144
|
+
|
|
145
|
+
`audio_callback` runs on a real-time thread (sounddevice); it holds `voices_lock` for the entire block.
|
|
146
|
+
|
|
147
|
+
Priority order:
|
|
148
|
+
|
|
149
|
+
1. **trim preview** — if `st.edit_action == "trim"` and `trim_looping`, render trim loop only
|
|
150
|
+
2. **split preview** — if `st.edit_action == "split"` and `split_looping`, render split loop only
|
|
151
|
+
3. **sample voices + synth voices** — mixed together; synth voices exist when playing on Synth, sample voices for samplers
|
|
152
|
+
4. **loop capture / playback / overdub** — loop_state machine
|
|
153
|
+
5. **metronome** — always mixed when `metronome_enabled`
|
|
154
|
+
|
|
155
|
+
### Voice-level audio reference
|
|
156
|
+
|
|
157
|
+
Each entry in `st.sample_voices` stores its own `audio` buffer and `vol`, not just an instrument/note id. This means that if the user switches instruments mid-ring-out, notes already sounding finish with their original buffers and volumes — the switch only affects *new* note-ons. This is what makes "switch instrument mid-loop" safe.
|
|
158
|
+
|
|
159
|
+
## Loop Overlay
|
|
160
|
+
|
|
161
|
+
Global buffer (`st.loop_buffer`). Same state machine as before:
|
|
162
|
+
|
|
163
|
+
- `idle → recording`: clears `loop_record_chunks`
|
|
164
|
+
- `recording → playing`: concatenates chunks (quantizing to a bar boundary when the metronome is on), starts playback
|
|
165
|
+
- `playing → overdub`: incoming mix summed into `loop_buffer` in real time
|
|
166
|
+
- `overdub → playing`: increments `loop_layer_count`
|
|
167
|
+
|
|
168
|
+
Triggered by `Space` (global, any focus) — advances the loop state machine. `Escape` on Loop focus clears. The loop submenu (Enter on loop focus) lets you select a specific state or toggle the metronome; while metro is on and row 4 is focused, arrows adjust BPM. Because the loop buffer is global and voice-level audio refs are independent, switching instruments mid-loop does not disturb audio already in the buffer; new overdubbed notes come out of the freshly selected instrument.
|
|
169
|
+
|
|
170
|
+
## State Locking
|
|
171
|
+
|
|
172
|
+
`st.voices_lock` is shared between:
|
|
173
|
+
- `audio_callback` (audio thread) — holds lock for entire block
|
|
174
|
+
- `midi_callback` (MIDI thread) — holds lock briefly for voice mutations
|
|
175
|
+
- `app.py` main loop — holds lock for loop transitions, some edit-action resets
|
|
176
|
+
|
|
177
|
+
All mutations to `voices`, `sample_voices`, `loop_buffer`, `loop_state`, and related fields must happen under this lock.
|
|
178
|
+
|
|
179
|
+
## Persistence
|
|
180
|
+
|
|
181
|
+
| Data | Location |
|
|
182
|
+
|------|----------|
|
|
183
|
+
| Samples | `samples/<inst_id>/note_<midi>.wav` (16-bit mono, 48kHz) |
|
|
184
|
+
| Pitch offsets | `samples/<inst_id>/pitch_offsets.json` |
|
|
185
|
+
| Volume offsets | `samples/<inst_id>/volume_offsets.json` |
|
|
186
|
+
| Config (devices, synesthesia, volume, instruments, current instrument, per-instrument chromatic state) | `config.json` |
|
|
187
|
+
|
|
188
|
+
Legacy `samples/note_*.wav` (pre-redesign) are migrated to `samples/sampler-a/` on first boot.
|
|
189
|
+
|