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/__init__.py +0 -0
- midi_cli/app.py +179 -0
- midi_cli/audio.py +278 -0
- midi_cli/config.py +133 -0
- midi_cli/loop.py +30 -0
- midi_cli/midi.py +154 -0
- midi_cli/sampler.py +221 -0
- midi_cli/state.py +121 -0
- midi_cli/ui.py +260 -0
- midi_cli-0.1.0.dist-info/METADATA +47 -0
- midi_cli-0.1.0.dist-info/RECORD +14 -0
- midi_cli-0.1.0.dist-info/WHEEL +4 -0
- midi_cli-0.1.0.dist-info/entry_points.txt +2 -0
- midi_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|