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/midi.py ADDED
@@ -0,0 +1,154 @@
1
+ import threading
2
+
3
+ import rtmidi
4
+
5
+ import midi_cli.state as st
6
+ from midi_cli.sampler import start_recording, stop_recording, compute_chromatic_samples_async, execute_copy
7
+ from midi_cli.loop import cycle_loop_state
8
+
9
+
10
+ def midi_callback(event, data):
11
+ message, delta_time = event
12
+ if len(message) < 3:
13
+ return
14
+
15
+ status, note, velocity = message[0], message[1], message[2]
16
+ msg_type = status & 0xF0
17
+
18
+ if msg_type == 0x90 and velocity > 0:
19
+ if st.mode == "record":
20
+ threading.Thread(target=start_recording, args=(note,), daemon=True).start()
21
+ return
22
+
23
+ if st.mode == "trim":
24
+ if st.trim_selected_note is None and note in st.samples:
25
+ st.trim_selected_note = note
26
+ st.trim_step = 0
27
+ st.trim_start = 0
28
+ st.trim_end = len(st.samples[note])
29
+ st.trim_loop_pos = 0
30
+ st.trim_looping = True
31
+ return
32
+
33
+ if st.mode == "copy":
34
+ if st.copy_confirm_pending:
35
+ return
36
+ if st.copy_source_note is None:
37
+ if note in st.samples:
38
+ st.copy_source_note = note
39
+ return
40
+ else:
41
+ st.copy_dest_note = note
42
+ if note in st.samples:
43
+ st.copy_confirm_pending = True
44
+ else:
45
+ execute_copy()
46
+ return
47
+
48
+ st.ui_active_note = st.midi_note_name(note)
49
+ if st.mode == "pitch":
50
+ with st.voices_lock:
51
+ if note in st.samples:
52
+ st.sample_voices[note] = {"pos": 0, "releasing": False, "gain": 1.0}
53
+ st.pitch_selected_note = note
54
+ return
55
+ if st.mode == "chromatic":
56
+ if st.chromatic_selecting:
57
+ if note in st.samples:
58
+ st.chromatic_root_note = note
59
+ with st.voices_lock:
60
+ st.sample_voices.clear()
61
+ st.chromatic_selecting = False
62
+ compute_chromatic_samples_async(note)
63
+ else:
64
+ with st.voices_lock:
65
+ if note in st.chromatic_samples:
66
+ st.sample_voices[note] = {"pos": 0, "releasing": False, "gain": 1.0}
67
+ return
68
+ with st.voices_lock:
69
+ if st.mode in ("sample", "loop"):
70
+ if note in st.samples:
71
+ st.sample_voices[note] = {"pos": 0, "releasing": False, "gain": 1.0}
72
+ else:
73
+ st.voices[note] = {"phase": 0.0, "gain": 0.0, "releasing": False, "age": 0, "faded_in": False}
74
+ st.sustained_notes.discard(note)
75
+ elif msg_type == 0x80 or (msg_type == 0x90 and velocity == 0):
76
+ if st.mode == "record":
77
+ threading.Thread(target=stop_recording, args=(note,), daemon=True).start()
78
+ return
79
+ if st.mode in ("trim", "copy"):
80
+ return
81
+ # Only clear if this note is still the active one
82
+ note_name = st.midi_note_name(note)
83
+ if st.ui_active_note == note_name:
84
+ st.ui_active_note = None
85
+ with st.voices_lock:
86
+ if st.mode in ("sample", "pitch", "chromatic"):
87
+ if note in st.sample_voices:
88
+ st.sample_voices[note]["releasing"] = True
89
+ else:
90
+ if note in st.voices:
91
+ if st.sustain:
92
+ st.sustained_notes.add(note)
93
+ else:
94
+ st.voices[note]["releasing"] = True
95
+ elif msg_type == 0xB0 and note == 64:
96
+ pedal_on = velocity >= 64
97
+ st.ui_sustain = pedal_on
98
+ if pedal_on and st.mode == "loop":
99
+ with st.voices_lock:
100
+ cycle_loop_state()
101
+ return
102
+ with st.voices_lock:
103
+ st.sustain = pedal_on
104
+ if not pedal_on:
105
+ for n in st.sustained_notes:
106
+ if n in st.voices:
107
+ st.voices[n]["releasing"] = True
108
+ st.sustained_notes.clear()
109
+
110
+
111
+ def trigger_keyboard_note(note):
112
+ if st.mode == "record":
113
+ if note in st.keyboard_recording_notes:
114
+ st.keyboard_recording_notes.discard(note)
115
+ threading.Thread(target=stop_recording, args=(note,), daemon=True).start()
116
+ else:
117
+ st.keyboard_recording_notes.add(note)
118
+ threading.Thread(target=start_recording, args=(note,), daemon=True).start()
119
+ return
120
+
121
+ old = st.keyboard_timers.pop(note, None)
122
+ if old:
123
+ old.cancel()
124
+ midi_callback(([0x90, note, 100], 0), None)
125
+ if st.mode in ("sample", "pitch", "chromatic", "trim", "copy", "loop"):
126
+ # Sample modes: one-shot, let the sample play to its natural end
127
+ return
128
+ if st.sustain:
129
+ midi_callback(([0x80, note, 0], 0), None)
130
+ else:
131
+ def note_off():
132
+ st.keyboard_timers.pop(note, None)
133
+ midi_callback(([0x80, note, 0], 0), None)
134
+ t = threading.Timer(st.KEYBOARD_NOTE_RELEASE_S, note_off)
135
+ t.daemon = True
136
+ st.keyboard_timers[note] = t
137
+ t.start()
138
+
139
+
140
+ def open_midi_input(port_name=None):
141
+ midi_in = rtmidi.MidiIn()
142
+ ports = midi_in.get_ports()
143
+ if not ports:
144
+ midi_in.delete()
145
+ return None, ""
146
+ port_index = 0
147
+ if port_name is not None:
148
+ if port_name in ports:
149
+ port_index = ports.index(port_name)
150
+ else:
151
+ print(f"Warning: MIDI port '{port_name}' not found, using port 0.")
152
+ midi_in.open_port(port_index)
153
+ midi_in.set_callback(midi_callback)
154
+ return midi_in, ports[port_index]
midi_cli/sampler.py ADDED
@@ -0,0 +1,221 @@
1
+ import json
2
+ import os
3
+ import threading
4
+ import time
5
+ import wave
6
+
7
+ import numpy as np
8
+ import pyrubberband as pyrb
9
+ import sounddevice as sd
10
+
11
+ import midi_cli.state as st
12
+ from midi_cli.audio import trim_silence
13
+
14
+
15
+ def load_pitch_offsets():
16
+ """Load pitch offsets from samples/pitch_offsets.json if it exists."""
17
+ path = os.path.join("samples", "pitch_offsets.json")
18
+ if not os.path.isfile(path):
19
+ return
20
+ with open(path, "r") as f:
21
+ raw = json.load(f)
22
+ st.pitch_offsets = {int(k): v for k, v in raw.items()}
23
+ for note, offset in st.pitch_offsets.items():
24
+ if note in st.samples:
25
+ recompute_pitched_sample(note)
26
+
27
+
28
+ def save_pitch_offsets():
29
+ """Save pitch offsets to samples/pitch_offsets.json, pruning zero entries."""
30
+ to_save = {k: v for k, v in st.pitch_offsets.items()
31
+ if v["semitones"] != 0 or v["cents"] != 0}
32
+ os.makedirs("samples", exist_ok=True)
33
+ path = os.path.join("samples", "pitch_offsets.json")
34
+ with open(path, "w") as f:
35
+ json.dump(to_save, f, indent=2)
36
+
37
+
38
+ def recompute_pitched_sample(note):
39
+ """Recompute the pitched version of a sample for the given note."""
40
+ offset = st.pitch_offsets.get(note)
41
+ if offset is None or (offset["semitones"] == 0 and offset["cents"] == 0):
42
+ with st.voices_lock:
43
+ st.pitched_samples.pop(note, None)
44
+ return
45
+ total_steps = offset["semitones"] + offset["cents"] / 100.0
46
+ result = pyrb.pitch_shift(st.samples[note], sr=st.SAMPLE_RATE, n_steps=total_steps)
47
+ with st.voices_lock:
48
+ st.pitched_samples[note] = result
49
+
50
+
51
+ def recompute_pitched_sample_async(note):
52
+ """Recompute pitched sample in a background thread."""
53
+ threading.Thread(target=recompute_pitched_sample, args=(note,), daemon=True).start()
54
+
55
+
56
+ def compute_chromatic_samples(root_note):
57
+ """Pre-compute pitched versions of the root sample across two octaves (one up, one down)."""
58
+ range_start = max(0, root_note - 12)
59
+ range_end = min(127, root_note + 12)
60
+ source = st.samples[root_note]
61
+ result = {}
62
+ for midi_note in range(range_start, range_end + 1):
63
+ offset = midi_note - root_note
64
+ if offset == 0:
65
+ result[midi_note] = source
66
+ else:
67
+ result[midi_note] = pyrb.pitch_shift(source, sr=st.SAMPLE_RATE, n_steps=offset)
68
+ with st.voices_lock:
69
+ st.chromatic_samples.clear()
70
+ st.chromatic_samples.update(result)
71
+ st.chromatic_computing = False
72
+
73
+
74
+ def compute_chromatic_samples_async(root_note):
75
+ """Launch chromatic pre-computation in a background thread."""
76
+ st.chromatic_computing = True
77
+ threading.Thread(target=compute_chromatic_samples, args=(root_note,), daemon=True).start()
78
+
79
+
80
+ def format_pitch_info(note, offset):
81
+ """Return a formatted string like 'C4 +3st +20c'."""
82
+ s = offset["semitones"]
83
+ c = offset["cents"]
84
+ s_str = f"+{s}st" if s >= 0 else f"{s}st"
85
+ c_str = f"+{c}c" if c >= 0 else f"{c}c"
86
+ return f"{st.midi_note_name(note):<4} {s_str} {c_str}"
87
+
88
+
89
+ def adjust_pitch(note, semitones, cents):
90
+ """Adjust pitch offset for a note by the given semitone/cent deltas."""
91
+ if note not in st.pitch_offsets:
92
+ st.pitch_offsets[note] = {"semitones": 0, "cents": 0}
93
+ offset = st.pitch_offsets[note]
94
+ offset["semitones"] += semitones
95
+ offset["cents"] += cents
96
+ # Normalize cents overflow into semitones
97
+ while offset["cents"] >= 100:
98
+ offset["cents"] -= 100
99
+ offset["semitones"] += 1
100
+ while offset["cents"] <= -100:
101
+ offset["cents"] += 100
102
+ offset["semitones"] -= 1
103
+ save_pitch_offsets()
104
+ recompute_pitched_sample_async(note)
105
+
106
+
107
+ def save_sample(note_number, audio):
108
+ """Store a sample in memory and save to disk as WAV."""
109
+ st.samples[note_number] = audio
110
+
111
+ os.makedirs("samples", exist_ok=True)
112
+ filepath = f"samples/note_{note_number}.wav"
113
+ with wave.open(filepath, "w") as wf:
114
+ wf.setnchannels(1)
115
+ wf.setsampwidth(2) # 16-bit
116
+ wf.setframerate(st.SAMPLE_RATE)
117
+ int_data = np.clip(audio * 32767, -32768, 32767).astype(np.int16)
118
+ wf.writeframes(int_data.tobytes())
119
+
120
+ if note_number in st.pitch_offsets:
121
+ recompute_pitched_sample_async(note_number)
122
+
123
+
124
+ def execute_copy():
125
+ """Copy sample and pitch offset from source to destination."""
126
+ src = st.copy_source_note
127
+ dst = st.copy_dest_note
128
+ if src is None or dst is None or src not in st.samples:
129
+ st.copy_source_note = None
130
+ st.copy_dest_note = None
131
+ st.copy_confirm_pending = False
132
+ return
133
+
134
+ # Copy pitch offset (or remove dest's offset if source has none)
135
+ if src in st.pitch_offsets:
136
+ st.pitch_offsets[dst] = dict(st.pitch_offsets[src])
137
+ else:
138
+ st.pitch_offsets.pop(dst, None)
139
+
140
+ # Copy sample data
141
+ save_sample(dst, st.samples[src].copy())
142
+ save_pitch_offsets()
143
+
144
+ # Recompute chromatic samples if dest was the chromatic root
145
+ if st.chromatic_root_note == dst:
146
+ compute_chromatic_samples_async(dst)
147
+
148
+ # Reset copy state
149
+ st.copy_source_note = None
150
+ st.copy_dest_note = None
151
+ st.copy_confirm_pending = False
152
+
153
+
154
+ def start_recording(note_number):
155
+ with st.voices_lock:
156
+ if st.recording_note is not None:
157
+ return # already recording; ignore
158
+ st.recording_note = note_number
159
+ st.recording_chunks = []
160
+ st.ui_recording = {"note": st.midi_note_name(note_number), "start_time": time.time()}
161
+
162
+ def _callback(indata, frames, time_info, status):
163
+ st.recording_chunks.append(indata[:, 0].copy())
164
+
165
+ stream = sd.InputStream(
166
+ samplerate=st.SAMPLE_RATE, channels=1, dtype="float32",
167
+ device=st.input_device, callback=_callback,
168
+ )
169
+ stream.start()
170
+ with st.voices_lock:
171
+ st.recording_stream = stream
172
+
173
+
174
+ def stop_recording(note_number):
175
+ with st.voices_lock:
176
+ if st.recording_note != note_number:
177
+ return # not recording this note; ignore
178
+ stream = st.recording_stream
179
+ chunks = list(st.recording_chunks)
180
+ st.recording_note = None
181
+ st.recording_stream = None
182
+ st.recording_chunks = []
183
+ st.ui_recording = None
184
+
185
+ if stream is not None:
186
+ stream.stop()
187
+ stream.close()
188
+
189
+ if chunks:
190
+ audio = np.concatenate(chunks).astype("float64")
191
+ audio = trim_silence(audio)
192
+ save_sample(note_number, audio)
193
+
194
+
195
+ def load_samples():
196
+ """Load any existing .wav files from ./samples/ directory."""
197
+ if not os.path.isdir("samples"):
198
+ return
199
+ for filename in os.listdir("samples"):
200
+ if filename.startswith("note_") and filename.endswith(".wav"):
201
+ try:
202
+ note_num = int(filename[5:-4])
203
+ except ValueError:
204
+ continue
205
+ filepath = os.path.join("samples", filename)
206
+ with wave.open(filepath, "r") as wf:
207
+ n_frames = wf.getnframes()
208
+ raw = wf.readframes(n_frames)
209
+ width = wf.getsampwidth()
210
+ if width == 2:
211
+ int_data = np.frombuffer(raw, dtype=np.int16)
212
+ audio = int_data.astype(np.float64) / 32767.0
213
+ elif width == 4:
214
+ int_data = np.frombuffer(raw, dtype=np.int32)
215
+ audio = int_data.astype(np.float64) / 2147483647.0
216
+ else:
217
+ continue
218
+ # If stereo, take first channel
219
+ if wf.getnchannels() > 1:
220
+ audio = audio[::wf.getnchannels()]
221
+ st.samples[note_num] = audio
midi_cli/state.py ADDED
@@ -0,0 +1,121 @@
1
+ import threading
2
+
3
+ import numpy as np
4
+
5
+ # Audio constants
6
+ SAMPLE_RATE = 48000
7
+ BLOCK_SIZE = 256
8
+ FADE_SAMPLES = int(0.005 * SAMPLE_RATE) # 5ms fade
9
+
10
+ # Note/keyboard constants
11
+ NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
12
+ KEYBOARD_NOTE_MAP = {'1': 60, '2': 61, '3': 62, '4': 63, '5': 64,
13
+ '6': 65, '7': 66, '8': 67, '9': 68, '0': 69}
14
+ KEYBOARD_NOTE_RELEASE_S = 0.25
15
+
16
+ # ANSI escape helpers
17
+ RST = "\033[0m"
18
+ DIM = "\033[2m"
19
+ BOLD = "\033[1m"
20
+ GREEN = "\033[32m"
21
+ RED = "\033[31m"
22
+ YELLOW = "\033[33m"
23
+ CYAN = "\033[36m"
24
+ ORANGE = "\033[38;5;208m"
25
+ CLEAR_LINE = "\033[2K"
26
+ HIDE_CURSOR = "\033[?25l"
27
+ SHOW_CURSOR = "\033[?25h"
28
+ NUM_UI_LINES = 4
29
+
30
+ # Per-sample gain step for 5ms fade
31
+ FADE_IN_STEP = 1.0 / FADE_SAMPLES
32
+ FADE_OUT_STEP = 1.0 / FADE_SAMPLES
33
+
34
+ # Two-stage exponential decay (piano-like)
35
+ # Stage 1: fast initial decay (~0.5s) — hammer brightness fading
36
+ STAGE1_SAMPLES = int(0.5 * SAMPLE_RATE)
37
+ STAGE1_DECAY = 0.4 ** (1.0 / STAGE1_SAMPLES) # drops to ~40% over 0.5s
38
+ # Stage 2: slow ring-out (~7s to silence)
39
+ STAGE2_DECAY = 0.001 ** (1.0 / (7.0 * SAMPLE_RATE))
40
+ SILENCE_THRESHOLD = 0.001
41
+
42
+ # Harmonic amplitudes (piano-like spectrum)
43
+ HARMONICS = np.array([1.0, 0.5, 0.25, 0.12, 0.06])
44
+ HARMONICS /= HARMONICS.sum() # normalize so total max = 1.0
45
+ H_MULTIPLIERS = np.arange(1, len(HARMONICS) + 1) # [1, 2, 3, 4, 5]
46
+
47
+
48
+ def midi_note_name(note_number):
49
+ return f"{NOTE_NAMES[note_number % 12]}{(note_number // 12) - 1}"
50
+
51
+
52
+ def note_to_freq(note):
53
+ return 440.0 * 2.0 ** ((note - 69) / 12.0)
54
+
55
+
56
+ # Voice: {note: {"phase": float, "gain": float, "releasing": bool}}
57
+ voices = {}
58
+ voices_lock = threading.Lock()
59
+ sustain = False
60
+ sustained_notes = set()
61
+
62
+ # Sampler state
63
+ MODES = ["synth", "sample", "record", "pitch", "chromatic", "trim", "copy", "loop"]
64
+ mode = "synth"
65
+ samples = {} # note_number -> numpy array (mono float64)
66
+ sample_voices = {} # note -> {"pos": int, "releasing": bool, "gain": float}
67
+
68
+ # Dynamic recording state
69
+ recording_note = None # int: MIDI note currently recording, or None
70
+ recording_chunks = [] # list of np.ndarray frames from InputStream callback
71
+ recording_stream = None # sd.InputStream instance, or None
72
+ keyboard_recording_notes = set() # notes with in-progress keyboard-toggle recordings
73
+
74
+ # Pitch mode state
75
+ pitch_offsets = {} # note_number -> {"semitones": int, "cents": int}
76
+ pitched_samples = {} # note_number -> pre-computed pitched numpy array
77
+ pitch_selected_note = None # currently selected note for pitch adjustment
78
+ CENTS_STEP = 10 # cents per left/right arrow press
79
+
80
+ # Chromatic mode state
81
+ chromatic_root_note = None # selected root MIDI note
82
+ chromatic_samples = {} # midi_note -> numpy array (pre-computed)
83
+ chromatic_computing = False # True while background thread runs
84
+ chromatic_selecting = False # True when waiting for user to pick root
85
+
86
+ # Trim mode state
87
+ trim_selected_note = None # MIDI note being trimmed (None = selecting)
88
+ trim_step = 0 # 0 = adjusting start, 1 = adjusting end
89
+ trim_start = 0 # start sample index
90
+ trim_end = 0 # end sample index
91
+ trim_loop_pos = 0 # current playback position for loop preview
92
+ trim_looping = False # True while preview loop is active
93
+ TRIM_WAVEFORM_HEIGHT = 6 # rows for waveform display
94
+ MIN_TRIM_SAMPLES = 480 # ~10ms minimum trim length
95
+ MIN_LOOP_PERIOD = int(2.0 * SAMPLE_RATE) # minimum loop cycle including silence
96
+
97
+ # Loop mode state
98
+ loop_state = "idle" # "idle", "recording", "playing", "overdub"
99
+ loop_buffer = None # np.ndarray once recorded, None when idle
100
+ loop_pos = 0 # current playback position (int)
101
+ loop_record_chunks = [] # list of np.ndarray, accumulated during recording
102
+
103
+ # Copy mode state
104
+ copy_source_note = None # MIDI note selected as source
105
+ copy_dest_note = None # MIDI note selected as destination
106
+ copy_confirm_pending = False # True when awaiting overwrite confirmation
107
+
108
+ # UI state
109
+ ui_active_note = None
110
+ ui_sustain = False
111
+ ui_recording = None # None or {"note": str, "start_time": float}
112
+ ui_midi_port = ""
113
+ prev_ui_lines = NUM_UI_LINES
114
+
115
+ keyboard_mock_mode = False
116
+ keyboard_timers = {} # note -> threading.Timer, for pending note-offs
117
+
118
+ # Device selections (set by startup wizard, None = system default / port 0)
119
+ input_device = None # audio input device name or None
120
+ output_device = None # audio output device name or None
121
+ midi_port = None # MIDI port name or None