midi-cli 0.1.3__tar.gz → 0.2.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,144 @@
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 → record → pitch → chromatic → trim → copy → loop → split → config
34
+ ```
35
+
36
+ - **Tab** advances to the next mode (wraps)
37
+ - **Shift+Tab** goes to the previous mode
38
+ - Switching away from `record`, `trim`, `copy`, `split`, or `loop` resets that mode's transient state
39
+
40
+ ### synth
41
+ Polyphonic additive synth (5 harmonics, piano-like two-stage decay).
42
+ Note on → voice added to `state.voices`. Note off → voice set to `releasing`.
43
+ Sustain pedal (CC 64) holds notes until pedal release.
44
+
45
+ ### sample
46
+ Plays back recorded samples. Note on → sample voice added to `state.sample_voices`, plays from position 0.
47
+ Note off → voice set to `releasing` (5ms fade out). Sample plays to its natural end before being removed.
48
+
49
+ ### record
50
+ Note on → `start_recording()` (opens `sd.InputStream`, accumulates chunks).
51
+ Note off → `stop_recording()` (closes stream, trims silence, saves WAV).
52
+ In keyboard mock mode: key press **toggles** recording on/off (press once to start, again to stop).
53
+
54
+ ### pitch
55
+ Note on → plays the sample and sets `pitch_selected_note`.
56
+ Arrow keys adjust pitch offset for the selected note (Up/Down: ±1 semitone, Left/Right: ±10 cents).
57
+ Changes saved to `samples/pitch_offsets.json` and recomputed asynchronously via pyrubberband.
58
+
59
+ ### chromatic
60
+ 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`.
61
+ On entry: if no root is set, enters "selecting" sub-state — next note-on with a sample sets the root and triggers pre-computation.
62
+ Press **r** to re-select root.
63
+
64
+ ### trim
65
+ Note on (with an existing sample) → selects that note, begins loop preview of full sample.
66
+ Two-step workflow:
67
+ 1. **Step 0** — adjust start point with arrow keys (fine: 0.1%, coarse: 1%), **Enter** to confirm
68
+ 2. **Step 1** — adjust end point with arrow keys, **Enter** to save the trimmed slice
69
+
70
+ **Escape** in step 1 goes back to step 0. **Escape** in step 0 deselects.
71
+
72
+ ### copy
73
+ Two-step note selection:
74
+ 1. Note on → sets `copy_source_note` (must have a sample)
75
+ 2. Note on → sets `copy_dest_note`; if dest already has a sample, prompts for confirmation (**Enter** to confirm, **Escape** to cancel)
76
+
77
+ ### loop
78
+ Loop state machine: `idle → recording → playing → overdub → playing → ...`
79
+ Triggered by **l** key or sustain pedal (CC 64).
80
+ Sample voices (from note-on) are mixed into the loop buffer during recording and overdub.
81
+ **Escape** resets to idle.
82
+
83
+ State transitions (`loop.py:cycle_loop_state`):
84
+ - `idle` → `recording`: clears buffer, starts accumulating audio blocks
85
+ - `recording` → `playing`: concatenates chunks into `loop_buffer`, starts playback
86
+ - `playing` → `overdub`: incoming audio mixed (summed) into `loop_buffer` in real time
87
+ - `overdub` → `playing`: increments `loop_layer_count`, returns to playback
88
+
89
+ ### split
90
+ Splits one sample into multiple slices and assigns each to a MIDI note.
91
+ Two phases:
92
+ - **markers**: select a source note, use arrow keys to position cursor, **Enter** to place a marker. **a** advances to assign phase.
93
+ - **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).
94
+
95
+ ### config
96
+ Up/Down navigates fields. Left/Right changes values. Fields: input device, output device, MIDI port, synesthesia toggle. Changes saved immediately on each key press.
97
+
98
+ ## Audio Callback
99
+
100
+ `audio_callback` runs on a real-time background thread (called by sounddevice). It holds `voices_lock` for the entire block.
101
+
102
+ Priority order inside the callback:
103
+ 1. **trim** loop preview (if `trim_looping`)
104
+ 2. **split** loop preview (if `split_looping`)
105
+ 3. **loop** mode — sample voices + loop buffer capture/playback/overdub
106
+ 4. **sample / pitch / chromatic** — sample voice playback
107
+ 5. **synth** — additive synthesis
108
+
109
+ Metronome is mixed in at the end of blocks 3–5 when `metronome_enabled` and the current mode is in `METRONOME_MODES`.
110
+
111
+ ## Key Behaviors
112
+
113
+ ### Note release
114
+ | Input source | Synth mode | Sample/pitch/chromatic modes |
115
+ |---|---|---|
116
+ | MIDI note-off | Triggers fade-out (`releasing=True`) | Triggers fade-out |
117
+ | Keyboard mock | Auto note-off after 250ms (`KEYBOARD_NOTE_RELEASE_S`) | No auto note-off — sample plays to end |
118
+
119
+ ### Sustain pedal (CC 64)
120
+ - In **synth**: holds notes; releasing pedal fades all held notes
121
+ - In **loop**: cycles loop state (same as **l** key)
122
+ - Other modes: ignored
123
+
124
+ ### Metronome
125
+ - **m** key toggles on/off in modes: `synth`, `sample`, `record`, `loop`, `chromatic`
126
+ - Arrow keys adjust BPM (±1 / ±5) when metronome is on and no mode-specific arrow binding takes priority
127
+ - BPM range: 20–300
128
+
129
+ ## State Locking
130
+
131
+ `state.voices_lock` is shared between:
132
+ - `audio_callback` (audio thread) — holds lock for entire block
133
+ - `midi_callback` (MIDI thread) — holds lock briefly for voice mutations
134
+ - `app.py` main loop — holds lock for loop state transitions and some mode resets
135
+
136
+ All mutations to `voices`, `sample_voices`, `loop_buffer`, `loop_state`, and related fields must happen under this lock.
137
+
138
+ ## Persistence
139
+
140
+ | Data | Location |
141
+ |------|----------|
142
+ | Samples | `samples/note_<midi>.wav` (16-bit mono, 48kHz) |
143
+ | Pitch offsets | `samples/pitch_offsets.json` |
144
+ | 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.2.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,15 @@ 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 _stop_active_voices():
18
+ """Clear all active voices on mode switch."""
19
+ with st.voices_lock:
20
+ st.sample_voices.clear()
21
+ st.voices.clear()
22
+
17
23
 
18
24
  def main():
19
25
  from midi_cli.config import load_defaults, get_input_devices, get_output_devices, get_midi_ports, save_config
@@ -61,15 +67,17 @@ def main():
61
67
  if ch in ("q", "\x03"): # q or Ctrl+C
62
68
  break
63
69
  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
70
+ with st.voices_lock:
71
+ st.split_markers.append(st.split_cursor_pos)
72
+ st.split_markers.sort()
73
+ # Restart loop from start of new region (last marker → end)
74
+ st.split_loop_pos = 0
68
75
  elif ch == "\r" and st.mode == "trim" and st.trim_selected_note is not None:
69
76
  if st.trim_step == 0:
70
77
  # Confirm start, advance to end adjustment
71
78
  st.trim_step = 1
72
- st.trim_loop_pos = st.trim_start
79
+ with st.voices_lock:
80
+ st.trim_loop_pos = st.trim_start
73
81
  else:
74
82
  # Confirm end, slice and save
75
83
  audio = st.samples[st.trim_selected_note][st.trim_start:st.trim_end]
@@ -91,10 +99,11 @@ def main():
91
99
  if st.mode == "copy":
92
100
  reset_copy_state()
93
101
  if st.mode == "split":
94
- reset_split_state()
102
+ st.reset_split_state()
95
103
  if st.mode == "loop":
96
104
  with st.voices_lock:
97
105
  reset_loop_state()
106
+ _stop_active_voices()
98
107
  st.mode = st.MODES[(st.MODES.index(st.mode) + 1) % len(st.MODES)]
99
108
  if st.mode == "chromatic":
100
109
  st.chromatic_selecting = st.chromatic_root_note is None
@@ -108,7 +117,8 @@ def main():
108
117
  if st.mode == "trim" and st.trim_selected_note is not None:
109
118
  if st.trim_step == 1:
110
119
  st.trim_step = 0
111
- st.trim_loop_pos = st.trim_start
120
+ with st.voices_lock:
121
+ st.trim_loop_pos = st.trim_start
112
122
  else:
113
123
  reset_trim_state()
114
124
  elif st.mode == "copy" and (st.copy_source_note is not None or st.copy_confirm_pending):
@@ -120,14 +130,17 @@ def main():
120
130
  if st.split_phase == "assign":
121
131
  if st.split_current_slice > 0:
122
132
  st.split_current_slice -= 1
123
- st.split_loop_pos = 0
133
+ with st.voices_lock:
134
+ st.split_loop_pos = 0
124
135
  else:
125
136
  st.split_phase = "markers"
126
- st.split_looping = False
137
+ with st.voices_lock:
138
+ st.split_looping = False
127
139
  else:
128
140
  # markers phase
129
141
  if st.split_markers:
130
- st.split_markers.pop()
142
+ with st.voices_lock:
143
+ st.split_markers.pop()
131
144
  else:
132
145
  st.split_selected_note = None
133
146
  else:
@@ -142,10 +155,11 @@ def main():
142
155
  if st.mode == "copy":
143
156
  reset_copy_state()
144
157
  if st.mode == "split":
145
- reset_split_state()
158
+ st.reset_split_state()
146
159
  if st.mode == "loop":
147
160
  with st.voices_lock:
148
161
  reset_loop_state()
162
+ _stop_active_voices()
149
163
  st.mode = st.MODES[(st.MODES.index(st.mode) - 1) % len(st.MODES)]
150
164
  if st.mode == "chromatic":
151
165
  st.chromatic_selecting = st.chromatic_root_note is None
@@ -255,14 +269,28 @@ def main():
255
269
  "midi_port": st.midi_port,
256
270
  "synesthesia_enabled": st.synesthesia_enabled,
257
271
  })
272
+ elif st.metronome_enabled and st.mode in st.METRONOME_MODES:
273
+ if seq == "[C": # Right: +1 BPM
274
+ st.bpm = min(st.BPM_MAX, st.bpm + 1)
275
+ elif seq == "[D": # Left: -1 BPM
276
+ st.bpm = max(st.BPM_MIN, st.bpm - 1)
277
+ elif seq == "[A": # Up: +5 BPM
278
+ st.bpm = min(st.BPM_MAX, st.bpm + 5)
279
+ elif seq == "[B": # Down: -5 BPM
280
+ st.bpm = max(st.BPM_MIN, st.bpm - 5)
258
281
  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
282
  st.split_phase = "assign"
260
283
  st.split_current_slice = 0
261
- st.split_loop_pos = 0
262
- st.split_looping = True
284
+ with st.voices_lock:
285
+ st.split_loop_pos = 0
286
+ st.split_looping = True
263
287
  elif ch == 'l' and st.mode == "loop":
264
288
  with st.voices_lock:
265
289
  cycle_loop_state()
290
+ elif ch == 'm' and st.mode in st.METRONOME_MODES:
291
+ with st.voices_lock:
292
+ st.metronome_enabled = not st.metronome_enabled
293
+ st.metronome_pos = 0
266
294
  elif st.keyboard_mock_mode and ch in st.KEYBOARD_NOTE_MAP:
267
295
  trigger_keyboard_note(st.KEYBOARD_NOTE_MAP[ch])
268
296
  elif st.keyboard_mock_mode and ch == '`':
@@ -2,6 +2,11 @@ import numpy as np
2
2
 
3
3
  import midi_cli.state as st
4
4
 
5
+ _CLICK_DUR = 0.012 # 12ms
6
+ _CLICK_FREQ = 1000.0
7
+ _t = np.linspace(0, _CLICK_DUR, int(st.SAMPLE_RATE * _CLICK_DUR), endpoint=False)
8
+ _CLICK_BUFFER = (np.sin(2 * np.pi * _CLICK_FREQ * _t) * np.exp(-_t * 300) * 0.7).astype(np.float64)
9
+
5
10
 
6
11
  def build_synth_envelope(gain, releasing, faded_in, age, frames):
7
12
  """Vectorized envelope: returns (gains_array, new_gain, new_age, new_faded_in)."""
@@ -74,6 +79,45 @@ def build_synth_envelope(gain, releasing, faded_in, age, frames):
74
79
  return gains, new_gain, new_age, faded_in
75
80
 
76
81
 
82
+ def _mix_sample_voices(frames, mix):
83
+ finished = []
84
+ for note, sv in st.sample_voices.items():
85
+ if st.mode == "chromatic":
86
+ sample_data = st.chromatic_samples.get(note)
87
+ if sample_data is None:
88
+ finished.append(note)
89
+ continue
90
+ else:
91
+ if note not in st.samples:
92
+ finished.append(note)
93
+ continue
94
+ ps = st.pitched_samples.get(note)
95
+ sample_data = ps if ps is not None else st.samples.get(note)
96
+ pos = sv["pos"]
97
+ gain = sv["gain"]
98
+ releasing = sv["releasing"]
99
+ remaining = len(sample_data) - pos
100
+ n = min(frames, remaining)
101
+ if n <= 0:
102
+ finished.append(note)
103
+ continue
104
+ buf = np.zeros(frames)
105
+ buf[:n] = sample_data[pos:pos + n] * gain
106
+ if releasing:
107
+ fade_gains = np.maximum(0.0, gain - st.FADE_OUT_STEP * (np.arange(n) + 1))
108
+ buf[:n] *= fade_gains
109
+ gain = max(0.0, gain - st.FADE_OUT_STEP * n)
110
+ else:
111
+ gain = min(1.0, gain + st.FADE_IN_STEP * n)
112
+ mix += buf
113
+ sv["pos"] = pos + n
114
+ sv["gain"] = gain
115
+ if (releasing and gain <= 0.0) or pos + n >= len(sample_data):
116
+ finished.append(note)
117
+ for note in finished:
118
+ del st.sample_voices[note]
119
+
120
+
77
121
  def audio_callback(outdata, frames, time_info, status):
78
122
  with st.voices_lock:
79
123
  if st.mode == "trim" and st.trim_looping and st.trim_selected_note is not None:
@@ -161,36 +205,7 @@ def audio_callback(outdata, frames, time_info, status):
161
205
  if st.mode == "loop":
162
206
  mix = np.zeros(frames)
163
207
 
164
- # Sample voice mixing (same logic as sample mode)
165
- finished = []
166
- for note, sv in st.sample_voices.items():
167
- if note not in st.samples:
168
- finished.append(note)
169
- continue
170
- pos = sv["pos"]
171
- gain = sv["gain"]
172
- releasing = sv["releasing"]
173
- sample_data = st.samples[note]
174
- remaining = len(sample_data) - pos
175
- n = min(frames, remaining)
176
- if n <= 0:
177
- finished.append(note)
178
- continue
179
- buf = np.zeros(frames)
180
- buf[:n] = sample_data[pos:pos + n] * gain
181
- if releasing:
182
- fade_gains = np.maximum(0.0, gain - st.FADE_OUT_STEP * (np.arange(n) + 1))
183
- buf[:n] *= fade_gains
184
- gain = max(0.0, gain - st.FADE_OUT_STEP * n)
185
- else:
186
- gain = min(1.0, gain + st.FADE_IN_STEP * n)
187
- mix += buf
188
- sv["pos"] = pos + n
189
- sv["gain"] = gain
190
- if (releasing and gain <= 0.0) or pos + n >= len(sample_data):
191
- finished.append(note)
192
- for note in finished:
193
- del st.sample_voices[note]
208
+ _mix_sample_voices(frames, mix)
194
209
 
195
210
  # Loop capture/playback
196
211
  if st.loop_state == "recording":
@@ -213,59 +228,36 @@ def audio_callback(outdata, frames, time_info, status):
213
228
  mix += loop_chunk
214
229
  st.loop_pos = (st.loop_pos + frames) % buf_len
215
230
 
231
+ if st.metronome_enabled and st.mode in st.METRONOME_MODES:
232
+ spb = int(st.SAMPLE_RATE * 60.0 / st.bpm)
233
+ click_len = len(_CLICK_BUFFER)
234
+ for i in range(frames):
235
+ beat_pos = (st.metronome_pos + i) % spb
236
+ if beat_pos < click_len:
237
+ mix[i] += _CLICK_BUFFER[beat_pos]
238
+ st.metronome_pos = (st.metronome_pos + frames) % spb
239
+
216
240
  mix *= 0.25
217
241
  np.clip(mix, -1.0, 1.0, out=mix)
218
242
  outdata[:, 0] = mix
219
243
  return
220
244
 
221
245
  if st.mode in ("sample", "pitch", "chromatic", "trim"):
222
- if not st.sample_voices:
246
+ if not st.sample_voices and not (st.metronome_enabled and st.mode in st.METRONOME_MODES):
223
247
  outdata[:] = 0
224
248
  return
225
249
 
226
250
  mix = np.zeros(frames)
227
- finished = []
228
-
229
- for note, sv in st.sample_voices.items():
230
- if st.mode == "chromatic":
231
- sample_data = st.chromatic_samples.get(note)
232
- if sample_data is None:
233
- finished.append(note)
234
- continue
235
- else:
236
- if note not in st.samples:
237
- finished.append(note)
238
- continue
239
- ps = st.pitched_samples.get(note)
240
- sample_data = ps if ps is not None else st.samples.get(note)
241
- pos = sv["pos"]
242
- gain = sv["gain"]
243
- releasing = sv["releasing"]
244
-
245
- remaining = len(sample_data) - pos
246
- n = min(frames, remaining)
247
- if n <= 0:
248
- finished.append(note)
249
- continue
250
-
251
- buf = np.zeros(frames)
252
- buf[:n] = sample_data[pos:pos + n] * gain
253
-
254
- if releasing:
255
- fade_gains = np.maximum(0.0, gain - st.FADE_OUT_STEP * (np.arange(n) + 1))
256
- buf[:n] *= fade_gains
257
- gain = max(0.0, gain - st.FADE_OUT_STEP * n)
258
- else:
259
- gain = min(1.0, gain + st.FADE_IN_STEP * n)
260
-
261
- mix += buf
262
- sv["pos"] = pos + n
263
- sv["gain"] = gain
264
- if (releasing and gain <= 0.0) or pos + n >= len(sample_data):
265
- finished.append(note)
251
+ _mix_sample_voices(frames, mix)
266
252
 
267
- for note in finished:
268
- del st.sample_voices[note]
253
+ if st.metronome_enabled and st.mode in st.METRONOME_MODES:
254
+ spb = int(st.SAMPLE_RATE * 60.0 / st.bpm)
255
+ click_len = len(_CLICK_BUFFER)
256
+ for i in range(frames):
257
+ beat_pos = (st.metronome_pos + i) % spb
258
+ if beat_pos < click_len:
259
+ mix[i] += _CLICK_BUFFER[beat_pos]
260
+ st.metronome_pos = (st.metronome_pos + frames) % spb
269
261
 
270
262
  mix *= 0.25
271
263
  np.clip(mix, -1.0, 1.0, out=mix)
@@ -273,7 +265,7 @@ def audio_callback(outdata, frames, time_info, status):
273
265
  return
274
266
 
275
267
  # Synth mode
276
- if not st.voices:
268
+ if not st.voices and not (st.metronome_enabled and st.mode in st.METRONOME_MODES):
277
269
  outdata[:] = 0
278
270
  return
279
271
 
@@ -307,6 +299,15 @@ def audio_callback(outdata, frames, time_info, status):
307
299
  for note in finished:
308
300
  del st.voices[note]
309
301
 
302
+ if st.metronome_enabled and st.mode in st.METRONOME_MODES:
303
+ spb = int(st.SAMPLE_RATE * 60.0 / st.bpm)
304
+ click_len = len(_CLICK_BUFFER)
305
+ for i in range(frames):
306
+ beat_pos = (st.metronome_pos + i) % spb
307
+ if beat_pos < click_len:
308
+ mix[i] += _CLICK_BUFFER[beat_pos]
309
+ st.metronome_pos = (st.metronome_pos + frames) % spb
310
+
310
311
  mix *= 0.25 # fixed headroom (up to ~4 voices at full level)
311
312
  np.clip(mix, -1.0, 1.0, out=mix)
312
313
  outdata[:, 0] = mix
@@ -51,8 +51,7 @@ def midi_callback(event, data):
51
51
  st.split_current_slice += 1
52
52
  st.split_loop_pos = 0
53
53
  if st.split_current_slice >= len(slices):
54
- from midi_cli.ui import reset_split_state
55
- reset_split_state()
54
+ st.reset_split_state()
56
55
  return
57
56
 
58
57
  if st.mode == "copy":
@@ -78,17 +77,19 @@ def midi_callback(event, data):
78
77
  st.pitch_selected_note = note
79
78
  return
80
79
  if st.mode == "chromatic":
81
- if st.chromatic_selecting:
82
- if note in st.samples:
83
- st.chromatic_root_note = note
84
- with st.voices_lock:
80
+ should_compute = False
81
+ with st.voices_lock:
82
+ if st.chromatic_selecting:
83
+ if note in st.samples:
84
+ st.chromatic_root_note = note
85
85
  st.sample_voices.clear()
86
- st.chromatic_selecting = False
87
- compute_chromatic_samples_async(note)
88
- else:
89
- with st.voices_lock:
86
+ st.chromatic_selecting = False
87
+ should_compute = True
88
+ else:
90
89
  if note in st.chromatic_samples:
91
90
  st.sample_voices[note] = {"pos": 0, "releasing": False, "gain": 1.0}
91
+ if should_compute:
92
+ compute_chromatic_samples_async(note)
92
93
  return
93
94
  with st.voices_lock:
94
95
  if st.mode in ("sample", "loop"):
@@ -17,11 +17,12 @@ KEYBOARD_NOTE_RELEASE_S = 0.25
17
17
  RST = "\033[0m"
18
18
  DIM = "\033[2m"
19
19
  BOLD = "\033[1m"
20
- GREEN = "\033[32m"
20
+ GREEN = "\033[38;5;65m"
21
21
  RED = "\033[31m"
22
22
  YELLOW = "\033[33m"
23
23
  CYAN = "\033[36m"
24
24
  ORANGE = "\033[38;5;208m"
25
+ SLATE = "\033[38;5;66m"
25
26
  CLEAR_LINE = "\033[2K"
26
27
 
27
28
  NOTE_SYNESTHESIA_COLORS = {
@@ -118,6 +119,21 @@ split_current_slice = 0 # which slice is being assigned
118
119
  split_loop_pos = 0 # audio preview playback position
119
120
  split_looping = False # True while preview loop is active
120
121
 
122
+
123
+ def reset_split_state():
124
+ """Reset all split mode state back to selection."""
125
+ global split_selected_note, split_markers, split_phase
126
+ global split_cursor_pos, split_current_slice, split_loop_pos, split_looping
127
+ split_selected_note = None
128
+ split_markers = []
129
+ split_phase = "markers"
130
+ split_cursor_pos = 0
131
+ split_current_slice = 0
132
+ with voices_lock:
133
+ split_loop_pos = 0
134
+ split_looping = False
135
+
136
+
121
137
  # Loop mode state
122
138
  loop_state = "idle" # "idle", "recording", "playing", "overdub"
123
139
  loop_buffer = None # np.ndarray once recorded, None when idle
@@ -125,6 +141,15 @@ loop_pos = 0 # current playback position (int)
125
141
  loop_record_chunks = [] # list of np.ndarray, accumulated during recording
126
142
  loop_layer_count = 0 # 1 after first recording, increments each overdub completion
127
143
 
144
+ METRONOME_MODES = {"synth", "sample", "record", "loop", "chromatic"}
145
+
146
+ # Metronome state
147
+ metronome_enabled = False
148
+ bpm = 120.0
149
+ metronome_pos = 0 # sample counter, wraps every beat
150
+ BPM_MIN = 20.0
151
+ BPM_MAX = 300.0
152
+
128
153
  # Copy mode state
129
154
  copy_source_note = None # MIDI note selected as source
130
155
  copy_dest_note = None # MIDI note selected as destination
@@ -20,59 +20,24 @@ def _synesthesia_color(note_name):
20
20
  return st.NOTE_SYNESTHESIA_COLORS.get(pc, "")
21
21
 
22
22
 
23
- def render_waveform(note):
24
- """Render a centered ASCII waveform of the sample with trim region highlighted."""
25
- audio = st.samples.get(note)
26
- if audio is None:
27
- return []
28
- cols = os.get_terminal_size().columns - 2 # leave margin
29
- cols = max(10, cols)
30
- n = len(audio)
31
- rows = st.TRIM_WAVEFORM_HEIGHT
32
- half = rows / 2 # rows above/below center (float for smooth mapping)
33
- blocks = " \u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588"
34
- blocks_rev = " \u2594\u2594\u2580\u2580\u2580\u2580\u2588\u2588"
35
-
36
- # Compute peak amplitude per column bin
23
+ def _render_waveform_core(audio, cols, rows, color_fn):
37
24
  peaks = np.zeros(cols)
25
+ n = len(audio)
38
26
  for c in range(cols):
39
27
  start = c * n // cols
40
28
  end = (c + 1) * n // cols
41
29
  if start < end:
42
30
  peaks[c] = np.max(np.abs(audio[start:end]))
43
-
44
31
  max_peak = np.max(peaks) if np.max(peaks) > 0 else 1.0
45
32
 
46
- # Compute which columns are inside the trim region
47
- trim_start_col = st.trim_start * cols // n if n > 0 else 0
48
- trim_end_col = (st.trim_end * cols + n - 1) // n if n > 0 else cols # ceiling
49
-
50
- # Active marker column
51
- if st.trim_step == 0:
52
- marker_col = trim_start_col
53
- else:
54
- marker_col = min(trim_end_col - 1, cols - 1)
55
-
56
- # Playback position column (only during audio, not silence padding)
57
- region_len = st.trim_end - st.trim_start
58
- if region_len > 0 and st.trim_looping and st.trim_loop_pos < region_len:
59
- play_offset = st.trim_loop_pos
60
- play_col = trim_start_col + play_offset * (trim_end_col - trim_start_col) // region_len
61
- play_col = max(trim_start_col, min(play_col, trim_end_col - 1))
62
- else:
63
- play_col = -1
64
-
65
- WHITE = "\033[97m"
66
-
33
+ blocks = " \u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588"
34
+ blocks_rev = " \u2594\u2594\u2580\u2580\u2580\u2580\u2588\u2588"
35
+ half = rows / 2
67
36
  lines = []
68
37
  for row in range(rows):
69
- # Center is between row half-1 and half
70
- # Upper half: rows 0..half-1 (top row = max amplitude)
71
- # Lower half: rows half..rows-1 (bottom row = max amplitude)
72
- dist_from_center = abs(row - half + 0.5) / half # 0 at center, 1 at edges
38
+ dist_from_center = abs(row - half + 0.5) / half
73
39
  threshold_low = dist_from_center
74
40
  threshold_high = dist_from_center + 1.0 / half
75
-
76
41
  is_upper = row < half
77
42
  line = " "
78
43
  for c in range(cols):
@@ -82,93 +47,62 @@ def render_waveform(note):
82
47
  else:
83
48
  frac = min(1.0, (level - threshold_low) / (threshold_high - threshold_low))
84
49
  idx = int(frac * (len(blocks) - 1))
85
- if is_upper:
86
- char = blocks[idx]
87
- else:
88
- # Bottom half: use reversed blocks so fill grows from top of cell
89
- char = blocks_rev[idx]
90
-
91
- in_region = trim_start_col <= c < trim_end_col
92
- is_boundary = c == trim_start_col or c == trim_end_col - 1
93
- if c == play_col:
94
- head = char if char.strip() else "\u2502"
95
- line += f"{st.RED}{st.BOLD}{head}{st.RST}"
96
- elif is_boundary:
97
- bar = char if char.strip() else "\u2502"
98
- if c == marker_col:
99
- line += f"{st.ORANGE}{st.BOLD}{bar}{st.RST}"
100
- else:
101
- line += f"{st.CYAN}{bar}{st.RST}"
102
- elif in_region:
103
- line += f"{st.CYAN}{char}{st.RST}"
104
- else:
105
- line += f"{st.DIM}{char}{st.RST}"
50
+ char = blocks[idx] if is_upper else blocks_rev[idx]
51
+ line += color_fn(c, char)
106
52
  lines.append(line)
107
53
  return lines
108
54
 
109
55
 
110
- def render_loop_waveform(audio, loop_state, loop_pos, loop_layer_count):
111
- """Render a 6-row waveform for loop mode with colored playhead."""
112
- cols = os.get_terminal_size().columns - 2
113
- cols = max(10, cols)
56
+ def render_waveform(note):
57
+ """Render a centered ASCII waveform of the sample with trim region highlighted."""
58
+ audio = st.samples.get(note)
59
+ if audio is None:
60
+ return []
61
+ cols = max(10, os.get_terminal_size().columns - 2)
114
62
  n = len(audio)
115
- rows = st.TRIM_WAVEFORM_HEIGHT
116
- half = rows / 2
117
- blocks = " \u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588"
118
- blocks_rev = " \u2594\u2594\u2580\u2580\u2580\u2580\u2588\u2588"
63
+ trim_start_col = st.trim_start * cols // n if n > 0 else 0
64
+ trim_end_col = (st.trim_end * cols + n - 1) // n if n > 0 else cols
65
+ marker_col = trim_start_col if st.trim_step == 0 else min(trim_end_col - 1, cols - 1)
66
+ region_len = st.trim_end - st.trim_start
67
+ if region_len > 0 and st.trim_looping and st.trim_loop_pos < region_len:
68
+ play_col = trim_start_col + st.trim_loop_pos * (trim_end_col - trim_start_col) // region_len
69
+ play_col = max(trim_start_col, min(play_col, trim_end_col - 1))
70
+ else:
71
+ play_col = -1
119
72
 
120
- # Compute peak amplitude per column bin
121
- peaks = np.zeros(cols)
122
- for c in range(cols):
123
- start = c * n // cols
124
- end = (c + 1) * n // cols
125
- if start < end:
126
- peaks[c] = np.max(np.abs(audio[start:end]))
73
+ def color_fn(c, char):
74
+ if c == play_col:
75
+ head = char if char.strip() else "\u2502"
76
+ return f"{st.RED}{st.BOLD}{head}{st.RST}"
77
+ if c == trim_start_col or c == trim_end_col - 1:
78
+ bar = char if char.strip() else "\u2502"
79
+ if c == marker_col:
80
+ return f"{st.ORANGE}{st.BOLD}{bar}{st.RST}"
81
+ return f"{st.CYAN}{bar}{st.RST}"
82
+ if trim_start_col <= c < trim_end_col:
83
+ return f"{st.CYAN}{char}{st.RST}"
84
+ return f"{st.DIM}{char}{st.RST}"
127
85
 
128
- max_peak = np.max(peaks) if np.max(peaks) > 0 else 1.0
86
+ return _render_waveform_core(audio, cols, st.TRIM_WAVEFORM_HEIGHT, color_fn)
129
87
 
130
- # Color by state
131
- if loop_state == "recording":
132
- color = st.RED
133
- elif loop_state == "overdub":
134
- color = st.YELLOW
135
- else:
136
- color = st.GREEN
137
88
 
138
- # Playhead column (only during playing/overdub)
89
+ def render_loop_waveform(audio, loop_state, loop_pos, loop_layer_count):
90
+ """Render a 6-row waveform for loop mode with colored playhead."""
91
+ cols = max(10, os.get_terminal_size().columns - 2)
92
+ n = len(audio)
93
+ color = st.RED if loop_state == "recording" else st.YELLOW if loop_state == "overdub" else st.GREEN
139
94
  if loop_state in ("playing", "overdub") and n > 0:
140
- play_col = loop_pos * cols // n
141
- play_col = max(0, min(play_col, cols - 1))
95
+ play_col = max(0, min(loop_pos * cols // n, cols - 1))
142
96
  else:
143
97
  play_col = -1
144
98
 
145
- lines = []
146
- for row in range(rows):
147
- dist_from_center = abs(row - half + 0.5) / half
148
- threshold_low = dist_from_center
149
- threshold_high = dist_from_center + 1.0 / half
150
-
151
- is_upper = row < half
152
- line = " "
153
- for c in range(cols):
154
- level = peaks[c] / max_peak
155
- if level <= threshold_low:
156
- char = " "
157
- else:
158
- frac = min(1.0, (level - threshold_low) / (threshold_high - threshold_low))
159
- idx = int(frac * (len(blocks) - 1))
160
- if is_upper:
161
- char = blocks[idx]
162
- else:
163
- char = blocks_rev[idx]
99
+ def color_fn(c, char):
100
+ if c == play_col:
101
+ head = char if char.strip() else "\u2502"
102
+ return f"{st.RED}{st.BOLD}{head}{st.RST}"
103
+ return f"{color}{char}{st.RST}"
164
104
 
165
- if c == play_col:
166
- head = char if char.strip() else "\u2502"
167
- line += f"{st.RED}{st.BOLD}{head}{st.RST}"
168
- else:
169
- line += f"{color}{char}{st.RST}"
170
- lines.append(line)
171
- return lines
105
+ return _render_waveform_core(audio, cols, st.TRIM_WAVEFORM_HEIGHT, color_fn)
172
106
 
173
107
 
174
108
  def reset_trim_state():
@@ -177,19 +111,10 @@ def reset_trim_state():
177
111
  st.trim_step = 0
178
112
  st.trim_start = 0
179
113
  st.trim_end = 0
180
- st.trim_loop_pos = 0
181
- st.trim_looping = False
182
-
114
+ with st.voices_lock:
115
+ st.trim_loop_pos = 0
116
+ st.trim_looping = False
183
117
 
184
- def reset_split_state():
185
- """Reset all split mode state back to selection."""
186
- st.split_selected_note = None
187
- st.split_markers = []
188
- st.split_phase = "markers"
189
- st.split_cursor_pos = 0
190
- st.split_current_slice = 0
191
- st.split_loop_pos = 0
192
- st.split_looping = False
193
118
 
194
119
 
195
120
  def render_split_waveform(note):
@@ -197,81 +122,50 @@ def render_split_waveform(note):
197
122
  audio = st.samples.get(note)
198
123
  if audio is None:
199
124
  return []
200
- cols = os.get_terminal_size().columns - 2
201
- cols = max(10, cols)
125
+ cols = max(10, os.get_terminal_size().columns - 2)
202
126
  n = len(audio)
203
- rows = st.TRIM_WAVEFORM_HEIGHT
204
- half = rows / 2
205
- blocks = " \u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588"
206
- blocks_rev = " \u2594\u2594\u2580\u2580\u2580\u2580\u2588\u2588"
207
-
208
- peaks = np.zeros(cols)
209
- for c in range(cols):
210
- start = c * n // cols
211
- end = (c + 1) * n // cols
212
- if start < end:
213
- peaks[c] = np.max(np.abs(audio[start:end]))
214
-
215
- max_peak = np.max(peaks) if np.max(peaks) > 0 else 1.0
216
127
 
217
128
  if st.split_phase == "markers":
218
129
  cursor_col = st.split_cursor_pos * cols // n if n > 0 else 0
219
130
  marker_cols = set(m * cols // n for m in st.split_markers if n > 0)
220
-
221
- # Playhead column — loop_pos is relative to last marker (or 0)
131
+ sorted_marker_cols = sorted(marker_cols)
222
132
  region_start = st.split_markers[-1] if st.split_markers else 0
223
133
  abs_play_pos = region_start + st.split_loop_pos
224
- if st.split_looping and n > 0 and abs_play_pos < n:
225
- play_col = abs_play_pos * cols // n
226
- else:
227
- play_col = -1
134
+ play_col = (abs_play_pos * cols // n) if st.split_looping and n > 0 and abs_play_pos < n else -1
135
+ parity = len(st.split_markers) % 2
136
+ right_color = st.CYAN if parity == 0 else st.SLATE
137
+ left_color = st.SLATE if parity == 0 else st.CYAN
138
+ num_regions = len(st.split_markers) + 1
228
139
 
229
- lines = []
230
- for row in range(rows):
231
- dist_from_center = abs(row - half + 0.5) / half
232
- threshold_low = dist_from_center
233
- threshold_high = dist_from_center + 1.0 / half
234
- is_upper = row < half
235
- line = " "
236
- for c in range(cols):
237
- level = peaks[c] / max_peak
238
- if level <= threshold_low:
239
- char = " "
240
- else:
241
- frac = min(1.0, (level - threshold_low) / (threshold_high - threshold_low))
242
- idx = int(frac * (len(blocks) - 1))
243
- char = blocks[idx] if is_upper else blocks_rev[idx]
244
-
245
- if c == play_col:
246
- head = char if char.strip() else "\u2502"
247
- line += f"{st.RED}{st.BOLD}{head}{st.RST}"
248
- elif c == cursor_col:
249
- bar = char if char.strip() else "\u2502"
250
- line += f"{st.ORANGE}{st.BOLD}{bar}{st.RST}"
251
- elif c in marker_cols:
252
- bar = char if char.strip() else "\u2502"
253
- line += f"{st.CYAN}{bar}{st.RST}"
254
- else:
255
- line += f"{st.DIM}{char}{st.RST}"
256
- lines.append(line)
257
- return lines
258
- else:
259
- # Assign phase: highlight current slice, dim others, show boundaries in orange
140
+ def color_fn(c, char):
141
+ if c == play_col:
142
+ head = char if char.strip() else "\u2502"
143
+ return f"{st.RED}{st.BOLD}{head}{st.RST}"
144
+ if c == cursor_col:
145
+ bar = char if char.strip() else "\u2502"
146
+ return f"{st.ORANGE}{st.BOLD}{bar}{st.RST}"
147
+ if c in marker_cols:
148
+ bar = char if char.strip() else "\u2502"
149
+ return f"{st.CYAN}{bar}{st.RST}"
150
+ ridx = sum(1 for mc in sorted_marker_cols if mc <= c)
151
+ if ridx == num_regions - 1:
152
+ color = st.DIM if c > cursor_col else left_color
153
+ else:
154
+ color = st.SLATE if ridx % 2 == 0 else st.CYAN
155
+ return f"{color}{char}{st.RST}"
156
+
157
+ else: # assign phase
260
158
  slices = get_split_slices()
261
159
  if not slices or st.split_current_slice >= len(slices):
262
160
  return []
263
161
  sl_start, sl_end = slices[st.split_current_slice]
264
162
  sl_start_col = sl_start * cols // n if n > 0 else 0
265
163
  sl_end_col = (sl_end * cols + n - 1) // n if n > 0 else cols
266
-
267
- # All boundary columns
268
164
  all_boundaries = set()
269
165
  for s, e in slices:
270
166
  if n > 0:
271
167
  all_boundaries.add(s * cols // n)
272
168
  all_boundaries.add(min((e * cols + n - 1) // n, cols - 1))
273
-
274
- # Playback position column
275
169
  region_len = sl_end - sl_start
276
170
  if region_len > 0 and st.split_looping and st.split_loop_pos < region_len:
277
171
  play_col = sl_start_col + st.split_loop_pos * (sl_end_col - sl_start_col) // region_len
@@ -279,35 +173,18 @@ def render_split_waveform(note):
279
173
  else:
280
174
  play_col = -1
281
175
 
282
- lines = []
283
- for row in range(rows):
284
- dist_from_center = abs(row - half + 0.5) / half
285
- threshold_low = dist_from_center
286
- threshold_high = dist_from_center + 1.0 / half
287
- is_upper = row < half
288
- line = " "
289
- for c in range(cols):
290
- level = peaks[c] / max_peak
291
- if level <= threshold_low:
292
- char = " "
293
- else:
294
- frac = min(1.0, (level - threshold_low) / (threshold_high - threshold_low))
295
- idx = int(frac * (len(blocks) - 1))
296
- char = blocks[idx] if is_upper else blocks_rev[idx]
297
-
298
- in_slice = sl_start_col <= c < sl_end_col
299
- if c == play_col:
300
- head = char if char.strip() else "\u2502"
301
- line += f"{st.RED}{st.BOLD}{head}{st.RST}"
302
- elif c in all_boundaries:
303
- bar = char if char.strip() else "\u2502"
304
- line += f"{st.ORANGE}{bar}{st.RST}"
305
- elif in_slice:
306
- line += f"{st.CYAN}{char}{st.RST}"
307
- else:
308
- line += f"{st.DIM}{char}{st.RST}"
309
- lines.append(line)
310
- return lines
176
+ def color_fn(c, char):
177
+ if c == play_col:
178
+ head = char if char.strip() else "\u2502"
179
+ return f"{st.RED}{st.BOLD}{head}{st.RST}"
180
+ if c in all_boundaries:
181
+ bar = char if char.strip() else "\u2502"
182
+ return f"{st.ORANGE}{bar}{st.RST}"
183
+ if sl_start_col <= c < sl_end_col:
184
+ return f"{st.YELLOW}{char}{st.RST}"
185
+ return f"{st.SLATE}{char}{st.RST}"
186
+
187
+ return _render_waveform_core(audio, cols, st.TRIM_WAVEFORM_HEIGHT, color_fn)
311
188
 
312
189
 
313
190
  def render_config_panel():
@@ -547,6 +424,8 @@ def draw_ui():
547
424
  line1 += f"{st.YELLOW}{st.BOLD}\u25cf{st.RST}"
548
425
  else:
549
426
  line1 += f"{st.DIM}\u25cb{st.RST}"
427
+ if st.metronome_enabled and st.mode in st.METRONOME_MODES:
428
+ line1 += f" {st.CYAN}{st.BOLD}\u2669 {st.bpm:.0f}{st.RST}"
550
429
  out += _line(line1)
551
430
 
552
431
  # Line 2: MIDI controller name or keyboard mode hint
@@ -567,8 +446,10 @@ def draw_ui():
567
446
  elif st.mode == "chromatic":
568
447
  if st.chromatic_selecting:
569
448
  line3 = f" {st.DIM}[Tab] mode [play] select [q] quit{st.RST}"
449
+ elif st.metronome_enabled:
450
+ line3 = f" {st.DIM}[Tab] mode [m] metro off [\u2190\u2192] bpm\u00b11 [\u2191\u2193] bpm\u00b15 [r] reselect [q] quit{st.RST}"
570
451
  else:
571
- line3 = f" {st.DIM}[Tab] mode [r] reselect [q] quit{st.RST}"
452
+ line3 = f" {st.DIM}[Tab] mode [r] reselect [m] metro [q] quit{st.RST}"
572
453
  elif st.mode == "copy":
573
454
  if st.copy_confirm_pending:
574
455
  line3 = f" {st.DIM}[enter] confirm [esc] cancel [q] quit{st.RST}"
@@ -584,11 +465,18 @@ def draw_ui():
584
465
  else:
585
466
  line3 = f" {st.DIM}[play] assign slice [esc] undo [q] quit{st.RST}"
586
467
  elif st.mode == "loop":
587
- line3 = f" {st.DIM}[sustain/l] record/play/dub [esc] clear [play] sample [q] quit{st.RST}"
468
+ if st.metronome_enabled:
469
+ line3 = f" {st.DIM}[Tab] mode [m] metro off [\u2190\u2192] bpm\u00b11 [\u2191\u2193] bpm\u00b15 [q] quit{st.RST}"
470
+ else:
471
+ line3 = f" {st.DIM}[sustain/l] record/play/dub [esc] clear [play] sample [m] metro [q] quit{st.RST}"
588
472
  elif st.mode == "config":
589
473
  line3 = f" {st.DIM}[\u2191\u2193] field [\u2190\u2192] change [Tab] mode [q] quit{st.RST}"
590
474
  else:
591
- line3 = f" {st.DIM}[Tab] mode [q] quit{st.RST}"
475
+ if st.metronome_enabled and st.mode in st.METRONOME_MODES:
476
+ line3 = f" {st.DIM}[Tab] mode [m] metro off [\u2190\u2192] bpm\u00b11 [\u2191\u2193] bpm\u00b15 [q] quit{st.RST}"
477
+ else:
478
+ metro_hint = " [m] metro" if st.mode in st.METRONOME_MODES else ""
479
+ line3 = f" {st.DIM}[Tab] mode{metro_hint} [q] quit{st.RST}"
592
480
  out += _line(line3)
593
481
 
594
482
  sys.stdout.write(out)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "midi-cli"
3
- version = "0.1.3"
3
+ version = "0.2.0"
4
4
  description = "A MIDI sampler and looper in your terminal"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -74,7 +74,7 @@ wheels = [
74
74
 
75
75
  [[package]]
76
76
  name = "midi-cli"
77
- version = "0.1.2"
77
+ version = "0.1.3"
78
78
  source = { editable = "." }
79
79
  dependencies = [
80
80
  { name = "numpy" },
@@ -1,65 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(gh issue:*)",
5
- "Bash(git:*)",
6
- "Bash(python3 -c \"import rtmidi; print\\(rtmidi.__version__\\)\")",
7
- "Bash(python3 -c \"import sounddevice; print\\(sounddevice.__version__\\)\")",
8
- "Bash(python3 -c \"import numpy; print\\(numpy.__version__\\)\")",
9
- "WebFetch(domain:spotlightkid.github.io)",
10
- "WebFetch(domain:python-sounddevice.readthedocs.io)",
11
- "Bash(python3 -m venv .venv)",
12
- "Bash(source .venv/bin/activate)",
13
- "Bash(pip install:*)",
14
- "Bash(chmod:*)",
15
- "Bash(ls /Users/mb/code/midi-cli/*.py /Users/mb/code/midi-cli/src/**/*.py)",
16
- "Bash(gh pr:*)",
17
- "Bash(wc -l /Users/mb/code/midi-cli/src/**/*.rs /Users/mb/code/midi-cli/src/*.rs)",
18
- "Bash(grep -E \"\\\\.\\(py|txt\\)$\")",
19
- "Bash(git commit:*)",
20
- "Bash(ambiguous\". Use explicit None check instead.:*)",
21
- "Bash(gh repo:*)",
22
- "Bash(wc -l /Users/mb/code/midi-cli/src/*.py /Users/mb/code/midi-cli/*.py)",
23
- "Bash(python3 -m py_compile main.py)",
24
- "Bash(python3:*)",
25
- "Bash(/Users/mb/code/midi-cli/.venv/bin/python -c \"import midi_cli.state; import midi_cli.audio; import midi_cli.sampler; import midi_cli.ui; import midi_cli.midi; import midi_cli.app; print\\('All imports OK'\\)\")",
26
- "Bash(/Users/mb/code/midi-cli/.venv/bin/pip list:*)",
27
- "Bash(/Users/mb/code/midi-cli/.venv/bin/python -c \"\nimport midi_cli.state as st\nprint\\(f'state: {len\\(st.MODES\\)} modes, SAMPLE_RATE={st.SAMPLE_RATE}'\\)\nimport midi_cli.audio\nprint\\(f'audio: audio_callback, build_synth_envelope, trim_silence OK'\\)\nprint\\('All non-pyrubberband imports OK'\\)\n\")",
28
- "Bash(uv run:*)",
29
- "Bash(wc -l /Users/mb/code/midi-cli/midi_cli/*.py /Users/mb/code/midi-cli/main.py)",
30
- "Bash(python -m py_compile midi_cli/state.py)",
31
- "Bash(python -m py_compile midi_cli/sampler.py)",
32
- "Bash(python -m py_compile midi_cli/midi.py)",
33
- "Bash(python -m py_compile midi_cli/ui.py)",
34
- "Bash(python -m py_compile midi_cli/app.py)",
35
- "Bash(brew search:*)",
36
- "Bash(brew info:*)",
37
- "WebSearch",
38
- "Bash(python:*)",
39
- "Bash(ls:*)",
40
- "Bash(uv build:*)",
41
- "Skill(update-config)",
42
- "WebFetch(domain:docs.astral.sh)",
43
- "Bash(source ~/.zshrc)",
44
- "Bash(uv publish:*)",
45
- "Bash(gh label:*)",
46
- "Bash(wc:*)",
47
- "WebFetch(domain:teenage.engineering)",
48
- "WebFetch(domain:op-forums.com)",
49
- "WebFetch(domain:www.sinesquares.net)",
50
- "WebFetch(domain:www.soundonsound.com)",
51
- "WebFetch(domain:www.morningdewmedia.com)",
52
- "WebFetch(domain:www.elektronauts.com)",
53
- "WebFetch(domain:musictech.com)",
54
- "WebFetch(domain:www.musicradar.com)",
55
- "WebFetch(domain:singularsound-publicly-downloadable.s3.us-east-2.amazonaws.com)",
56
- "WebFetch(domain:www.chompiclub.com)",
57
- "WebFetch(domain:www.macprovideo.com)",
58
- "WebFetch(domain:cdn.roland.com)",
59
- "WebFetch(domain:community.native-instruments.com)",
60
- "WebFetch(domain:www.manualslib.com)",
61
- "WebFetch(domain:modwiggler.com)",
62
- "Bash(gh api:*)"
63
- ]
64
- }
65
- }
midi_cli-0.1.3/main.py DELETED
@@ -1,6 +0,0 @@
1
- #!/usr/bin/env -S uv run --project .
2
- """Thin wrapper so ./main.py keeps working."""
3
- from midi_cli.app import main
4
-
5
- if __name__ == "__main__":
6
- main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes