midi-cli 0.1.3__tar.gz → 0.3.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.
@@ -8,3 +8,4 @@ build/
8
8
  samples/
9
9
  samples-*/
10
10
  config.json
11
+ .claude/settings.local.json
@@ -0,0 +1,149 @@
1
+ # Architecture
2
+
3
+ Developer reference for midi-cli: modes, key behavior, and app flow.
4
+
5
+ ## Module Overview
6
+
7
+ | File | Responsibility |
8
+ |------|---------------|
9
+ | `app.py` | Entry point, main loop, raw key input, mode switching |
10
+ | `state.py` | All global state and constants (single source of truth) |
11
+ | `audio.py` | `sd.OutputStream` callback; synth rendering, sample playback, loop mixing, metronome |
12
+ | `midi.py` | MIDI input callback; keyboard mock input (`trigger_keyboard_note`) |
13
+ | `sampler.py` | Sample I/O (load/save WAV), pitch shifting, chromatic pre-computation, split/copy |
14
+ | `loop.py` | Loop state machine transitions (`cycle_loop_state`) |
15
+ | `ui.py` | Terminal rendering (`draw_ui`); trim/copy/split state resets |
16
+ | `config.py` | Config file load/save, device enumeration |
17
+
18
+ ## Startup Flow
19
+
20
+ 1. `load_defaults()` reads saved config (devices, MIDI port, synesthesia)
21
+ 2. MIDI input opened; if unavailable, falls back to keyboard mock mode
22
+ 3. `load_samples()` reads `samples/note_*.wav` into `state.samples`
23
+ 4. `load_pitch_offsets()` reads `samples/pitch_offsets.json`
24
+ 5. `sd.OutputStream` started (runs `audio_callback` on a background thread)
25
+ 6. Terminal set to raw mode, alternate screen entered
26
+ 7. Main loop: `draw_ui()` → `select()` with 50ms timeout → handle key
27
+
28
+ ## Modes
29
+
30
+ Modes are stored in `state.mode`. The ordered list is defined in `state.MODES`:
31
+
32
+ ```
33
+ synth → sample → pitch → chromatic → trim → copy → loop → split
34
+ ```
35
+
36
+ - **Tab** advances to the next mode (wraps)
37
+ - **Shift+Tab** goes to the previous mode
38
+ - Switching away from `trim`, `copy`, `split`, or `loop` resets that mode's transient state
39
+ - **`r`** is a global key toggle (arm/disarm recording) — not a mode
40
+ - **`c`** opens the config overlay — not a mode
41
+
42
+ ### synth
43
+ Polyphonic additive synth (5 harmonics, piano-like two-stage decay).
44
+ Note on → voice added to `state.voices`. Note off → voice set to `releasing`.
45
+ Sustain pedal (CC 64) holds notes until pedal release.
46
+
47
+ ### sample
48
+ Plays back recorded samples. Note on → sample voice added to `state.sample_voices`, plays from position 0.
49
+ Note off → voice set to `releasing` (5ms fade out). Sample plays to its natural end before being removed.
50
+
51
+ ### Record armed (`r` key)
52
+ `record_armed` is a boolean flag in state, not a mode. Press **r** to arm (yellow ARM indicator appears); press **r** again to disarm.
53
+ While armed: note on → `start_recording()`, note off → `stop_recording()`.
54
+ In keyboard mock mode: key press toggles recording on/off per-note via `keyboard_recording_notes`.
55
+ Tabbing away while armed automatically disarms and stops any active recording.
56
+ In chromatic mode, **r** means "reselect root" — it does not arm recording.
57
+
58
+ ### pitch
59
+ Note on → plays the sample and sets `pitch_selected_note`.
60
+ Arrow keys adjust pitch offset for the selected note (Up/Down: ±1 semitone, Left/Right: ±10 cents).
61
+ Changes saved to `samples/pitch_offsets.json` and recomputed asynchronously via pyrubberband.
62
+
63
+ ### chromatic
64
+ One sample is pitch-shifted across a two-octave range (±12 semitones from root) and pre-computed in a background thread into `state.chromatic_samples`.
65
+ On entry: if no root is set, enters "selecting" sub-state — next note-on with a sample sets the root and triggers pre-computation.
66
+ Press **r** to re-select root.
67
+
68
+ ### trim
69
+ Note on (with an existing sample) → selects that note, begins loop preview of full sample.
70
+ Two-step workflow:
71
+ 1. **Step 0** — adjust start point with arrow keys (fine: 0.1%, coarse: 1%), **Enter** to confirm
72
+ 2. **Step 1** — adjust end point with arrow keys, **Enter** to save the trimmed slice
73
+
74
+ **Escape** in step 1 goes back to step 0. **Escape** in step 0 deselects.
75
+
76
+ ### copy
77
+ Two-step note selection:
78
+ 1. Note on → sets `copy_source_note` (must have a sample)
79
+ 2. Note on → sets `copy_dest_note`; if dest already has a sample, prompts for confirmation (**Enter** to confirm, **Escape** to cancel)
80
+
81
+ ### loop
82
+ Loop state machine: `idle → recording → playing → overdub → playing → ...`
83
+ Triggered by **l** key.
84
+ Sample voices (from note-on) are mixed into the loop buffer during recording and overdub.
85
+ **Escape** resets to idle.
86
+
87
+ State transitions (`loop.py:cycle_loop_state`):
88
+ - `idle` → `recording`: clears buffer, starts accumulating audio blocks
89
+ - `recording` → `playing`: concatenates chunks into `loop_buffer`, starts playback
90
+ - `playing` → `overdub`: incoming audio mixed (summed) into `loop_buffer` in real time
91
+ - `overdub` → `playing`: increments `loop_layer_count`, returns to playback
92
+
93
+ ### split
94
+ Splits one sample into multiple slices and assigns each to a MIDI note.
95
+ Two phases:
96
+ - **markers**: select a source note, use arrow keys to position cursor, **Enter** to place a marker. **a** advances to assign phase.
97
+ - **assign**: each note-on saves the current slice to that key and advances to the next. **Escape** steps back one slice (or back to markers phase).
98
+
99
+ ### Config overlay (`c` key)
100
+ Press **c** from any mode to open the config overlay (rendered in the waveform area). All keys except **q** are swallowed while the overlay is open.
101
+ Up/Down navigates fields. Left/Right changes values. Fields: input device, output device, MIDI port, synesthesia toggle. Changes saved immediately on each arrow press.
102
+ Press **c** or **Escape** to close the overlay. Focus always resets to field 0 on open.
103
+
104
+ ## Audio Callback
105
+
106
+ `audio_callback` runs on a real-time background thread (called by sounddevice). It holds `voices_lock` for the entire block.
107
+
108
+ Priority order inside the callback:
109
+ 1. **trim** loop preview (if `trim_looping`)
110
+ 2. **split** loop preview (if `split_looping`)
111
+ 3. **loop** mode — sample voices + loop buffer capture/playback/overdub
112
+ 4. **sample / pitch / chromatic** — sample voice playback
113
+ 5. **synth** — additive synthesis
114
+
115
+ Metronome is mixed in at the end of blocks 3–5 when `metronome_enabled` and the current mode is in `METRONOME_MODES`.
116
+
117
+ ## Key Behaviors
118
+
119
+ ### Note release
120
+ | Input source | Synth mode | Sample/pitch/chromatic modes |
121
+ |---|---|---|
122
+ | MIDI note-off | Triggers fade-out (`releasing=True`) | Triggers fade-out |
123
+ | Keyboard mock | Auto note-off after 250ms (`KEYBOARD_NOTE_RELEASE_S`) | No auto note-off — sample plays to end |
124
+
125
+ ### Sustain pedal (CC 64)
126
+ - In **synth**: holds notes; releasing pedal fades all held notes
127
+ - Other modes: ignored
128
+
129
+ ### Metronome
130
+ - **m** key toggles on/off in modes: `synth`, `sample`, `loop`, `chromatic`
131
+ - Arrow keys adjust BPM (±1 / ±5) when metronome is on and no mode-specific arrow binding takes priority
132
+ - BPM range: 20–300
133
+
134
+ ## State Locking
135
+
136
+ `state.voices_lock` is shared between:
137
+ - `audio_callback` (audio thread) — holds lock for entire block
138
+ - `midi_callback` (MIDI thread) — holds lock briefly for voice mutations
139
+ - `app.py` main loop — holds lock for loop state transitions and some mode resets
140
+
141
+ All mutations to `voices`, `sample_voices`, `loop_buffer`, `loop_state`, and related fields must happen under this lock.
142
+
143
+ ## Persistence
144
+
145
+ | Data | Location |
146
+ |------|----------|
147
+ | Samples | `samples/note_<midi>.wav` (16-bit mono, 48kHz) |
148
+ | Pitch offsets | `samples/pitch_offsets.json` |
149
+ | Config (devices, synesthesia) | `~/.config/midi_cli/config.json` |
@@ -0,0 +1,5 @@
1
+ # midi-cli
2
+
3
+ See [ARCHITECTURE.md](ARCHITECTURE.md) for a full developer reference: modes, key behaviors, app flow, state locking, and persistence.
4
+
5
+ When making changes that affect modes, key bindings, audio behavior, state, or persistence, update ARCHITECTURE.md to reflect the new behavior.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: midi-cli
3
- Version: 0.1.3
3
+ Version: 0.3.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
@@ -11,9 +11,35 @@ import midi_cli.state as st
11
11
  from midi_cli.audio import audio_callback
12
12
  from midi_cli.sampler import load_samples, load_pitch_offsets, save_sample, adjust_pitch, compute_chromatic_samples_async, execute_copy, stop_recording
13
13
  from midi_cli.midi import open_midi_input, trigger_keyboard_note, midi_callback
14
- from midi_cli.ui import draw_ui, reset_trim_state, reset_copy_state, reset_split_state
14
+ from midi_cli.ui import draw_ui, reset_trim_state, reset_copy_state
15
15
  from midi_cli.loop import reset_loop_state, cycle_loop_state
16
16
 
17
+ def _read_escape_seq(fd):
18
+ seq = [os.read(fd, 1).decode("utf-8", errors="replace")]
19
+ if seq[0] == 'O':
20
+ r, _, _ = select.select([fd], [], [], 0.02)
21
+ if r:
22
+ seq.append(os.read(fd, 1).decode("utf-8", errors="replace"))
23
+ elif seq[0] == '[':
24
+ while True:
25
+ r, _, _ = select.select([fd], [], [], 0.02)
26
+ if not r:
27
+ break
28
+ b = os.read(fd, 1).decode("utf-8", errors="replace")
29
+ seq.append(b)
30
+ if b.isalpha() or b == '~':
31
+ break
32
+ if len(seq) > 16:
33
+ break
34
+ return "".join(seq)
35
+
36
+
37
+ def _stop_active_voices():
38
+ """Clear all active voices on mode switch."""
39
+ with st.voices_lock:
40
+ st.sample_voices.clear()
41
+ st.voices.clear()
42
+
17
43
 
18
44
  def main():
19
45
  from midi_cli.config import load_defaults, get_input_devices, get_output_devices, get_midi_ports, save_config
@@ -60,16 +86,90 @@ def main():
60
86
  ch = os.read(fd, 1).decode("utf-8", errors="replace")
61
87
  if ch in ("q", "\x03"): # q or Ctrl+C
62
88
  break
89
+ elif st.config_overlay_open:
90
+ if ch == "c":
91
+ st.config_overlay_open = False
92
+ elif ch == "\x1b":
93
+ esc_ready, _, _ = select.select([fd], [], [], 0.02)
94
+ if not esc_ready:
95
+ st.config_overlay_open = False
96
+ else:
97
+ seq = _read_escape_seq(fd)
98
+ if seq == "[Z": # Shift+Tab: ignore while overlay open
99
+ pass
100
+ elif seq == "[A": # Up: previous field
101
+ st.config_focused_field = (st.config_focused_field - 1) % 4
102
+ elif seq == "[B": # Down: next field
103
+ st.config_focused_field = (st.config_focused_field + 1) % 4
104
+ elif seq in ("[C", "[D"): # Right/Left: change value
105
+ direction = 1 if seq == "[C" else -1
106
+ field = st.config_focused_field
107
+ if field == 3: # Synesthesia toggle
108
+ st.synesthesia_enabled = not st.synesthesia_enabled
109
+ elif field == 0: # Input device
110
+ devices = get_input_devices()
111
+ if devices:
112
+ cur = st.input_device
113
+ idx = devices.index(cur) if cur in devices else 0
114
+ st.input_device = devices[(idx + direction) % len(devices)]
115
+ elif field == 1: # Output device
116
+ devices = get_output_devices()
117
+ if devices:
118
+ cur = st.output_device
119
+ idx = devices.index(cur) if cur in devices else 0
120
+ new_device = devices[(idx + direction) % len(devices)]
121
+ stream.stop()
122
+ stream.close()
123
+ st.output_device = new_device
124
+ stream = sd.OutputStream(
125
+ samplerate=st.SAMPLE_RATE,
126
+ blocksize=st.BLOCK_SIZE,
127
+ channels=1,
128
+ callback=audio_callback,
129
+ device=st.output_device,
130
+ )
131
+ stream.start()
132
+ elif field == 2: # MIDI port
133
+ ports = get_midi_ports()
134
+ options = [None] + ports
135
+ cur = st.midi_port
136
+ idx = options.index(cur) if cur in options else 0
137
+ new_port = options[(idx + direction) % len(options)]
138
+ if midi_in is not None:
139
+ midi_in.close_port()
140
+ midi_in.delete()
141
+ midi_in = None
142
+ st.midi_port = new_port
143
+ if new_port is None:
144
+ st.keyboard_mock_mode = True
145
+ st.ui_midi_port = ""
146
+ else:
147
+ midi_in, port_name = open_midi_input(new_port)
148
+ if midi_in is None:
149
+ st.keyboard_mock_mode = True
150
+ st.ui_midi_port = ""
151
+ else:
152
+ st.keyboard_mock_mode = False
153
+ st.ui_midi_port = port_name
154
+ save_config({
155
+ "input_device": st.input_device,
156
+ "output_device": st.output_device,
157
+ "midi_port": st.midi_port,
158
+ "synesthesia_enabled": st.synesthesia_enabled,
159
+ })
160
+ # all other keys are silently swallowed by the overlay
63
161
  elif ch == "\r" and st.mode == "split" and st.split_selected_note is not None and st.split_phase == "markers":
64
- st.split_markers.append(st.split_cursor_pos)
65
- st.split_markers.sort()
66
- # Restart loop from start of new region (last marker → end)
67
- st.split_loop_pos = 0
162
+ with st.voices_lock:
163
+ st.split_markers.append(st.split_cursor_pos)
164
+ st.split_markers.sort()
165
+ # Restart loop from start of new region (last marker → end)
166
+ st.split_loop_pos = 0
68
167
  elif ch == "\r" and st.mode == "trim" and st.trim_selected_note is not None:
69
168
  if st.trim_step == 0:
70
169
  # Confirm start, advance to end adjustment
71
170
  st.trim_step = 1
72
- st.trim_loop_pos = st.trim_start
171
+ with st.voices_lock:
172
+ st.trim_loop_pos = st.trim_start
73
173
  else:
74
174
  # Confirm end, slice and save
75
175
  audio = st.samples[st.trim_selected_note][st.trim_start:st.trim_end]
@@ -82,7 +182,8 @@ def main():
82
182
  elif ch == "\r" and st.mode == "copy" and st.copy_confirm_pending:
83
183
  execute_copy()
84
184
  elif ch == "\t": # Tab: next mode
85
- if st.mode == "record":
185
+ if st.record_armed:
186
+ st.record_armed = False
86
187
  st.keyboard_recording_notes.clear()
87
188
  if st.recording_note is not None:
88
189
  threading.Thread(target=stop_recording, args=(st.recording_note,), daemon=True).start()
@@ -91,15 +192,27 @@ def main():
91
192
  if st.mode == "copy":
92
193
  reset_copy_state()
93
194
  if st.mode == "split":
94
- reset_split_state()
195
+ st.reset_split_state()
95
196
  if st.mode == "loop":
96
197
  with st.voices_lock:
97
198
  reset_loop_state()
199
+ _stop_active_voices()
98
200
  st.mode = st.MODES[(st.MODES.index(st.mode) + 1) % len(st.MODES)]
99
201
  if st.mode == "chromatic":
100
202
  st.chromatic_selecting = st.chromatic_root_note is None
101
203
  elif ch == "r" and st.mode == "chromatic" and not st.chromatic_selecting:
102
204
  st.chromatic_selecting = True
205
+ elif ch == "r" and st.mode == "sample":
206
+ if st.record_armed:
207
+ st.record_armed = False
208
+ st.keyboard_recording_notes.clear()
209
+ if st.recording_note is not None:
210
+ threading.Thread(target=stop_recording, args=(st.recording_note,), daemon=True).start()
211
+ else:
212
+ st.record_armed = True
213
+ elif ch == "c":
214
+ st.config_overlay_open = True
215
+ st.config_focused_field = 0
103
216
  elif ch == "\x1b": # escape sequence
104
217
  # Use select on fd with timeout to distinguish bare Escape from sequences
105
218
  esc_ready, _, _ = select.select([fd], [], [], 0.02)
@@ -108,7 +221,8 @@ def main():
108
221
  if st.mode == "trim" and st.trim_selected_note is not None:
109
222
  if st.trim_step == 1:
110
223
  st.trim_step = 0
111
- st.trim_loop_pos = st.trim_start
224
+ with st.voices_lock:
225
+ st.trim_loop_pos = st.trim_start
112
226
  else:
113
227
  reset_trim_state()
114
228
  elif st.mode == "copy" and (st.copy_source_note is not None or st.copy_confirm_pending):
@@ -120,20 +234,28 @@ def main():
120
234
  if st.split_phase == "assign":
121
235
  if st.split_current_slice > 0:
122
236
  st.split_current_slice -= 1
123
- st.split_loop_pos = 0
237
+ with st.voices_lock:
238
+ st.split_loop_pos = 0
124
239
  else:
125
240
  st.split_phase = "markers"
126
- st.split_looping = False
241
+ with st.voices_lock:
242
+ st.split_looping = False
127
243
  else:
128
244
  # markers phase
129
245
  if st.split_markers:
130
- st.split_markers.pop()
246
+ with st.voices_lock:
247
+ st.split_markers.pop()
131
248
  else:
132
249
  st.split_selected_note = None
133
250
  else:
134
- seq = os.read(fd, 2).decode("utf-8", errors="replace")
251
+ # Read a complete VT escape sequence. F5-F10 send CSI
252
+ # sequences like \x1b[15~ (5 bytes); reading only 2
253
+ # bytes would leak digits into the next iteration and
254
+ # trigger unintended notes via KEYBOARD_NOTE_MAP.
255
+ seq = _read_escape_seq(fd)
135
256
  if seq == "[Z": # Shift+Tab: previous mode
136
- if st.mode == "record":
257
+ if st.record_armed:
258
+ st.record_armed = False
137
259
  st.keyboard_recording_notes.clear()
138
260
  if st.recording_note is not None:
139
261
  threading.Thread(target=stop_recording, args=(st.recording_note,), daemon=True).start()
@@ -142,10 +264,11 @@ def main():
142
264
  if st.mode == "copy":
143
265
  reset_copy_state()
144
266
  if st.mode == "split":
145
- reset_split_state()
267
+ st.reset_split_state()
146
268
  if st.mode == "loop":
147
269
  with st.voices_lock:
148
270
  reset_loop_state()
271
+ _stop_active_voices()
149
272
  st.mode = st.MODES[(st.MODES.index(st.mode) - 1) % len(st.MODES)]
150
273
  if st.mode == "chromatic":
151
274
  st.chromatic_selecting = st.chromatic_root_note is None
@@ -194,75 +317,28 @@ def main():
194
317
  st.split_cursor_pos = min(sample_len - 1, st.split_cursor_pos + coarse)
195
318
  elif seq == "[B": # Down: move cursor back coarse
196
319
  st.split_cursor_pos = max(0, st.split_cursor_pos - coarse)
197
- elif st.mode == "config":
198
- if seq == "[A": # Up: previous field
199
- st.config_focused_field = (st.config_focused_field - 1) % 4
200
- elif seq == "[B": # Down: next field
201
- st.config_focused_field = (st.config_focused_field + 1) % 4
202
- elif seq in ("[C", "[D"): # Right/Left: change value
203
- direction = 1 if seq == "[C" else -1
204
- field = st.config_focused_field
205
- if field == 3: # Synesthesia toggle
206
- st.synesthesia_enabled = not st.synesthesia_enabled
207
- elif field == 0: # Input device
208
- devices = get_input_devices()
209
- if devices:
210
- cur = st.input_device
211
- idx = devices.index(cur) if cur in devices else 0
212
- st.input_device = devices[(idx + direction) % len(devices)]
213
- elif field == 1: # Output device
214
- devices = get_output_devices()
215
- if devices:
216
- cur = st.output_device
217
- idx = devices.index(cur) if cur in devices else 0
218
- new_device = devices[(idx + direction) % len(devices)]
219
- stream.stop()
220
- stream.close()
221
- st.output_device = new_device
222
- stream = sd.OutputStream(
223
- samplerate=st.SAMPLE_RATE,
224
- blocksize=st.BLOCK_SIZE,
225
- channels=1,
226
- callback=audio_callback,
227
- device=st.output_device,
228
- )
229
- stream.start()
230
- elif field == 2: # MIDI port
231
- ports = get_midi_ports()
232
- options = [None] + ports
233
- cur = st.midi_port
234
- idx = options.index(cur) if cur in options else 0
235
- new_port = options[(idx + direction) % len(options)]
236
- if midi_in is not None:
237
- midi_in.close_port()
238
- midi_in.delete()
239
- midi_in = None
240
- st.midi_port = new_port
241
- if new_port is None:
242
- st.keyboard_mock_mode = True
243
- st.ui_midi_port = ""
244
- else:
245
- midi_in, port_name = open_midi_input(new_port)
246
- if midi_in is None:
247
- st.keyboard_mock_mode = True
248
- st.ui_midi_port = ""
249
- else:
250
- st.keyboard_mock_mode = False
251
- st.ui_midi_port = port_name
252
- save_config({
253
- "input_device": st.input_device,
254
- "output_device": st.output_device,
255
- "midi_port": st.midi_port,
256
- "synesthesia_enabled": st.synesthesia_enabled,
257
- })
320
+ elif st.metronome_enabled and st.mode in st.METRONOME_MODES:
321
+ if seq == "[C": # Right: +1 BPM
322
+ st.bpm = min(st.BPM_MAX, st.bpm + 1)
323
+ elif seq == "[D": # Left: -1 BPM
324
+ st.bpm = max(st.BPM_MIN, st.bpm - 1)
325
+ elif seq == "[A": # Up: +5 BPM
326
+ st.bpm = min(st.BPM_MAX, st.bpm + 5)
327
+ elif seq == "[B": # Down: -5 BPM
328
+ st.bpm = max(st.BPM_MIN, st.bpm - 5)
258
329
  elif ch == 'a' and st.mode == "split" and st.split_selected_note is not None and st.split_phase == "markers" and st.split_markers:
259
330
  st.split_phase = "assign"
260
331
  st.split_current_slice = 0
261
- st.split_loop_pos = 0
262
- st.split_looping = True
332
+ with st.voices_lock:
333
+ st.split_loop_pos = 0
334
+ st.split_looping = True
263
335
  elif ch == 'l' and st.mode == "loop":
264
336
  with st.voices_lock:
265
337
  cycle_loop_state()
338
+ elif ch == 'm' and st.mode in st.METRONOME_MODES:
339
+ with st.voices_lock:
340
+ st.metronome_enabled = not st.metronome_enabled
341
+ st.metronome_pos = 0
266
342
  elif st.keyboard_mock_mode and ch in st.KEYBOARD_NOTE_MAP:
267
343
  trigger_keyboard_note(st.KEYBOARD_NOTE_MAP[ch])
268
344
  elif st.keyboard_mock_mode and ch == '`':