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.
@@ -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
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: midi-cli
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A MIDI sampler and looper in your terminal
5
5
  Project-URL: Homepage, https://github.com/mnbpdx/midi-cli
6
6
  Project-URL: Repository, https://github.com/mnbpdx/midi-cli