# 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. """即席オーケストラ・教材2/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) @dataclass(frozen=True) class State: seats: tuple = INITIAL_SEATS def initial(): return State() class App: def __init__(self): self.reset() def reset(self): self.state = initial() self.seat = 1 self.person = 0 self.gated = False self.message = "READY" 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": new = placement(self.state.seats, self.seat, (MUSICIANS + ".")[self.person]) self.message = "SAME" if new == self.state.seats else "PLACED" self.state = replace(self.state, seats=new) self.gated = True def advance(self): pass # No model clock in this stage. def draw(self, p): p.cls(1) p.text(10, 10, "POCKET ORCHESTRA / STAGE 2", 10) p.text(364, 10, "SILENT VISUAL LESSON", 6) seats = self.state.seats future = seats 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, "K -> RIGHT +1 B -> LEFT -1 EMPTY BLOCKS", 9) score = arranged(seats) active = -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) p.text(10, 294, "EACH CELL IS (TIME, PITCH). 8 STEPS REPEAT.", 6) p.text(10, 326, "STATIC SCORE / DRAWING NEVER ADVANCES TIME", 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, "ESC QUIT / NO AUDIO", 6) COLORS = {"K": 9, "B": 10, "F": 11, "U": 12} KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'Z', 'R') DEMO_FRAMES = 16 def demo(app, frame): key = {1: 'RIGHT', 3: 'DOWN', 5: '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())