midi-cli 0.1.3__tar.gz → 0.2.0__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.3 → midi_cli-0.2.0}/.gitignore +1 -0
- midi_cli-0.2.0/ARCHITECTURE.md +144 -0
- midi_cli-0.2.0/CLAUDE.md +5 -0
- {midi_cli-0.1.3 → midi_cli-0.2.0}/PKG-INFO +1 -1
- {midi_cli-0.1.3 → midi_cli-0.2.0}/midi_cli/app.py +42 -14
- {midi_cli-0.1.3 → midi_cli-0.2.0}/midi_cli/audio.py +74 -73
- {midi_cli-0.1.3 → midi_cli-0.2.0}/midi_cli/midi.py +11 -10
- {midi_cli-0.1.3 → midi_cli-0.2.0}/midi_cli/state.py +26 -1
- {midi_cli-0.1.3 → midi_cli-0.2.0}/midi_cli/ui.py +102 -214
- {midi_cli-0.1.3 → midi_cli-0.2.0}/pyproject.toml +1 -1
- {midi_cli-0.1.3 → midi_cli-0.2.0}/uv.lock +1 -1
- midi_cli-0.1.3/.claude/settings.local.json +0 -65
- midi_cli-0.1.3/main.py +0 -6
- {midi_cli-0.1.3 → midi_cli-0.2.0}/LICENSE +0 -0
- {midi_cli-0.1.3 → midi_cli-0.2.0}/README.md +0 -0
- {midi_cli-0.1.3 → midi_cli-0.2.0}/midi_cli/__init__.py +0 -0
- {midi_cli-0.1.3 → midi_cli-0.2.0}/midi_cli/config.py +0 -0
- {midi_cli-0.1.3 → midi_cli-0.2.0}/midi_cli/loop.py +0 -0
- {midi_cli-0.1.3 → midi_cli-0.2.0}/midi_cli/sampler.py +0 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
Developer reference for midi-cli: modes, key behavior, and app flow.
|
|
4
|
+
|
|
5
|
+
## Module Overview
|
|
6
|
+
|
|
7
|
+
| File | Responsibility |
|
|
8
|
+
|------|---------------|
|
|
9
|
+
| `app.py` | Entry point, main loop, raw key input, mode switching |
|
|
10
|
+
| `state.py` | All global state and constants (single source of truth) |
|
|
11
|
+
| `audio.py` | `sd.OutputStream` callback; synth rendering, sample playback, loop mixing, metronome |
|
|
12
|
+
| `midi.py` | MIDI input callback; keyboard mock input (`trigger_keyboard_note`) |
|
|
13
|
+
| `sampler.py` | Sample I/O (load/save WAV), pitch shifting, chromatic pre-computation, split/copy |
|
|
14
|
+
| `loop.py` | Loop state machine transitions (`cycle_loop_state`) |
|
|
15
|
+
| `ui.py` | Terminal rendering (`draw_ui`); trim/copy/split state resets |
|
|
16
|
+
| `config.py` | Config file load/save, device enumeration |
|
|
17
|
+
|
|
18
|
+
## Startup Flow
|
|
19
|
+
|
|
20
|
+
1. `load_defaults()` reads saved config (devices, MIDI port, synesthesia)
|
|
21
|
+
2. MIDI input opened; if unavailable, falls back to keyboard mock mode
|
|
22
|
+
3. `load_samples()` reads `samples/note_*.wav` into `state.samples`
|
|
23
|
+
4. `load_pitch_offsets()` reads `samples/pitch_offsets.json`
|
|
24
|
+
5. `sd.OutputStream` started (runs `audio_callback` on a background thread)
|
|
25
|
+
6. Terminal set to raw mode, alternate screen entered
|
|
26
|
+
7. Main loop: `draw_ui()` → `select()` with 50ms timeout → handle key
|
|
27
|
+
|
|
28
|
+
## Modes
|
|
29
|
+
|
|
30
|
+
Modes are stored in `state.mode`. The ordered list is defined in `state.MODES`:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
synth → sample → record → pitch → chromatic → trim → copy → loop → split → config
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- **Tab** advances to the next mode (wraps)
|
|
37
|
+
- **Shift+Tab** goes to the previous mode
|
|
38
|
+
- Switching away from `record`, `trim`, `copy`, `split`, or `loop` resets that mode's transient state
|
|
39
|
+
|
|
40
|
+
### synth
|
|
41
|
+
Polyphonic additive synth (5 harmonics, piano-like two-stage decay).
|
|
42
|
+
Note on → voice added to `state.voices`. Note off → voice set to `releasing`.
|
|
43
|
+
Sustain pedal (CC 64) holds notes until pedal release.
|
|
44
|
+
|
|
45
|
+
### sample
|
|
46
|
+
Plays back recorded samples. Note on → sample voice added to `state.sample_voices`, plays from position 0.
|
|
47
|
+
Note off → voice set to `releasing` (5ms fade out). Sample plays to its natural end before being removed.
|
|
48
|
+
|
|
49
|
+
### record
|
|
50
|
+
Note on → `start_recording()` (opens `sd.InputStream`, accumulates chunks).
|
|
51
|
+
Note off → `stop_recording()` (closes stream, trims silence, saves WAV).
|
|
52
|
+
In keyboard mock mode: key press **toggles** recording on/off (press once to start, again to stop).
|
|
53
|
+
|
|
54
|
+
### pitch
|
|
55
|
+
Note on → plays the sample and sets `pitch_selected_note`.
|
|
56
|
+
Arrow keys adjust pitch offset for the selected note (Up/Down: ±1 semitone, Left/Right: ±10 cents).
|
|
57
|
+
Changes saved to `samples/pitch_offsets.json` and recomputed asynchronously via pyrubberband.
|
|
58
|
+
|
|
59
|
+
### chromatic
|
|
60
|
+
One sample is pitch-shifted across a two-octave range (±12 semitones from root) and pre-computed in a background thread into `state.chromatic_samples`.
|
|
61
|
+
On entry: if no root is set, enters "selecting" sub-state — next note-on with a sample sets the root and triggers pre-computation.
|
|
62
|
+
Press **r** to re-select root.
|
|
63
|
+
|
|
64
|
+
### trim
|
|
65
|
+
Note on (with an existing sample) → selects that note, begins loop preview of full sample.
|
|
66
|
+
Two-step workflow:
|
|
67
|
+
1. **Step 0** — adjust start point with arrow keys (fine: 0.1%, coarse: 1%), **Enter** to confirm
|
|
68
|
+
2. **Step 1** — adjust end point with arrow keys, **Enter** to save the trimmed slice
|
|
69
|
+
|
|
70
|
+
**Escape** in step 1 goes back to step 0. **Escape** in step 0 deselects.
|
|
71
|
+
|
|
72
|
+
### copy
|
|
73
|
+
Two-step note selection:
|
|
74
|
+
1. Note on → sets `copy_source_note` (must have a sample)
|
|
75
|
+
2. Note on → sets `copy_dest_note`; if dest already has a sample, prompts for confirmation (**Enter** to confirm, **Escape** to cancel)
|
|
76
|
+
|
|
77
|
+
### loop
|
|
78
|
+
Loop state machine: `idle → recording → playing → overdub → playing → ...`
|
|
79
|
+
Triggered by **l** key or sustain pedal (CC 64).
|
|
80
|
+
Sample voices (from note-on) are mixed into the loop buffer during recording and overdub.
|
|
81
|
+
**Escape** resets to idle.
|
|
82
|
+
|
|
83
|
+
State transitions (`loop.py:cycle_loop_state`):
|
|
84
|
+
- `idle` → `recording`: clears buffer, starts accumulating audio blocks
|
|
85
|
+
- `recording` → `playing`: concatenates chunks into `loop_buffer`, starts playback
|
|
86
|
+
- `playing` → `overdub`: incoming audio mixed (summed) into `loop_buffer` in real time
|
|
87
|
+
- `overdub` → `playing`: increments `loop_layer_count`, returns to playback
|
|
88
|
+
|
|
89
|
+
### split
|
|
90
|
+
Splits one sample into multiple slices and assigns each to a MIDI note.
|
|
91
|
+
Two phases:
|
|
92
|
+
- **markers**: select a source note, use arrow keys to position cursor, **Enter** to place a marker. **a** advances to assign phase.
|
|
93
|
+
- **assign**: each note-on saves the current slice to that key and advances to the next. **Escape** steps back one slice (or back to markers phase).
|
|
94
|
+
|
|
95
|
+
### config
|
|
96
|
+
Up/Down navigates fields. Left/Right changes values. Fields: input device, output device, MIDI port, synesthesia toggle. Changes saved immediately on each key press.
|
|
97
|
+
|
|
98
|
+
## Audio Callback
|
|
99
|
+
|
|
100
|
+
`audio_callback` runs on a real-time background thread (called by sounddevice). It holds `voices_lock` for the entire block.
|
|
101
|
+
|
|
102
|
+
Priority order inside the callback:
|
|
103
|
+
1. **trim** loop preview (if `trim_looping`)
|
|
104
|
+
2. **split** loop preview (if `split_looping`)
|
|
105
|
+
3. **loop** mode — sample voices + loop buffer capture/playback/overdub
|
|
106
|
+
4. **sample / pitch / chromatic** — sample voice playback
|
|
107
|
+
5. **synth** — additive synthesis
|
|
108
|
+
|
|
109
|
+
Metronome is mixed in at the end of blocks 3–5 when `metronome_enabled` and the current mode is in `METRONOME_MODES`.
|
|
110
|
+
|
|
111
|
+
## Key Behaviors
|
|
112
|
+
|
|
113
|
+
### Note release
|
|
114
|
+
| Input source | Synth mode | Sample/pitch/chromatic modes |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| MIDI note-off | Triggers fade-out (`releasing=True`) | Triggers fade-out |
|
|
117
|
+
| Keyboard mock | Auto note-off after 250ms (`KEYBOARD_NOTE_RELEASE_S`) | No auto note-off — sample plays to end |
|
|
118
|
+
|
|
119
|
+
### Sustain pedal (CC 64)
|
|
120
|
+
- In **synth**: holds notes; releasing pedal fades all held notes
|
|
121
|
+
- In **loop**: cycles loop state (same as **l** key)
|
|
122
|
+
- Other modes: ignored
|
|
123
|
+
|
|
124
|
+
### Metronome
|
|
125
|
+
- **m** key toggles on/off in modes: `synth`, `sample`, `record`, `loop`, `chromatic`
|
|
126
|
+
- Arrow keys adjust BPM (±1 / ±5) when metronome is on and no mode-specific arrow binding takes priority
|
|
127
|
+
- BPM range: 20–300
|
|
128
|
+
|
|
129
|
+
## State Locking
|
|
130
|
+
|
|
131
|
+
`state.voices_lock` is shared between:
|
|
132
|
+
- `audio_callback` (audio thread) — holds lock for entire block
|
|
133
|
+
- `midi_callback` (MIDI thread) — holds lock briefly for voice mutations
|
|
134
|
+
- `app.py` main loop — holds lock for loop state transitions and some mode resets
|
|
135
|
+
|
|
136
|
+
All mutations to `voices`, `sample_voices`, `loop_buffer`, `loop_state`, and related fields must happen under this lock.
|
|
137
|
+
|
|
138
|
+
## Persistence
|
|
139
|
+
|
|
140
|
+
| Data | Location |
|
|
141
|
+
|------|----------|
|
|
142
|
+
| Samples | `samples/note_<midi>.wav` (16-bit mono, 48kHz) |
|
|
143
|
+
| Pitch offsets | `samples/pitch_offsets.json` |
|
|
144
|
+
| Config (devices, synesthesia) | `~/.config/midi_cli/config.json` |
|
midi_cli-0.2.0/CLAUDE.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# midi-cli
|
|
2
|
+
|
|
3
|
+
See [ARCHITECTURE.md](ARCHITECTURE.md) for a full developer reference: modes, key behaviors, app flow, state locking, and persistence.
|
|
4
|
+
|
|
5
|
+
When making changes that affect modes, key bindings, audio behavior, state, or persistence, update ARCHITECTURE.md to reflect the new behavior.
|
|
@@ -11,9 +11,15 @@ 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
|
|
15
15
|
from midi_cli.loop import reset_loop_state, cycle_loop_state
|
|
16
16
|
|
|
17
|
+
def _stop_active_voices():
|
|
18
|
+
"""Clear all active voices on mode switch."""
|
|
19
|
+
with st.voices_lock:
|
|
20
|
+
st.sample_voices.clear()
|
|
21
|
+
st.voices.clear()
|
|
22
|
+
|
|
17
23
|
|
|
18
24
|
def main():
|
|
19
25
|
from midi_cli.config import load_defaults, get_input_devices, get_output_devices, get_midi_ports, save_config
|
|
@@ -61,15 +67,17 @@ def main():
|
|
|
61
67
|
if ch in ("q", "\x03"): # q or Ctrl+C
|
|
62
68
|
break
|
|
63
69
|
elif ch == "\r" and st.mode == "split" and st.split_selected_note is not None and st.split_phase == "markers":
|
|
64
|
-
st.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
with st.voices_lock:
|
|
71
|
+
st.split_markers.append(st.split_cursor_pos)
|
|
72
|
+
st.split_markers.sort()
|
|
73
|
+
# Restart loop from start of new region (last marker → end)
|
|
74
|
+
st.split_loop_pos = 0
|
|
68
75
|
elif ch == "\r" and st.mode == "trim" and st.trim_selected_note is not None:
|
|
69
76
|
if st.trim_step == 0:
|
|
70
77
|
# Confirm start, advance to end adjustment
|
|
71
78
|
st.trim_step = 1
|
|
72
|
-
|
|
79
|
+
with st.voices_lock:
|
|
80
|
+
st.trim_loop_pos = st.trim_start
|
|
73
81
|
else:
|
|
74
82
|
# Confirm end, slice and save
|
|
75
83
|
audio = st.samples[st.trim_selected_note][st.trim_start:st.trim_end]
|
|
@@ -91,10 +99,11 @@ def main():
|
|
|
91
99
|
if st.mode == "copy":
|
|
92
100
|
reset_copy_state()
|
|
93
101
|
if st.mode == "split":
|
|
94
|
-
reset_split_state()
|
|
102
|
+
st.reset_split_state()
|
|
95
103
|
if st.mode == "loop":
|
|
96
104
|
with st.voices_lock:
|
|
97
105
|
reset_loop_state()
|
|
106
|
+
_stop_active_voices()
|
|
98
107
|
st.mode = st.MODES[(st.MODES.index(st.mode) + 1) % len(st.MODES)]
|
|
99
108
|
if st.mode == "chromatic":
|
|
100
109
|
st.chromatic_selecting = st.chromatic_root_note is None
|
|
@@ -108,7 +117,8 @@ def main():
|
|
|
108
117
|
if st.mode == "trim" and st.trim_selected_note is not None:
|
|
109
118
|
if st.trim_step == 1:
|
|
110
119
|
st.trim_step = 0
|
|
111
|
-
|
|
120
|
+
with st.voices_lock:
|
|
121
|
+
st.trim_loop_pos = st.trim_start
|
|
112
122
|
else:
|
|
113
123
|
reset_trim_state()
|
|
114
124
|
elif st.mode == "copy" and (st.copy_source_note is not None or st.copy_confirm_pending):
|
|
@@ -120,14 +130,17 @@ def main():
|
|
|
120
130
|
if st.split_phase == "assign":
|
|
121
131
|
if st.split_current_slice > 0:
|
|
122
132
|
st.split_current_slice -= 1
|
|
123
|
-
st.
|
|
133
|
+
with st.voices_lock:
|
|
134
|
+
st.split_loop_pos = 0
|
|
124
135
|
else:
|
|
125
136
|
st.split_phase = "markers"
|
|
126
|
-
st.
|
|
137
|
+
with st.voices_lock:
|
|
138
|
+
st.split_looping = False
|
|
127
139
|
else:
|
|
128
140
|
# markers phase
|
|
129
141
|
if st.split_markers:
|
|
130
|
-
st.
|
|
142
|
+
with st.voices_lock:
|
|
143
|
+
st.split_markers.pop()
|
|
131
144
|
else:
|
|
132
145
|
st.split_selected_note = None
|
|
133
146
|
else:
|
|
@@ -142,10 +155,11 @@ def main():
|
|
|
142
155
|
if st.mode == "copy":
|
|
143
156
|
reset_copy_state()
|
|
144
157
|
if st.mode == "split":
|
|
145
|
-
reset_split_state()
|
|
158
|
+
st.reset_split_state()
|
|
146
159
|
if st.mode == "loop":
|
|
147
160
|
with st.voices_lock:
|
|
148
161
|
reset_loop_state()
|
|
162
|
+
_stop_active_voices()
|
|
149
163
|
st.mode = st.MODES[(st.MODES.index(st.mode) - 1) % len(st.MODES)]
|
|
150
164
|
if st.mode == "chromatic":
|
|
151
165
|
st.chromatic_selecting = st.chromatic_root_note is None
|
|
@@ -255,14 +269,28 @@ def main():
|
|
|
255
269
|
"midi_port": st.midi_port,
|
|
256
270
|
"synesthesia_enabled": st.synesthesia_enabled,
|
|
257
271
|
})
|
|
272
|
+
elif st.metronome_enabled and st.mode in st.METRONOME_MODES:
|
|
273
|
+
if seq == "[C": # Right: +1 BPM
|
|
274
|
+
st.bpm = min(st.BPM_MAX, st.bpm + 1)
|
|
275
|
+
elif seq == "[D": # Left: -1 BPM
|
|
276
|
+
st.bpm = max(st.BPM_MIN, st.bpm - 1)
|
|
277
|
+
elif seq == "[A": # Up: +5 BPM
|
|
278
|
+
st.bpm = min(st.BPM_MAX, st.bpm + 5)
|
|
279
|
+
elif seq == "[B": # Down: -5 BPM
|
|
280
|
+
st.bpm = max(st.BPM_MIN, st.bpm - 5)
|
|
258
281
|
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
282
|
st.split_phase = "assign"
|
|
260
283
|
st.split_current_slice = 0
|
|
261
|
-
st.
|
|
262
|
-
|
|
284
|
+
with st.voices_lock:
|
|
285
|
+
st.split_loop_pos = 0
|
|
286
|
+
st.split_looping = True
|
|
263
287
|
elif ch == 'l' and st.mode == "loop":
|
|
264
288
|
with st.voices_lock:
|
|
265
289
|
cycle_loop_state()
|
|
290
|
+
elif ch == 'm' and st.mode in st.METRONOME_MODES:
|
|
291
|
+
with st.voices_lock:
|
|
292
|
+
st.metronome_enabled = not st.metronome_enabled
|
|
293
|
+
st.metronome_pos = 0
|
|
266
294
|
elif st.keyboard_mock_mode and ch in st.KEYBOARD_NOTE_MAP:
|
|
267
295
|
trigger_keyboard_note(st.KEYBOARD_NOTE_MAP[ch])
|
|
268
296
|
elif st.keyboard_mock_mode and ch == '`':
|
|
@@ -2,6 +2,11 @@ import numpy as np
|
|
|
2
2
|
|
|
3
3
|
import midi_cli.state as st
|
|
4
4
|
|
|
5
|
+
_CLICK_DUR = 0.012 # 12ms
|
|
6
|
+
_CLICK_FREQ = 1000.0
|
|
7
|
+
_t = np.linspace(0, _CLICK_DUR, int(st.SAMPLE_RATE * _CLICK_DUR), endpoint=False)
|
|
8
|
+
_CLICK_BUFFER = (np.sin(2 * np.pi * _CLICK_FREQ * _t) * np.exp(-_t * 300) * 0.7).astype(np.float64)
|
|
9
|
+
|
|
5
10
|
|
|
6
11
|
def build_synth_envelope(gain, releasing, faded_in, age, frames):
|
|
7
12
|
"""Vectorized envelope: returns (gains_array, new_gain, new_age, new_faded_in)."""
|
|
@@ -74,6 +79,45 @@ def build_synth_envelope(gain, releasing, faded_in, age, frames):
|
|
|
74
79
|
return gains, new_gain, new_age, faded_in
|
|
75
80
|
|
|
76
81
|
|
|
82
|
+
def _mix_sample_voices(frames, mix):
|
|
83
|
+
finished = []
|
|
84
|
+
for note, sv in st.sample_voices.items():
|
|
85
|
+
if st.mode == "chromatic":
|
|
86
|
+
sample_data = st.chromatic_samples.get(note)
|
|
87
|
+
if sample_data is None:
|
|
88
|
+
finished.append(note)
|
|
89
|
+
continue
|
|
90
|
+
else:
|
|
91
|
+
if note not in st.samples:
|
|
92
|
+
finished.append(note)
|
|
93
|
+
continue
|
|
94
|
+
ps = st.pitched_samples.get(note)
|
|
95
|
+
sample_data = ps if ps is not None else st.samples.get(note)
|
|
96
|
+
pos = sv["pos"]
|
|
97
|
+
gain = sv["gain"]
|
|
98
|
+
releasing = sv["releasing"]
|
|
99
|
+
remaining = len(sample_data) - pos
|
|
100
|
+
n = min(frames, remaining)
|
|
101
|
+
if n <= 0:
|
|
102
|
+
finished.append(note)
|
|
103
|
+
continue
|
|
104
|
+
buf = np.zeros(frames)
|
|
105
|
+
buf[:n] = sample_data[pos:pos + n] * gain
|
|
106
|
+
if releasing:
|
|
107
|
+
fade_gains = np.maximum(0.0, gain - st.FADE_OUT_STEP * (np.arange(n) + 1))
|
|
108
|
+
buf[:n] *= fade_gains
|
|
109
|
+
gain = max(0.0, gain - st.FADE_OUT_STEP * n)
|
|
110
|
+
else:
|
|
111
|
+
gain = min(1.0, gain + st.FADE_IN_STEP * n)
|
|
112
|
+
mix += buf
|
|
113
|
+
sv["pos"] = pos + n
|
|
114
|
+
sv["gain"] = gain
|
|
115
|
+
if (releasing and gain <= 0.0) or pos + n >= len(sample_data):
|
|
116
|
+
finished.append(note)
|
|
117
|
+
for note in finished:
|
|
118
|
+
del st.sample_voices[note]
|
|
119
|
+
|
|
120
|
+
|
|
77
121
|
def audio_callback(outdata, frames, time_info, status):
|
|
78
122
|
with st.voices_lock:
|
|
79
123
|
if st.mode == "trim" and st.trim_looping and st.trim_selected_note is not None:
|
|
@@ -161,36 +205,7 @@ def audio_callback(outdata, frames, time_info, status):
|
|
|
161
205
|
if st.mode == "loop":
|
|
162
206
|
mix = np.zeros(frames)
|
|
163
207
|
|
|
164
|
-
|
|
165
|
-
finished = []
|
|
166
|
-
for note, sv in st.sample_voices.items():
|
|
167
|
-
if note not in st.samples:
|
|
168
|
-
finished.append(note)
|
|
169
|
-
continue
|
|
170
|
-
pos = sv["pos"]
|
|
171
|
-
gain = sv["gain"]
|
|
172
|
-
releasing = sv["releasing"]
|
|
173
|
-
sample_data = st.samples[note]
|
|
174
|
-
remaining = len(sample_data) - pos
|
|
175
|
-
n = min(frames, remaining)
|
|
176
|
-
if n <= 0:
|
|
177
|
-
finished.append(note)
|
|
178
|
-
continue
|
|
179
|
-
buf = np.zeros(frames)
|
|
180
|
-
buf[:n] = sample_data[pos:pos + n] * gain
|
|
181
|
-
if releasing:
|
|
182
|
-
fade_gains = np.maximum(0.0, gain - st.FADE_OUT_STEP * (np.arange(n) + 1))
|
|
183
|
-
buf[:n] *= fade_gains
|
|
184
|
-
gain = max(0.0, gain - st.FADE_OUT_STEP * n)
|
|
185
|
-
else:
|
|
186
|
-
gain = min(1.0, gain + st.FADE_IN_STEP * n)
|
|
187
|
-
mix += buf
|
|
188
|
-
sv["pos"] = pos + n
|
|
189
|
-
sv["gain"] = gain
|
|
190
|
-
if (releasing and gain <= 0.0) or pos + n >= len(sample_data):
|
|
191
|
-
finished.append(note)
|
|
192
|
-
for note in finished:
|
|
193
|
-
del st.sample_voices[note]
|
|
208
|
+
_mix_sample_voices(frames, mix)
|
|
194
209
|
|
|
195
210
|
# Loop capture/playback
|
|
196
211
|
if st.loop_state == "recording":
|
|
@@ -213,59 +228,36 @@ def audio_callback(outdata, frames, time_info, status):
|
|
|
213
228
|
mix += loop_chunk
|
|
214
229
|
st.loop_pos = (st.loop_pos + frames) % buf_len
|
|
215
230
|
|
|
231
|
+
if st.metronome_enabled and st.mode in st.METRONOME_MODES:
|
|
232
|
+
spb = int(st.SAMPLE_RATE * 60.0 / st.bpm)
|
|
233
|
+
click_len = len(_CLICK_BUFFER)
|
|
234
|
+
for i in range(frames):
|
|
235
|
+
beat_pos = (st.metronome_pos + i) % spb
|
|
236
|
+
if beat_pos < click_len:
|
|
237
|
+
mix[i] += _CLICK_BUFFER[beat_pos]
|
|
238
|
+
st.metronome_pos = (st.metronome_pos + frames) % spb
|
|
239
|
+
|
|
216
240
|
mix *= 0.25
|
|
217
241
|
np.clip(mix, -1.0, 1.0, out=mix)
|
|
218
242
|
outdata[:, 0] = mix
|
|
219
243
|
return
|
|
220
244
|
|
|
221
245
|
if st.mode in ("sample", "pitch", "chromatic", "trim"):
|
|
222
|
-
if not st.sample_voices:
|
|
246
|
+
if not st.sample_voices and not (st.metronome_enabled and st.mode in st.METRONOME_MODES):
|
|
223
247
|
outdata[:] = 0
|
|
224
248
|
return
|
|
225
249
|
|
|
226
250
|
mix = np.zeros(frames)
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
for note, sv in st.sample_voices.items():
|
|
230
|
-
if st.mode == "chromatic":
|
|
231
|
-
sample_data = st.chromatic_samples.get(note)
|
|
232
|
-
if sample_data is None:
|
|
233
|
-
finished.append(note)
|
|
234
|
-
continue
|
|
235
|
-
else:
|
|
236
|
-
if note not in st.samples:
|
|
237
|
-
finished.append(note)
|
|
238
|
-
continue
|
|
239
|
-
ps = st.pitched_samples.get(note)
|
|
240
|
-
sample_data = ps if ps is not None else st.samples.get(note)
|
|
241
|
-
pos = sv["pos"]
|
|
242
|
-
gain = sv["gain"]
|
|
243
|
-
releasing = sv["releasing"]
|
|
244
|
-
|
|
245
|
-
remaining = len(sample_data) - pos
|
|
246
|
-
n = min(frames, remaining)
|
|
247
|
-
if n <= 0:
|
|
248
|
-
finished.append(note)
|
|
249
|
-
continue
|
|
250
|
-
|
|
251
|
-
buf = np.zeros(frames)
|
|
252
|
-
buf[:n] = sample_data[pos:pos + n] * gain
|
|
253
|
-
|
|
254
|
-
if releasing:
|
|
255
|
-
fade_gains = np.maximum(0.0, gain - st.FADE_OUT_STEP * (np.arange(n) + 1))
|
|
256
|
-
buf[:n] *= fade_gains
|
|
257
|
-
gain = max(0.0, gain - st.FADE_OUT_STEP * n)
|
|
258
|
-
else:
|
|
259
|
-
gain = min(1.0, gain + st.FADE_IN_STEP * n)
|
|
260
|
-
|
|
261
|
-
mix += buf
|
|
262
|
-
sv["pos"] = pos + n
|
|
263
|
-
sv["gain"] = gain
|
|
264
|
-
if (releasing and gain <= 0.0) or pos + n >= len(sample_data):
|
|
265
|
-
finished.append(note)
|
|
251
|
+
_mix_sample_voices(frames, mix)
|
|
266
252
|
|
|
267
|
-
|
|
268
|
-
|
|
253
|
+
if st.metronome_enabled and st.mode in st.METRONOME_MODES:
|
|
254
|
+
spb = int(st.SAMPLE_RATE * 60.0 / st.bpm)
|
|
255
|
+
click_len = len(_CLICK_BUFFER)
|
|
256
|
+
for i in range(frames):
|
|
257
|
+
beat_pos = (st.metronome_pos + i) % spb
|
|
258
|
+
if beat_pos < click_len:
|
|
259
|
+
mix[i] += _CLICK_BUFFER[beat_pos]
|
|
260
|
+
st.metronome_pos = (st.metronome_pos + frames) % spb
|
|
269
261
|
|
|
270
262
|
mix *= 0.25
|
|
271
263
|
np.clip(mix, -1.0, 1.0, out=mix)
|
|
@@ -273,7 +265,7 @@ def audio_callback(outdata, frames, time_info, status):
|
|
|
273
265
|
return
|
|
274
266
|
|
|
275
267
|
# Synth mode
|
|
276
|
-
if not st.voices:
|
|
268
|
+
if not st.voices and not (st.metronome_enabled and st.mode in st.METRONOME_MODES):
|
|
277
269
|
outdata[:] = 0
|
|
278
270
|
return
|
|
279
271
|
|
|
@@ -307,6 +299,15 @@ def audio_callback(outdata, frames, time_info, status):
|
|
|
307
299
|
for note in finished:
|
|
308
300
|
del st.voices[note]
|
|
309
301
|
|
|
302
|
+
if st.metronome_enabled and st.mode in st.METRONOME_MODES:
|
|
303
|
+
spb = int(st.SAMPLE_RATE * 60.0 / st.bpm)
|
|
304
|
+
click_len = len(_CLICK_BUFFER)
|
|
305
|
+
for i in range(frames):
|
|
306
|
+
beat_pos = (st.metronome_pos + i) % spb
|
|
307
|
+
if beat_pos < click_len:
|
|
308
|
+
mix[i] += _CLICK_BUFFER[beat_pos]
|
|
309
|
+
st.metronome_pos = (st.metronome_pos + frames) % spb
|
|
310
|
+
|
|
310
311
|
mix *= 0.25 # fixed headroom (up to ~4 voices at full level)
|
|
311
312
|
np.clip(mix, -1.0, 1.0, out=mix)
|
|
312
313
|
outdata[:, 0] = mix
|
|
@@ -51,8 +51,7 @@ def midi_callback(event, data):
|
|
|
51
51
|
st.split_current_slice += 1
|
|
52
52
|
st.split_loop_pos = 0
|
|
53
53
|
if st.split_current_slice >= len(slices):
|
|
54
|
-
|
|
55
|
-
reset_split_state()
|
|
54
|
+
st.reset_split_state()
|
|
56
55
|
return
|
|
57
56
|
|
|
58
57
|
if st.mode == "copy":
|
|
@@ -78,17 +77,19 @@ def midi_callback(event, data):
|
|
|
78
77
|
st.pitch_selected_note = note
|
|
79
78
|
return
|
|
80
79
|
if st.mode == "chromatic":
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
should_compute = False
|
|
81
|
+
with st.voices_lock:
|
|
82
|
+
if st.chromatic_selecting:
|
|
83
|
+
if note in st.samples:
|
|
84
|
+
st.chromatic_root_note = note
|
|
85
85
|
st.sample_voices.clear()
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
with st.voices_lock:
|
|
86
|
+
st.chromatic_selecting = False
|
|
87
|
+
should_compute = True
|
|
88
|
+
else:
|
|
90
89
|
if note in st.chromatic_samples:
|
|
91
90
|
st.sample_voices[note] = {"pos": 0, "releasing": False, "gain": 1.0}
|
|
91
|
+
if should_compute:
|
|
92
|
+
compute_chromatic_samples_async(note)
|
|
92
93
|
return
|
|
93
94
|
with st.voices_lock:
|
|
94
95
|
if st.mode in ("sample", "loop"):
|
|
@@ -17,11 +17,12 @@ KEYBOARD_NOTE_RELEASE_S = 0.25
|
|
|
17
17
|
RST = "\033[0m"
|
|
18
18
|
DIM = "\033[2m"
|
|
19
19
|
BOLD = "\033[1m"
|
|
20
|
-
GREEN = "\033[
|
|
20
|
+
GREEN = "\033[38;5;65m"
|
|
21
21
|
RED = "\033[31m"
|
|
22
22
|
YELLOW = "\033[33m"
|
|
23
23
|
CYAN = "\033[36m"
|
|
24
24
|
ORANGE = "\033[38;5;208m"
|
|
25
|
+
SLATE = "\033[38;5;66m"
|
|
25
26
|
CLEAR_LINE = "\033[2K"
|
|
26
27
|
|
|
27
28
|
NOTE_SYNESTHESIA_COLORS = {
|
|
@@ -118,6 +119,21 @@ split_current_slice = 0 # which slice is being assigned
|
|
|
118
119
|
split_loop_pos = 0 # audio preview playback position
|
|
119
120
|
split_looping = False # True while preview loop is active
|
|
120
121
|
|
|
122
|
+
|
|
123
|
+
def reset_split_state():
|
|
124
|
+
"""Reset all split mode state back to selection."""
|
|
125
|
+
global split_selected_note, split_markers, split_phase
|
|
126
|
+
global split_cursor_pos, split_current_slice, split_loop_pos, split_looping
|
|
127
|
+
split_selected_note = None
|
|
128
|
+
split_markers = []
|
|
129
|
+
split_phase = "markers"
|
|
130
|
+
split_cursor_pos = 0
|
|
131
|
+
split_current_slice = 0
|
|
132
|
+
with voices_lock:
|
|
133
|
+
split_loop_pos = 0
|
|
134
|
+
split_looping = False
|
|
135
|
+
|
|
136
|
+
|
|
121
137
|
# Loop mode state
|
|
122
138
|
loop_state = "idle" # "idle", "recording", "playing", "overdub"
|
|
123
139
|
loop_buffer = None # np.ndarray once recorded, None when idle
|
|
@@ -125,6 +141,15 @@ loop_pos = 0 # current playback position (int)
|
|
|
125
141
|
loop_record_chunks = [] # list of np.ndarray, accumulated during recording
|
|
126
142
|
loop_layer_count = 0 # 1 after first recording, increments each overdub completion
|
|
127
143
|
|
|
144
|
+
METRONOME_MODES = {"synth", "sample", "record", "loop", "chromatic"}
|
|
145
|
+
|
|
146
|
+
# Metronome state
|
|
147
|
+
metronome_enabled = False
|
|
148
|
+
bpm = 120.0
|
|
149
|
+
metronome_pos = 0 # sample counter, wraps every beat
|
|
150
|
+
BPM_MIN = 20.0
|
|
151
|
+
BPM_MAX = 300.0
|
|
152
|
+
|
|
128
153
|
# Copy mode state
|
|
129
154
|
copy_source_note = None # MIDI note selected as source
|
|
130
155
|
copy_dest_note = None # MIDI note selected as destination
|
|
@@ -20,59 +20,24 @@ def _synesthesia_color(note_name):
|
|
|
20
20
|
return st.NOTE_SYNESTHESIA_COLORS.get(pc, "")
|
|
21
21
|
|
|
22
22
|
|
|
23
|
-
def
|
|
24
|
-
"""Render a centered ASCII waveform of the sample with trim region highlighted."""
|
|
25
|
-
audio = st.samples.get(note)
|
|
26
|
-
if audio is None:
|
|
27
|
-
return []
|
|
28
|
-
cols = os.get_terminal_size().columns - 2 # leave margin
|
|
29
|
-
cols = max(10, cols)
|
|
30
|
-
n = len(audio)
|
|
31
|
-
rows = st.TRIM_WAVEFORM_HEIGHT
|
|
32
|
-
half = rows / 2 # rows above/below center (float for smooth mapping)
|
|
33
|
-
blocks = " \u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588"
|
|
34
|
-
blocks_rev = " \u2594\u2594\u2580\u2580\u2580\u2580\u2588\u2588"
|
|
35
|
-
|
|
36
|
-
# Compute peak amplitude per column bin
|
|
23
|
+
def _render_waveform_core(audio, cols, rows, color_fn):
|
|
37
24
|
peaks = np.zeros(cols)
|
|
25
|
+
n = len(audio)
|
|
38
26
|
for c in range(cols):
|
|
39
27
|
start = c * n // cols
|
|
40
28
|
end = (c + 1) * n // cols
|
|
41
29
|
if start < end:
|
|
42
30
|
peaks[c] = np.max(np.abs(audio[start:end]))
|
|
43
|
-
|
|
44
31
|
max_peak = np.max(peaks) if np.max(peaks) > 0 else 1.0
|
|
45
32
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
# Active marker column
|
|
51
|
-
if st.trim_step == 0:
|
|
52
|
-
marker_col = trim_start_col
|
|
53
|
-
else:
|
|
54
|
-
marker_col = min(trim_end_col - 1, cols - 1)
|
|
55
|
-
|
|
56
|
-
# Playback position column (only during audio, not silence padding)
|
|
57
|
-
region_len = st.trim_end - st.trim_start
|
|
58
|
-
if region_len > 0 and st.trim_looping and st.trim_loop_pos < region_len:
|
|
59
|
-
play_offset = st.trim_loop_pos
|
|
60
|
-
play_col = trim_start_col + play_offset * (trim_end_col - trim_start_col) // region_len
|
|
61
|
-
play_col = max(trim_start_col, min(play_col, trim_end_col - 1))
|
|
62
|
-
else:
|
|
63
|
-
play_col = -1
|
|
64
|
-
|
|
65
|
-
WHITE = "\033[97m"
|
|
66
|
-
|
|
33
|
+
blocks = " \u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588"
|
|
34
|
+
blocks_rev = " \u2594\u2594\u2580\u2580\u2580\u2580\u2588\u2588"
|
|
35
|
+
half = rows / 2
|
|
67
36
|
lines = []
|
|
68
37
|
for row in range(rows):
|
|
69
|
-
|
|
70
|
-
# Upper half: rows 0..half-1 (top row = max amplitude)
|
|
71
|
-
# Lower half: rows half..rows-1 (bottom row = max amplitude)
|
|
72
|
-
dist_from_center = abs(row - half + 0.5) / half # 0 at center, 1 at edges
|
|
38
|
+
dist_from_center = abs(row - half + 0.5) / half
|
|
73
39
|
threshold_low = dist_from_center
|
|
74
40
|
threshold_high = dist_from_center + 1.0 / half
|
|
75
|
-
|
|
76
41
|
is_upper = row < half
|
|
77
42
|
line = " "
|
|
78
43
|
for c in range(cols):
|
|
@@ -82,93 +47,62 @@ def render_waveform(note):
|
|
|
82
47
|
else:
|
|
83
48
|
frac = min(1.0, (level - threshold_low) / (threshold_high - threshold_low))
|
|
84
49
|
idx = int(frac * (len(blocks) - 1))
|
|
85
|
-
if is_upper
|
|
86
|
-
|
|
87
|
-
else:
|
|
88
|
-
# Bottom half: use reversed blocks so fill grows from top of cell
|
|
89
|
-
char = blocks_rev[idx]
|
|
90
|
-
|
|
91
|
-
in_region = trim_start_col <= c < trim_end_col
|
|
92
|
-
is_boundary = c == trim_start_col or c == trim_end_col - 1
|
|
93
|
-
if c == play_col:
|
|
94
|
-
head = char if char.strip() else "\u2502"
|
|
95
|
-
line += f"{st.RED}{st.BOLD}{head}{st.RST}"
|
|
96
|
-
elif is_boundary:
|
|
97
|
-
bar = char if char.strip() else "\u2502"
|
|
98
|
-
if c == marker_col:
|
|
99
|
-
line += f"{st.ORANGE}{st.BOLD}{bar}{st.RST}"
|
|
100
|
-
else:
|
|
101
|
-
line += f"{st.CYAN}{bar}{st.RST}"
|
|
102
|
-
elif in_region:
|
|
103
|
-
line += f"{st.CYAN}{char}{st.RST}"
|
|
104
|
-
else:
|
|
105
|
-
line += f"{st.DIM}{char}{st.RST}"
|
|
50
|
+
char = blocks[idx] if is_upper else blocks_rev[idx]
|
|
51
|
+
line += color_fn(c, char)
|
|
106
52
|
lines.append(line)
|
|
107
53
|
return lines
|
|
108
54
|
|
|
109
55
|
|
|
110
|
-
def
|
|
111
|
-
"""Render a
|
|
112
|
-
|
|
113
|
-
|
|
56
|
+
def render_waveform(note):
|
|
57
|
+
"""Render a centered ASCII waveform of the sample with trim region highlighted."""
|
|
58
|
+
audio = st.samples.get(note)
|
|
59
|
+
if audio is None:
|
|
60
|
+
return []
|
|
61
|
+
cols = max(10, os.get_terminal_size().columns - 2)
|
|
114
62
|
n = len(audio)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
63
|
+
trim_start_col = st.trim_start * cols // n if n > 0 else 0
|
|
64
|
+
trim_end_col = (st.trim_end * cols + n - 1) // n if n > 0 else cols
|
|
65
|
+
marker_col = trim_start_col if st.trim_step == 0 else min(trim_end_col - 1, cols - 1)
|
|
66
|
+
region_len = st.trim_end - st.trim_start
|
|
67
|
+
if region_len > 0 and st.trim_looping and st.trim_loop_pos < region_len:
|
|
68
|
+
play_col = trim_start_col + st.trim_loop_pos * (trim_end_col - trim_start_col) // region_len
|
|
69
|
+
play_col = max(trim_start_col, min(play_col, trim_end_col - 1))
|
|
70
|
+
else:
|
|
71
|
+
play_col = -1
|
|
119
72
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
73
|
+
def color_fn(c, char):
|
|
74
|
+
if c == play_col:
|
|
75
|
+
head = char if char.strip() else "\u2502"
|
|
76
|
+
return f"{st.RED}{st.BOLD}{head}{st.RST}"
|
|
77
|
+
if c == trim_start_col or c == trim_end_col - 1:
|
|
78
|
+
bar = char if char.strip() else "\u2502"
|
|
79
|
+
if c == marker_col:
|
|
80
|
+
return f"{st.ORANGE}{st.BOLD}{bar}{st.RST}"
|
|
81
|
+
return f"{st.CYAN}{bar}{st.RST}"
|
|
82
|
+
if trim_start_col <= c < trim_end_col:
|
|
83
|
+
return f"{st.CYAN}{char}{st.RST}"
|
|
84
|
+
return f"{st.DIM}{char}{st.RST}"
|
|
127
85
|
|
|
128
|
-
|
|
86
|
+
return _render_waveform_core(audio, cols, st.TRIM_WAVEFORM_HEIGHT, color_fn)
|
|
129
87
|
|
|
130
|
-
# Color by state
|
|
131
|
-
if loop_state == "recording":
|
|
132
|
-
color = st.RED
|
|
133
|
-
elif loop_state == "overdub":
|
|
134
|
-
color = st.YELLOW
|
|
135
|
-
else:
|
|
136
|
-
color = st.GREEN
|
|
137
88
|
|
|
138
|
-
|
|
89
|
+
def render_loop_waveform(audio, loop_state, loop_pos, loop_layer_count):
|
|
90
|
+
"""Render a 6-row waveform for loop mode with colored playhead."""
|
|
91
|
+
cols = max(10, os.get_terminal_size().columns - 2)
|
|
92
|
+
n = len(audio)
|
|
93
|
+
color = st.RED if loop_state == "recording" else st.YELLOW if loop_state == "overdub" else st.GREEN
|
|
139
94
|
if loop_state in ("playing", "overdub") and n > 0:
|
|
140
|
-
play_col = loop_pos * cols // n
|
|
141
|
-
play_col = max(0, min(play_col, cols - 1))
|
|
95
|
+
play_col = max(0, min(loop_pos * cols // n, cols - 1))
|
|
142
96
|
else:
|
|
143
97
|
play_col = -1
|
|
144
98
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
is_upper = row < half
|
|
152
|
-
line = " "
|
|
153
|
-
for c in range(cols):
|
|
154
|
-
level = peaks[c] / max_peak
|
|
155
|
-
if level <= threshold_low:
|
|
156
|
-
char = " "
|
|
157
|
-
else:
|
|
158
|
-
frac = min(1.0, (level - threshold_low) / (threshold_high - threshold_low))
|
|
159
|
-
idx = int(frac * (len(blocks) - 1))
|
|
160
|
-
if is_upper:
|
|
161
|
-
char = blocks[idx]
|
|
162
|
-
else:
|
|
163
|
-
char = blocks_rev[idx]
|
|
99
|
+
def color_fn(c, char):
|
|
100
|
+
if c == play_col:
|
|
101
|
+
head = char if char.strip() else "\u2502"
|
|
102
|
+
return f"{st.RED}{st.BOLD}{head}{st.RST}"
|
|
103
|
+
return f"{color}{char}{st.RST}"
|
|
164
104
|
|
|
165
|
-
|
|
166
|
-
head = char if char.strip() else "\u2502"
|
|
167
|
-
line += f"{st.RED}{st.BOLD}{head}{st.RST}"
|
|
168
|
-
else:
|
|
169
|
-
line += f"{color}{char}{st.RST}"
|
|
170
|
-
lines.append(line)
|
|
171
|
-
return lines
|
|
105
|
+
return _render_waveform_core(audio, cols, st.TRIM_WAVEFORM_HEIGHT, color_fn)
|
|
172
106
|
|
|
173
107
|
|
|
174
108
|
def reset_trim_state():
|
|
@@ -177,19 +111,10 @@ def reset_trim_state():
|
|
|
177
111
|
st.trim_step = 0
|
|
178
112
|
st.trim_start = 0
|
|
179
113
|
st.trim_end = 0
|
|
180
|
-
st.
|
|
181
|
-
|
|
182
|
-
|
|
114
|
+
with st.voices_lock:
|
|
115
|
+
st.trim_loop_pos = 0
|
|
116
|
+
st.trim_looping = False
|
|
183
117
|
|
|
184
|
-
def reset_split_state():
|
|
185
|
-
"""Reset all split mode state back to selection."""
|
|
186
|
-
st.split_selected_note = None
|
|
187
|
-
st.split_markers = []
|
|
188
|
-
st.split_phase = "markers"
|
|
189
|
-
st.split_cursor_pos = 0
|
|
190
|
-
st.split_current_slice = 0
|
|
191
|
-
st.split_loop_pos = 0
|
|
192
|
-
st.split_looping = False
|
|
193
118
|
|
|
194
119
|
|
|
195
120
|
def render_split_waveform(note):
|
|
@@ -197,81 +122,50 @@ def render_split_waveform(note):
|
|
|
197
122
|
audio = st.samples.get(note)
|
|
198
123
|
if audio is None:
|
|
199
124
|
return []
|
|
200
|
-
cols = os.get_terminal_size().columns - 2
|
|
201
|
-
cols = max(10, cols)
|
|
125
|
+
cols = max(10, os.get_terminal_size().columns - 2)
|
|
202
126
|
n = len(audio)
|
|
203
|
-
rows = st.TRIM_WAVEFORM_HEIGHT
|
|
204
|
-
half = rows / 2
|
|
205
|
-
blocks = " \u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588"
|
|
206
|
-
blocks_rev = " \u2594\u2594\u2580\u2580\u2580\u2580\u2588\u2588"
|
|
207
|
-
|
|
208
|
-
peaks = np.zeros(cols)
|
|
209
|
-
for c in range(cols):
|
|
210
|
-
start = c * n // cols
|
|
211
|
-
end = (c + 1) * n // cols
|
|
212
|
-
if start < end:
|
|
213
|
-
peaks[c] = np.max(np.abs(audio[start:end]))
|
|
214
|
-
|
|
215
|
-
max_peak = np.max(peaks) if np.max(peaks) > 0 else 1.0
|
|
216
127
|
|
|
217
128
|
if st.split_phase == "markers":
|
|
218
129
|
cursor_col = st.split_cursor_pos * cols // n if n > 0 else 0
|
|
219
130
|
marker_cols = set(m * cols // n for m in st.split_markers if n > 0)
|
|
220
|
-
|
|
221
|
-
# Playhead column — loop_pos is relative to last marker (or 0)
|
|
131
|
+
sorted_marker_cols = sorted(marker_cols)
|
|
222
132
|
region_start = st.split_markers[-1] if st.split_markers else 0
|
|
223
133
|
abs_play_pos = region_start + st.split_loop_pos
|
|
224
|
-
if st.split_looping and n > 0 and abs_play_pos < n
|
|
225
|
-
|
|
226
|
-
else
|
|
227
|
-
|
|
134
|
+
play_col = (abs_play_pos * cols // n) if st.split_looping and n > 0 and abs_play_pos < n else -1
|
|
135
|
+
parity = len(st.split_markers) % 2
|
|
136
|
+
right_color = st.CYAN if parity == 0 else st.SLATE
|
|
137
|
+
left_color = st.SLATE if parity == 0 else st.CYAN
|
|
138
|
+
num_regions = len(st.split_markers) + 1
|
|
228
139
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
line += f"{st.RED}{st.BOLD}{head}{st.RST}"
|
|
248
|
-
elif c == cursor_col:
|
|
249
|
-
bar = char if char.strip() else "\u2502"
|
|
250
|
-
line += f"{st.ORANGE}{st.BOLD}{bar}{st.RST}"
|
|
251
|
-
elif c in marker_cols:
|
|
252
|
-
bar = char if char.strip() else "\u2502"
|
|
253
|
-
line += f"{st.CYAN}{bar}{st.RST}"
|
|
254
|
-
else:
|
|
255
|
-
line += f"{st.DIM}{char}{st.RST}"
|
|
256
|
-
lines.append(line)
|
|
257
|
-
return lines
|
|
258
|
-
else:
|
|
259
|
-
# Assign phase: highlight current slice, dim others, show boundaries in orange
|
|
140
|
+
def color_fn(c, char):
|
|
141
|
+
if c == play_col:
|
|
142
|
+
head = char if char.strip() else "\u2502"
|
|
143
|
+
return f"{st.RED}{st.BOLD}{head}{st.RST}"
|
|
144
|
+
if c == cursor_col:
|
|
145
|
+
bar = char if char.strip() else "\u2502"
|
|
146
|
+
return f"{st.ORANGE}{st.BOLD}{bar}{st.RST}"
|
|
147
|
+
if c in marker_cols:
|
|
148
|
+
bar = char if char.strip() else "\u2502"
|
|
149
|
+
return f"{st.CYAN}{bar}{st.RST}"
|
|
150
|
+
ridx = sum(1 for mc in sorted_marker_cols if mc <= c)
|
|
151
|
+
if ridx == num_regions - 1:
|
|
152
|
+
color = st.DIM if c > cursor_col else left_color
|
|
153
|
+
else:
|
|
154
|
+
color = st.SLATE if ridx % 2 == 0 else st.CYAN
|
|
155
|
+
return f"{color}{char}{st.RST}"
|
|
156
|
+
|
|
157
|
+
else: # assign phase
|
|
260
158
|
slices = get_split_slices()
|
|
261
159
|
if not slices or st.split_current_slice >= len(slices):
|
|
262
160
|
return []
|
|
263
161
|
sl_start, sl_end = slices[st.split_current_slice]
|
|
264
162
|
sl_start_col = sl_start * cols // n if n > 0 else 0
|
|
265
163
|
sl_end_col = (sl_end * cols + n - 1) // n if n > 0 else cols
|
|
266
|
-
|
|
267
|
-
# All boundary columns
|
|
268
164
|
all_boundaries = set()
|
|
269
165
|
for s, e in slices:
|
|
270
166
|
if n > 0:
|
|
271
167
|
all_boundaries.add(s * cols // n)
|
|
272
168
|
all_boundaries.add(min((e * cols + n - 1) // n, cols - 1))
|
|
273
|
-
|
|
274
|
-
# Playback position column
|
|
275
169
|
region_len = sl_end - sl_start
|
|
276
170
|
if region_len > 0 and st.split_looping and st.split_loop_pos < region_len:
|
|
277
171
|
play_col = sl_start_col + st.split_loop_pos * (sl_end_col - sl_start_col) // region_len
|
|
@@ -279,35 +173,18 @@ def render_split_waveform(note):
|
|
|
279
173
|
else:
|
|
280
174
|
play_col = -1
|
|
281
175
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
frac = min(1.0, (level - threshold_low) / (threshold_high - threshold_low))
|
|
295
|
-
idx = int(frac * (len(blocks) - 1))
|
|
296
|
-
char = blocks[idx] if is_upper else blocks_rev[idx]
|
|
297
|
-
|
|
298
|
-
in_slice = sl_start_col <= c < sl_end_col
|
|
299
|
-
if c == play_col:
|
|
300
|
-
head = char if char.strip() else "\u2502"
|
|
301
|
-
line += f"{st.RED}{st.BOLD}{head}{st.RST}"
|
|
302
|
-
elif c in all_boundaries:
|
|
303
|
-
bar = char if char.strip() else "\u2502"
|
|
304
|
-
line += f"{st.ORANGE}{bar}{st.RST}"
|
|
305
|
-
elif in_slice:
|
|
306
|
-
line += f"{st.CYAN}{char}{st.RST}"
|
|
307
|
-
else:
|
|
308
|
-
line += f"{st.DIM}{char}{st.RST}"
|
|
309
|
-
lines.append(line)
|
|
310
|
-
return lines
|
|
176
|
+
def color_fn(c, char):
|
|
177
|
+
if c == play_col:
|
|
178
|
+
head = char if char.strip() else "\u2502"
|
|
179
|
+
return f"{st.RED}{st.BOLD}{head}{st.RST}"
|
|
180
|
+
if c in all_boundaries:
|
|
181
|
+
bar = char if char.strip() else "\u2502"
|
|
182
|
+
return f"{st.ORANGE}{bar}{st.RST}"
|
|
183
|
+
if sl_start_col <= c < sl_end_col:
|
|
184
|
+
return f"{st.YELLOW}{char}{st.RST}"
|
|
185
|
+
return f"{st.SLATE}{char}{st.RST}"
|
|
186
|
+
|
|
187
|
+
return _render_waveform_core(audio, cols, st.TRIM_WAVEFORM_HEIGHT, color_fn)
|
|
311
188
|
|
|
312
189
|
|
|
313
190
|
def render_config_panel():
|
|
@@ -547,6 +424,8 @@ def draw_ui():
|
|
|
547
424
|
line1 += f"{st.YELLOW}{st.BOLD}\u25cf{st.RST}"
|
|
548
425
|
else:
|
|
549
426
|
line1 += f"{st.DIM}\u25cb{st.RST}"
|
|
427
|
+
if st.metronome_enabled and st.mode in st.METRONOME_MODES:
|
|
428
|
+
line1 += f" {st.CYAN}{st.BOLD}\u2669 {st.bpm:.0f}{st.RST}"
|
|
550
429
|
out += _line(line1)
|
|
551
430
|
|
|
552
431
|
# Line 2: MIDI controller name or keyboard mode hint
|
|
@@ -567,8 +446,10 @@ def draw_ui():
|
|
|
567
446
|
elif st.mode == "chromatic":
|
|
568
447
|
if st.chromatic_selecting:
|
|
569
448
|
line3 = f" {st.DIM}[Tab] mode [play] select [q] quit{st.RST}"
|
|
449
|
+
elif st.metronome_enabled:
|
|
450
|
+
line3 = f" {st.DIM}[Tab] mode [m] metro off [\u2190\u2192] bpm\u00b11 [\u2191\u2193] bpm\u00b15 [r] reselect [q] quit{st.RST}"
|
|
570
451
|
else:
|
|
571
|
-
line3 = f" {st.DIM}[Tab] mode [r] reselect [q] quit{st.RST}"
|
|
452
|
+
line3 = f" {st.DIM}[Tab] mode [r] reselect [m] metro [q] quit{st.RST}"
|
|
572
453
|
elif st.mode == "copy":
|
|
573
454
|
if st.copy_confirm_pending:
|
|
574
455
|
line3 = f" {st.DIM}[enter] confirm [esc] cancel [q] quit{st.RST}"
|
|
@@ -584,11 +465,18 @@ def draw_ui():
|
|
|
584
465
|
else:
|
|
585
466
|
line3 = f" {st.DIM}[play] assign slice [esc] undo [q] quit{st.RST}"
|
|
586
467
|
elif st.mode == "loop":
|
|
587
|
-
|
|
468
|
+
if st.metronome_enabled:
|
|
469
|
+
line3 = f" {st.DIM}[Tab] mode [m] metro off [\u2190\u2192] bpm\u00b11 [\u2191\u2193] bpm\u00b15 [q] quit{st.RST}"
|
|
470
|
+
else:
|
|
471
|
+
line3 = f" {st.DIM}[sustain/l] record/play/dub [esc] clear [play] sample [m] metro [q] quit{st.RST}"
|
|
588
472
|
elif st.mode == "config":
|
|
589
473
|
line3 = f" {st.DIM}[\u2191\u2193] field [\u2190\u2192] change [Tab] mode [q] quit{st.RST}"
|
|
590
474
|
else:
|
|
591
|
-
|
|
475
|
+
if st.metronome_enabled and st.mode in st.METRONOME_MODES:
|
|
476
|
+
line3 = f" {st.DIM}[Tab] mode [m] metro off [\u2190\u2192] bpm\u00b11 [\u2191\u2193] bpm\u00b15 [q] quit{st.RST}"
|
|
477
|
+
else:
|
|
478
|
+
metro_hint = " [m] metro" if st.mode in st.METRONOME_MODES else ""
|
|
479
|
+
line3 = f" {st.DIM}[Tab] mode{metro_hint} [q] quit{st.RST}"
|
|
592
480
|
out += _line(line3)
|
|
593
481
|
|
|
594
482
|
sys.stdout.write(out)
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"permissions": {
|
|
3
|
-
"allow": [
|
|
4
|
-
"Bash(gh issue:*)",
|
|
5
|
-
"Bash(git:*)",
|
|
6
|
-
"Bash(python3 -c \"import rtmidi; print\\(rtmidi.__version__\\)\")",
|
|
7
|
-
"Bash(python3 -c \"import sounddevice; print\\(sounddevice.__version__\\)\")",
|
|
8
|
-
"Bash(python3 -c \"import numpy; print\\(numpy.__version__\\)\")",
|
|
9
|
-
"WebFetch(domain:spotlightkid.github.io)",
|
|
10
|
-
"WebFetch(domain:python-sounddevice.readthedocs.io)",
|
|
11
|
-
"Bash(python3 -m venv .venv)",
|
|
12
|
-
"Bash(source .venv/bin/activate)",
|
|
13
|
-
"Bash(pip install:*)",
|
|
14
|
-
"Bash(chmod:*)",
|
|
15
|
-
"Bash(ls /Users/mb/code/midi-cli/*.py /Users/mb/code/midi-cli/src/**/*.py)",
|
|
16
|
-
"Bash(gh pr:*)",
|
|
17
|
-
"Bash(wc -l /Users/mb/code/midi-cli/src/**/*.rs /Users/mb/code/midi-cli/src/*.rs)",
|
|
18
|
-
"Bash(grep -E \"\\\\.\\(py|txt\\)$\")",
|
|
19
|
-
"Bash(git commit:*)",
|
|
20
|
-
"Bash(ambiguous\". Use explicit None check instead.:*)",
|
|
21
|
-
"Bash(gh repo:*)",
|
|
22
|
-
"Bash(wc -l /Users/mb/code/midi-cli/src/*.py /Users/mb/code/midi-cli/*.py)",
|
|
23
|
-
"Bash(python3 -m py_compile main.py)",
|
|
24
|
-
"Bash(python3:*)",
|
|
25
|
-
"Bash(/Users/mb/code/midi-cli/.venv/bin/python -c \"import midi_cli.state; import midi_cli.audio; import midi_cli.sampler; import midi_cli.ui; import midi_cli.midi; import midi_cli.app; print\\('All imports OK'\\)\")",
|
|
26
|
-
"Bash(/Users/mb/code/midi-cli/.venv/bin/pip list:*)",
|
|
27
|
-
"Bash(/Users/mb/code/midi-cli/.venv/bin/python -c \"\nimport midi_cli.state as st\nprint\\(f'state: {len\\(st.MODES\\)} modes, SAMPLE_RATE={st.SAMPLE_RATE}'\\)\nimport midi_cli.audio\nprint\\(f'audio: audio_callback, build_synth_envelope, trim_silence OK'\\)\nprint\\('All non-pyrubberband imports OK'\\)\n\")",
|
|
28
|
-
"Bash(uv run:*)",
|
|
29
|
-
"Bash(wc -l /Users/mb/code/midi-cli/midi_cli/*.py /Users/mb/code/midi-cli/main.py)",
|
|
30
|
-
"Bash(python -m py_compile midi_cli/state.py)",
|
|
31
|
-
"Bash(python -m py_compile midi_cli/sampler.py)",
|
|
32
|
-
"Bash(python -m py_compile midi_cli/midi.py)",
|
|
33
|
-
"Bash(python -m py_compile midi_cli/ui.py)",
|
|
34
|
-
"Bash(python -m py_compile midi_cli/app.py)",
|
|
35
|
-
"Bash(brew search:*)",
|
|
36
|
-
"Bash(brew info:*)",
|
|
37
|
-
"WebSearch",
|
|
38
|
-
"Bash(python:*)",
|
|
39
|
-
"Bash(ls:*)",
|
|
40
|
-
"Bash(uv build:*)",
|
|
41
|
-
"Skill(update-config)",
|
|
42
|
-
"WebFetch(domain:docs.astral.sh)",
|
|
43
|
-
"Bash(source ~/.zshrc)",
|
|
44
|
-
"Bash(uv publish:*)",
|
|
45
|
-
"Bash(gh label:*)",
|
|
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:*)"
|
|
63
|
-
]
|
|
64
|
-
}
|
|
65
|
-
}
|
midi_cli-0.1.3/main.py
DELETED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|