midi-cli 0.1.0__py3-none-any.whl

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/ui.py ADDED
@@ -0,0 +1,260 @@
1
+ import os
2
+ import sys
3
+ import time
4
+
5
+ import numpy as np
6
+
7
+ import midi_cli.state as st
8
+ from midi_cli.sampler import format_pitch_info
9
+
10
+
11
+ def render_waveform(note):
12
+ """Render a centered ASCII waveform of the sample with trim region highlighted."""
13
+ audio = st.samples.get(note)
14
+ if audio is None:
15
+ return []
16
+ cols = os.get_terminal_size().columns - 2 # leave margin
17
+ cols = max(10, cols)
18
+ n = len(audio)
19
+ rows = st.TRIM_WAVEFORM_HEIGHT
20
+ half = rows / 2 # rows above/below center (float for smooth mapping)
21
+ blocks = " \u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588"
22
+ blocks_rev = " \u2594\u2594\u2580\u2580\u2580\u2580\u2588\u2588"
23
+
24
+ # Compute peak amplitude per column bin
25
+ peaks = np.zeros(cols)
26
+ for c in range(cols):
27
+ start = c * n // cols
28
+ end = (c + 1) * n // cols
29
+ if start < end:
30
+ peaks[c] = np.max(np.abs(audio[start:end]))
31
+
32
+ max_peak = np.max(peaks) if np.max(peaks) > 0 else 1.0
33
+
34
+ # Compute which columns are inside the trim region
35
+ trim_start_col = st.trim_start * cols // n if n > 0 else 0
36
+ trim_end_col = (st.trim_end * cols + n - 1) // n if n > 0 else cols # ceiling
37
+
38
+ # Active marker column
39
+ if st.trim_step == 0:
40
+ marker_col = trim_start_col
41
+ else:
42
+ marker_col = min(trim_end_col - 1, cols - 1)
43
+
44
+ # Playback position column (only during audio, not silence padding)
45
+ region_len = st.trim_end - st.trim_start
46
+ if region_len > 0 and st.trim_looping and st.trim_loop_pos < region_len:
47
+ play_offset = st.trim_loop_pos
48
+ play_col = trim_start_col + play_offset * (trim_end_col - trim_start_col) // region_len
49
+ play_col = max(trim_start_col, min(play_col, trim_end_col - 1))
50
+ else:
51
+ play_col = -1
52
+
53
+ WHITE = "\033[97m"
54
+
55
+ lines = []
56
+ for row in range(rows):
57
+ # Center is between row half-1 and half
58
+ # Upper half: rows 0..half-1 (top row = max amplitude)
59
+ # Lower half: rows half..rows-1 (bottom row = max amplitude)
60
+ dist_from_center = abs(row - half + 0.5) / half # 0 at center, 1 at edges
61
+ threshold_low = dist_from_center
62
+ threshold_high = dist_from_center + 1.0 / half
63
+
64
+ is_upper = row < half
65
+ line = " "
66
+ for c in range(cols):
67
+ level = peaks[c] / max_peak
68
+ if level <= threshold_low:
69
+ char = " "
70
+ else:
71
+ frac = min(1.0, (level - threshold_low) / (threshold_high - threshold_low))
72
+ idx = int(frac * (len(blocks) - 1))
73
+ if is_upper:
74
+ char = blocks[idx]
75
+ else:
76
+ # Bottom half: use reversed blocks so fill grows from top of cell
77
+ char = blocks_rev[idx]
78
+
79
+ in_region = trim_start_col <= c < trim_end_col
80
+ is_boundary = c == trim_start_col or c == trim_end_col - 1
81
+ if c == play_col:
82
+ head = char if char.strip() else "\u2502"
83
+ line += f"{st.RED}{st.BOLD}{head}{st.RST}"
84
+ elif is_boundary:
85
+ bar = char if char.strip() else "\u2502"
86
+ if c == marker_col:
87
+ line += f"{st.ORANGE}{st.BOLD}{bar}{st.RST}"
88
+ else:
89
+ line += f"{st.CYAN}{bar}{st.RST}"
90
+ elif in_region:
91
+ line += f"{st.CYAN}{char}{st.RST}"
92
+ else:
93
+ line += f"{st.DIM}{char}{st.RST}"
94
+ lines.append(line)
95
+ return lines
96
+
97
+
98
+ def reset_trim_state():
99
+ """Reset all trim mode state back to selection."""
100
+ st.trim_selected_note = None
101
+ st.trim_step = 0
102
+ st.trim_start = 0
103
+ st.trim_end = 0
104
+ st.trim_loop_pos = 0
105
+ st.trim_looping = False
106
+
107
+
108
+ def reset_copy_state():
109
+ """Reset all copy mode state back to selection."""
110
+ st.copy_source_note = None
111
+ st.copy_dest_note = None
112
+ st.copy_confirm_pending = False
113
+
114
+
115
+ def draw_ui(initialized):
116
+ """Render the TUI in place using ANSI escape codes."""
117
+ with st.voices_lock:
118
+ ui_rec = st.ui_recording
119
+ out = ""
120
+ total_lines = st.NUM_UI_LINES # base 4 lines
121
+
122
+ # Move cursor back up to overwrite previous frame
123
+ if initialized:
124
+ out += f"\033[{st.prev_ui_lines}A"
125
+
126
+ # Line 0: app name + mode selector (compact, left-aligned)
127
+ line0 = f" {st.BOLD}midi-cli{st.RST} "
128
+ for i, m in enumerate(st.MODES):
129
+ if i > 0:
130
+ line0 += f"{st.DIM} \u2502 {st.RST}"
131
+ if m == st.mode:
132
+ line0 += f"{st.BOLD}{m}{st.RST}"
133
+ else:
134
+ line0 += f"{st.DIM}{m}{st.RST}"
135
+ out += f"{st.CLEAR_LINE}\r{line0}\r\n"
136
+
137
+ # Line 1: note/rec indicator + sustain + sample count (or pitch/trim info)
138
+ line1 = ""
139
+ if st.mode == "trim":
140
+ if st.trim_selected_note is not None:
141
+ name = st.midi_note_name(st.trim_selected_note)
142
+ start_ms = st.trim_start / st.SAMPLE_RATE * 1000
143
+ end_ms = st.trim_end / st.SAMPLE_RATE * 1000
144
+ total_ms = len(st.samples[st.trim_selected_note]) / st.SAMPLE_RATE * 1000
145
+ if st.trim_step == 0:
146
+ line1 += f" {st.GREEN}{st.BOLD}\u2666{st.RST} {st.BOLD}{name} start={start_ms:.0f}ms{st.RST} end={end_ms:.0f}ms {st.DIM}{total_ms:.0f}ms total{st.RST}"
147
+ else:
148
+ line1 += f" {st.GREEN}{st.BOLD}\u2666{st.RST} {st.BOLD}{name}{st.RST} start={start_ms:.0f}ms {st.BOLD}end={end_ms:.0f}ms{st.RST} {st.DIM}{total_ms:.0f}ms total{st.RST}"
149
+ else:
150
+ line1 += f" {st.DIM}play a key to select{st.RST}"
151
+ elif st.mode == "pitch":
152
+ if st.pitch_selected_note is not None:
153
+ offset = st.pitch_offsets.get(st.pitch_selected_note, {"semitones": 0, "cents": 0})
154
+ info = format_pitch_info(st.pitch_selected_note, offset)
155
+ line1 += f" {st.GREEN}{st.BOLD}\u2666{st.RST} {st.BOLD}{info}{st.RST}"
156
+ else:
157
+ line1 += f" {st.DIM}play a key to select{st.RST}"
158
+ elif st.mode == "chromatic":
159
+ if st.chromatic_selecting:
160
+ line1 += f" {st.DIM}play a key to select{st.RST}"
161
+ elif st.chromatic_computing and st.chromatic_root_note is not None:
162
+ line1 += f" {st.YELLOW}{st.BOLD}computing {st.midi_note_name(st.chromatic_root_note)}...{st.RST}"
163
+ elif st.chromatic_root_note is not None:
164
+ root_name = st.midi_note_name(st.chromatic_root_note)
165
+ range_start = st.midi_note_name(max(0, st.chromatic_root_note - 12))
166
+ range_end = st.midi_note_name(min(127, st.chromatic_root_note + 12))
167
+ line1 += f" {st.GREEN}{st.BOLD}\u2666{st.RST} {st.BOLD}{root_name} {range_start}\u2013{range_end}{st.RST}"
168
+ else:
169
+ line1 += f" {st.DIM}play a key to select{st.RST}"
170
+ elif st.mode == "copy":
171
+ if st.copy_confirm_pending and st.copy_source_note is not None and st.copy_dest_note is not None:
172
+ src_name = st.midi_note_name(st.copy_source_note)
173
+ dst_name = st.midi_note_name(st.copy_dest_note)
174
+ line1 += f" {st.YELLOW}{st.BOLD}overwrite {dst_name}? {src_name} \u2192 {dst_name}{st.RST}"
175
+ elif st.copy_source_note is not None:
176
+ src_name = st.midi_note_name(st.copy_source_note)
177
+ line1 += f" {st.GREEN}{st.BOLD}\u2666{st.RST} {st.BOLD}{src_name}{st.RST} {st.DIM}play a key to paste{st.RST}"
178
+ else:
179
+ line1 += f" {st.DIM}play a key to copy{st.RST}"
180
+ elif st.mode == "loop":
181
+ state = st.loop_state
182
+ if state == "recording":
183
+ line1 += f" {st.RED}{st.BOLD}[REC]{st.RST}"
184
+ elif state == "playing":
185
+ buf_secs = len(st.loop_buffer) / st.SAMPLE_RATE if st.loop_buffer is not None else 0
186
+ line1 += f" {st.GREEN}{st.BOLD}[PLAY]{st.RST} {st.DIM}{buf_secs:.2f}s{st.RST}"
187
+ elif state == "overdub":
188
+ buf_secs = len(st.loop_buffer) / st.SAMPLE_RATE if st.loop_buffer is not None else 0
189
+ line1 += f" {st.YELLOW}{st.BOLD}[DUB]{st.RST} {st.DIM}{buf_secs:.2f}s{st.RST}"
190
+ else:
191
+ line1 += f" {st.DIM}idle{st.RST}"
192
+ elif ui_rec:
193
+ elapsed = time.time() - ui_rec["start_time"]
194
+ line1 += f" {st.RED}{st.BOLD}\u25cf REC {ui_rec['note']} {elapsed:.1f}s{st.RST}"
195
+ elif st.ui_active_note:
196
+ line1 += f" {st.GREEN}{st.BOLD}\u25cf{st.RST} {st.BOLD}{st.ui_active_note:<4}{st.RST}"
197
+ else:
198
+ line1 += f" {st.DIM} {st.RST} " # blank note area
199
+
200
+ line1 += f" {st.DIM}sustain {st.RST}"
201
+ if st.ui_sustain:
202
+ line1 += f"{st.YELLOW}{st.BOLD}\u25cf{st.RST}"
203
+ else:
204
+ line1 += f"{st.DIM}\u25cb{st.RST}"
205
+ line1 += f" {st.DIM}{st.CYAN}{len(st.samples)} samples{st.RST}"
206
+ out += f"{st.CLEAR_LINE}\r{line1}\r\n"
207
+
208
+ # Line 2: MIDI controller name or keyboard mode hint
209
+ if st.keyboard_mock_mode:
210
+ line2 = f" {st.DIM}keyboard mode [1-0] play [` ] sustain{st.RST}"
211
+ else:
212
+ line2 = f" {st.DIM}{st.ui_midi_port}{st.RST}" if st.ui_midi_port else ""
213
+ out += f"{st.CLEAR_LINE}\r{line2}\r\n"
214
+
215
+ # Line 3: help keys
216
+ if st.mode == "trim":
217
+ if st.trim_selected_note is None:
218
+ line3 = f" {st.DIM}[Tab] mode [play] select [q] quit{st.RST}"
219
+ else:
220
+ line3 = f" {st.DIM}[\u2190\u2192] fine [\u2191\u2193] coarse [enter] set [esc] cancel [q] quit{st.RST}"
221
+ elif st.mode == "pitch":
222
+ line3 = f" {st.DIM}[Tab] mode [\u2191\u2193] semitone [\u2190\u2192] cents [q] quit{st.RST}"
223
+ elif st.mode == "chromatic":
224
+ if st.chromatic_selecting:
225
+ line3 = f" {st.DIM}[Tab] mode [play] select [q] quit{st.RST}"
226
+ else:
227
+ line3 = f" {st.DIM}[Tab] mode [r] reselect [q] quit{st.RST}"
228
+ elif st.mode == "copy":
229
+ if st.copy_confirm_pending:
230
+ line3 = f" {st.DIM}[enter] confirm [esc] cancel [q] quit{st.RST}"
231
+ elif st.copy_source_note is not None:
232
+ line3 = f" {st.DIM}[Tab] mode [play] paste [esc] cancel [q] quit{st.RST}"
233
+ else:
234
+ line3 = f" {st.DIM}[Tab] mode [play] select [q] quit{st.RST}"
235
+ elif st.mode == "loop":
236
+ line3 = f" {st.DIM}[sustain/l] record/play/dub [esc] clear [play] sample [q] quit{st.RST}"
237
+ else:
238
+ line3 = f" {st.DIM}[Tab] mode [q] quit{st.RST}"
239
+ out += f"{st.CLEAR_LINE}\r{line3}\r\n"
240
+
241
+ # Waveform (trim mode only, when a note is selected)
242
+ if st.mode == "trim" and st.trim_selected_note is not None:
243
+ out += f"{st.CLEAR_LINE}\r\n" # blank separator
244
+ total_lines += 1
245
+ waveform_lines = render_waveform(st.trim_selected_note)
246
+ for wl in waveform_lines:
247
+ out += f"{st.CLEAR_LINE}\r{wl}\r\n"
248
+ total_lines += 1
249
+
250
+ # Clear any stale lines from previous frame
251
+ if st.prev_ui_lines > total_lines:
252
+ for _ in range(st.prev_ui_lines - total_lines):
253
+ out += f"{st.CLEAR_LINE}\r\n"
254
+ # Move cursor back up to end of current content
255
+ out += f"\033[{st.prev_ui_lines - total_lines}A"
256
+
257
+ st.prev_ui_lines = total_lines
258
+
259
+ sys.stdout.write(out)
260
+ sys.stdout.flush()
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: midi-cli
3
+ Version: 0.1.0
4
+ Summary: A MIDI sampler and looper in your terminal
5
+ Project-URL: Homepage, https://github.com/mnbpdx/midi-cli
6
+ Project-URL: Repository, https://github.com/mnbpdx/midi-cli
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Classifier: Environment :: Console
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: MacOS
12
+ Classifier: Topic :: Multimedia :: Sound/Audio
13
+ Classifier: Topic :: Multimedia :: Sound/Audio :: MIDI
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: numpy
16
+ Requires-Dist: pyrubberband
17
+ Requires-Dist: python-rtmidi
18
+ Requires-Dist: sounddevice
19
+ Description-Content-Type: text/markdown
20
+
21
+ # midi-cli
22
+
23
+ A little midi player in your terminal, tested in zsh on macOS.
24
+
25
+ ## Install
26
+
27
+ ```sh
28
+ pipx install midi-cli
29
+ ```
30
+
31
+ Or with pip:
32
+
33
+ ```sh
34
+ pip install midi-cli
35
+ ```
36
+
37
+ ### For development
38
+
39
+ ```sh
40
+ uv pip install -e .
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ```sh
46
+ midi-cli
47
+ ```
@@ -0,0 +1,14 @@
1
+ midi_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ midi_cli/app.py,sha256=seCrzlfW6hxKeAE5E7m9Lsg4m2JwFrg82DXh42CQIZI,8956
3
+ midi_cli/audio.py,sha256=yKQU_PCq0gGDFY9kNuuK0f3d_Ff3B8igGz0xiOfoqpE,10838
4
+ midi_cli/config.py,sha256=buJv6lwtkVAVREDYCMUv9fv0ojUE8AoYX-g-JH_gj2k,4206
5
+ midi_cli/loop.py,sha256=lAMGn5EapzPENAUsx9Y69rAG8e3Gsp6ik9DEh79dN0A,896
6
+ midi_cli/midi.py,sha256=8EUjr1xkg2jk2xDEwhy3ZzbGGoWSZ_7UDHvemOfNZJI,5655
7
+ midi_cli/sampler.py,sha256=dD6XAqGoPx7OcqrdnbmoYhGxpA5sBJ4MZbQC3FNCAdw,7560
8
+ midi_cli/state.py,sha256=ytvSiKT6yDURzepEG_yg4cwL_aqa1Ko7EvPgEoe0Jfg,4476
9
+ midi_cli/ui.py,sha256=ZwaTDpZEWOily5kMX7ZDGOoGLeVQi3ci158sNiyXVxM,11008
10
+ midi_cli-0.1.0.dist-info/METADATA,sha256=A-6lNM7bP1aCKWQmnmD0g0v9rWw9zqbK9MR50SizYmI,901
11
+ midi_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
12
+ midi_cli-0.1.0.dist-info/entry_points.txt,sha256=x16I5cXtaVPDBTt2uuxYxYdRzMcQj-j67DekuhJTywQ,47
13
+ midi_cli-0.1.0.dist-info/licenses/LICENSE,sha256=lNsGRfBlQKeTX_f0ztlh9K1dDzgdMXTIOumoIhlbB_4,1063
14
+ midi_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ midi-cli = midi_cli.app:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mnbpdx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.