accompy / src /accompanist.py
jasonmdong's picture
Sync from GitHub via hub-sync
c78e8af verified
Raw
History Blame Contribute Delete
4.34 kB
"""
Left-hand scheduler with tempo tracking and right-hand sync.
How it works:
- A background thread fires LEFT_HAND events according to a running tempo clock.
- The clock is anchored to the last matched right-hand note (sync point).
- Before playing any LH event at beat B, the scheduler checks whether B has
reached the next expected RH beat. If so, it pauses and waits for the player.
- When the player hits the RH note, the clock resyncs to that beat, tempo
updates via moving average, and the scheduler resumes.
- If the player jumps ahead, LH events that are now in the past are skipped.
"""
import time
import threading
from .synth import play_chord
class Accompanist:
def __init__(self, left_hand: list, right_hand: list, initial_bps: float = 2.0):
self._events = sorted(left_hand, key=lambda x: x[1])
# Sorted unique beat positions of every right-hand note (sync points).
self._rh_beats = sorted({event[1] for event in right_hand})
self._bps = initial_bps
self._sync_beat = 0.0
self._sync_time = None # None = waiting for first RH note
self._next_sync = self._rh_beats[0] if self._rh_beats else float('inf')
self._lh_idx = 0
self._lock = threading.Lock()
self._resume = threading.Event()
self._running = False
self._thread = None
# ── Public API ────────────────────────────────────────────────────────
def start(self):
self._running = True
self._thread = threading.Thread(target=self._loop, daemon=True)
self._thread.start()
def stop(self):
self._running = False
self._resume.set()
if self._thread:
self._thread.join(timeout=1.0)
def on_rh_note(self, beat: float, bps: float):
"""Call this whenever the player matches a right-hand note."""
with self._lock:
self._bps = bps
self._sync_beat = beat
self._sync_time = time.perf_counter()
# Next sync point is the first RH beat strictly after this one.
self._next_sync = next(
(b for b in self._rh_beats if b > beat + 0.01),
float('inf')
)
# Skip LH events that are now in the past (player jumped ahead).
while (self._lh_idx < len(self._events)
and self._events[self._lh_idx][1] < beat - 0.05):
self._lh_idx += 1
self._resume.set()
# ── Internal ──────────────────────────────────────────────────────────
def _current_beat(self) -> float:
if self._sync_time is None:
return 0.0
return self._sync_beat + (time.perf_counter() - self._sync_time) * self._bps
def _loop(self):
while self._running:
# ── Wait for the first RH note before doing anything ──
with self._lock:
started = self._sync_time is not None
if not started:
self._resume.wait(timeout=0.1)
self._resume.clear()
continue
# ── All LH events played β†’ done ──
with self._lock:
if self._lh_idx >= len(self._events):
break
pitches, beat = self._events[self._lh_idx][:2]
next_sync = self._next_sync
# ── Pause before the next RH sync point ──
if beat >= next_sync - 0.01:
self._resume.wait(timeout=5.0)
self._resume.clear()
continue
# ── Fire when the clock reaches this event's beat ──
current = self._current_beat()
if current >= beat - 0.003:
play_chord(pitches)
with self._lock:
self._lh_idx += 1
else:
with self._lock:
bps = self._bps
wait = max(0.0, (beat - current) / bps * 0.85)
time.sleep(min(wait, 0.05))