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.
- {midi_cli-0.1.2 → midi_cli-0.1.3}/.claude/settings.local.json +17 -1
- {midi_cli-0.1.2 → midi_cli-0.1.3}/.gitignore +1 -0
- {midi_cli-0.1.2 → midi_cli-0.1.3}/PKG-INFO +5 -2
- midi_cli-0.1.3/README.md +30 -0
- {midi_cli-0.1.2 → midi_cli-0.1.3}/midi_cli/app.py +120 -13
- {midi_cli-0.1.2 → midi_cli-0.1.3}/midi_cli/audio.py +47 -0
- midi_cli-0.1.3/midi_cli/config.py +103 -0
- {midi_cli-0.1.2 → midi_cli-0.1.3}/midi_cli/midi.py +28 -3
- {midi_cli-0.1.2 → midi_cli-0.1.3}/midi_cli/sampler.py +13 -1
- {midi_cli-0.1.2 → midi_cli-0.1.3}/midi_cli/state.py +32 -3
- {midi_cli-0.1.2 → midi_cli-0.1.3}/midi_cli/ui.py +280 -22
- {midi_cli-0.1.2 → midi_cli-0.1.3}/pyproject.toml +1 -1
- {midi_cli-0.1.2 → midi_cli-0.1.3}/uv.lock +163 -163
- midi_cli-0.1.2/README.md +0 -27
- midi_cli-0.1.2/midi_cli/config.py +0 -133
- {midi_cli-0.1.2 → midi_cli-0.1.3}/LICENSE +0 -0
- {midi_cli-0.1.2 → midi_cli-0.1.3}/main.py +0 -0
- {midi_cli-0.1.2 → midi_cli-0.1.3}/midi_cli/__init__.py +0 -0
- {midi_cli-0.1.2 → midi_cli-0.1.3}/midi_cli/loop.py +0 -0
|
@@ -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
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: midi-cli
|
|
3
|
-
Version: 0.1.
|
|
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
|
|
midi_cli-0.1.3/README.md
ADDED
|
@@ -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
|
|
20
|
-
cfg =
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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)
|