Roblox macros that play from outside the game: Anime Vanguards, and a falling-notes piano event.
1193 lines
47 KiB
Python
1193 lines
47 KiB
Python
"""A falling-notes event - the lane lights up, the key goes down."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from collections import Counter, deque
|
|
|
|
from .. import config, screen, winput
|
|
from ..runner import Runner
|
|
|
|
NAME = "piano"
|
|
|
|
DEFAULTS = {
|
|
"hotkeys": {"pause": "f7", "stop": "f8", "capture": "f6"},
|
|
# One per lane, left to right: its key, its point, and the colours it learnt.
|
|
"lanes": [],
|
|
"detect": {
|
|
"threshold": 45, # how far from empty counts as a note, per channel
|
|
"offset_ms": 0, # wait this long after seeing one before pressing
|
|
"min_hold_ms": 35, # a tap still has to be long enough to register
|
|
"gap_ms": 45, # ignore a second note in a lane sooner than this
|
|
"let_go_ms": 12, # a held key waits this long for the note to come back
|
|
"hold_notes": True, # keep the key down while the note is still there
|
|
"max_hold_ms": 4000, # let go anyway after this, so a lane cannot jam
|
|
},
|
|
"finish": {
|
|
# When a song counts as over.
|
|
"quiet_seconds": 4.0, # a track that stops changing for this long is done
|
|
"gone_seconds": 1.5, # no lane on its own track for this long: not the event
|
|
"track_tolerance": 24, # and how near its own colour a lane has to be
|
|
"min_seconds": 20.0, # never call it done sooner than this into a song
|
|
"max_seconds": 420.0, # and never sit through more than this
|
|
"movement": 8, # per-channel change that counts as the track moving
|
|
"clock": None, # {"at": [x, y]} on the song timer — see `Timer`
|
|
"probe": None, # {"at": [x, y], "color": [r, g, b]} on the results panel
|
|
"tolerance": 26, # colour slack on that pixel
|
|
"check_every": 0.4, # how often to look at it
|
|
},
|
|
# Picked once each, then it drives itself in and back out again.
|
|
"routes": {
|
|
"into": [
|
|
{"do": "click", "at": None, "after": 1.5, "note": "Events"},
|
|
{"do": "click", "at": None, "after": 1.5, "note": "Special Events"},
|
|
{"do": "scroll", "at": None, "amount": -12, "after": 1.0,
|
|
"note": "scroll the list to the bottom"},
|
|
{"do": "click", "at": None, "after": 2.0, "note": "the Piano event"},
|
|
{"do": "click", "at": None, "after": 4.0, "note": "Play"},
|
|
],
|
|
"back": [
|
|
{"do": "click", "at": None, "after": 2.5, "note": "leave the results screen"},
|
|
],
|
|
},
|
|
"session": {
|
|
# Ten songs at four difficulties: 40 runs, resumed where it left off.
|
|
"songs": {
|
|
# One [x, y] per row of the list.
|
|
"points": [],
|
|
"first": None,
|
|
"last": None,
|
|
"count": 0, # how many songs there are; 0 means "the rows picked"
|
|
"visible": 0, # how many fit on screen; 0 means all of them
|
|
"scroll_at": None, # where to put the cursor to scroll the list
|
|
"top_clicks": 15, # wheel clicks that get it back to the top
|
|
"page_clicks": -3, # and how many move it on one screenful
|
|
},
|
|
# `clicks` is how many times to press this one's point.
|
|
"difficulties": [{"name": "easy", "at": None, "clicks": 0},
|
|
{"name": "medium", "at": None, "clicks": 1},
|
|
{"name": "hard", "at": None, "clicks": 2},
|
|
{"name": "expert", "at": None, "clicks": 3}],
|
|
"play": None, # [x, y] on the button that starts the song
|
|
# Where a finished song leaves you: back on the song "list".
|
|
"after_song": "list",
|
|
"pick_seconds": 1.2, # after each menu click
|
|
"settle_seconds": 4.0, # after pressing play, before watching for notes
|
|
"between_seconds": 2.5, # a breath between runs
|
|
},
|
|
}
|
|
|
|
|
|
# ------------------------------------------------------------------ lanes
|
|
def spread(keys: str, left, right) -> list[dict]:
|
|
"""Lane points evenly spaced between the two ends of the hit line."""
|
|
keys = list(keys)
|
|
if not keys:
|
|
return []
|
|
return [{"key": key, "at": at, "empty": None}
|
|
for key, at in zip(keys, between(left, right, len(keys)))]
|
|
|
|
|
|
def between(first, last, count: int) -> list[list[int]]:
|
|
"""`count` points evenly spaced from one to the other, both ends included."""
|
|
count = max(1, int(count))
|
|
if count == 1 or not last:
|
|
return [[int(first[0]), int(first[1])]]
|
|
return [[round(first[0] + (last[0] - first[0]) * index / (count - 1)),
|
|
round(first[1] + (last[1] - first[1]) * index / (count - 1))]
|
|
for index in range(count)]
|
|
|
|
|
|
def _median(values):
|
|
ordered = sorted(values)
|
|
return ordered[len(ordered) // 2]
|
|
|
|
|
|
def _apart(a, b) -> int:
|
|
"""How far two colours are, on their furthest channel."""
|
|
return max(abs(int(p) - int(q)) for p, q in zip(a, b))
|
|
|
|
|
|
def study(watched: list[list[tuple]], noise_gate: int = 10) -> dict:
|
|
"""Work out what each lane looks like empty, from a lane that is in use."""
|
|
lanes = []
|
|
for colours in watched:
|
|
colours = [colour for colour in colours if colour]
|
|
if not colours:
|
|
lanes.append({"empty": None, "note": None, "covered": 0.0,
|
|
"noise": 0, "distance": 0})
|
|
continue
|
|
|
|
# Quantised, so noise cannot split one background into a hundred near colours.
|
|
common = Counter((r >> 3, g >> 3, b >> 3) for r, g, b in colours)
|
|
bucket = common.most_common(1)[0][0]
|
|
empty_like = [c for c in colours if (c[0] >> 3, c[1] >> 3, c[2] >> 3) == bucket]
|
|
empty = tuple(_median([c[channel] for c in empty_like]) for channel in range(3))
|
|
|
|
deviations = [_apart(colour, empty) for colour in colours]
|
|
noise = max((d for d in deviations if d <= noise_gate), default=0)
|
|
notes = [colour for colour, drift in zip(colours, deviations)
|
|
if drift > max(noise_gate, noise * 2)]
|
|
note = (tuple(_median([c[channel] for c in notes]) for channel in range(3))
|
|
if len(notes) >= max(3, len(colours) // 100) else None)
|
|
covered = len(notes) / len(colours)
|
|
lanes.append({
|
|
"empty": empty,
|
|
"note": note,
|
|
"covered": covered,
|
|
"noise": noise,
|
|
"distance": _apart(note, empty) if note else 0,
|
|
# More than half of a watch under a note means the majority colour was the note.
|
|
"suspect": covered > 0.45,
|
|
"repaired": False,
|
|
})
|
|
|
|
_turn_suspects_around(lanes)
|
|
seen = [lane for lane in lanes if lane["note"] and not lane["suspect"]]
|
|
noise = max((lane["noise"] for lane in lanes), default=0)
|
|
nearest = min((lane["distance"] for lane in seen), default=0)
|
|
separable = bool(seen) and nearest > noise * 1.5 + 6
|
|
return {
|
|
"lanes": lanes,
|
|
"noise": noise,
|
|
"nearest": nearest,
|
|
"separable": separable,
|
|
# Halfway between the worst noise and the faintest note.
|
|
"threshold": max(8, min(200, round((noise + nearest) / 2))) if separable else None,
|
|
}
|
|
|
|
|
|
def _turn_suspects_around(lanes: list[dict]) -> None:
|
|
"""A lane read backwards, put right by what the other lanes agree on."""
|
|
settled = [lane["empty"] for lane in lanes
|
|
if lane["empty"] and not lane["suspect"]]
|
|
if not settled:
|
|
return
|
|
for lane in lanes:
|
|
if not (lane["suspect"] and lane["note"]):
|
|
continue
|
|
agree = sum(1 for background in settled if _apart(lane["note"], background) <= 12)
|
|
if agree * 2 >= len(settled):
|
|
lane["empty"], lane["note"] = lane["note"], lane["empty"]
|
|
lane["covered"] = 1.0 - lane["covered"]
|
|
lane["distance"] = _apart(lane["note"], lane["empty"])
|
|
lane["suspect"] = False
|
|
lane["repaired"] = True
|
|
|
|
|
|
def timeline(colours, empty, note, threshold: int, min_run: int = 2):
|
|
"""What a lane did, as runs of track / note / something else."""
|
|
runs = []
|
|
for colour in colours:
|
|
if not colour:
|
|
what = "unreadable"
|
|
elif empty and screen.close_enough(colour, empty, threshold):
|
|
what = "track"
|
|
elif note and screen.close_enough(colour, note, threshold):
|
|
what = "note"
|
|
else:
|
|
what = "other"
|
|
if runs and runs[-1][0] == what:
|
|
runs[-1][1] += 1
|
|
runs[-1][2].append(colour)
|
|
else:
|
|
runs.append([what, 1, [colour]])
|
|
|
|
kept = []
|
|
for what, count, seen in runs:
|
|
if count < min_run and kept: # a single odd scan is noise, not a run
|
|
kept[-1][1] += count
|
|
continue
|
|
seen = [colour for colour in seen if colour]
|
|
middle = (tuple(_median([c[channel] for c in seen]) for channel in range(3))
|
|
if seen else None)
|
|
kept.append([what, count, middle])
|
|
return [(what, count, middle) for what, count, middle in kept]
|
|
|
|
|
|
def find_lanes(columns, movement, want: int = 0, gate: float = 0.35,
|
|
min_width: int = 6) -> list[dict]:
|
|
"""Where along a row did the picture actually change?"""
|
|
if not movement:
|
|
return []
|
|
top = max(movement)
|
|
if top <= 0:
|
|
return []
|
|
step = (columns[1] - columns[0]) if len(columns) > 1 else 1
|
|
floor = max(top * gate, 12)
|
|
|
|
groups, current = [], None
|
|
for x, value in zip(columns, movement):
|
|
if value < floor:
|
|
continue
|
|
# A gap of a couple of samples is the same lane, not two of them.
|
|
if current is not None and x - current[-1][0] <= step * 3:
|
|
current.append((x, value))
|
|
else:
|
|
if current:
|
|
groups.append(current)
|
|
current = [(x, value)]
|
|
if current:
|
|
groups.append(current)
|
|
|
|
found = []
|
|
for group in groups:
|
|
left, right = group[0][0], group[-1][0]
|
|
if right - left + step < min_width:
|
|
continue
|
|
found.append({
|
|
"centre": (left + right) // 2,
|
|
"left": left,
|
|
"right": right,
|
|
"score": round(sum(value for _x, value in group) / len(group)),
|
|
})
|
|
if want and len(found) > want:
|
|
found.sort(key=lambda lane: lane["score"], reverse=True)
|
|
found = found[:want]
|
|
found.sort(key=lambda lane: lane["centre"])
|
|
return found
|
|
|
|
|
|
def pick_row(rows, columns, movements, want: int, gate: float = 0.35,
|
|
min_width: int = 6):
|
|
"""Of every row watched, the lowest one that shows all the lanes at once."""
|
|
qualifying = []
|
|
for row, movement in zip(rows, movements):
|
|
found = find_lanes(columns, movement, want=want, gate=gate, min_width=min_width)
|
|
if len(found) != want:
|
|
continue
|
|
qualifying.append((row, found, min(lane["score"] for lane in found)))
|
|
if not qualifying:
|
|
return None
|
|
strongest = max(strength for _row, _found, strength in qualifying)
|
|
solid = [item for item in qualifying if item[2] >= strongest * 0.5]
|
|
row, found, _strength = max(solid, key=lambda item: item[0])
|
|
return row, found
|
|
|
|
|
|
def profile(columns, movement, width: int = 96) -> str:
|
|
"""The same numbers as a picture, because a picture is read at a glance."""
|
|
if not columns:
|
|
return ""
|
|
span = max(columns[-1] - columns[0], 1)
|
|
buckets = [0] * width
|
|
for x, value in zip(columns, movement):
|
|
index = min(width - 1, (x - columns[0]) * width // (span + 1))
|
|
buckets[index] = max(buckets[index], value)
|
|
top = max(buckets) or 1
|
|
blocks = " \u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588"
|
|
return "".join(blocks[min(8, value * 8 // top)] for value in buckets)
|
|
|
|
|
|
def marks(columns, found, width: int = 96) -> str:
|
|
"""A row of carets under the profile, one per lane it settled on."""
|
|
if not columns:
|
|
return ""
|
|
span = max(columns[-1] - columns[0], 1)
|
|
row = [" "] * width
|
|
for lane in found:
|
|
index = min(width - 1, (lane["centre"] - columns[0]) * width // (span + 1))
|
|
row[index] = "^"
|
|
return "".join(row)
|
|
|
|
|
|
class Lane:
|
|
"""One lane's key, and whether it is currently down."""
|
|
|
|
def __init__(self, spec: dict):
|
|
self.key = spec.get("key") or ""
|
|
self.at = (int(spec["at"][0]), int(spec["at"][1]))
|
|
self.empty = tuple(spec.get("empty") or ())
|
|
self.note = tuple(spec.get("note") or ())
|
|
self.clicks = spec.get("do") == "click"
|
|
self.hits = 0
|
|
self.stuck = 0
|
|
self.lit = False
|
|
self.down = False
|
|
self.down_at = 0.0
|
|
self.armed_at = None
|
|
self.free_at = None
|
|
# Long ago, not zero, so the first note is never "too soon after the last".
|
|
self.freed_at = float("-inf")
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
return bool(self.empty or self.note)
|
|
|
|
def on_track(self, colour, tolerance: int) -> bool:
|
|
"""Is this the empty track - the thing this lane is *usually* looking at?"""
|
|
return bool(colour and self.empty
|
|
and screen.close_enough(colour, self.empty, tolerance))
|
|
|
|
def looks_lit(self, colour, threshold: int) -> bool:
|
|
if not colour:
|
|
return False
|
|
if self.note:
|
|
return screen.close_enough(colour, self.note, threshold)
|
|
if self.empty:
|
|
return not screen.close_enough(colour, self.empty, threshold)
|
|
return False
|
|
|
|
# ------------------------------------------------------------ the key
|
|
def press(self, now: float) -> None:
|
|
self.armed_at = None
|
|
self.down = True
|
|
self.down_at = now
|
|
self.hits += 1
|
|
if self.clicks:
|
|
winput.click(*self.at)
|
|
else:
|
|
winput.key_down(self.key)
|
|
|
|
def release(self, now: float) -> None:
|
|
self.down = False
|
|
self.free_at = None
|
|
self.freed_at = now
|
|
if not self.clicks:
|
|
winput.key_up(self.key)
|
|
|
|
def forget(self) -> None:
|
|
"""Whatever we thought we were holding, we are not holding it now."""
|
|
self.down = False
|
|
self.armed_at = None
|
|
self.free_at = None
|
|
self.lit = False
|
|
|
|
def rest(self, now: float) -> None:
|
|
"""Nothing out there is moving - let go, and do not press again."""
|
|
if self.down:
|
|
self.release(now)
|
|
self.armed_at = None
|
|
self.free_at = None
|
|
self.lit = False
|
|
|
|
def update(self, colour, now: float, tuning: dict) -> None:
|
|
self.lit = self.looks_lit(colour, tuning["threshold"])
|
|
|
|
if self.down:
|
|
held = now - self.down_at
|
|
if self.lit and tuning["hold_notes"]:
|
|
self.free_at = None # still under the note
|
|
elif self.free_at is None:
|
|
# The note has left the sampler - which is above the line.
|
|
self.free_at = now + tuning["offset"] + tuning["let_go"]
|
|
if tuning["max_hold"] and held >= tuning["max_hold"]:
|
|
# The lane never went back to looking empty.
|
|
self.stuck += 1
|
|
self.release(now)
|
|
elif (self.free_at is not None and now >= self.free_at
|
|
and held >= tuning["min_hold"]):
|
|
self.release(now)
|
|
return
|
|
|
|
if self.armed_at is not None:
|
|
if now >= self.armed_at:
|
|
self.press(now)
|
|
return
|
|
|
|
if self.lit and now - self.freed_at >= tuning["gap"]:
|
|
self.armed_at = now + tuning["offset"]
|
|
if tuning["offset"] <= 0:
|
|
self.press(now)
|
|
|
|
|
|
# -------------------------------------------------------------- calibrate
|
|
def calibrate(cfg: dict) -> dict:
|
|
"""Set the lanes up from a terminal. The window does this better."""
|
|
from .. import routine
|
|
|
|
keys = cfg["hotkeys"]
|
|
print("\ncalibrating a falling-notes event")
|
|
print("The window (python macro.py gui) shows you what it is reading while")
|
|
print("you do this, which is worth a lot here. Carrying on anyway:\n")
|
|
|
|
order = input(" the key for each lane, left to right, no spaces (e.g. asdfg): ").strip()
|
|
if not order:
|
|
print(" nothing to do without lanes.")
|
|
return cfg
|
|
|
|
print("\nPut the sampling points ON the hit line — where you would press.")
|
|
left = routine.capture_point("the LEFTMOST lane, on the hit line", keys)
|
|
right = routine.capture_point("the RIGHTMOST lane, on the hit line", keys)
|
|
if not left or not right:
|
|
print(" need both ends to space the lanes out. Nothing changed.")
|
|
return cfg
|
|
|
|
cfg["lanes"] = spread(order, left["at"], right["at"])
|
|
for lane in cfg["lanes"]:
|
|
print(f" {lane['key']} at {lane['at']}")
|
|
|
|
print("\nNow let the track run empty — no notes crossing the line.")
|
|
input(" press Enter when it is clear: ")
|
|
with screen.around([lane["at"] for lane in cfg["lanes"]]) as region:
|
|
region.read()
|
|
for lane in cfg["lanes"]:
|
|
colour = region.at(*lane["at"])
|
|
lane["empty"] = list(colour or ())
|
|
print(f" {lane['key']} empty track {colour}")
|
|
return cfg
|
|
|
|
|
|
# -------------------------------------------------------------- the end
|
|
def probe_matches(point: dict | None, tolerance: int) -> bool:
|
|
"""Is the pixel that only the results screen paints showing it right now?"""
|
|
if not point or not point.get("at") or not point.get("color"):
|
|
return False
|
|
x, y = point["at"]
|
|
return screen.close_enough(screen.pixel(x, y), point["color"], tolerance)
|
|
|
|
|
|
class Timer:
|
|
"""The song's own clock, watched only for whether it is still counting."""
|
|
|
|
WIDE, TALL = 110, 34 # enough to cover a clock, small enough to be cheap
|
|
|
|
def __init__(self, at, every: float = 0.2):
|
|
x, y = int(at[0]), int(at[1])
|
|
self.region = screen.Region(x - self.WIDE // 2, y - self.TALL // 2,
|
|
self.WIDE, self.TALL)
|
|
self.every = float(every)
|
|
self.was = None
|
|
self.looked_at = 0.0
|
|
self.ticks = 0
|
|
|
|
def look(self, now: float) -> bool:
|
|
"""Did the clock change since the last look?"""
|
|
if now - self.looked_at < self.every:
|
|
return False
|
|
self.looked_at = now
|
|
if not self.region.read():
|
|
return False
|
|
picture = bytes(self.region.pixels)
|
|
changed = self.was is not None and picture != self.was
|
|
self.was = picture
|
|
self.ticks += bool(changed)
|
|
return changed
|
|
|
|
def close(self) -> None:
|
|
self.region.close()
|
|
|
|
|
|
class Ending:
|
|
"""Whether the song is over, and which of three signals says so."""
|
|
|
|
def __init__(self, settings: dict | None = None):
|
|
settings = settings or {}
|
|
self.quiet = float(settings.get("quiet_seconds", 4.0))
|
|
self.gone = float(settings.get("gone_seconds", 1.5))
|
|
self.track_tolerance = int(settings.get("track_tolerance", 24))
|
|
self.least = float(settings.get("min_seconds", 20.0))
|
|
self.most = float(settings.get("max_seconds", 420.0))
|
|
self.movement = int(settings.get("movement", 8))
|
|
self.probe = settings.get("probe")
|
|
self.clock = settings.get("clock") or {}
|
|
self.tolerance = int(settings.get("tolerance", 26))
|
|
self.check_every = float(settings.get("check_every", 0.4))
|
|
self.began = self.moved_at = self.looked_at = 0.0
|
|
self.was = None
|
|
self.probe_since = None
|
|
# When the music actually started, and the recent movements that say so.
|
|
self.started = None
|
|
self.moves = deque()
|
|
# And the same question asked of the clock, if there is one.
|
|
self.clock_at = None
|
|
self.clock_ticks = 0
|
|
# When a lane last looked at its own empty track, when half of them last did.
|
|
self.on_track_at = None
|
|
self.most_track_at = None
|
|
self.can_see_track = False
|
|
|
|
# movements inside `WINDOW` seconds that mean a song is under way.
|
|
BUSY = 12
|
|
WINDOW = 3.0
|
|
|
|
@property
|
|
def has_clock(self) -> bool:
|
|
return bool(self.clock.get("at"))
|
|
|
|
@property
|
|
def has_probe(self) -> bool:
|
|
return bool(self.probe and self.probe.get("at") and self.probe.get("color"))
|
|
|
|
def begin(self, now: float) -> None:
|
|
self.began = self.moved_at = self.looked_at = now
|
|
self.was = None
|
|
self.probe_since = None
|
|
self.started = None
|
|
self.moves.clear()
|
|
self.clock_at = None
|
|
self.clock_ticks = 0
|
|
self.on_track_at = None
|
|
self.most_track_at = None
|
|
|
|
def skipped(self, now: float, gap: float) -> None:
|
|
"""Time the macro was not watching - paused, or behind another window."""
|
|
self.began += gap
|
|
if self.started is not None:
|
|
self.started += gap
|
|
self.moved_at = self.looked_at = now
|
|
self.was = None
|
|
self.probe_since = None
|
|
self.moves.clear()
|
|
|
|
def tracked(self, on: int, lanes: int, now: float) -> None:
|
|
"""How many lanes are looking at their own empty track right now?"""
|
|
if on:
|
|
self.on_track_at = now
|
|
if lanes and on * 2 >= lanes:
|
|
self.most_track_at = now
|
|
|
|
def on_the_event(self, now: float) -> bool:
|
|
"""Is the track on screen right now?"""
|
|
if not self.can_see_track:
|
|
return True
|
|
return self.on_track_at is not None and now - self.on_track_at < self.gone
|
|
|
|
def clocked(self, ticked: bool, now: float) -> None:
|
|
"""The song timer, as of this look."""
|
|
if not ticked:
|
|
return
|
|
self.clock_ticks += 1
|
|
self.clock_at = now
|
|
if self.started is None and self.clock_ticks >= 2 and self.on_the_event(now):
|
|
self.started = now
|
|
|
|
def saw(self, colours, now: float) -> None:
|
|
"""The strip as of this scan."""
|
|
if self.was is not None and len(self.was) == len(colours):
|
|
for before, after in zip(self.was, colours):
|
|
if before and after and _apart(before, after) > self.movement:
|
|
self.moved_at = now
|
|
self.moves.append(now)
|
|
break
|
|
self.was = list(colours)
|
|
while self.moves and now - self.moves[0] > self.WINDOW:
|
|
self.moves.popleft()
|
|
if self.started is None and len(self.moves) >= self.BUSY \
|
|
and self.on_the_event(now):
|
|
self.started = self.moves[0]
|
|
|
|
def still_for(self, now: float) -> float:
|
|
return now - self.moved_at
|
|
|
|
def verdict(self, now: float) -> str | None:
|
|
"""Why the song is over - or None, meaning it is not."""
|
|
if self.started is None:
|
|
return None
|
|
if now - self.started >= self.most:
|
|
return "timeout"
|
|
if now - self.started < self.least:
|
|
return None
|
|
if self.has_probe and now - self.looked_at >= self.check_every:
|
|
self.looked_at = now
|
|
if probe_matches(self.probe, self.tolerance):
|
|
# Twice, a beat apart: one frame of an effect crossing that pixel is not a results screen.
|
|
if self.probe_since is None:
|
|
self.probe_since = now
|
|
elif now - self.probe_since >= 0.5:
|
|
return "results"
|
|
else:
|
|
self.probe_since = None
|
|
if self.on_track_at is not None and now - self.on_track_at >= self.gone:
|
|
return "gone"
|
|
if self.most_track_at is not None \
|
|
and now - self.most_track_at >= self.gone * 3:
|
|
return "gone"
|
|
if self.has_clock and self.clock_at is not None \
|
|
and now - self.clock_at >= self.quiet:
|
|
return "clock"
|
|
# A quiet stretch of chart stops the track moving without stopping the numbers counting.
|
|
patience = self.quiet * (3 if self.has_clock else 1)
|
|
if now - self.moved_at >= patience:
|
|
return "quiet"
|
|
return None
|
|
|
|
|
|
# --------------------------------------------------------------- routes
|
|
class Look:
|
|
"""The screen, coarsely, for asking whether a click did anything."""
|
|
|
|
STEP = 8
|
|
REACTED = 0.06 # the least a click has to move, however still it is
|
|
HOPELESS = 0.25 # and the point past which nothing can be told apart
|
|
|
|
def __init__(self):
|
|
width, height = screen.screen_size()
|
|
self.region = screen.Region(0, 0, width, height)
|
|
self.stride = width * 4
|
|
self.rows = range(0, height, self.STEP)
|
|
|
|
def take(self):
|
|
if not self.region.read():
|
|
return None
|
|
picture = bytes(self.region.pixels)
|
|
return b"".join(picture[row * self.stride:(row + 1) * self.stride:self.STEP * 4]
|
|
for row in self.rows)
|
|
|
|
@staticmethod
|
|
def apart(before, after) -> float:
|
|
"""How much of it changed, 0 to 1. An unreadable screen counts as all."""
|
|
if not before or not after or len(before) != len(after):
|
|
return 1.0
|
|
return sum(1 for a, b in zip(before, after) if a != b) / len(before)
|
|
|
|
def close(self) -> None:
|
|
self.region.close()
|
|
|
|
|
|
def sure_click(runner: Runner, at, label: str = "", tries: int = 3,
|
|
settle: float = 0.15, patience: float = 1.6,
|
|
look: "Look | None" = None) -> bool:
|
|
"""Click, and click again if the screen did not react."""
|
|
x, y = int(at[0]), int(at[1])
|
|
mine = look is None
|
|
look = look or Look()
|
|
try:
|
|
winput.move_to(x, y)
|
|
runner.wait(settle)
|
|
# What the screen does with nobody touching it.
|
|
steady = look.take()
|
|
runner.wait(0.4) # the same sort of span the click is judged over
|
|
steady, ambient = look.take(), look.apart(steady, look.take())
|
|
if ambient > look.HOPELESS:
|
|
runner.log(f" {label or 'that click'}: this screen moves "
|
|
f"{ambient:.0%} on its own, so a click cannot be told apart "
|
|
f"— clicking once and hoping")
|
|
winput.mouse_down()
|
|
runner.wait(0.05)
|
|
winput.mouse_up()
|
|
runner.wait(settle)
|
|
return True
|
|
bar = max(look.REACTED, ambient * 2.5)
|
|
|
|
for attempt in range(1, max(1, tries) + 1):
|
|
if attempt > 1:
|
|
# Off the button and back on, so the game gets a fresh hover before the press - some UI.
|
|
winput.move_to(x + 4, y + 4)
|
|
runner.wait(0.05)
|
|
winput.move_to(x, y)
|
|
runner.wait(0.08)
|
|
winput.mouse_down()
|
|
runner.wait(0.05)
|
|
winput.mouse_up()
|
|
waited, moved, after = 0.0, 0.0, steady
|
|
while waited < patience:
|
|
runner.wait(settle)
|
|
waited += settle
|
|
after = look.take()
|
|
moved = look.apart(steady, after)
|
|
if moved >= bar:
|
|
if attempt > 1:
|
|
runner.log(f" (that one needed {attempt} goes)")
|
|
return True
|
|
steady = after
|
|
runner.log(f" {label or 'that click'}: the screen moved {moved:.0%} after "
|
|
f"{tries} goes, against {bar:.0%} needed — carrying on anyway")
|
|
return False
|
|
finally:
|
|
if mine:
|
|
look.close()
|
|
|
|
|
|
def wheel(runner: Runner, clicks: int) -> None:
|
|
"""Wheel clicks one at a time, because a list scrolled in one jump does not."""
|
|
step = 1 if clicks > 0 else -1
|
|
for _ in range(abs(int(clicks))):
|
|
winput.scroll(step)
|
|
runner.wait(0.03)
|
|
|
|
|
|
def walk(runner: Runner, steps: list[dict], label: str = "") -> int:
|
|
"""Do a route: the picked steps, in order."""
|
|
if label:
|
|
runner.log(label)
|
|
done = 0
|
|
# A beat before the first step.
|
|
runner.wait(0.4)
|
|
look = Look()
|
|
for index, step in enumerate(steps or [], start=1):
|
|
kind = step.get("do", "click")
|
|
note = step.get("note") or kind
|
|
at = step.get("at")
|
|
if kind in ("click", "scroll") and not at:
|
|
runner.log(f" {index}. {note} — not picked yet, skipped")
|
|
continue
|
|
runner.gate()
|
|
if kind == "click":
|
|
if step.get("sure") is False or step.get("button", "left") != "left":
|
|
winput.click(int(at[0]), int(at[1]), button=step.get("button", "left"))
|
|
else:
|
|
sure_click(runner, at, note, look=look)
|
|
elif kind == "scroll":
|
|
winput.move_to(int(at[0]), int(at[1]))
|
|
runner.wait(0.12)
|
|
wheel(runner, int(step.get("amount", -5)))
|
|
elif kind == "key":
|
|
winput.tap(step.get("key", "e"))
|
|
elif kind != "wait":
|
|
runner.log(f" {index}. {note}: no idea what {kind!r} means, skipped")
|
|
continue
|
|
runner.log(f" {index}. {note}")
|
|
runner.wait(float(step.get("after", 0.8)))
|
|
done += 1
|
|
look.close()
|
|
return done
|
|
|
|
|
|
def picked(steps: list[dict] | None) -> bool:
|
|
"""Is there anything in this route worth walking?"""
|
|
return any(step.get("at") or step.get("do") in ("key", "wait")
|
|
for step in steps or [])
|
|
|
|
|
|
# ---------------------------------------------------------------- chart
|
|
def tuning_of(cfg: dict) -> dict:
|
|
"""Milliseconds are what a person thinks in; seconds are what a loop uses."""
|
|
detect = cfg["detect"]
|
|
return {
|
|
"threshold": int(detect.get("threshold", 45)),
|
|
"offset": float(detect.get("offset_ms", 0)) / 1000.0,
|
|
"min_hold": float(detect.get("min_hold_ms", 35)) / 1000.0,
|
|
"gap": float(detect.get("gap_ms", 45)) / 1000.0,
|
|
"let_go": float(detect.get("let_go_ms", 12)) / 1000.0,
|
|
"hold_notes": bool(detect.get("hold_notes", True)),
|
|
# Zero means never give up, which is a real answer for a game that genuinely holds a note.
|
|
"max_hold": float(detect.get("max_hold_ms", 2500)) / 1000.0,
|
|
}
|
|
|
|
|
|
class Chart:
|
|
"""The lanes, the strip they live in, and one song at a time."""
|
|
|
|
REST_AFTER = 1.2 # a picture this still is one to let go of and watch
|
|
WATCH_EVERY = 8 # px between the columns watched for "is it moving?"
|
|
|
|
def __init__(self, cfg: dict, runner: Runner):
|
|
self.cfg = cfg
|
|
self.runner = runner
|
|
self.lanes = [Lane(spec) for spec in cfg.get("lanes") or []]
|
|
self.tuning = tuning_of(cfg)
|
|
self.ending = Ending(cfg.get("finish"))
|
|
self.region = screen.around([lane.at for lane in self.lanes]) if self.lanes else None
|
|
# A row of the strip watched only for movement.
|
|
if self.region is not None:
|
|
self.watch_row = self.region.top + self.region.height // 2
|
|
self.watch_at = list(range(self.region.left,
|
|
self.region.left + self.region.width,
|
|
self.WATCH_EVERY))
|
|
clock = (cfg.get("finish") or {}).get("clock") or {}
|
|
self.timer = Timer(clock["at"]) if clock.get("at") else None
|
|
self.ending.can_see_track = any(lane.empty for lane in self.lanes)
|
|
self.songs = 0
|
|
self.notes = 0
|
|
self.scans = 0
|
|
self.last = {"notes": 0, "seconds": 0.0, "why": "none"}
|
|
|
|
def close(self) -> None:
|
|
if self.region is not None:
|
|
self.region.close()
|
|
self.region = None
|
|
if self.timer is not None:
|
|
self.timer.close()
|
|
self.timer = None
|
|
|
|
def look(self):
|
|
"""One read: a colour per lane, and a row across the whole strip."""
|
|
if not self.region.read():
|
|
return None, None
|
|
return ([self.region.at(*lane.at) for lane in self.lanes],
|
|
[self.region.at(x, self.watch_row) for x in self.watch_at])
|
|
|
|
# ----------------------------------------------------------- playing
|
|
def play(self, label: str = "") -> str:
|
|
"""Play until the song ends."""
|
|
runner = self.runner
|
|
for lane in self.lanes:
|
|
lane.hits = lane.stuck = 0
|
|
lane.forget()
|
|
|
|
started = time.perf_counter()
|
|
self.ending.begin(started)
|
|
last_scan = started
|
|
told = started
|
|
scans = 0
|
|
waiting = True
|
|
why = "stopped"
|
|
runner.stats.update(lit=[False] * len(self.lanes), hits=[0] * len(self.lanes),
|
|
song=label or "a song")
|
|
try:
|
|
while True:
|
|
runner.gate()
|
|
colours, strip = self.look()
|
|
if colours is None:
|
|
runner.wait(0.05)
|
|
continue
|
|
|
|
now = time.perf_counter()
|
|
if now - last_scan > 0.25:
|
|
# We were paused or behind another window, and the runner has already dropped every key.
|
|
for lane in self.lanes:
|
|
lane.forget()
|
|
self.ending.skipped(now, now - last_scan)
|
|
last_scan = now
|
|
scans += 1
|
|
self.scans += 1
|
|
|
|
near = self.ending.track_tolerance
|
|
self.ending.tracked(
|
|
sum(1 for lane, colour in zip(self.lanes, colours)
|
|
if lane.on_track(colour, near)), len(self.lanes), now)
|
|
if self.timer is not None:
|
|
self.ending.clocked(self.timer.look(now), now)
|
|
self.ending.saw(strip, now)
|
|
if self.ending.started is None:
|
|
runner.status(f"waiting for {label or 'a song'} to start")
|
|
elif waiting:
|
|
waiting = False
|
|
runner.log(f"{label or 'a song'} is under way")
|
|
# Nothing is pressed at a picture that has stopped.
|
|
resting = (self.ending.still_for(now) >= self.REST_AFTER
|
|
or not self.ending.on_the_event(now))
|
|
for lane, colour in zip(self.lanes, colours):
|
|
if resting:
|
|
lane.rest(now)
|
|
else:
|
|
lane.update(colour, now, self.tuning)
|
|
|
|
if now - told >= 0.5:
|
|
elapsed = now - started
|
|
runner.stats.update(
|
|
notes=self.notes + sum(lane.hits for lane in self.lanes),
|
|
scans=self.scans, rate=scans / elapsed if elapsed else 0.0,
|
|
lit=[lane.lit for lane in self.lanes],
|
|
hits=[lane.hits for lane in self.lanes])
|
|
told = now
|
|
|
|
verdict = self.ending.verdict(now)
|
|
if verdict:
|
|
why = verdict
|
|
break
|
|
finally:
|
|
for lane in self.lanes:
|
|
lane.rest(time.perf_counter())
|
|
winput.release_all()
|
|
|
|
played = sum(lane.hits for lane in self.lanes)
|
|
elapsed = time.perf_counter() - (self.ending.started or started)
|
|
self.notes += played
|
|
self.songs += 1
|
|
self.last = {"notes": played, "seconds": elapsed, "why": why}
|
|
runner.stats.update(notes=self.notes)
|
|
runner.log(f"{label or 'song'}: {SAID.get(why, why)} after "
|
|
f"{elapsed / 60:.1f} min — {played} notes, "
|
|
f"{scans / elapsed if elapsed else 0:.0f} scans a second")
|
|
for lane in self.lanes:
|
|
if lane.stuck:
|
|
runner.log(f" {lane.key}: {lane.stuck} let go on the time limit — "
|
|
f"that lane is not seeing its track come back")
|
|
return why
|
|
|
|
def report(self) -> None:
|
|
runner = self.runner
|
|
runner.log(f"{self.songs} songs, {self.notes} notes, {self.scans} scans")
|
|
for lane in self.lanes:
|
|
runner.log(f" {lane.key or 'click'}: {lane.hits} in the last song")
|
|
|
|
|
|
SAID = {
|
|
"gone": "the track went off screen",
|
|
"clock": "the timer stopped counting",
|
|
"results": "the results screen came up",
|
|
"quiet": "the track went still",
|
|
"timeout": "gave up on the clock",
|
|
"stopped": "stopped",
|
|
}
|
|
|
|
|
|
# -------------------------------------------------------------- session
|
|
PROGRESS = "piano_progress"
|
|
PROGRESS_DEFAULTS = {"done": {}, "runs": 0, "notes": 0}
|
|
|
|
|
|
def load_progress() -> dict:
|
|
return config.load(PROGRESS, PROGRESS_DEFAULTS)
|
|
|
|
|
|
def save_progress(data: dict) -> None:
|
|
config.save(PROGRESS, data)
|
|
|
|
|
|
def reset_progress() -> None:
|
|
save_progress({"done": {}, "runs": 0, "notes": 0})
|
|
|
|
|
|
def song_rows(songs: dict) -> list:
|
|
"""Every row of one screenful of the song list, top to bottom."""
|
|
points = [list(point) for point in (songs.get("points") or []) if point]
|
|
if points:
|
|
return points
|
|
if songs.get("first"):
|
|
count = max(1, int(songs.get("visible") or 0) or int(songs.get("count") or 1))
|
|
return between(songs.get("first"), songs.get("last"), count)
|
|
return []
|
|
|
|
|
|
def song_count(songs: dict) -> int:
|
|
"""How many songs there are: the rows picked, unless the list scrolls."""
|
|
rows = song_rows(songs)
|
|
if not int(songs.get("visible") or 0):
|
|
return len(rows)
|
|
return max(len(rows), int(songs.get("count") or 0))
|
|
|
|
|
|
def song_point(songs: dict, index: int):
|
|
"""Where song `index` is, and which screenful of the list it is on."""
|
|
rows = song_rows(songs)
|
|
if not rows:
|
|
return None, 0
|
|
visible = int(songs.get("visible") or 0) or len(rows)
|
|
visible = max(1, min(visible, len(rows)))
|
|
return rows[index % visible], index // visible
|
|
|
|
|
|
class Session:
|
|
"""Ten songs at four difficulties, and the memory to pick up where it left off."""
|
|
|
|
def __init__(self, cfg: dict, runner: Runner, chart: "Chart"):
|
|
self.cfg = cfg
|
|
self.runner = runner
|
|
self.settings = cfg.get("session") or {}
|
|
self.routes = cfg.get("routes") or {}
|
|
self.chart = chart
|
|
self.progress = load_progress()
|
|
self.announced = False
|
|
# Some events drop you back on the song list when a song ends and some throw you out.
|
|
self.exits = (self.settings.get("after_song") or "list") == "out"
|
|
self.needs_way_in = True
|
|
self.played = 0
|
|
|
|
# ------------------------------------------------------------- plan
|
|
def jobs(self) -> list[tuple]:
|
|
"""Every run there is to do, song by song, easiest difficulty first."""
|
|
songs = song_count(self.settings.get("songs") or {})
|
|
levels = self.settings.get("difficulties") or [{"name": "as it is", "at": None}]
|
|
return [(song, index, level)
|
|
for song in range(songs) for index, level in enumerate(levels)]
|
|
|
|
@staticmethod
|
|
def key_of(song: int, index: int) -> str:
|
|
# By position, not by name: renaming "expert" should not lose a night's progress.
|
|
return f"{song}:{index}"
|
|
|
|
def pending(self) -> list[tuple]:
|
|
done = self.progress.get("done") or {}
|
|
return [job for job in self.jobs() if self.key_of(job[0], job[1]) not in done]
|
|
|
|
@staticmethod
|
|
def name_of(song: int, index: int, level: dict) -> str:
|
|
return f"song {song + 1} {level.get('name') or f'difficulty {index + 1}'}"
|
|
|
|
# ------------------------------------------------------------ menus
|
|
CYCLE_GAP = 0.35 # between clicks on a difficulty button that cycles
|
|
|
|
def choose(self, song: int, index: int, level: dict) -> bool:
|
|
"""Click the song, set the difficulty, press play. False if it cannot."""
|
|
songs = self.settings.get("songs") or {}
|
|
point, page = song_point(songs, song)
|
|
if point is None:
|
|
self.runner.log("no song rows picked — Event tab, pick one point per row "
|
|
"of the song list")
|
|
return False
|
|
|
|
pause = float(self.settings.get("pick_seconds", 1.2))
|
|
if page:
|
|
self.scroll_to(page, songs)
|
|
self.runner.log(f" song {song + 1} at {point[0]},{point[1]}")
|
|
sure_click(self.runner, point, f"song {song + 1}")
|
|
self.runner.wait(pause)
|
|
|
|
name = level.get("name") or f"difficulty {index + 1}"
|
|
at = level.get("at")
|
|
clicks = 1 if level.get("clicks") is None else int(level["clicks"])
|
|
if not at:
|
|
self.runner.log(f" {name}: no point picked — playing whatever difficulty "
|
|
f"is already chosen")
|
|
elif clicks <= 0:
|
|
self.runner.log(f" {name}: nothing to click, it is where the panel starts")
|
|
else:
|
|
for click in range(clicks):
|
|
winput.click(*at)
|
|
self.runner.wait(self.CYCLE_GAP if click + 1 < clicks else 0)
|
|
self.runner.log(f" {name}: {clicks} clicks at {at[0]},{at[1]}"
|
|
if clicks > 1 else
|
|
f" {name}: one click at {at[0]},{at[1]}")
|
|
self.runner.wait(pause)
|
|
|
|
if self.settings.get("play"):
|
|
# Not the difficulty clicks above: those cycle a label through four values.
|
|
sure_click(self.runner, self.settings["play"], "play")
|
|
self.runner.wait(float(self.settings.get("settle_seconds", 4.0)))
|
|
return True
|
|
|
|
def scroll_to(self, page: int, songs: dict) -> None:
|
|
at = songs.get("scroll_at") or songs.get("first")
|
|
if not at:
|
|
return
|
|
winput.move_to(int(at[0]), int(at[1]))
|
|
self.runner.wait(0.15)
|
|
wheel(self.runner, int(songs.get("top_clicks", 15)))
|
|
self.runner.wait(0.4)
|
|
wheel(self.runner, page * int(songs.get("page_clicks", -3)))
|
|
self.runner.wait(0.4)
|
|
self.runner.log(f" wound the list down to screenful {page + 1}")
|
|
|
|
# ------------------------------------------------------------- runs
|
|
def remember(self, song: int, index: int, level: dict, why: str) -> None:
|
|
run = self.chart.last
|
|
done = self.progress.setdefault("done", {})
|
|
done[self.key_of(song, index)] = {
|
|
"song": song + 1,
|
|
"difficulty": level.get("name") or index + 1,
|
|
"notes": run["notes"],
|
|
"seconds": round(run["seconds"], 1),
|
|
"why": why,
|
|
"when": time.strftime("%Y-%m-%d %H:%M"),
|
|
}
|
|
self.progress["runs"] = len(done)
|
|
self.progress["notes"] = sum(int(item.get("notes", 0)) for item in done.values())
|
|
save_progress(self.progress)
|
|
|
|
def announce(self) -> None:
|
|
total = len(self.jobs())
|
|
done = len(self.progress.get("done") or {})
|
|
if done:
|
|
self.runner.log(f"picking up where it left off — {done} of {total} runs done")
|
|
else:
|
|
self.runner.log(f"{total} runs to do")
|
|
if self.exits:
|
|
self.runner.log("this event drops you out after a song, so the way in "
|
|
"gets walked again before every run")
|
|
|
|
def way_in(self) -> None:
|
|
if picked(self.routes.get("into")):
|
|
walk(self.runner, self.routes["into"], "walking the way in —")
|
|
else:
|
|
self.runner.log("no way in picked — carrying on from wherever you are, "
|
|
"which wants to be the song list")
|
|
|
|
def tick(self) -> None:
|
|
if not self.announced:
|
|
self.announced = True
|
|
self.announce()
|
|
if self.needs_way_in:
|
|
self.needs_way_in = False
|
|
self.way_in()
|
|
|
|
left = self.pending()
|
|
total = len(self.jobs())
|
|
self.runner.stats.update(runs=total - len(left), total=total)
|
|
if not left:
|
|
self.report()
|
|
self.runner.stopped = True
|
|
return
|
|
|
|
song, index, level = left[0]
|
|
label = self.name_of(song, index, level)
|
|
self.runner.log(f"{label} — run {total - len(left) + 1} of {total}")
|
|
if not self.choose(song, index, level):
|
|
self.runner.stopped = True
|
|
return
|
|
|
|
why = self.chart.play(label=label)
|
|
self.remember(song, index, level, why)
|
|
self.played += 1
|
|
self.runner.stats.update(runs=len(self.progress["done"]), total=total)
|
|
|
|
if picked(self.routes.get("back")):
|
|
walk(self.runner, self.routes["back"], " back to the list —")
|
|
if self.exits:
|
|
self.needs_way_in = True
|
|
self.runner.wait(float(self.settings.get("between_seconds", 2.5)))
|
|
|
|
def report(self) -> None:
|
|
done = self.progress.get("done") or {}
|
|
notes = sum(int(run.get("notes", 0)) for run in done.values())
|
|
minutes = sum(float(run.get("seconds", 0)) for run in done.values()) / 60
|
|
self.runner.log(f"all {len(done)} runs done — {notes} notes over "
|
|
f"{minutes:.0f} minutes of song, {self.played} of them today")
|
|
odd = sorted(f"{run['song']} {run['difficulty']}"
|
|
for run in done.values() if run.get("why") == "timeout")
|
|
if odd:
|
|
self.runner.log(f" ended on the clock rather than on an ending: "
|
|
f"{', '.join(odd)} — worth replaying by hand")
|
|
|
|
|
|
# -------------------------------------------------------------------- run
|
|
def migrate(cfg: dict) -> dict:
|
|
"""An older config kept the end of a song at the top level."""
|
|
cfg.pop("quiet_seconds", None)
|
|
return cfg
|
|
|
|
|
|
def _lanes_ready(cfg: dict, runner: Runner) -> bool:
|
|
lanes = cfg.get("lanes") or []
|
|
if not lanes:
|
|
runner.log("no lanes set up — calibrate first (the window makes this easy)")
|
|
return False
|
|
unknown = sorted({lane.get("key") for lane in lanes
|
|
if lane.get("do") != "click" and lane.get("key") not in winput.KEYS})
|
|
if unknown:
|
|
runner.log(f"lanes named {', '.join(repr(key) for key in unknown)} are not keys "
|
|
f"this can press — fix them before starting")
|
|
return False
|
|
unset = [lane.get("key") or "?" for lane in lanes
|
|
if not (lane.get("empty") or lane.get("note"))]
|
|
if unset:
|
|
runner.log(f"lanes {''.join(unset)!r} have no empty-track colour — "
|
|
f"they will never fire until you sample one")
|
|
return True
|
|
|
|
|
|
def run(once: bool = False, runner: Runner | None = None, mode: str | None = None) -> None:
|
|
"""Play: mode is "loop", "once" or "session". `once` is the older spelling."""
|
|
mode = mode or ("once" if once else "loop")
|
|
cfg = migrate(config.load(NAME, DEFAULTS))
|
|
config.save(NAME, cfg)
|
|
runner = runner or Runner("Piano", cfg["hotkeys"])
|
|
|
|
if not _lanes_ready(cfg, runner):
|
|
return
|
|
|
|
chart = Chart(cfg, runner)
|
|
ending = chart.ending
|
|
runner.log(f"{len(chart.lanes)} lanes, reading "
|
|
f"{chart.region.width}x{chart.region.height} px at "
|
|
f"{[lane.at for lane in chart.lanes]}")
|
|
knows = ["the track goes off screen"]
|
|
if chart.timer is not None:
|
|
knows.insert(0, "the timer stops")
|
|
if ending.has_probe:
|
|
knows.insert(0, "the results pixel shows")
|
|
runner.log(f"threshold {chart.tuning['threshold']}, offset "
|
|
f"{cfg['detect']['offset_ms']}ms — a song ends when "
|
|
f"{' or '.join(knows)} for {ending.quiet:.0f}s")
|
|
runner.stats.update(notes=0, scans=0, rate=0.0, started=time.monotonic(),
|
|
lit=[False] * len(chart.lanes), hits=[0] * len(chart.lanes))
|
|
|
|
if mode == "session":
|
|
tick = Session(cfg, runner, chart).tick
|
|
elif mode == "once":
|
|
def tick():
|
|
chart.play(label="the song")
|
|
runner.stopped = True
|
|
else:
|
|
def tick():
|
|
# play() waits for the next song by itself.
|
|
chart.play()
|
|
|
|
try:
|
|
runner.loop(tick)
|
|
finally:
|
|
chart.close()
|
|
chart.report()
|