midi-cli 0.1.3__tar.gz → 0.3.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.3.0}/.gitignore +1 -0
- midi_cli-0.3.0/ARCHITECTURE.md +149 -0
- midi_cli-0.3.0/CLAUDE.md +5 -0
- {midi_cli-0.1.3 → midi_cli-0.3.0}/PKG-INFO +1 -1
- {midi_cli-0.1.3 → midi_cli-0.3.0}/midi_cli/app.py +154 -78
- {midi_cli-0.1.3 → midi_cli-0.3.0}/midi_cli/audio.py +74 -73
- {midi_cli-0.1.3 → midi_cli-0.3.0}/midi_cli/midi.py +14 -17
- {midi_cli-0.1.3 → midi_cli-0.3.0}/midi_cli/state.py +29 -2
- {midi_cli-0.1.3 → midi_cli-0.3.0}/midi_cli/ui.py +125 -233
- {midi_cli-0.1.3 → midi_cli-0.3.0}/pyproject.toml +1 -1
- {midi_cli-0.1.3 → midi_cli-0.3.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.3.0}/LICENSE +0 -0
- {midi_cli-0.1.3 → midi_cli-0.3.0}/README.md +0 -0
- {midi_cli-0.1.3 → midi_cli-0.3.0}/midi_cli/__init__.py +0 -0
- {midi_cli-0.1.3 → midi_cli-0.3.0}/midi_cli/config.py +0 -0
- {midi_cli-0.1.3 → midi_cli-0.3.0}/midi_cli/loop.py +0 -0
- {midi_cli-0.1.3 → midi_cli-0.3.0}/midi_cli/sampler.py +0 -0
|
@@ -0,0 +1,149 @@
|
|
|
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 → pitch → chromatic → trim → copy → loop → split
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- **Tab** advances to the next mode (wraps)
|
|
37
|
+
- **Shift+Tab** goes to the previous mode
|
|
38
|
+
- Switching away from `trim`, `copy`, `split`, or `loop` resets that mode's transient state
|
|
39
|
+
- **`r`** is a global key toggle (arm/disarm recording) — not a mode
|
|
40
|
+
- **`c`** opens the config overlay — not a mode
|
|
41
|
+
|
|
42
|
+
### synth
|
|
43
|
+
Polyphonic additive synth (5 harmonics, piano-like two-stage decay).
|
|
44
|
+
Note on → voice added to `state.voices`. Note off → voice set to `releasing`.
|
|
45
|
+
Sustain pedal (CC 64) holds notes until pedal release.
|
|
46
|
+
|
|
47
|
+
### sample
|
|
48
|
+
Plays back recorded samples. Note on → sample voice added to `state.sample_voices`, plays from position 0.
|
|
49
|
+
Note off → voice set to `releasing` (5ms fade out). Sample plays to its natural end before being removed.
|
|
50
|
+
|
|
51
|
+
### Record armed (`r` key)
|
|
52
|
+
`record_armed` is a boolean flag in state, not a mode. Press **r** to arm (yellow ARM indicator appears); press **r** again to disarm.
|
|
53
|
+
While armed: note on → `start_recording()`, note off → `stop_recording()`.
|
|
54
|
+
In keyboard mock mode: key press toggles recording on/off per-note via `keyboard_recording_notes`.
|
|
55
|
+
Tabbing away while armed automatically disarms and stops any active recording.
|
|
56
|
+
In chromatic mode, **r** means "reselect root" — it does not arm recording.
|
|
57
|
+
|
|
58
|
+
### pitch
|
|
59
|
+
Note on → plays the sample and sets `pitch_selected_note`.
|
|
60
|
+
Arrow keys adjust pitch offset for the selected note (Up/Down: ±1 semitone, Left/Right: ±10 cents).
|
|
61
|
+
Changes saved to `samples/pitch_offsets.json` and recomputed asynchronously via pyrubberband.
|
|
62
|
+
|
|
63
|
+
### chromatic
|
|
64
|
+
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`.
|
|
65
|
+
On entry: if no root is set, enters "selecting" sub-state — next note-on with a sample sets the root and triggers pre-computation.
|
|
66
|
+
Press **r** to re-select root.
|
|
67
|
+
|
|
68
|
+
### trim
|
|
69
|
+
Note on (with an existing sample) → selects that note, begins loop preview of full sample.
|
|
70
|
+
Two-step workflow:
|
|
71
|
+
1. **Step 0** — adjust start point with arrow keys (fine: 0.1%, coarse: 1%), **Enter** to confirm
|
|
72
|
+
2. **Step 1** — adjust end point with arrow keys, **Enter** to save the trimmed slice
|
|
73
|
+
|
|
74
|
+
**Escape** in step 1 goes back to step 0. **Escape** in step 0 deselects.
|
|
75
|
+
|
|
76
|
+
### copy
|
|
77
|
+
Two-step note selection:
|
|
78
|
+
1. Note on → sets `copy_source_note` (must have a sample)
|
|
79
|
+
2. Note on → sets `copy_dest_note`; if dest already has a sample, prompts for confirmation (**Enter** to confirm, **Escape** to cancel)
|
|
80
|
+
|
|
81
|
+
### loop
|
|
82
|
+
Loop state machine: `idle → recording → playing → overdub → playing → ...`
|
|
83
|
+
Triggered by **l** key.
|
|
84
|
+
Sample voices (from note-on) are mixed into the loop buffer during recording and overdub.
|
|
85
|
+
**Escape** resets to idle.
|
|
86
|
+
|
|
87
|
+
State transitions (`loop.py:cycle_loop_state`):
|
|
88
|
+
- `idle` → `recording`: clears buffer, starts accumulating audio blocks
|
|
89
|
+
- `recording` → `playing`: concatenates chunks into `loop_buffer`, starts playback
|
|
90
|
+
- `playing` → `overdub`: incoming audio mixed (summed) into `loop_buffer` in real time
|
|
91
|
+
- `overdub` → `playing`: increments `loop_layer_count`, returns to playback
|
|
92
|
+
|
|
93
|
+
### split
|
|
94
|
+
Splits one sample into multiple slices and assigns each to a MIDI note.
|
|
95
|
+
Two phases:
|
|
96
|
+
- **markers**: select a source note, use arrow keys to position cursor, **Enter** to place a marker. **a** advances to assign phase.
|
|
97
|
+
- **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).
|
|
98
|
+
|
|
99
|
+
### Config overlay (`c` key)
|
|
100
|
+
Press **c** from any mode to open the config overlay (rendered in the waveform area). All keys except **q** are swallowed while the overlay is open.
|
|
101
|
+
Up/Down navigates fields. Left/Right changes values. Fields: input device, output device, MIDI port, synesthesia toggle. Changes saved immediately on each arrow press.
|
|
102
|
+
Press **c** or **Escape** to close the overlay. Focus always resets to field 0 on open.
|
|
103
|
+
|
|
104
|
+
## Audio Callback
|
|
105
|
+
|
|
106
|
+
`audio_callback` runs on a real-time background thread (called by sounddevice). It holds `voices_lock` for the entire block.
|
|
107
|
+
|
|
108
|
+
Priority order inside the callback:
|
|
109
|
+
1. **trim** loop preview (if `trim_looping`)
|
|
110
|
+
2. **split** loop preview (if `split_looping`)
|
|
111
|
+
3. **loop** mode — sample voices + loop buffer capture/playback/overdub
|
|
112
|
+
4. **sample / pitch / chromatic** — sample voice playback
|
|
113
|
+
5. **synth** — additive synthesis
|
|
114
|
+
|
|
115
|
+
Metronome is mixed in at the end of blocks 3–5 when `metronome_enabled` and the current mode is in `METRONOME_MODES`.
|
|
116
|
+
|
|
117
|
+
## Key Behaviors
|
|
118
|
+
|
|
119
|
+
### Note release
|
|
120
|
+
| Input source | Synth mode | Sample/pitch/chromatic modes |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| MIDI note-off | Triggers fade-out (`releasing=True`) | Triggers fade-out |
|
|
123
|
+
| Keyboard mock | Auto note-off after 250ms (`KEYBOARD_NOTE_RELEASE_S`) | No auto note-off — sample plays to end |
|
|
124
|
+
|
|
125
|
+
### Sustain pedal (CC 64)
|
|
126
|
+
- In **synth**: holds notes; releasing pedal fades all held notes
|
|
127
|
+
- Other modes: ignored
|
|
128
|
+
|
|
129
|
+
### Metronome
|
|
130
|
+
- **m** key toggles on/off in modes: `synth`, `sample`, `loop`, `chromatic`
|
|
131
|
+
- Arrow keys adjust BPM (±1 / ±5) when metronome is on and no mode-specific arrow binding takes priority
|
|
132
|
+
- BPM range: 20–300
|
|
133
|
+
|
|
134
|
+
## State Locking
|
|
135
|
+
|
|
136
|
+
`state.voices_lock` is shared between:
|
|
137
|
+
- `audio_callback` (audio thread) — holds lock for entire block
|
|
138
|
+
- `midi_callback` (MIDI thread) — holds lock briefly for voice mutations
|
|
139
|
+
- `app.py` main loop — holds lock for loop state transitions and some mode resets
|
|
140
|
+
|
|
141
|
+
All mutations to `voices`, `sample_voices`, `loop_buffer`, `loop_state`, and related fields must happen under this lock.
|
|
142
|
+
|
|
143
|
+
## Persistence
|
|
144
|
+
|
|
145
|
+
| Data | Location |
|
|
146
|
+
|------|----------|
|
|
147
|
+
| Samples | `samples/note_<midi>.wav` (16-bit mono, 48kHz) |
|
|
148
|
+
| Pitch offsets | `samples/pitch_offsets.json` |
|
|
149
|
+
| Config (devices, synesthesia) | `~/.config/midi_cli/config.json` |
|
midi_cli-0.3.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,35 @@ 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 _read_escape_seq(fd):
|
|
18
|
+
seq = [os.read(fd, 1).decode("utf-8", errors="replace")]
|
|
19
|
+
if seq[0] == 'O':
|
|
20
|
+
r, _, _ = select.select([fd], [], [], 0.02)
|
|
21
|
+
if r:
|
|
22
|
+
seq.append(os.read(fd, 1).decode("utf-8", errors="replace"))
|
|
23
|
+
elif seq[0] == '[':
|
|
24
|
+
while True:
|
|
25
|
+
r, _, _ = select.select([fd], [], [], 0.02)
|
|
26
|
+
if not r:
|
|
27
|
+
break
|
|
28
|
+
b = os.read(fd, 1).decode("utf-8", errors="replace")
|
|
29
|
+
seq.append(b)
|
|
30
|
+
if b.isalpha() or b == '~':
|
|
31
|
+
break
|
|
32
|
+
if len(seq) > 16:
|
|
33
|
+
break
|
|
34
|
+
return "".join(seq)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _stop_active_voices():
|
|
38
|
+
"""Clear all active voices on mode switch."""
|
|
39
|
+
with st.voices_lock:
|
|
40
|
+
st.sample_voices.clear()
|
|
41
|
+
st.voices.clear()
|
|
42
|
+
|
|
17
43
|
|
|
18
44
|
def main():
|
|
19
45
|
from midi_cli.config import load_defaults, get_input_devices, get_output_devices, get_midi_ports, save_config
|
|
@@ -60,16 +86,90 @@ def main():
|
|
|
60
86
|
ch = os.read(fd, 1).decode("utf-8", errors="replace")
|
|
61
87
|
if ch in ("q", "\x03"): # q or Ctrl+C
|
|
62
88
|
break
|
|
89
|
+
elif st.config_overlay_open:
|
|
90
|
+
if ch == "c":
|
|
91
|
+
st.config_overlay_open = False
|
|
92
|
+
elif ch == "\x1b":
|
|
93
|
+
esc_ready, _, _ = select.select([fd], [], [], 0.02)
|
|
94
|
+
if not esc_ready:
|
|
95
|
+
st.config_overlay_open = False
|
|
96
|
+
else:
|
|
97
|
+
seq = _read_escape_seq(fd)
|
|
98
|
+
if seq == "[Z": # Shift+Tab: ignore while overlay open
|
|
99
|
+
pass
|
|
100
|
+
elif seq == "[A": # Up: previous field
|
|
101
|
+
st.config_focused_field = (st.config_focused_field - 1) % 4
|
|
102
|
+
elif seq == "[B": # Down: next field
|
|
103
|
+
st.config_focused_field = (st.config_focused_field + 1) % 4
|
|
104
|
+
elif seq in ("[C", "[D"): # Right/Left: change value
|
|
105
|
+
direction = 1 if seq == "[C" else -1
|
|
106
|
+
field = st.config_focused_field
|
|
107
|
+
if field == 3: # Synesthesia toggle
|
|
108
|
+
st.synesthesia_enabled = not st.synesthesia_enabled
|
|
109
|
+
elif field == 0: # Input device
|
|
110
|
+
devices = get_input_devices()
|
|
111
|
+
if devices:
|
|
112
|
+
cur = st.input_device
|
|
113
|
+
idx = devices.index(cur) if cur in devices else 0
|
|
114
|
+
st.input_device = devices[(idx + direction) % len(devices)]
|
|
115
|
+
elif field == 1: # Output device
|
|
116
|
+
devices = get_output_devices()
|
|
117
|
+
if devices:
|
|
118
|
+
cur = st.output_device
|
|
119
|
+
idx = devices.index(cur) if cur in devices else 0
|
|
120
|
+
new_device = devices[(idx + direction) % len(devices)]
|
|
121
|
+
stream.stop()
|
|
122
|
+
stream.close()
|
|
123
|
+
st.output_device = new_device
|
|
124
|
+
stream = sd.OutputStream(
|
|
125
|
+
samplerate=st.SAMPLE_RATE,
|
|
126
|
+
blocksize=st.BLOCK_SIZE,
|
|
127
|
+
channels=1,
|
|
128
|
+
callback=audio_callback,
|
|
129
|
+
device=st.output_device,
|
|
130
|
+
)
|
|
131
|
+
stream.start()
|
|
132
|
+
elif field == 2: # MIDI port
|
|
133
|
+
ports = get_midi_ports()
|
|
134
|
+
options = [None] + ports
|
|
135
|
+
cur = st.midi_port
|
|
136
|
+
idx = options.index(cur) if cur in options else 0
|
|
137
|
+
new_port = options[(idx + direction) % len(options)]
|
|
138
|
+
if midi_in is not None:
|
|
139
|
+
midi_in.close_port()
|
|
140
|
+
midi_in.delete()
|
|
141
|
+
midi_in = None
|
|
142
|
+
st.midi_port = new_port
|
|
143
|
+
if new_port is None:
|
|
144
|
+
st.keyboard_mock_mode = True
|
|
145
|
+
st.ui_midi_port = ""
|
|
146
|
+
else:
|
|
147
|
+
midi_in, port_name = open_midi_input(new_port)
|
|
148
|
+
if midi_in is None:
|
|
149
|
+
st.keyboard_mock_mode = True
|
|
150
|
+
st.ui_midi_port = ""
|
|
151
|
+
else:
|
|
152
|
+
st.keyboard_mock_mode = False
|
|
153
|
+
st.ui_midi_port = port_name
|
|
154
|
+
save_config({
|
|
155
|
+
"input_device": st.input_device,
|
|
156
|
+
"output_device": st.output_device,
|
|
157
|
+
"midi_port": st.midi_port,
|
|
158
|
+
"synesthesia_enabled": st.synesthesia_enabled,
|
|
159
|
+
})
|
|
160
|
+
# all other keys are silently swallowed by the overlay
|
|
63
161
|
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
|
-
|
|
162
|
+
with st.voices_lock:
|
|
163
|
+
st.split_markers.append(st.split_cursor_pos)
|
|
164
|
+
st.split_markers.sort()
|
|
165
|
+
# Restart loop from start of new region (last marker → end)
|
|
166
|
+
st.split_loop_pos = 0
|
|
68
167
|
elif ch == "\r" and st.mode == "trim" and st.trim_selected_note is not None:
|
|
69
168
|
if st.trim_step == 0:
|
|
70
169
|
# Confirm start, advance to end adjustment
|
|
71
170
|
st.trim_step = 1
|
|
72
|
-
|
|
171
|
+
with st.voices_lock:
|
|
172
|
+
st.trim_loop_pos = st.trim_start
|
|
73
173
|
else:
|
|
74
174
|
# Confirm end, slice and save
|
|
75
175
|
audio = st.samples[st.trim_selected_note][st.trim_start:st.trim_end]
|
|
@@ -82,7 +182,8 @@ def main():
|
|
|
82
182
|
elif ch == "\r" and st.mode == "copy" and st.copy_confirm_pending:
|
|
83
183
|
execute_copy()
|
|
84
184
|
elif ch == "\t": # Tab: next mode
|
|
85
|
-
if st.
|
|
185
|
+
if st.record_armed:
|
|
186
|
+
st.record_armed = False
|
|
86
187
|
st.keyboard_recording_notes.clear()
|
|
87
188
|
if st.recording_note is not None:
|
|
88
189
|
threading.Thread(target=stop_recording, args=(st.recording_note,), daemon=True).start()
|
|
@@ -91,15 +192,27 @@ def main():
|
|
|
91
192
|
if st.mode == "copy":
|
|
92
193
|
reset_copy_state()
|
|
93
194
|
if st.mode == "split":
|
|
94
|
-
reset_split_state()
|
|
195
|
+
st.reset_split_state()
|
|
95
196
|
if st.mode == "loop":
|
|
96
197
|
with st.voices_lock:
|
|
97
198
|
reset_loop_state()
|
|
199
|
+
_stop_active_voices()
|
|
98
200
|
st.mode = st.MODES[(st.MODES.index(st.mode) + 1) % len(st.MODES)]
|
|
99
201
|
if st.mode == "chromatic":
|
|
100
202
|
st.chromatic_selecting = st.chromatic_root_note is None
|
|
101
203
|
elif ch == "r" and st.mode == "chromatic" and not st.chromatic_selecting:
|
|
102
204
|
st.chromatic_selecting = True
|
|
205
|
+
elif ch == "r" and st.mode == "sample":
|
|
206
|
+
if st.record_armed:
|
|
207
|
+
st.record_armed = False
|
|
208
|
+
st.keyboard_recording_notes.clear()
|
|
209
|
+
if st.recording_note is not None:
|
|
210
|
+
threading.Thread(target=stop_recording, args=(st.recording_note,), daemon=True).start()
|
|
211
|
+
else:
|
|
212
|
+
st.record_armed = True
|
|
213
|
+
elif ch == "c":
|
|
214
|
+
st.config_overlay_open = True
|
|
215
|
+
st.config_focused_field = 0
|
|
103
216
|
elif ch == "\x1b": # escape sequence
|
|
104
217
|
# Use select on fd with timeout to distinguish bare Escape from sequences
|
|
105
218
|
esc_ready, _, _ = select.select([fd], [], [], 0.02)
|
|
@@ -108,7 +221,8 @@ def main():
|
|
|
108
221
|
if st.mode == "trim" and st.trim_selected_note is not None:
|
|
109
222
|
if st.trim_step == 1:
|
|
110
223
|
st.trim_step = 0
|
|
111
|
-
|
|
224
|
+
with st.voices_lock:
|
|
225
|
+
st.trim_loop_pos = st.trim_start
|
|
112
226
|
else:
|
|
113
227
|
reset_trim_state()
|
|
114
228
|
elif st.mode == "copy" and (st.copy_source_note is not None or st.copy_confirm_pending):
|
|
@@ -120,20 +234,28 @@ def main():
|
|
|
120
234
|
if st.split_phase == "assign":
|
|
121
235
|
if st.split_current_slice > 0:
|
|
122
236
|
st.split_current_slice -= 1
|
|
123
|
-
st.
|
|
237
|
+
with st.voices_lock:
|
|
238
|
+
st.split_loop_pos = 0
|
|
124
239
|
else:
|
|
125
240
|
st.split_phase = "markers"
|
|
126
|
-
st.
|
|
241
|
+
with st.voices_lock:
|
|
242
|
+
st.split_looping = False
|
|
127
243
|
else:
|
|
128
244
|
# markers phase
|
|
129
245
|
if st.split_markers:
|
|
130
|
-
st.
|
|
246
|
+
with st.voices_lock:
|
|
247
|
+
st.split_markers.pop()
|
|
131
248
|
else:
|
|
132
249
|
st.split_selected_note = None
|
|
133
250
|
else:
|
|
134
|
-
|
|
251
|
+
# Read a complete VT escape sequence. F5-F10 send CSI
|
|
252
|
+
# sequences like \x1b[15~ (5 bytes); reading only 2
|
|
253
|
+
# bytes would leak digits into the next iteration and
|
|
254
|
+
# trigger unintended notes via KEYBOARD_NOTE_MAP.
|
|
255
|
+
seq = _read_escape_seq(fd)
|
|
135
256
|
if seq == "[Z": # Shift+Tab: previous mode
|
|
136
|
-
if st.
|
|
257
|
+
if st.record_armed:
|
|
258
|
+
st.record_armed = False
|
|
137
259
|
st.keyboard_recording_notes.clear()
|
|
138
260
|
if st.recording_note is not None:
|
|
139
261
|
threading.Thread(target=stop_recording, args=(st.recording_note,), daemon=True).start()
|
|
@@ -142,10 +264,11 @@ def main():
|
|
|
142
264
|
if st.mode == "copy":
|
|
143
265
|
reset_copy_state()
|
|
144
266
|
if st.mode == "split":
|
|
145
|
-
reset_split_state()
|
|
267
|
+
st.reset_split_state()
|
|
146
268
|
if st.mode == "loop":
|
|
147
269
|
with st.voices_lock:
|
|
148
270
|
reset_loop_state()
|
|
271
|
+
_stop_active_voices()
|
|
149
272
|
st.mode = st.MODES[(st.MODES.index(st.mode) - 1) % len(st.MODES)]
|
|
150
273
|
if st.mode == "chromatic":
|
|
151
274
|
st.chromatic_selecting = st.chromatic_root_note is None
|
|
@@ -194,75 +317,28 @@ def main():
|
|
|
194
317
|
st.split_cursor_pos = min(sample_len - 1, st.split_cursor_pos + coarse)
|
|
195
318
|
elif seq == "[B": # Down: move cursor back coarse
|
|
196
319
|
st.split_cursor_pos = max(0, st.split_cursor_pos - coarse)
|
|
197
|
-
elif st.mode
|
|
198
|
-
if seq == "[
|
|
199
|
-
st.
|
|
200
|
-
elif seq == "[
|
|
201
|
-
st.
|
|
202
|
-
elif seq
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
st.synesthesia_enabled = not st.synesthesia_enabled
|
|
207
|
-
elif field == 0: # Input device
|
|
208
|
-
devices = get_input_devices()
|
|
209
|
-
if devices:
|
|
210
|
-
cur = st.input_device
|
|
211
|
-
idx = devices.index(cur) if cur in devices else 0
|
|
212
|
-
st.input_device = devices[(idx + direction) % len(devices)]
|
|
213
|
-
elif field == 1: # Output device
|
|
214
|
-
devices = get_output_devices()
|
|
215
|
-
if devices:
|
|
216
|
-
cur = st.output_device
|
|
217
|
-
idx = devices.index(cur) if cur in devices else 0
|
|
218
|
-
new_device = devices[(idx + direction) % len(devices)]
|
|
219
|
-
stream.stop()
|
|
220
|
-
stream.close()
|
|
221
|
-
st.output_device = new_device
|
|
222
|
-
stream = sd.OutputStream(
|
|
223
|
-
samplerate=st.SAMPLE_RATE,
|
|
224
|
-
blocksize=st.BLOCK_SIZE,
|
|
225
|
-
channels=1,
|
|
226
|
-
callback=audio_callback,
|
|
227
|
-
device=st.output_device,
|
|
228
|
-
)
|
|
229
|
-
stream.start()
|
|
230
|
-
elif field == 2: # MIDI port
|
|
231
|
-
ports = get_midi_ports()
|
|
232
|
-
options = [None] + ports
|
|
233
|
-
cur = st.midi_port
|
|
234
|
-
idx = options.index(cur) if cur in options else 0
|
|
235
|
-
new_port = options[(idx + direction) % len(options)]
|
|
236
|
-
if midi_in is not None:
|
|
237
|
-
midi_in.close_port()
|
|
238
|
-
midi_in.delete()
|
|
239
|
-
midi_in = None
|
|
240
|
-
st.midi_port = new_port
|
|
241
|
-
if new_port is None:
|
|
242
|
-
st.keyboard_mock_mode = True
|
|
243
|
-
st.ui_midi_port = ""
|
|
244
|
-
else:
|
|
245
|
-
midi_in, port_name = open_midi_input(new_port)
|
|
246
|
-
if midi_in is None:
|
|
247
|
-
st.keyboard_mock_mode = True
|
|
248
|
-
st.ui_midi_port = ""
|
|
249
|
-
else:
|
|
250
|
-
st.keyboard_mock_mode = False
|
|
251
|
-
st.ui_midi_port = port_name
|
|
252
|
-
save_config({
|
|
253
|
-
"input_device": st.input_device,
|
|
254
|
-
"output_device": st.output_device,
|
|
255
|
-
"midi_port": st.midi_port,
|
|
256
|
-
"synesthesia_enabled": st.synesthesia_enabled,
|
|
257
|
-
})
|
|
320
|
+
elif st.metronome_enabled and st.mode in st.METRONOME_MODES:
|
|
321
|
+
if seq == "[C": # Right: +1 BPM
|
|
322
|
+
st.bpm = min(st.BPM_MAX, st.bpm + 1)
|
|
323
|
+
elif seq == "[D": # Left: -1 BPM
|
|
324
|
+
st.bpm = max(st.BPM_MIN, st.bpm - 1)
|
|
325
|
+
elif seq == "[A": # Up: +5 BPM
|
|
326
|
+
st.bpm = min(st.BPM_MAX, st.bpm + 5)
|
|
327
|
+
elif seq == "[B": # Down: -5 BPM
|
|
328
|
+
st.bpm = max(st.BPM_MIN, st.bpm - 5)
|
|
258
329
|
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
330
|
st.split_phase = "assign"
|
|
260
331
|
st.split_current_slice = 0
|
|
261
|
-
st.
|
|
262
|
-
|
|
332
|
+
with st.voices_lock:
|
|
333
|
+
st.split_loop_pos = 0
|
|
334
|
+
st.split_looping = True
|
|
263
335
|
elif ch == 'l' and st.mode == "loop":
|
|
264
336
|
with st.voices_lock:
|
|
265
337
|
cycle_loop_state()
|
|
338
|
+
elif ch == 'm' and st.mode in st.METRONOME_MODES:
|
|
339
|
+
with st.voices_lock:
|
|
340
|
+
st.metronome_enabled = not st.metronome_enabled
|
|
341
|
+
st.metronome_pos = 0
|
|
266
342
|
elif st.keyboard_mock_mode and ch in st.KEYBOARD_NOTE_MAP:
|
|
267
343
|
trigger_keyboard_note(st.KEYBOARD_NOTE_MAP[ch])
|
|
268
344
|
elif st.keyboard_mock_mode and ch == '`':
|