accompy / src /main.py
jasonmdong's picture
Sync from GitHub via hub-sync
c78e8af verified
Raw
History Blame Contribute Delete
8.75 kB
"""
Main entry point for the real-time piano accompanist.
Usage:
python main.py # MIDI keyboard input
python main.py --keyboard # Computer keyboard input
MIDI mode: choose input/output ports when prompted, then play the
right-hand melody on your MIDI keyboard.
Keyboard mode: press these keys to play notes (home row = white keys):
a=C s=D d=E f=F g=G h=A j=B k=C(high)
Play the right-hand melody of Twinkle Twinkle Little Star and the
left-hand accompaniment will follow your tempo automatically.
"""
import sys
import os
import importlib.util
import time
import threading
import queue
import tty
import termios
import rtmidi
from .tracker import ScoreTracker
from .accompanist import Accompanist
from .synth import play_note as synth_play_note
from .paths import get_scores_dir
def load_score(name: str):
"""Load RIGHT_HAND and LEFT_HAND from the current scores directory."""
path = os.path.join(str(get_scores_dir()), f"{name}.py")
if not os.path.exists(path):
print(f"Score not found: {path}")
print("Available scores:")
list_scores()
sys.exit(1)
spec = importlib.util.spec_from_file_location("_score", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod.RIGHT_HAND, mod.LEFT_HAND
def list_scores():
files = sorted(f[:-3] for f in os.listdir(str(get_scores_dir())) if f.endswith(".py"))
for f in files:
print(f" {f}")
def get_score_name() -> str:
"""Return the score name from --score flag, defaulting to 'twinkle'."""
for i, arg in enumerate(sys.argv[1:], 1):
if arg == "--score" and i < len(sys.argv):
return sys.argv[i + 1]
if arg.startswith("--score="):
return arg.split("=", 1)[1]
return "twinkle"
NOTE_ON_MASK = 0x90
NOTE_OFF_MASK = 0x80
# Computer keyboard → MIDI note mapping
#
# White keys: z x c v b n m (C3–B3)
# a s d f g h j (C4–B4)
# q w e r t y u (C5–B5) i=C6
#
# Sharps (Shift+white key):
# Z X V B N (C#3 D#3 F#3 G#3 A#3)
# A S F G H (C#4 D#4 F#4 G#4 A#4)
# Q W R T Y (C#5 D#5 F#5 G#5 A#5)
#
KEY_TO_PITCH = {
# C3 octave — whites
'z': 48, 'x': 50, 'c': 52, 'v': 53, 'b': 55, 'n': 57, 'm': 59,
# C3 octave — sharps (Shift)
'Z': 49, 'X': 51, 'V': 54, 'B': 56, 'N': 58,
# C4 octave — whites
'a': 60, 's': 62, 'd': 64, 'f': 65, 'g': 67, 'h': 69, 'j': 71,
# C4 octave — sharps (Shift)
'A': 61, 'S': 63, 'F': 66, 'G': 68, 'H': 70,
# C5 octave — whites
'q': 72, 'w': 74, 'e': 76, 'r': 77, 't': 79, 'y': 81, 'u': 83,
# C5 octave — sharps (Shift)
'Q': 73, 'W': 75, 'R': 78, 'T': 80, 'Y': 82,
# C6
'i': 84,
}
_NOTE_NAMES_ALL = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']
def _pitch_name(midi: int) -> str:
return _NOTE_NAMES_ALL[midi % 12] + str((midi // 12) - 1)
NOTE_NAMES = {v: _pitch_name(v) for v in KEY_TO_PITCH.values()}
def list_ports(midi_obj, label: str) -> list[str]:
ports = [midi_obj.get_port_name(i) for i in range(midi_obj.get_port_count())]
print(f"\nAvailable {label} ports:")
for i, name in enumerate(ports):
print(f" [{i}] {name}")
return ports
def choose_port(ports: list[str], label: str) -> int:
if not ports:
print(f"No {label} ports found. Connect a MIDI device and try again.")
sys.exit(1)
if len(ports) == 1:
print(f"Auto-selecting only {label} port: {ports[0]}")
return 0
while True:
try:
idx = int(input(f"Choose {label} port number: "))
if 0 <= idx < len(ports):
return idx
except ValueError:
pass
print("Invalid choice, try again.")
def _read_keys(note_queue: queue.Queue, stop_event: threading.Event):
"""Background thread: read single keypresses from stdin in raw mode."""
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
while not stop_event.is_set():
# Non-blocking read with a short timeout via select
import select
ready, _, _ = select.select([sys.stdin], [], [], 0.05)
if ready:
ch = sys.stdin.read(1)
if ch == '\x03': # Ctrl+C
note_queue.put(None) # sentinel to stop main loop
break
if ch in KEY_TO_PITCH:
note_queue.put(KEY_TO_PITCH[ch])
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
def prompt_bpm() -> float:
while True:
try:
val = input("Starting BPM (press Enter for 60): ").strip()
if val == "":
return 60.0
bpm = float(val)
if 20 <= bpm <= 300:
return bpm
except ValueError:
pass
print("Please enter a number between 20 and 300.")
def main_keyboard():
right, left = load_score(get_score_name())
initial_bps = prompt_bpm() / 60.0
tracker = ScoreTracker(right, initial_bps=initial_bps)
accompanist = Accompanist(left, right, initial_bps=initial_bps)
accompanist.start()
print("\nKeyboard mode — play the melody (left hand will follow your tempo):")
print(" Low (C3–B3): z x c v b n m")
print(" Mid (C4–B4): a s d f g h j")
print(" High (C5–B5): q w e r t y u i=C6")
print("Press Ctrl+C to stop.\n")
note_queue: queue.Queue = queue.Queue()
stop_event = threading.Event()
reader = threading.Thread(target=_read_keys, args=(note_queue, stop_event), daemon=True)
reader.start()
try:
while not tracker.is_finished():
try:
pitch = note_queue.get(timeout=0.1)
except queue.Empty:
continue
if pitch is None: # Ctrl+C from reader thread
break
beat = tracker.on_note(pitch)
synth_play_note(pitch)
if beat is not None:
bps = tracker.beats_per_second()
accompanist.on_rh_note(beat, bps)
sys.stdout.write(f" {NOTE_NAMES.get(pitch, pitch):<3} beat={beat:.1f} tempo={bps*60:.0f} BPM\r\n")
else:
sys.stdout.write(f" {NOTE_NAMES.get(pitch, pitch):<3} (no match)\r\n")
sys.stdout.flush()
except KeyboardInterrupt:
pass
finally:
stop_event.set()
accompanist.stop()
print("\r\nStopping.")
def main():
midi_in = rtmidi.MidiIn()
midi_out = rtmidi.MidiOut()
in_ports = list_ports(midi_in, "INPUT")
out_ports = list_ports(midi_out, "OUTPUT")
in_idx = choose_port(in_ports, "INPUT")
out_idx = choose_port(out_ports, "OUTPUT")
midi_in.open_port(in_idx)
midi_out.open_port(out_idx)
# Ignore SysEx, timing, and active sensing messages.
midi_in.ignore_types(sysex=True, timing=True, active_sense=True)
right, left = load_score(get_score_name())
initial_bps = prompt_bpm() / 60.0
tracker = ScoreTracker(right, initial_bps=initial_bps)
accompanist = Accompanist(left, right, initial_bps=initial_bps)
accompanist.start()
print("\nReady. Play the right-hand melody — left hand will follow your tempo.")
print("Press Ctrl+C to stop.\n")
try:
while not tracker.is_finished():
msg_and_dt = midi_in.get_message()
if msg_and_dt is None:
time.sleep(0.001)
continue
msg, _ = msg_and_dt
if len(msg) < 3:
continue
status, pitch, velocity = msg[0], msg[1], msg[2]
# Only react to note-on messages with non-zero velocity.
is_note_on = (status & 0xF0) == NOTE_ON_MASK and velocity > 0
if not is_note_on:
continue
beat = tracker.on_note(pitch)
if beat is not None:
bps = tracker.beats_per_second()
print(f" note={pitch:3d} beat={beat:.1f} tempo={bps*60:.0f} BPM")
accompanist.on_rh_note(beat, bps)
else:
print(f" note={pitch:3d} (no match)")
except KeyboardInterrupt:
pass
finally:
print("\nStopping.")
accompanist.stop()
midi_in.close_port()
midi_out.close_port()
if __name__ == "__main__":
if "--list" in sys.argv:
print("Available scores:")
list_scores()
elif "--keyboard" in sys.argv or "-k" in sys.argv:
main_keyboard()
else:
main()