midi-cli 0.1.2__tar.gz → 0.1.3__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.
@@ -43,7 +43,23 @@
43
43
  "Bash(source ~/.zshrc)",
44
44
  "Bash(uv publish:*)",
45
45
  "Bash(gh label:*)",
46
- "Bash(wc:*)"
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:*)"
47
63
  ]
48
64
  }
49
65
  }
@@ -6,4 +6,5 @@ venv/
6
6
  dist/
7
7
  build/
8
8
  samples/
9
+ samples-*/
9
10
  config.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: midi-cli
3
- Version: 0.1.2
3
+ Version: 0.1.3
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
@@ -20,7 +20,10 @@ Description-Content-Type: text/markdown
20
20
 
21
21
  # midi-cli
22
22
 
23
- A little midi player in your terminal, tested in zsh on macOS.
23
+ A little midi player in your terminal, tested in zsh on macOS. Vibe coded, obviously.
24
+
25
+ <img width="3384" height="692" alt="image" src="https://github.com/user-attachments/assets/4e5ff869-447c-45d8-9b66-49052f340aed" />
26
+
24
27
 
25
28
  ## Install
26
29
 
@@ -0,0 +1,30 @@
1
+ # midi-cli
2
+
3
+ A little midi player in your terminal, tested in zsh on macOS. Vibe coded, obviously.
4
+
5
+ <img width="3384" height="692" alt="image" src="https://github.com/user-attachments/assets/4e5ff869-447c-45d8-9b66-49052f340aed" />
6
+
7
+
8
+ ## Install
9
+
10
+ ```sh
11
+ pipx install midi-cli
12
+ ```
13
+
14
+ Or with pip:
15
+
16
+ ```sh
17
+ pip install midi-cli
18
+ ```
19
+
20
+ ### For development
21
+
22
+ ```sh
23
+ uv pip install -e .
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```sh
29
+ midi-cli
30
+ ```
@@ -11,24 +11,26 @@ 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
14
+ from midi_cli.ui import draw_ui, reset_trim_state, reset_copy_state, reset_split_state
15
15
  from midi_cli.loop import reset_loop_state, cycle_loop_state
16
16
 
17
17
 
18
18
  def main():
19
- from midi_cli.config import run_setup_wizard
20
- cfg = run_setup_wizard()
19
+ from midi_cli.config import load_defaults, get_input_devices, get_output_devices, get_midi_ports, save_config
20
+ cfg = load_defaults()
21
21
  st.input_device = cfg.get("input_device")
22
22
  st.output_device = cfg.get("output_device")
23
23
  st.midi_port = cfg.get("midi_port")
24
- sys.stdout.write("\033[2J\033[H")
25
- sys.stdout.flush()
26
-
27
- midi_in, port_name = open_midi_input(st.midi_port)
28
- if midi_in is None:
24
+ st.synesthesia_enabled = cfg.get("synesthesia_enabled", True)
25
+ if st.midi_port is None:
26
+ midi_in = None
29
27
  st.keyboard_mock_mode = True
30
28
  else:
31
- st.ui_midi_port = port_name
29
+ midi_in, port_name = open_midi_input(st.midi_port)
30
+ if midi_in is None:
31
+ st.keyboard_mock_mode = True
32
+ else:
33
+ st.ui_midi_port = port_name
32
34
 
33
35
  load_samples()
34
36
  load_pitch_offsets()
@@ -45,20 +47,24 @@ def main():
45
47
  fd = sys.stdin.fileno()
46
48
  old_settings = termios.tcgetattr(fd)
47
49
  sys.stdout.write(st.HIDE_CURSOR)
50
+ sys.stdout.write("\033[?1049h") # enter alternate screen
48
51
  sys.stdout.flush()
49
52
 
50
53
  try:
51
54
  tty.setraw(fd)
52
- initialized = False
53
55
  while True:
54
- draw_ui(initialized)
55
- initialized = True
56
+ draw_ui()
56
57
 
57
58
  ready, _, _ = select.select([fd], [], [], 0.05)
58
59
  if ready:
59
60
  ch = os.read(fd, 1).decode("utf-8", errors="replace")
60
61
  if ch in ("q", "\x03"): # q or Ctrl+C
61
62
  break
63
+ 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
62
68
  elif ch == "\r" and st.mode == "trim" and st.trim_selected_note is not None:
63
69
  if st.trim_step == 0:
64
70
  # Confirm start, advance to end adjustment
@@ -84,6 +90,8 @@ def main():
84
90
  reset_trim_state()
85
91
  if st.mode == "copy":
86
92
  reset_copy_state()
93
+ if st.mode == "split":
94
+ reset_split_state()
87
95
  if st.mode == "loop":
88
96
  with st.voices_lock:
89
97
  reset_loop_state()
@@ -98,12 +106,30 @@ def main():
98
106
  if not esc_ready:
99
107
  # Bare Escape
100
108
  if st.mode == "trim" and st.trim_selected_note is not None:
101
- reset_trim_state()
109
+ if st.trim_step == 1:
110
+ st.trim_step = 0
111
+ st.trim_loop_pos = st.trim_start
112
+ else:
113
+ reset_trim_state()
102
114
  elif st.mode == "copy" and (st.copy_source_note is not None or st.copy_confirm_pending):
103
115
  reset_copy_state()
104
116
  elif st.mode == "loop":
105
117
  with st.voices_lock:
106
118
  reset_loop_state()
119
+ elif st.mode == "split" and st.split_selected_note is not None:
120
+ if st.split_phase == "assign":
121
+ if st.split_current_slice > 0:
122
+ st.split_current_slice -= 1
123
+ st.split_loop_pos = 0
124
+ else:
125
+ st.split_phase = "markers"
126
+ st.split_looping = False
127
+ else:
128
+ # markers phase
129
+ if st.split_markers:
130
+ st.split_markers.pop()
131
+ else:
132
+ st.split_selected_note = None
107
133
  else:
108
134
  seq = os.read(fd, 2).decode("utf-8", errors="replace")
109
135
  if seq == "[Z": # Shift+Tab: previous mode
@@ -115,6 +141,8 @@ def main():
115
141
  reset_trim_state()
116
142
  if st.mode == "copy":
117
143
  reset_copy_state()
144
+ if st.mode == "split":
145
+ reset_split_state()
118
146
  if st.mode == "loop":
119
147
  with st.voices_lock:
120
148
  reset_loop_state()
@@ -154,6 +182,84 @@ def main():
154
182
  adjust_pitch(st.pitch_selected_note, 0, st.CENTS_STEP)
155
183
  elif seq == "[D": # Left: -10 cents
156
184
  adjust_pitch(st.pitch_selected_note, 0, -st.CENTS_STEP)
185
+ elif st.mode == "split" and st.split_selected_note is not None and st.split_phase == "markers":
186
+ sample_len = len(st.samples[st.split_selected_note])
187
+ fine = max(1, sample_len // 1000)
188
+ coarse = max(1, sample_len // 100)
189
+ if seq == "[C": # Right: move cursor forward fine
190
+ st.split_cursor_pos = min(sample_len - 1, st.split_cursor_pos + fine)
191
+ elif seq == "[D": # Left: move cursor back fine
192
+ st.split_cursor_pos = max(0, st.split_cursor_pos - fine)
193
+ elif seq == "[A": # Up: move cursor forward coarse
194
+ st.split_cursor_pos = min(sample_len - 1, st.split_cursor_pos + coarse)
195
+ elif seq == "[B": # Down: move cursor back coarse
196
+ 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
+ })
258
+ 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
+ st.split_phase = "assign"
260
+ st.split_current_slice = 0
261
+ st.split_loop_pos = 0
262
+ st.split_looping = True
157
263
  elif ch == 'l' and st.mode == "loop":
158
264
  with st.voices_lock:
159
265
  cycle_loop_state()
@@ -166,6 +272,7 @@ def main():
166
272
  pass
167
273
  finally:
168
274
  termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
275
+ sys.stdout.write("\033[?1049l") # exit alternate screen
169
276
  sys.stdout.write(st.SHOW_CURSOR)
170
277
  sys.stdout.flush()
171
278
  stream.stop()
@@ -111,6 +111,53 @@ def audio_callback(outdata, frames, time_info, status):
111
111
  outdata[:, 0] = mix
112
112
  return
113
113
 
114
+ if st.mode == "split" and st.split_looping and st.split_selected_note is not None:
115
+ sample_data = st.samples.get(st.split_selected_note)
116
+ if sample_data is None:
117
+ outdata[:] = 0
118
+ return
119
+
120
+ if st.split_phase == "markers":
121
+ # Loop from most recent marker to end of sample
122
+ region_start = st.split_markers[-1] if st.split_markers else 0
123
+ region_end = len(sample_data)
124
+ else:
125
+ from midi_cli.sampler import get_split_slices
126
+ slices = get_split_slices()
127
+ if not slices or st.split_current_slice >= len(slices):
128
+ outdata[:] = 0
129
+ return
130
+ region_start, region_end = slices[st.split_current_slice]
131
+
132
+ region_len = region_end - region_start
133
+ if region_len <= 0:
134
+ outdata[:] = 0
135
+ return
136
+ cycle_len = max(region_len, st.MIN_LOOP_PERIOD)
137
+ if st.split_loop_pos < 0 or st.split_loop_pos >= cycle_len:
138
+ st.split_loop_pos = 0
139
+ mix = np.zeros(frames)
140
+ written = 0
141
+ while written < frames:
142
+ if st.split_loop_pos < region_len:
143
+ src_pos = region_start + st.split_loop_pos
144
+ avail = region_len - st.split_loop_pos
145
+ n = min(frames - written, avail)
146
+ mix[written:written + n] = sample_data[src_pos:src_pos + n]
147
+ st.split_loop_pos += n
148
+ written += n
149
+ else:
150
+ avail = cycle_len - st.split_loop_pos
151
+ n = min(frames - written, avail)
152
+ st.split_loop_pos += n
153
+ written += n
154
+ if st.split_loop_pos >= cycle_len:
155
+ st.split_loop_pos = 0
156
+ mix *= 0.25
157
+ np.clip(mix, -1.0, 1.0, out=mix)
158
+ outdata[:, 0] = mix
159
+ return
160
+
114
161
  if st.mode == "loop":
115
162
  mix = np.zeros(frames)
116
163
 
@@ -0,0 +1,103 @@
1
+ import json
2
+ import os
3
+
4
+ import rtmidi
5
+ import sounddevice as sd
6
+
7
+
8
+ CONFIG_PATH = "config.json"
9
+
10
+
11
+ def load_config():
12
+ """Read config.json; return empty dict if missing."""
13
+ if not os.path.isfile(CONFIG_PATH):
14
+ return {}
15
+ with open(CONFIG_PATH, "r") as f:
16
+ return json.load(f)
17
+
18
+
19
+ def save_config(cfg):
20
+ """Write config.json."""
21
+ with open(CONFIG_PATH, "w") as f:
22
+ json.dump(cfg, f, indent=2)
23
+
24
+
25
+ def _get_input_devices():
26
+ """Return input device names with the system default first."""
27
+ devs = sd.query_devices()
28
+ names = [d["name"] for d in devs if d["max_input_channels"] > 0]
29
+ try:
30
+ idx = sd.default.device[0]
31
+ if idx >= 0:
32
+ default_name = devs[idx]["name"]
33
+ if default_name in names:
34
+ names.remove(default_name)
35
+ names.insert(0, default_name)
36
+ except Exception:
37
+ pass
38
+ return names
39
+
40
+
41
+ def _get_output_devices():
42
+ """Return output device names with the system default first."""
43
+ devs = sd.query_devices()
44
+ names = [d["name"] for d in devs if d["max_output_channels"] > 0]
45
+ try:
46
+ idx = sd.default.device[1]
47
+ if idx >= 0:
48
+ default_name = devs[idx]["name"]
49
+ if default_name in names:
50
+ names.remove(default_name)
51
+ names.insert(0, default_name)
52
+ except Exception:
53
+ pass
54
+ return names
55
+
56
+
57
+ def get_input_devices():
58
+ """Return input device names with the system default first."""
59
+ return _get_input_devices()
60
+
61
+
62
+ def get_output_devices():
63
+ """Return output device names with the system default first."""
64
+ return _get_output_devices()
65
+
66
+
67
+ def get_midi_ports():
68
+ """Return available MIDI input port names."""
69
+ _tmp = rtmidi.MidiIn()
70
+ ports = _tmp.get_ports()
71
+ _tmp.delete()
72
+ return ports
73
+
74
+
75
+ def load_defaults():
76
+ """
77
+ Load config.json and fill missing keys with system defaults.
78
+ Silently drops saved devices that are no longer available.
79
+ Returns config dict with input_device, output_device, midi_port, synesthesia_enabled.
80
+ """
81
+ cfg = load_config()
82
+
83
+ input_names = _get_input_devices()
84
+ saved_in = cfg.get("input_device")
85
+ if saved_in not in input_names:
86
+ saved_in = input_names[0] if input_names else None
87
+ cfg["input_device"] = saved_in
88
+
89
+ output_names = _get_output_devices()
90
+ saved_out = cfg.get("output_device")
91
+ if saved_out not in output_names:
92
+ saved_out = output_names[0] if output_names else None
93
+ cfg["output_device"] = saved_out
94
+
95
+ midi_ports = get_midi_ports()
96
+ if "midi_port" not in cfg:
97
+ cfg["midi_port"] = midi_ports[0] if midi_ports else None
98
+ elif cfg["midi_port"] is not None and cfg["midi_port"] not in midi_ports:
99
+ cfg["midi_port"] = None # saved port gone; fall back to keyboard mode
100
+
101
+ cfg.setdefault("synesthesia_enabled", False)
102
+
103
+ return cfg
@@ -3,7 +3,7 @@ import threading
3
3
  import rtmidi
4
4
 
5
5
  import midi_cli.state as st
6
- from midi_cli.sampler import start_recording, stop_recording, compute_chromatic_samples_async, execute_copy
6
+ from midi_cli.sampler import start_recording, stop_recording, compute_chromatic_samples_async, execute_copy, save_sample, get_split_slices
7
7
  from midi_cli.loop import cycle_loop_state
8
8
 
9
9
 
@@ -15,6 +15,9 @@ def midi_callback(event, data):
15
15
  status, note, velocity = message[0], message[1], message[2]
16
16
  msg_type = status & 0xF0
17
17
 
18
+ if msg_type in (0x90, 0x80):
19
+ st.seen_notes.add(note)
20
+
18
21
  if msg_type == 0x90 and velocity > 0:
19
22
  if st.mode == "record":
20
23
  threading.Thread(target=start_recording, args=(note,), daemon=True).start()
@@ -30,6 +33,28 @@ def midi_callback(event, data):
30
33
  st.trim_looping = True
31
34
  return
32
35
 
36
+ if st.mode == "split":
37
+ if st.split_phase == "markers":
38
+ if st.split_selected_note is None and note in st.samples:
39
+ st.split_selected_note = note
40
+ st.split_cursor_pos = 0
41
+ st.split_markers = []
42
+ st.split_loop_pos = 0
43
+ st.split_looping = True
44
+ else:
45
+ # Assign phase: save current slice to this key
46
+ slices = get_split_slices()
47
+ if slices and st.split_current_slice < len(slices):
48
+ sl_start, sl_end = slices[st.split_current_slice]
49
+ audio_slice = st.samples[st.split_selected_note][sl_start:sl_end].copy()
50
+ save_sample(note, audio_slice)
51
+ st.split_current_slice += 1
52
+ st.split_loop_pos = 0
53
+ if st.split_current_slice >= len(slices):
54
+ from midi_cli.ui import reset_split_state
55
+ reset_split_state()
56
+ return
57
+
33
58
  if st.mode == "copy":
34
59
  if st.copy_confirm_pending:
35
60
  return
@@ -76,7 +101,7 @@ def midi_callback(event, data):
76
101
  if st.mode == "record":
77
102
  threading.Thread(target=stop_recording, args=(note,), daemon=True).start()
78
103
  return
79
- if st.mode in ("trim", "copy"):
104
+ if st.mode in ("trim", "copy", "split"):
80
105
  return
81
106
  # Only clear if this note is still the active one
82
107
  note_name = st.midi_note_name(note)
@@ -122,7 +147,7 @@ def trigger_keyboard_note(note):
122
147
  if old:
123
148
  old.cancel()
124
149
  midi_callback(([0x90, note, 100], 0), None)
125
- if st.mode in ("sample", "pitch", "chromatic", "trim", "copy", "loop"):
150
+ if st.mode in ("sample", "pitch", "chromatic", "trim", "copy", "loop", "split"):
126
151
  # Sample modes: one-shot, let the sample play to its natural end
127
152
  return
128
153
  if st.sustain:
@@ -118,7 +118,9 @@ def save_sample(note_number, audio):
118
118
  wf.writeframes(int_data.tobytes())
119
119
 
120
120
  if note_number in st.pitch_offsets:
121
- recompute_pitched_sample_async(note_number)
121
+ st.pitch_offsets.pop(note_number)
122
+ st.pitched_samples.pop(note_number, None)
123
+ save_pitch_offsets()
122
124
 
123
125
 
124
126
  def execute_copy():
@@ -192,6 +194,16 @@ def stop_recording(note_number):
192
194
  save_sample(note_number, audio)
193
195
 
194
196
 
197
+ def get_split_slices():
198
+ """Return list of (start, end) sample index pairs based on split markers."""
199
+ note = st.split_selected_note
200
+ if note not in st.samples:
201
+ return []
202
+ n = len(st.samples[note])
203
+ boundaries = [0] + sorted(st.split_markers) + [n]
204
+ return [(boundaries[i], boundaries[i + 1]) for i in range(len(boundaries) - 1)]
205
+
206
+
195
207
  def load_samples():
196
208
  """Load any existing .wav files from ./samples/ directory."""
197
209
  if not os.path.isdir("samples"):
@@ -23,6 +23,21 @@ YELLOW = "\033[33m"
23
23
  CYAN = "\033[36m"
24
24
  ORANGE = "\033[38;5;208m"
25
25
  CLEAR_LINE = "\033[2K"
26
+
27
+ NOTE_SYNESTHESIA_COLORS = {
28
+ "C": "\033[38;5;196m", # red
29
+ "C#": "\033[38;5;202m", # red-orange
30
+ "D": "\033[38;5;208m", # orange
31
+ "D#": "\033[38;5;226m", # yellow
32
+ "E": "\033[38;5;154m", # yellow-green
33
+ "F": "\033[38;5;82m", # green
34
+ "F#": "\033[38;5;48m", # cyan-green
35
+ "G": "\033[38;5;51m", # cyan
36
+ "G#": "\033[38;5;33m", # sky blue
37
+ "A": "\033[38;5;63m", # blue-violet
38
+ "A#": "\033[38;5;129m", # purple
39
+ "B": "\033[38;5;201m", # magenta
40
+ }
26
41
  HIDE_CURSOR = "\033[?25l"
27
42
  SHOW_CURSOR = "\033[?25h"
28
43
  NUM_UI_LINES = 4
@@ -60,7 +75,7 @@ sustain = False
60
75
  sustained_notes = set()
61
76
 
62
77
  # Sampler state
63
- MODES = ["synth", "sample", "record", "pitch", "chromatic", "trim", "copy", "loop"]
78
+ MODES = ["synth", "sample", "record", "pitch", "chromatic", "trim", "copy", "loop", "split", "config"]
64
79
  mode = "synth"
65
80
  samples = {} # note_number -> numpy array (mono float64)
66
81
  sample_voices = {} # note -> {"pos": int, "releasing": bool, "gain": float}
@@ -94,6 +109,15 @@ TRIM_WAVEFORM_HEIGHT = 6 # rows for waveform display
94
109
  MIN_TRIM_SAMPLES = 480 # ~10ms minimum trim length
95
110
  MIN_LOOP_PERIOD = int(2.0 * SAMPLE_RATE) # minimum loop cycle including silence
96
111
 
112
+ # Split mode state
113
+ split_selected_note = None # source MIDI note
114
+ split_markers = [] # sorted sample indices (split points)
115
+ split_phase = "markers" # "markers" or "assign"
116
+ split_cursor_pos = 0 # cursor position while placing markers
117
+ split_current_slice = 0 # which slice is being assigned
118
+ split_loop_pos = 0 # audio preview playback position
119
+ split_looping = False # True while preview loop is active
120
+
97
121
  # Loop mode state
98
122
  loop_state = "idle" # "idle", "recording", "playing", "overdub"
99
123
  loop_buffer = None # np.ndarray once recorded, None when idle
@@ -106,14 +130,19 @@ copy_source_note = None # MIDI note selected as source
106
130
  copy_dest_note = None # MIDI note selected as destination
107
131
  copy_confirm_pending = False # True when awaiting overwrite confirmation
108
132
 
133
+ # Config mode state
134
+ config_focused_field = 0 # 0=input, 1=output, 2=midi, 3=synesthesia
135
+
136
+ # UI settings
137
+ synesthesia_enabled = False
138
+
109
139
  # UI state
110
140
  ui_active_note = None
111
141
  ui_sustain = False
112
142
  ui_recording = None # None or {"note": str, "start_time": float}
113
143
  ui_midi_port = ""
114
- prev_ui_lines = NUM_UI_LINES
115
-
116
144
  keyboard_mock_mode = False
145
+ seen_notes = set() # MIDI note numbers observed this session (for range detection)
117
146
  keyboard_timers = {} # note -> threading.Timer, for pending note-offs
118
147
 
119
148
  # Device selections (set by startup wizard, None = system default / port 0)