accompy / src /tracker.py
jasonmdong's picture
Sync from GitHub via hub-sync
c78e8af verified
Raw
History Blame Contribute Delete
3.14 kB
"""
Tracks the player's position in the score and estimates current tempo.
Strategy (Option A — event-driven):
- Maintain an ordered list of expected right-hand pitches.
- When a new MIDI note arrives, try to match it to the next expected note
(with a small pitch-tolerance window for transpositions or octave errors).
- Record real-time timestamps for each matched note.
- Estimate beats-per-second from recent inter-note timing.
"""
import time
from collections import deque
# How many recent inter-note intervals to average for tempo estimation.
TEMPO_WINDOW = 4
class ScoreTracker:
def __init__(self, right_hand: list, initial_bps: float = 2.0):
self.score = right_hand # [(pitch, beat), ...]
self.position = 0 # index into self.score for next expected note
self.timestamps = deque() # (real_time, beat) for recent matches
self._default_bps = initial_bps
def on_note(self, pitch: int) -> float | None:
"""
Call this when the player plays a note.
Returns the current beat position if matched, or None if unrecognized.
"""
matched_idx = self._find_match(pitch)
if matched_idx is None:
return None
self.position = matched_idx + 1
beat = self.score[matched_idx][1]
now = time.perf_counter()
self.timestamps.append((now, beat))
if len(self.timestamps) > TEMPO_WINDOW + 1:
self.timestamps.popleft()
return beat
def current_beat_position(self) -> float:
"""Best estimate of the current beat, interpolated from last match."""
if not self.timestamps:
return 0.0
last_time, last_beat = self.timestamps[-1]
elapsed = time.perf_counter() - last_time
return last_beat + elapsed * self.beats_per_second()
def beats_per_second(self) -> float:
"""Estimate BPS from recent matched notes."""
ts = list(self.timestamps)
if len(ts) < 2:
return self._default_bps
# Compute BPS from each consecutive pair and average.
rates = []
for i in range(1, len(ts)):
dt = ts[i][0] - ts[i-1][0]
db = ts[i][1] - ts[i-1][1]
if dt > 0 and db > 0:
rates.append(db / dt)
return sum(rates) / len(rates) if rates else self._default_bps
def seconds_until_beat(self, target_beat: float) -> float:
"""How many seconds from now until a given beat position."""
current = self.current_beat_position()
remaining_beats = target_beat - current
return remaining_beats / self.beats_per_second()
def is_finished(self) -> bool:
return self.position >= len(self.score)
def _find_match(self, pitch: int) -> int | None:
"""
Match only the next expected score note.
Returns the matched index in self.score, or None.
"""
if self.position >= len(self.score):
return None
expected_pitch = self.score[self.position][0]
return self.position if pitch == expected_pitch else None