# MIT License # # Copyright (c) 2026 # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. # # Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed # under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt. """即席オーケストラ・教材4/5。原作Aを使う無音の視覚教材。""" from dataclasses import dataclass, replace MUSICIANS = "KBFU" INITIAL_SEATS = ("K", "F", ".", "B", "U") # Original A score. Keep each time and its pitch in the same pair. SCORE = { "K": ((0, "c1"), (4, "c1")), "B": ((2, "g4"), (6, "a4")), "F": ((0, "c4"), (2, "e4"), (4, "g4"), (6, "e4")), "U": ((1, "c3"), (5, "g2")), } STEPS = 8 def phases(seats): shifts = {} for i, musician in enumerate(seats): if musician == ".": continue left_drum = 1 if i > 0 and seats[i - 1] == "K" else 0 right_bell = 1 if i < 4 and seats[i + 1] == "B" else 0 shifts[musician] = left_drum - right_bell return shifts def arranged(seats): shifts = phases(seats) return { musician: tuple(sorted(((time + shifts[musician]) % STEPS, pitch) for time, pitch in notes)) for musician, notes in SCORE.items() if musician in shifts } def placement(seats, seat, musician): if type(seat) is not int or not 0 <= seat < 5: return seats if type(musician) is not str or musician not in tuple(MUSICIANS + "."): return seats result = list(seats) if musician != "." and musician in result: previous = result.index(musician) result[previous], result[seat] = result[seat], musician else: result[seat] = musician return tuple(result) def simultaneous(seats): score = arranged(seats) return tuple(tuple(m for m in MUSICIANS if m in score and any(time == beat for time, pitch in score[m])) for beat in range(STEPS)) def evaluation(seats): at = simultaneous(seats) players = len(arranged(seats)) rests = sum(not group for group in at) peak = max(map(len, at)) checks = (("three", players >= 3), ("zero_four", bool(at[0] and at[4])), ("seven_rest", not at[7]), ("no_three", peak < 3)) return {"checks": list(checks), "passed": all(ok for _, ok in checks), "rest": rests, "peak": peak, "players": players, "steps": at, "shifts": phases(seats)} STEP_FRAMES = 10 BAR_FRAMES = STEPS * STEP_FRAMES COUNT_IN = 30 @dataclass(frozen=True) class State: seats: tuple = INITIAL_SEATS queued: tuple = () phase: int = 0 beat: int = -1 bars: int = 0 mode: str = "stopped" countin: int = 0 resume_mode: str = "preview" tick: int = 0 events: tuple = () event_hash: str = "" event_count: int = 0 outcome: str = "" @dataclass(frozen=True) class Timeline: state: State = State() def initial(): return Timeline() def target(state): return state.queued or state.seats def digest(value): import hashlib import json data = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) return hashlib.sha256(data.encode("utf-8")).hexdigest() def valid_action(action): if not isinstance(action, (tuple, list)) or not action or type(action[0]) is not str: return False if action[0] == "place": return (len(action) == 3 and type(action[1]) is int and 0 <= action[1] < 5 and type(action[2]) is str and action[2] in tuple(MUSICIANS + ".")) return len(action) == 1 and action[0] in ('play', 'pause', 'frame') def step(timeline, action): if not valid_action(action): return timeline, "invalid" kind = action[0] s = timeline.state ns = s if kind == "place": if s.mode in ("paused", "show"): return timeline, "locked" new = placement(target(s), action[1], action[2]) if new == target(s): return timeline, "same" if s.mode == "preview": ns = replace(s, queued=new) reason = "queued" else: ns = replace(s, seats=new, queued=(), mode="stopped", outcome="") reason = "placed" elif kind == "play": if s.mode == "paused": return timeline, "locked" if s.mode in ("preview", "show"): ns = replace(s, seats=target(s), queued=(), mode="stopped", phase=0, beat=-1, countin=0, events=()) reason = "stop" else: ns = replace(s, seats=target(s), queued=(), phase=0, beat=-1, bars=0, mode="preview", countin=COUNT_IN, events=(), event_hash="", event_count=0, outcome="") reason = "preview" elif kind == "pause": if s.mode == "paused": ns = replace(s, mode=s.resume_mode, phase=0, beat=-1, countin=COUNT_IN if s.resume_mode in ("preview", "show") else 0, events=()) reason = "resync" else: ns = replace(s, mode="paused", resume_mode=s.mode, beat=-1, events=()) reason = "pause" else: # One model frame. Drawing never calls this branch. if s.mode not in ("preview", "show"): return timeline, "idle" if s.countin: ns = replace(s, countin=s.countin - 1, tick=s.tick + 1, beat=-1, events=()) reason = "countin" else: seats = s.queued if s.phase == 0 and s.queued else s.seats queued = () if s.phase == 0 else s.queued events = () if s.phase % STEP_FRAMES == 0: at = s.phase // STEP_FRAMES for musician, notes in arranged(seats).items(): for note, pitch in notes: if note == at: events += ((s.tick, s.bars, at, musician, pitch),) phase = (s.phase + 1) % BAR_FRAMES bars = s.bars + (phase == 0) mode, outcome = s.mode, s.outcome ns = replace(s, seats=seats, queued=queued, phase=phase, beat=s.phase // STEP_FRAMES, bars=bars, mode=mode, tick=s.tick + 1, events=events, outcome=outcome, event_hash=digest((s.event_hash, events)) if events else s.event_hash, event_count=s.event_count + len(events)) reason = "finished" if mode == "result" else "frame" return Timeline(ns), reason class App: def __init__(self): self.reset() def reset(self): self.timeline = initial() self.seat = 1 self.person = 0 self.gated = False self.message = "READY" @property def state(self): return self.timeline.state def control(self, pressed, held): if "R" in pressed: self.reset() self.gated = True return if self.gated: if not pressed and not held: self.gated = False return if len(pressed | held) > 1: self.message = "RELEASE ALL KEYS" self.gated = True return if len(pressed) != 1: return key = next(iter(pressed)) if key == "LEFT": self.seat = (self.seat - 1) % 5 elif key == "RIGHT": self.seat = (self.seat + 1) % 5 elif key == "UP": self.person = (self.person - 1) % 5 elif key == "DOWN": self.person = (self.person + 1) % 5 elif key == "Z": self.timeline, self.message = step(self.timeline, ("place", self.seat, (MUSICIANS + ".")[self.person])) elif key in {'SPACE': 'play', 'P': 'pause'}: self.timeline, self.message = step(self.timeline, ({'SPACE': 'play', 'P': 'pause'}[key],)) self.gated = True def advance(self): self.timeline, reason = step(self.timeline, ("frame",)) if reason == "finished": self.message = self.state.outcome.upper() self.gated = True def draw(self, p): p.cls(1) p.text(10, 10, "POCKET ORCHESTRA / STAGE 4", 10) p.text(364, 10, "SILENT VISUAL LESSON", 6) seats = self.state.seats future = target(self.state) p.text(10, 28, "K DRUM B BELL F FLUTE U BASS . EMPTY", 7) for i, musician in enumerate(future): x = 10 + i * 108 p.rect(x, 45, 100, 43, 2) p.rectb(x, 45, 100, 43, 10 if i == self.seat else 5) p.text(x + 8, 53, str(i + 1) + ": " + musician, 7) shift = phases(future).get(musician, 0) p.text(x + 8, 70, "SHIFT " + format(shift, "+d") if musician != "." else "EMPTY", 6) p.text(10, 97, "CURRENT " + "".join(seats) + " CANDIDATE " + (MUSICIANS + ".")[self.person], 7) p.text(10, 111, ("QUEUED " + "".join(self.state.queued) + " / NEXT BAR") if self.state.queued else "NO PENDING SEATS", 9) score = arranged(seats) active = self.state.beat if self.state.mode in ("preview", "show") else -1 p.text(10, 131, "ORIGINAL: OUTLINE ARRANGED: FILLED / TIME+PITCH", 6) for beat in range(STEPS): x = 139 + beat * 51 p.text(x, 150, str(beat), 10) if beat == active: p.rect(x - 8, 162, 42, 120, 3) for row, musician in enumerate(MUSICIANS): y = 175 + row * 29 p.text(10, y, musician, 7) p.text(33, y, format(phases(seats).get(musician, 0), "+d") if musician in score else "OFF", 6) for beat in range(STEPS): p.pset(141 + beat * 51, y + 3, 5) for time, pitch in SCORE[musician]: p.rectb(137 + time * 51, y - 2, 8, 8, 5) for time, pitch in score.get(musician, ()): p.rect(138 + time * 51, y - 1, 6, 6, COLORS[musician]) p.text(131 + time * 51, y + 10, pitch.upper(), 7) result = evaluation(future) names = ("3+ PLAYERS", "SOUND 0/4", "REST AT 7", "PEAK <= 2") for i, ((name, ok), label) in enumerate(zip(result["checks"], names)): p.text(10 + i * 135, 291, ("OK " if ok else "-- ") + label, 11 if ok else 13) p.text(10, 307, "TARGET RESTS " + str(result["rest"]) + " PEAK " + str(result["peak"]) + " PASS " + str(result["passed"]), 6) s = self.state p.text(10, 326, s.mode.upper() + " TICK " + str(s.tick) + " PHASE " + str(s.phase) + " BEAT " + str(s.beat) + " BARS " + str(s.bars) + " COUNT " + str(s.countin), 7) p.text(10, 346, self.message.upper(), 10) p.text(10, 366, "ARROWS SEAT/PERSON Z PLACE R RESET", 7) p.text(10, 382, "SPACE PREVIEW/STOP P PAUSE/RESYNC ESC QUIT", 6) COLORS = {"K": 9, "B": 10, "F": 11, "U": 12} KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'Z', 'R', 'SPACE', 'P') DEMO_FRAMES = 48 def demo(app, frame): key = {1: 'SPACE', 41: 'RIGHT', 43: 'DOWN', 45: 'Z'}.get(frame) app.control({key} if key else set(), {key} if key else set()) def main(args): # CLI validation is complete before this optional graphics import. from pathlib import Path import pyxel as p app = App() shot = Path(args.screenshot).resolve() if args.screenshot else None if shot: shot.parent.mkdir(parents=True, exist_ok=True) limit = args.qa_frames or (DEMO_FRAMES if args.demo_checkpoint else 12) p.init(560, 400, title="056 Pocket Orchestra", fps=30, display_scale=1, headless=args.headless_smoke) keys = {key: getattr(p, "KEY_" + key) for key in KEYS} frame = 0 def update(): nonlocal frame frame += 1 if args.headless_smoke and frame >= limit: if shot: p.screenshot(str(shot), scale=1) p.quit() return if args.headless_smoke and args.demo_checkpoint: demo(app, frame) elif not args.headless_smoke: app.control({k for k, v in keys.items() if p.btnp(v)}, {k for k, v in keys.items() if p.btn(v)}) app.advance() p.run(update, lambda: app.draw(p)) def parse_args(argv=None): import argparse parser = argparse.ArgumentParser(description="056 silent visual tutorial; Python 3.11+") parser.add_argument("--headless-smoke", action="store_true") parser.add_argument("--demo-checkpoint", action="store_true") parser.add_argument("--screenshot") parser.add_argument("--qa-frames", type=int) args = parser.parse_args(argv) import sys if sys.version_info < (3, 11): parser.error("Python 3.11 or newer is required") if (args.demo_checkpoint or args.screenshot or args.qa_frames is not None) and not args.headless_smoke: parser.error("QA options require --headless-smoke") if args.qa_frames is not None and not 2 <= args.qa_frames <= 600: parser.error("--qa-frames must be in 2..600") if args.demo_checkpoint and args.qa_frames is not None and args.qa_frames < DEMO_FRAMES: parser.error("--qa-frames is too short for the demo checkpoint") if args.screenshot and not args.screenshot.lower().endswith(".png"): parser.error("--screenshot must end in .png") return args if __name__ == "__main__": main(parse_args())