midi-cli 0.1.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.0/.claude/settings.local.json +42 -0
- midi_cli-0.1.0/.gitignore +9 -0
- midi_cli-0.1.0/LICENSE +21 -0
- midi_cli-0.1.0/PKG-INFO +47 -0
- midi_cli-0.1.0/README.md +27 -0
- midi_cli-0.1.0/main.py +6 -0
- midi_cli-0.1.0/midi_cli/__init__.py +0 -0
- midi_cli-0.1.0/midi_cli/app.py +179 -0
- midi_cli-0.1.0/midi_cli/audio.py +278 -0
- midi_cli-0.1.0/midi_cli/config.py +133 -0
- midi_cli-0.1.0/midi_cli/loop.py +30 -0
- midi_cli-0.1.0/midi_cli/midi.py +154 -0
- midi_cli-0.1.0/midi_cli/sampler.py +221 -0
- midi_cli-0.1.0/midi_cli/state.py +121 -0
- midi_cli-0.1.0/midi_cli/ui.py +260 -0
- midi_cli-0.1.0/pyproject.toml +26 -0
- midi_cli-0.1.0/uv.lock +246 -0
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
]
|
|
41
|
+
}
|
|
42
|
+
}
|
midi_cli-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mnbpdx
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
midi_cli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: midi-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A MIDI sampler and looper in your terminal
|
|
5
|
+
Project-URL: Homepage, https://github.com/mnbpdx/midi-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/mnbpdx/midi-cli
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: MacOS
|
|
12
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
13
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: MIDI
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: numpy
|
|
16
|
+
Requires-Dist: pyrubberband
|
|
17
|
+
Requires-Dist: python-rtmidi
|
|
18
|
+
Requires-Dist: sounddevice
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# midi-cli
|
|
22
|
+
|
|
23
|
+
A little midi player in your terminal, tested in zsh on macOS.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
pipx install midi-cli
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or with pip:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
pip install midi-cli
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### For development
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
uv pip install -e .
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
midi-cli
|
|
47
|
+
```
|
midi_cli-0.1.0/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# midi-cli
|
|
2
|
+
|
|
3
|
+
A little midi player in your terminal, tested in zsh on macOS.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pipx install midi-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or with pip:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pip install midi-cli
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### For development
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
uv pip install -e .
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
midi-cli
|
|
27
|
+
```
|
midi_cli-0.1.0/main.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import select
|
|
3
|
+
import sys
|
|
4
|
+
import termios
|
|
5
|
+
import threading
|
|
6
|
+
import tty
|
|
7
|
+
|
|
8
|
+
import sounddevice as sd
|
|
9
|
+
|
|
10
|
+
import midi_cli.state as st
|
|
11
|
+
from midi_cli.audio import audio_callback
|
|
12
|
+
from midi_cli.sampler import load_samples, load_pitch_offsets, save_sample, adjust_pitch, compute_chromatic_samples_async, execute_copy, stop_recording
|
|
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
|
|
15
|
+
from midi_cli.loop import reset_loop_state, cycle_loop_state
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main():
|
|
19
|
+
from midi_cli.config import run_setup_wizard
|
|
20
|
+
cfg = run_setup_wizard()
|
|
21
|
+
st.input_device = cfg.get("input_device")
|
|
22
|
+
st.output_device = cfg.get("output_device")
|
|
23
|
+
st.midi_port = cfg.get("midi_port")
|
|
24
|
+
sys.stdout.write("\033[2J\033[H")
|
|
25
|
+
sys.stdout.flush()
|
|
26
|
+
|
|
27
|
+
midi_in, port_name = open_midi_input(st.midi_port)
|
|
28
|
+
if midi_in is None:
|
|
29
|
+
st.keyboard_mock_mode = True
|
|
30
|
+
else:
|
|
31
|
+
st.ui_midi_port = port_name
|
|
32
|
+
|
|
33
|
+
load_samples()
|
|
34
|
+
load_pitch_offsets()
|
|
35
|
+
|
|
36
|
+
stream = sd.OutputStream(
|
|
37
|
+
samplerate=st.SAMPLE_RATE,
|
|
38
|
+
blocksize=st.BLOCK_SIZE,
|
|
39
|
+
channels=1,
|
|
40
|
+
callback=audio_callback,
|
|
41
|
+
device=st.output_device,
|
|
42
|
+
)
|
|
43
|
+
stream.start()
|
|
44
|
+
|
|
45
|
+
fd = sys.stdin.fileno()
|
|
46
|
+
old_settings = termios.tcgetattr(fd)
|
|
47
|
+
sys.stdout.write(st.HIDE_CURSOR)
|
|
48
|
+
sys.stdout.flush()
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
tty.setraw(fd)
|
|
52
|
+
initialized = False
|
|
53
|
+
while True:
|
|
54
|
+
draw_ui(initialized)
|
|
55
|
+
initialized = True
|
|
56
|
+
|
|
57
|
+
ready, _, _ = select.select([fd], [], [], 0.05)
|
|
58
|
+
if ready:
|
|
59
|
+
ch = os.read(fd, 1).decode("utf-8", errors="replace")
|
|
60
|
+
if ch in ("q", "\x03"): # q or Ctrl+C
|
|
61
|
+
break
|
|
62
|
+
elif ch == "\r" and st.mode == "trim" and st.trim_selected_note is not None:
|
|
63
|
+
if st.trim_step == 0:
|
|
64
|
+
# Confirm start, advance to end adjustment
|
|
65
|
+
st.trim_step = 1
|
|
66
|
+
st.trim_loop_pos = st.trim_start
|
|
67
|
+
else:
|
|
68
|
+
# Confirm end, slice and save
|
|
69
|
+
audio = st.samples[st.trim_selected_note][st.trim_start:st.trim_end]
|
|
70
|
+
note = st.trim_selected_note
|
|
71
|
+
save_sample(note, audio)
|
|
72
|
+
# Recompute chromatic if this was the root
|
|
73
|
+
if st.chromatic_root_note == note:
|
|
74
|
+
compute_chromatic_samples_async(note)
|
|
75
|
+
reset_trim_state()
|
|
76
|
+
elif ch == "\r" and st.mode == "copy" and st.copy_confirm_pending:
|
|
77
|
+
execute_copy()
|
|
78
|
+
elif ch == "\t": # Tab: next mode
|
|
79
|
+
if st.mode == "record":
|
|
80
|
+
st.keyboard_recording_notes.clear()
|
|
81
|
+
if st.recording_note is not None:
|
|
82
|
+
threading.Thread(target=stop_recording, args=(st.recording_note,), daemon=True).start()
|
|
83
|
+
if st.mode == "trim":
|
|
84
|
+
reset_trim_state()
|
|
85
|
+
if st.mode == "copy":
|
|
86
|
+
reset_copy_state()
|
|
87
|
+
if st.mode == "loop":
|
|
88
|
+
with st.voices_lock:
|
|
89
|
+
reset_loop_state()
|
|
90
|
+
st.mode = st.MODES[(st.MODES.index(st.mode) + 1) % len(st.MODES)]
|
|
91
|
+
if st.mode == "chromatic":
|
|
92
|
+
st.chromatic_selecting = st.chromatic_root_note is None
|
|
93
|
+
elif ch == "r" and st.mode == "chromatic" and not st.chromatic_selecting:
|
|
94
|
+
st.chromatic_selecting = True
|
|
95
|
+
elif ch == "\x1b": # escape sequence
|
|
96
|
+
# Use select on fd with timeout to distinguish bare Escape from sequences
|
|
97
|
+
esc_ready, _, _ = select.select([fd], [], [], 0.02)
|
|
98
|
+
if not esc_ready:
|
|
99
|
+
# Bare Escape
|
|
100
|
+
if st.mode == "trim" and st.trim_selected_note is not None:
|
|
101
|
+
reset_trim_state()
|
|
102
|
+
elif st.mode == "copy" and (st.copy_source_note is not None or st.copy_confirm_pending):
|
|
103
|
+
reset_copy_state()
|
|
104
|
+
elif st.mode == "loop":
|
|
105
|
+
with st.voices_lock:
|
|
106
|
+
reset_loop_state()
|
|
107
|
+
else:
|
|
108
|
+
seq = os.read(fd, 2).decode("utf-8", errors="replace")
|
|
109
|
+
if seq == "[Z": # Shift+Tab: previous mode
|
|
110
|
+
if st.mode == "record":
|
|
111
|
+
st.keyboard_recording_notes.clear()
|
|
112
|
+
if st.recording_note is not None:
|
|
113
|
+
threading.Thread(target=stop_recording, args=(st.recording_note,), daemon=True).start()
|
|
114
|
+
if st.mode == "trim":
|
|
115
|
+
reset_trim_state()
|
|
116
|
+
if st.mode == "copy":
|
|
117
|
+
reset_copy_state()
|
|
118
|
+
if st.mode == "loop":
|
|
119
|
+
with st.voices_lock:
|
|
120
|
+
reset_loop_state()
|
|
121
|
+
st.mode = st.MODES[(st.MODES.index(st.mode) - 1) % len(st.MODES)]
|
|
122
|
+
if st.mode == "chromatic":
|
|
123
|
+
st.chromatic_selecting = st.chromatic_root_note is None
|
|
124
|
+
elif st.mode == "trim" and st.trim_selected_note is not None:
|
|
125
|
+
sample_len = len(st.samples[st.trim_selected_note])
|
|
126
|
+
fine = max(1, sample_len // 1000) # 0.1%
|
|
127
|
+
coarse = max(1, sample_len // 100) # 1%
|
|
128
|
+
if st.trim_step == 0:
|
|
129
|
+
if seq == "[D": # Left: decrease start
|
|
130
|
+
st.trim_start = max(0, st.trim_start - fine)
|
|
131
|
+
elif seq == "[C": # Right: increase start
|
|
132
|
+
st.trim_start = min(st.trim_end - st.MIN_TRIM_SAMPLES, st.trim_start + fine)
|
|
133
|
+
elif seq == "[B": # Down: decrease start (coarse)
|
|
134
|
+
st.trim_start = max(0, st.trim_start - coarse)
|
|
135
|
+
elif seq == "[A": # Up: increase start (coarse)
|
|
136
|
+
st.trim_start = min(st.trim_end - st.MIN_TRIM_SAMPLES, st.trim_start + coarse)
|
|
137
|
+
else:
|
|
138
|
+
if seq == "[D": # Left: decrease end
|
|
139
|
+
st.trim_end = max(st.trim_start + st.MIN_TRIM_SAMPLES, st.trim_end - fine)
|
|
140
|
+
elif seq == "[C": # Right: increase end
|
|
141
|
+
st.trim_end = min(sample_len, st.trim_end + fine)
|
|
142
|
+
elif seq == "[B": # Down: decrease end (coarse)
|
|
143
|
+
st.trim_end = max(st.trim_start + st.MIN_TRIM_SAMPLES, st.trim_end - coarse)
|
|
144
|
+
elif seq == "[A": # Up: increase end (coarse)
|
|
145
|
+
st.trim_end = min(sample_len, st.trim_end + coarse)
|
|
146
|
+
st.trim_start = max(0, st.trim_start)
|
|
147
|
+
st.trim_end = max(st.trim_start + st.MIN_TRIM_SAMPLES, st.trim_end)
|
|
148
|
+
elif st.mode == "pitch" and st.pitch_selected_note is not None:
|
|
149
|
+
if seq == "[A": # Up: +1 semitone
|
|
150
|
+
adjust_pitch(st.pitch_selected_note, 1, 0)
|
|
151
|
+
elif seq == "[B": # Down: -1 semitone
|
|
152
|
+
adjust_pitch(st.pitch_selected_note, -1, 0)
|
|
153
|
+
elif seq == "[C": # Right: +10 cents
|
|
154
|
+
adjust_pitch(st.pitch_selected_note, 0, st.CENTS_STEP)
|
|
155
|
+
elif seq == "[D": # Left: -10 cents
|
|
156
|
+
adjust_pitch(st.pitch_selected_note, 0, -st.CENTS_STEP)
|
|
157
|
+
elif ch == 'l' and st.mode == "loop":
|
|
158
|
+
with st.voices_lock:
|
|
159
|
+
cycle_loop_state()
|
|
160
|
+
elif st.keyboard_mock_mode and ch in st.KEYBOARD_NOTE_MAP:
|
|
161
|
+
trigger_keyboard_note(st.KEYBOARD_NOTE_MAP[ch])
|
|
162
|
+
elif st.keyboard_mock_mode and ch == '`':
|
|
163
|
+
new_val = 0 if st.sustain else 127
|
|
164
|
+
midi_callback(([0xB0, 64, new_val], 0), None)
|
|
165
|
+
except (KeyboardInterrupt, EOFError):
|
|
166
|
+
pass
|
|
167
|
+
finally:
|
|
168
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
169
|
+
sys.stdout.write(st.SHOW_CURSOR)
|
|
170
|
+
sys.stdout.flush()
|
|
171
|
+
stream.stop()
|
|
172
|
+
stream.close()
|
|
173
|
+
if midi_in is not None:
|
|
174
|
+
midi_in.close_port()
|
|
175
|
+
midi_in.delete()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
main()
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
import midi_cli.state as st
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def build_synth_envelope(gain, releasing, faded_in, age, frames):
|
|
7
|
+
"""Vectorized envelope: returns (gains_array, new_gain, new_age, new_faded_in)."""
|
|
8
|
+
idx = np.arange(frames)
|
|
9
|
+
|
|
10
|
+
if releasing:
|
|
11
|
+
gains = np.maximum(0.0, gain - st.FADE_OUT_STEP * (idx + 1))
|
|
12
|
+
new_gain = max(0.0, gain - st.FADE_OUT_STEP * frames)
|
|
13
|
+
return gains, new_gain, age, faded_in
|
|
14
|
+
|
|
15
|
+
if not faded_in:
|
|
16
|
+
# Fade-in with concurrent stage1 decay:
|
|
17
|
+
# Recurrence: g' = (g + FADE_IN_STEP) * STAGE1_DECAY
|
|
18
|
+
# We compute iteratively to find the clamp point, then switch to pure decay.
|
|
19
|
+
gains = np.empty(frames)
|
|
20
|
+
g = gain
|
|
21
|
+
clamp_idx = frames # assume no clamp within block
|
|
22
|
+
for i in range(frames):
|
|
23
|
+
g = (g + st.FADE_IN_STEP) * st.STAGE1_DECAY
|
|
24
|
+
if g >= 1.0:
|
|
25
|
+
g = 1.0
|
|
26
|
+
clamp_idx = i
|
|
27
|
+
break
|
|
28
|
+
gains[i] = g
|
|
29
|
+
|
|
30
|
+
if clamp_idx < frames:
|
|
31
|
+
# Faded in at clamp_idx
|
|
32
|
+
gains[clamp_idx] = g
|
|
33
|
+
remaining = frames - clamp_idx - 1
|
|
34
|
+
if remaining > 0:
|
|
35
|
+
post_age = age + clamp_idx + 1
|
|
36
|
+
if post_age < st.STAGE1_SAMPLES:
|
|
37
|
+
s1_left = st.STAGE1_SAMPLES - post_age
|
|
38
|
+
if remaining <= s1_left:
|
|
39
|
+
gains[clamp_idx + 1:] = g * st.STAGE1_DECAY ** (idx[:remaining] + 1)
|
|
40
|
+
else:
|
|
41
|
+
gains[clamp_idx + 1:clamp_idx + 1 + s1_left] = g * st.STAGE1_DECAY ** (idx[:s1_left] + 1)
|
|
42
|
+
g_at_boundary = g * st.STAGE1_DECAY ** s1_left
|
|
43
|
+
rest = remaining - s1_left
|
|
44
|
+
gains[clamp_idx + 1 + s1_left:] = g_at_boundary * st.STAGE2_DECAY ** (idx[:rest] + 1)
|
|
45
|
+
else:
|
|
46
|
+
gains[clamp_idx + 1:] = g * st.STAGE2_DECAY ** (idx[:remaining] + 1)
|
|
47
|
+
new_gain = gains[-1]
|
|
48
|
+
new_age = age + frames
|
|
49
|
+
return gains, new_gain, new_age, True
|
|
50
|
+
else:
|
|
51
|
+
# Still fading in at end of block
|
|
52
|
+
new_gain = g
|
|
53
|
+
new_age = age + frames
|
|
54
|
+
return gains, new_gain, new_age, False
|
|
55
|
+
|
|
56
|
+
# Already faded in — pure decay
|
|
57
|
+
if age >= st.STAGE1_SAMPLES:
|
|
58
|
+
# Pure stage2
|
|
59
|
+
gains = gain * st.STAGE2_DECAY ** (idx + 1)
|
|
60
|
+
elif age + frames <= st.STAGE1_SAMPLES:
|
|
61
|
+
# Pure stage1
|
|
62
|
+
gains = gain * st.STAGE1_DECAY ** (idx + 1)
|
|
63
|
+
else:
|
|
64
|
+
# Stage1 → stage2 boundary mid-block
|
|
65
|
+
s1_left = st.STAGE1_SAMPLES - age
|
|
66
|
+
gains = np.empty(frames)
|
|
67
|
+
gains[:s1_left] = gain * st.STAGE1_DECAY ** (idx[:s1_left] + 1)
|
|
68
|
+
g_at_boundary = gain * st.STAGE1_DECAY ** s1_left
|
|
69
|
+
s2_count = frames - s1_left
|
|
70
|
+
gains[s1_left:] = g_at_boundary * st.STAGE2_DECAY ** (idx[:s2_count] + 1)
|
|
71
|
+
|
|
72
|
+
new_gain = gains[-1]
|
|
73
|
+
new_age = age + frames
|
|
74
|
+
return gains, new_gain, new_age, faded_in
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def audio_callback(outdata, frames, time_info, status):
|
|
78
|
+
with st.voices_lock:
|
|
79
|
+
if st.mode == "trim" and st.trim_looping and st.trim_selected_note is not None:
|
|
80
|
+
sample_data = st.samples.get(st.trim_selected_note)
|
|
81
|
+
if sample_data is None or st.trim_end <= st.trim_start:
|
|
82
|
+
outdata[:] = 0
|
|
83
|
+
return
|
|
84
|
+
# Bounds-check loop pos
|
|
85
|
+
region_len = st.trim_end - st.trim_start
|
|
86
|
+
cycle_len = max(region_len, st.MIN_LOOP_PERIOD)
|
|
87
|
+
if st.trim_loop_pos < 0 or st.trim_loop_pos >= cycle_len:
|
|
88
|
+
st.trim_loop_pos = 0
|
|
89
|
+
mix = np.zeros(frames)
|
|
90
|
+
written = 0
|
|
91
|
+
while written < frames:
|
|
92
|
+
if st.trim_loop_pos < region_len:
|
|
93
|
+
# Playing audio portion
|
|
94
|
+
src_pos = st.trim_start + st.trim_loop_pos
|
|
95
|
+
avail = region_len - st.trim_loop_pos
|
|
96
|
+
n = min(frames - written, avail)
|
|
97
|
+
mix[written:written + n] = sample_data[src_pos:src_pos + n]
|
|
98
|
+
st.trim_loop_pos += n
|
|
99
|
+
written += n
|
|
100
|
+
else:
|
|
101
|
+
# Silence padding portion
|
|
102
|
+
avail = cycle_len - st.trim_loop_pos
|
|
103
|
+
n = min(frames - written, avail)
|
|
104
|
+
# mix is already zeros
|
|
105
|
+
st.trim_loop_pos += n
|
|
106
|
+
written += n
|
|
107
|
+
if st.trim_loop_pos >= cycle_len:
|
|
108
|
+
st.trim_loop_pos = 0
|
|
109
|
+
mix *= 0.25
|
|
110
|
+
np.clip(mix, -1.0, 1.0, out=mix)
|
|
111
|
+
outdata[:, 0] = mix
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
if st.mode == "loop":
|
|
115
|
+
mix = np.zeros(frames)
|
|
116
|
+
|
|
117
|
+
# Sample voice mixing (same logic as sample mode)
|
|
118
|
+
finished = []
|
|
119
|
+
for note, sv in st.sample_voices.items():
|
|
120
|
+
if note not in st.samples:
|
|
121
|
+
finished.append(note)
|
|
122
|
+
continue
|
|
123
|
+
pos = sv["pos"]
|
|
124
|
+
gain = sv["gain"]
|
|
125
|
+
releasing = sv["releasing"]
|
|
126
|
+
sample_data = st.samples[note]
|
|
127
|
+
remaining = len(sample_data) - pos
|
|
128
|
+
n = min(frames, remaining)
|
|
129
|
+
if n <= 0:
|
|
130
|
+
finished.append(note)
|
|
131
|
+
continue
|
|
132
|
+
buf = np.zeros(frames)
|
|
133
|
+
buf[:n] = sample_data[pos:pos + n] * gain
|
|
134
|
+
if releasing:
|
|
135
|
+
fade_gains = np.maximum(0.0, gain - st.FADE_OUT_STEP * (np.arange(n) + 1))
|
|
136
|
+
buf[:n] *= fade_gains
|
|
137
|
+
gain = max(0.0, gain - st.FADE_OUT_STEP * n)
|
|
138
|
+
else:
|
|
139
|
+
gain = min(1.0, gain + st.FADE_IN_STEP * n)
|
|
140
|
+
mix += buf
|
|
141
|
+
sv["pos"] = pos + n
|
|
142
|
+
sv["gain"] = gain
|
|
143
|
+
if (releasing and gain <= 0.0) or pos + n >= len(sample_data):
|
|
144
|
+
finished.append(note)
|
|
145
|
+
for note in finished:
|
|
146
|
+
del st.sample_voices[note]
|
|
147
|
+
|
|
148
|
+
# Loop capture/playback
|
|
149
|
+
if st.loop_state == "recording":
|
|
150
|
+
st.loop_record_chunks.append(mix.copy())
|
|
151
|
+
elif st.loop_state in ("playing", "overdub") and st.loop_buffer is not None:
|
|
152
|
+
buf = st.loop_buffer
|
|
153
|
+
buf_len = len(buf)
|
|
154
|
+
loop_chunk = np.zeros(frames)
|
|
155
|
+
written = 0
|
|
156
|
+
pos = st.loop_pos
|
|
157
|
+
while written < frames:
|
|
158
|
+
avail = buf_len - pos
|
|
159
|
+
take = min(avail, frames - written)
|
|
160
|
+
loop_chunk[written:written + take] = buf[pos:pos + take]
|
|
161
|
+
if st.loop_state == "overdub":
|
|
162
|
+
buf[pos:pos + take] += mix[written:written + take]
|
|
163
|
+
np.clip(buf[pos:pos + take], -1.0, 1.0, out=buf[pos:pos + take])
|
|
164
|
+
written += take
|
|
165
|
+
pos = (pos + take) % buf_len
|
|
166
|
+
mix += loop_chunk
|
|
167
|
+
st.loop_pos = (st.loop_pos + frames) % buf_len
|
|
168
|
+
|
|
169
|
+
mix *= 0.25
|
|
170
|
+
np.clip(mix, -1.0, 1.0, out=mix)
|
|
171
|
+
outdata[:, 0] = mix
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
if st.mode in ("sample", "pitch", "chromatic", "trim"):
|
|
175
|
+
if not st.sample_voices:
|
|
176
|
+
outdata[:] = 0
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
mix = np.zeros(frames)
|
|
180
|
+
finished = []
|
|
181
|
+
|
|
182
|
+
for note, sv in st.sample_voices.items():
|
|
183
|
+
if st.mode == "chromatic":
|
|
184
|
+
sample_data = st.chromatic_samples.get(note)
|
|
185
|
+
if sample_data is None:
|
|
186
|
+
finished.append(note)
|
|
187
|
+
continue
|
|
188
|
+
else:
|
|
189
|
+
if note not in st.samples:
|
|
190
|
+
finished.append(note)
|
|
191
|
+
continue
|
|
192
|
+
ps = st.pitched_samples.get(note)
|
|
193
|
+
sample_data = ps if ps is not None else st.samples.get(note)
|
|
194
|
+
pos = sv["pos"]
|
|
195
|
+
gain = sv["gain"]
|
|
196
|
+
releasing = sv["releasing"]
|
|
197
|
+
|
|
198
|
+
remaining = len(sample_data) - pos
|
|
199
|
+
n = min(frames, remaining)
|
|
200
|
+
if n <= 0:
|
|
201
|
+
finished.append(note)
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
buf = np.zeros(frames)
|
|
205
|
+
buf[:n] = sample_data[pos:pos + n] * gain
|
|
206
|
+
|
|
207
|
+
if releasing:
|
|
208
|
+
fade_gains = np.maximum(0.0, gain - st.FADE_OUT_STEP * (np.arange(n) + 1))
|
|
209
|
+
buf[:n] *= fade_gains
|
|
210
|
+
gain = max(0.0, gain - st.FADE_OUT_STEP * n)
|
|
211
|
+
else:
|
|
212
|
+
gain = min(1.0, gain + st.FADE_IN_STEP * n)
|
|
213
|
+
|
|
214
|
+
mix += buf
|
|
215
|
+
sv["pos"] = pos + n
|
|
216
|
+
sv["gain"] = gain
|
|
217
|
+
if (releasing and gain <= 0.0) or pos + n >= len(sample_data):
|
|
218
|
+
finished.append(note)
|
|
219
|
+
|
|
220
|
+
for note in finished:
|
|
221
|
+
del st.sample_voices[note]
|
|
222
|
+
|
|
223
|
+
mix *= 0.25
|
|
224
|
+
np.clip(mix, -1.0, 1.0, out=mix)
|
|
225
|
+
outdata[:, 0] = mix
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
# Synth mode
|
|
229
|
+
if not st.voices:
|
|
230
|
+
outdata[:] = 0
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
mix = np.zeros(frames)
|
|
234
|
+
finished = []
|
|
235
|
+
|
|
236
|
+
for note, v in st.voices.items():
|
|
237
|
+
freq = st.note_to_freq(note)
|
|
238
|
+
phase = v["phase"]
|
|
239
|
+
gain = v["gain"]
|
|
240
|
+
releasing = v["releasing"]
|
|
241
|
+
age = v["age"]
|
|
242
|
+
faded_in = v["faded_in"]
|
|
243
|
+
|
|
244
|
+
phase_step = 2.0 * np.pi * freq / st.SAMPLE_RATE
|
|
245
|
+
phases = phase + np.arange(frames) * phase_step
|
|
246
|
+
phase_matrix = phases[:, None] * st.H_MULTIPLIERS[None, :]
|
|
247
|
+
raw_signal = np.sin(phase_matrix) @ st.HARMONICS
|
|
248
|
+
gains, gain, age, faded_in = build_synth_envelope(gain, releasing, faded_in, age, frames)
|
|
249
|
+
buf = raw_signal * gains
|
|
250
|
+
phase = phases[-1] + phase_step
|
|
251
|
+
|
|
252
|
+
mix += buf
|
|
253
|
+
v["phase"] = phase % (2.0 * np.pi)
|
|
254
|
+
v["gain"] = gain
|
|
255
|
+
v["age"] = age
|
|
256
|
+
v["faded_in"] = faded_in
|
|
257
|
+
if (releasing and gain == 0.0) or gain < st.SILENCE_THRESHOLD:
|
|
258
|
+
finished.append(note)
|
|
259
|
+
|
|
260
|
+
for note in finished:
|
|
261
|
+
del st.voices[note]
|
|
262
|
+
|
|
263
|
+
mix *= 0.25 # fixed headroom (up to ~4 voices at full level)
|
|
264
|
+
np.clip(mix, -1.0, 1.0, out=mix)
|
|
265
|
+
outdata[:, 0] = mix
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def trim_silence(audio, threshold_db=-20):
|
|
269
|
+
"""Trim leading and trailing silence from an audio array."""
|
|
270
|
+
threshold = 10 ** (threshold_db / 20.0) # convert dB to linear
|
|
271
|
+
above = np.abs(audio) > threshold
|
|
272
|
+
if not np.any(above):
|
|
273
|
+
return audio # all silence, return as-is
|
|
274
|
+
indices = np.where(above)[0]
|
|
275
|
+
pad = int(0.05 * st.SAMPLE_RATE) # 50ms pad
|
|
276
|
+
start = max(indices[0] - pad, 0)
|
|
277
|
+
end = min(indices[-1] + 1 + pad, len(audio))
|
|
278
|
+
return audio[start:end]
|