play.py: WAV caching, instant playback, 'r' to render in picker

Tracks auto-cache to wavs/ on first play. Subsequent plays load the
WAV instantly (no render wait). 'r' key in picker force-renders.
Checkmark shows cached tracks. Picker returns to loop after playback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-03 12:26:44 -04:00
parent 860dc1f323
commit 9f6ea88665
+70 -17
View File
@@ -394,6 +394,7 @@ def pick_track():
selected = [0]
result = [None]
action = ["play"]
def _picker(stdscr):
import time, math
@@ -427,7 +428,7 @@ def pick_track():
pass
stdscr.addstr(3, max(0, (w - 15) // 2), "" * 15,
curses.color_pair(2))
stdscr.addstr(4, max(0, (w - 28) // 2), "↑/↓ navigate ↵ play q quit",
stdscr.addstr(4, max(0, (w - 42) // 2), "↑/↓ navigate ↵ play r render q quit",
curses.A_DIM)
# Track list
@@ -438,7 +439,8 @@ def pick_track():
break
name_col = 22
meta_str = f"{bpm:>3} BPM {m}:{s:02d}" if bpm else ""
cached = "" if _wav_path(f).exists() else " "
meta_str = f"{cached} {bpm:>3} BPM {m}:{s:02d}" if bpm else ""
name_display = title[:name_col - 1].ljust(name_col - 1)
num = f"{i + 1:>2}."
@@ -495,6 +497,11 @@ def pick_track():
selected[0] = (selected[0] + 1) % len(entries)
elif key in (curses.KEY_ENTER, 10, 13):
result[0] = entries[selected[0]][0]
action[0] = "play"
return
elif key == ord("r"):
result[0] = entries[selected[0]][0]
action[0] = "render"
return
elif key == ord("q") or key == 27:
return
@@ -502,9 +509,9 @@ def pick_track():
try:
curses.wrapper(_picker)
except KeyboardInterrupt:
return None
return None, None
return result[0]
return result[0], action[0]
def build_parser():
@@ -572,15 +579,17 @@ examples:
return p
def _play_track(path, args):
"""Load, render, and play a single track."""
path = Path(path)
if not path.exists():
print(f"File not found: {path}")
return
WAVS_DIR = Path(__file__).parent / "wavs"
def _wav_path(track_path):
"""Get the WAV cache path for a track."""
return WAVS_DIR / (Path(track_path).stem + ".wav")
def _render_and_cache(path, args):
"""Render a track and save to WAV cache. Returns (buf, sr, offset_sec)."""
score, mod = load_score(path)
title = get_title(mod, path)
if args.bpm:
score.bpm = args.bpm
@@ -592,9 +601,6 @@ def _play_track(path, args):
apply_mute(score, set(args.mute.split(",")))
apply_volume(score, args.volume)
show_info(score, mod, path)
print()
from_sec = parse_time(args.from_time) if args.from_time else None
to_sec = parse_time(args.to_time) if args.to_time else None
@@ -607,6 +613,49 @@ def _play_track(path, args):
loop=args.loop,
)
# Cache to WAV if no custom args that would change the output
if not any([args.bpm, args.pitch, args.solo, args.mute,
args.from_measure, args.to_measure,
args.from_time, args.to_time,
args.volume != 1.0, args.loop != 1]):
WAVS_DIR.mkdir(exist_ok=True)
wav = _wav_path(path)
save_wav(buf, sr, str(wav))
sys.stderr.write(f" Cached -> {wav}\n")
return buf, sr, offset_sec, score, mod
def _play_track(path, args, force_render=False):
"""Load, render, and play a single track. Uses cached WAV if available."""
path = Path(path)
if not path.exists():
print(f"File not found: {path}")
return
score, mod = load_score(path)
title = get_title(mod, path)
show_info(score, mod, path)
print()
# Check for cached WAV (only if no custom args)
wav = _wav_path(path)
has_custom = any([args.bpm, args.pitch, args.solo, args.mute,
args.from_measure, args.to_measure,
args.from_time, args.to_time,
args.volume != 1.0, args.loop != 1])
if wav.exists() and not force_render and not has_custom:
import numpy
import scipy.io.wavfile
sys.stderr.write(f" Playing cached WAV\n\n")
sr, pcm = scipy.io.wavfile.read(str(wav))
buf = pcm.astype(numpy.float64) / 32767.0
offset_sec = 0.0
else:
buf, sr, offset_sec, score, mod = _render_and_cache(path, args)
if args.output:
save_wav(buf, sr, args.output)
duration_sec = len(buf) / sr
@@ -642,10 +691,14 @@ def main():
# ── Track picker when no score given ─────────────────────────
if not args.score:
while True:
path = pick_track()
if path is None:
result = pick_track()
if result is None or result[0] is None:
return
_play_track(path, args)
path, act = result
if act == "render":
_play_track(path, args, force_render=True)
else:
_play_track(path, args)
return
else:
path = Path(args.score)