Keyboard modal, VU meter fix, play_recording.py

- Keyboard mode is now a proper modal: kbd enters, Esc exits
- All keys go to MIDI while in keyboard mode, Up/Down change octave
- Header shows KBD and REC indicators
- VU meters use ASCII-safe characters
- play_recording.py: render MIDI through full engine

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 20:06:57 -04:00
parent c49ec27b1b
commit ac2801d07d
2 changed files with 107 additions and 27 deletions
+55
View File
@@ -0,0 +1,55 @@
"""Play a recorded MIDI file through pytheory's full renderer.
Takes a MIDI file captured by the live engine and plays it back
through the complete synthesis pipeline — with ensemble, effects,
reverb, and master compression.
Usage:
python play_recording.py recording.mid
python play_recording.py recording.mid --bpm 110
"""
import sys
import sounddevice as sd
from pytheory import Score
from pytheory.play import render_score, SAMPLE_RATE
def main():
if len(sys.argv) < 2:
print(" Usage: python play_recording.py <file.mid> [--bpm N]")
return
filename = sys.argv[1]
bpm = None
if "--bpm" in sys.argv:
idx = sys.argv.index("--bpm")
if idx + 1 < len(sys.argv):
bpm = int(sys.argv[idx + 1])
print(f" Loading {filename}...")
score = Score.from_midi(filename)
if bpm:
score.bpm = bpm
print(f" {score}")
print(f" Rendering...")
buf = render_score(score)
duration = len(buf) / SAMPLE_RATE
print(f" Playing ({duration:.1f}s)...")
try:
sd.play(buf, SAMPLE_RATE)
sd.wait()
except KeyboardInterrupt:
sd.stop()
print("\n Stopped.")
print(" Done.")
if __name__ == "__main__":
main()
+52 -27
View File
@@ -153,6 +153,7 @@ class LiveTUI:
tab_matches = [] tab_matches = []
tab_idx = -1 tab_idx = -1
tab_prefix = "" tab_prefix = ""
self.self.kbd_active = False
while self.running: while self.running:
try: try:
@@ -177,7 +178,11 @@ class LiveTUI:
stdscr.addstr(0, x, badge, curses.color_pair(badge_cp) | curses.A_BOLD) stdscr.addstr(0, x, badge, curses.color_pair(badge_cp) | curses.A_BOLD)
x += len(badge) x += len(badge)
info = f" BPM:{self.bpm} drums:{self.current_drum} seed:{self.seed}" kbd_mode = ""
if self.engine._keyboard_channel:
kbd_mode = f" KBD:ch{self.engine._keyboard_channel}"
rec_mode = " ●REC" if self.engine._recording else ""
info = f" BPM:{self.bpm} drums:{self.current_drum} seed:{self.seed}{kbd_mode}{rec_mode}"
try: try:
stdscr.addstr(0, x, info[:w - x], curses.color_pair(6)) stdscr.addstr(0, x, info[:w - x], curses.color_pair(6))
# Fill rest of header # Fill rest of header
@@ -245,8 +250,8 @@ class LiveTUI:
for i, inst in enumerate(self.picks, 1): for i, inst in enumerate(self.picks, 1):
if i in self.engine.channels: if i in self.engine.channels:
lv = self.engine.channels[i].level lv = self.engine.channels[i].level
bars = int(lv * 20) bars = int(min(lv, 1.0) * 16)
meter = "" * bars + "" * (20 - bars) meter = "|" * bars + "-" * (16 - bars)
color = 1 if bars < 15 else 3 if bars < 18 else 4 color = 1 if bars < 15 else 3 if bars < 18 else 4
try: try:
stdscr.addstr(y, cfg_x, f"{i}", curses.A_BOLD) stdscr.addstr(y, cfg_x, f"{i}", curses.A_BOLD)
@@ -306,8 +311,14 @@ class LiveTUI:
iy = h - 2 iy = h - 2
try: try:
stdscr.addstr(iy - 1, 0, "" * (w - 1), curses.A_DIM) stdscr.addstr(iy - 1, 0, "" * (w - 1), curses.A_DIM)
stdscr.addstr(iy, 0, " $ ", if self.kbd_active:
curses.color_pair(1) | curses.A_BOLD) stdscr.addstr(iy, 0, " KEYBOARD ",
curses.color_pair(7) | curses.A_BOLD)
stdscr.addstr(iy, 10, " Esc=exit Up/Down=octave ",
curses.color_pair(3))
else:
stdscr.addstr(iy, 0, " $ ",
curses.color_pair(1) | curses.A_BOLD)
stdscr.addstr(iy, 3, cmd_buf[:w - 5]) stdscr.addstr(iy, 3, cmd_buf[:w - 5])
# Cursor at position # Cursor at position
cx = 3 + cursor_pos cx = 3 + cursor_pos
@@ -327,7 +338,29 @@ class LiveTUI:
ch = stdscr.getch() ch = stdscr.getch()
if ch == -1: if ch == -1:
continue continue
elif ch == 10 or ch == 13:
# KEYBOARD MODE: all keys go to MIDI
if self.kbd_active:
if ch == 27: # Escape exits keyboard mode
self.kbd_active = False
self.engine._keyboard_channel = None
self.log("Keyboard off (Esc)", 3)
elif ch == curses.KEY_UP:
self.engine._keyboard_octave = min(8, self.engine._keyboard_octave + 1)
self.log(f"Octave ↑ {self.engine._keyboard_octave}", 2)
elif ch == curses.KEY_DOWN:
self.engine._keyboard_octave = max(0, self.engine._keyboard_octave - 1)
self.log(f"Octave ↓ {self.engine._keyboard_octave}", 2)
elif 32 <= ch < 127:
key = chr(ch).lower()
if self.engine.keyboard_note(key, on=True):
def _off(k=key):
time.sleep(0.2)
self.engine.keyboard_note(k, on=False)
threading.Thread(target=_off, daemon=True).start()
continue
if ch == 10 or ch == 13:
if cmd_buf.strip(): if cmd_buf.strip():
cmd_history.append(cmd_buf) cmd_history.append(cmd_buf)
self._handle_command(cmd_buf.strip()) self._handle_command(cmd_buf.strip())
@@ -335,8 +368,13 @@ class LiveTUI:
cursor_pos = 0 cursor_pos = 0
history_idx = -1 history_idx = -1
elif ch == 27: elif ch == 27:
cmd_buf = "" if self.engine._keyboard_channel and not cmd_buf:
cursor_pos = 0 # Escape exits keyboard mode
self.engine._keyboard_channel = None
self.log("Keyboard off (Esc)", 3)
else:
cmd_buf = ""
cursor_pos = 0
elif ch == curses.KEY_BACKSPACE or ch == 127: elif ch == curses.KEY_BACKSPACE or ch == 127:
if cursor_pos > 0: if cursor_pos > 0:
cmd_buf = cmd_buf[:cursor_pos - 1] + cmd_buf[cursor_pos:] cmd_buf = cmd_buf[:cursor_pos - 1] + cmd_buf[cursor_pos:]
@@ -380,19 +418,8 @@ class LiveTUI:
tab_matches = [] tab_matches = []
cursor_pos = len(cmd_buf) cursor_pos = len(cmd_buf)
elif 32 <= ch < 127: elif 32 <= ch < 127:
key = chr(ch) cmd_buf = cmd_buf[:cursor_pos] + chr(ch) + cmd_buf[cursor_pos:]
# Keyboard-as-MIDI: if keyboard mode is on and not typing a command cursor_pos += 1
if (self.engine._keyboard_channel and
not cmd_buf and
self.engine.keyboard_note(key.lower(), on=True)):
# Schedule note-off after 200ms
def _off(k=key.lower()):
time.sleep(0.2)
self.engine.keyboard_note(k, on=False)
threading.Thread(target=_off, daemon=True).start()
else:
cmd_buf = cmd_buf[:cursor_pos] + key + cmd_buf[cursor_pos:]
cursor_pos += 1
tab_matches = [] tab_matches = []
tab_idx = -1 tab_idx = -1
@@ -592,15 +619,13 @@ class LiveTUI:
self.engine._keyboard_channel = ch_num self.engine._keyboard_channel = ch_num
if len(parts) >= 3: if len(parts) >= 3:
self.engine._keyboard_octave = int(parts[2]) self.engine._keyboard_octave = int(parts[2])
self.log(f"Keyboard → ch{ch_num} oct{self.engine._keyboard_octave}", 1)
except ValueError: except ValueError:
self.log("kbd <channel> [octave]", 4) self.log("kbd <channel> [octave]", 4)
elif self.engine._keyboard_channel: return
self.engine._keyboard_channel = None
self.log("Keyboard off", 3)
else: else:
self.engine._keyboard_channel = 1 self.engine._keyboard_channel = self.engine._keyboard_channel or 1
self.log(f"Keyboard → ch1 oct{self.engine._keyboard_octave}", 1) self.kbd_active = True
self.log(f"♪ Keyboard ON ch{self.engine._keyboard_channel} oct{self.engine._keyboard_octave} (Esc=exit, ↑↓=octave)", 1)
elif verb == "rec": elif verb == "rec":
self.engine.start_recording() self.engine.start_recording()
self.log("● Recording...", 4) self.log("● Recording...", 4)