Spaces:
Runtime error
Runtime error
| """ | |
| 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() | |