"""耳コピ2（楽器を選ぶ）ブラウザ版

CLI 版（g74-ear-voices/main.py）と中身はまったく同じ。楽器の作り方（piano・organ・pluck・
xylophone）も、絵の描き方（Screen・roll・board・draw）も、遊びの判断（Game・obey）も
1 文字も変えずに持ってきている。

違うのは入口と出口だけ。
  入口: 端末はキー 1 文字、ブラウザは鍵盤のクリックとキー。どちらも obey() に入る
  出口: 絵は端末が ▀ の並び、ブラウザは canvas。音は端末が afplay、ブラウザは Audio。
        ただし **鳴らしている wav の bytes は同じもの**。wav_bytes() が作った bytes を、
        端末はファイルへ、ブラウザは data URI へ渡しているだけ。

持ってこなかったのは Screen.render() と、それを使う run() / show() / Speaker と検査だけ。
"""

import base64
import io
import math
import random
import wave
from array import array
from collections import deque
from dataclasses import dataclass, field
from functools import reduce
from typing import Callable, TypeAlias

from pyscript import document, when, window
RATE = 22050                                        # 1 秒あたりの標本の数


NOTE = 0.34                                         # お題の 1 音の長さ（秒）


TAP = 0.5                                           # 鍵盤を押したときの長さ（秒）


VOLUME = 0.34                                       # 0〜1。16 bit の最大値にかける割合


WIDTH = 128                                         # 画面の横（ドット）。端末では 1 ドット = 1 桁


HEIGHT = 80                                         # 縦。端末では 2 ドット = 1 行 → 40 行


OFFSET = 6                                          # 曲を鍵盤のどこに置くか（真ん中あたり）


SPAN = 12                                           # ロールに映す音の幅（半音）


ALMOST = 2                                          # 残りがこれ以下なら ALMOST


ROLL_TOP = 4                                        # ロールの上端


SEMI = 3                                            # 半音 1 つぶんの高さ（ドット）


BOARD_TOP = 48                                      # 鍵盤の上端


WHITE_W = 8                                         # 白鍵の幅


WHITE_H = 32                                        # 白鍵の高さ


BLACK_W = 6                                         # 黒鍵の幅


BLACK_H = 20                                        # 黒鍵の高さ


LEFT = 4                                            # 鍵盤の左端


INK = (232, 234, 226)                               # 白鍵


SHADE = (176, 180, 170)                             # 白鍵の押されたところ


EBONY = (26, 30, 28)                                # 黒鍵


LIT = (232, 176, 84)                                # 押している鍵


SURE = (60, 122, 158)                               # 確定した音


HINT = (44, 80, 100)                                # 1 音目の高さの下書き（黒鍵）


HINT_W = (186, 200, 206)                            # 同じ（白鍵）


TRY = (206, 138, 74)                                # 打ち込み中の音


MISS = (198, 82, 70)                                # 答え合わせで違っていた音


BAND = (30, 40, 46)                                 # いま打ち込む列の帯


SURE_W = (150, 186, 208)                            # 決まった音の白鍵


SURE_B = (38, 72, 94)                               # 決まった音の黒鍵


FAINT = (74, 86, 80)                                # 1 音目の高さの目盛り


GRID = (38, 46, 42)                                 # 半音ごとの目盛り


BACK = (18, 22, 20)                                 # 背景


LINE = (44, 52, 48)                                 # 仕切り


def pitch_hz(note: int) -> float:
    """半音の番号を周波数にする。ド = 0、ラ = 9 がちょうど 440 Hz。

    半音 1 つで 2 の 12 乗根倍。12 個かければ 2 倍＝ちょうど 1 オクターブ上になる。
    """
    return 440.0 * 2.0 ** ((note - 9) / 12)


Voice: TypeAlias = Callable[[int, float], array]    # 音の番号と長さを受けて、標本の列を返すもの


def shape(i: int, count: int, attack: float = RATE / 30, release: float = RATE / 10) -> float:
    """音の立ち上がりと消え方。0 から 1 のあいだの倍率を返す。

    これが無いと、音の出始めと終わりで波が急に切れて「プツッ」と鳴る。
    楽器で長さが違う。ピアノは打った瞬間に立ち上がり、エレクトーンはふわっと入る。
    """
    return min(1.0, i / attack, (count - i) / release)


def pack(count: int, gain: float, wave_at: Callable[[int], float]) -> array:
    """標本の数だけ wave_at(i) を呼んで、16 bit の列に詰める。どの楽器もここを通る。"""
    samples = array("h")
    for i in range(count):
        samples.append(int(32767 * VOLUME * gain * wave_at(i)))
    return samples


PIANO = ((1, 1.0), (2, 0.6), (3, 0.3), (4, 0.15), (5, 0.08))     # 倍音とその強さ


def piano(note: int, seconds: float) -> array:
    """ピアノ。倍音を重ねて、打った瞬間から減衰させる。

    高い倍音ほど早く消える（exp(-k t)）。これが「打った直後は明るく、すぐ丸くなる」
    ピアノらしさになる。
    """
    count, hz = int(RATE * seconds), pitch_hz(note)

    def wave_at(i: int) -> float:
        t = i / RATE
        value = sum(s * math.sin(math.tau * hz * k * t) * math.exp(-k * t) for k, s in PIANO)
        return shape(i, count, attack=RATE / 200) * math.exp(-3.5 * t) * value

    return pack(count, 0.55, wave_at)


DRAWBARS = ((0.5, 0.8), (1, 1.0), (2, 0.7), (3, 0.4), (4, 0.3), (6, 0.15), (8, 0.1))


LOUD_ORGAN = 2.1                                    # 重ねたときの山の高さ（測った値）


def organ(note: int, seconds: float) -> array:
    """エレクトーン。倍音を足し合わせて持続させる。

    本物のオルガンは「ドローバー」を引いて倍音の量を決める。それを表にしたのが DRAWBARS。
    reduce で表を 1 つの波に畳む。減衰しないかわりに、ゆれ（トレモロ）を少し乗せる。
    """
    count, hz = int(RATE * seconds), pitch_hz(note)

    def wave_at(i: int) -> float:
        phase = hz * i / RATE
        value = reduce(lambda total, bar: total + bar[1] * math.sin(math.tau * bar[0] * phase),
                       DRAWBARS, 0.0) / LOUD_ORGAN
        wobble = 1 + 0.06 * math.sin(math.tau * 6 * i / RATE)
        return shape(i, count, attack=RATE / 60) * wobble * value

    return pack(count, 1.0, wave_at)


def pluck(note: int, seconds: float, damp: float, blend: float) -> array:
    """弦をはじく（Karplus-Strong）。

    弦の長さぶんの雑音を用意し、先頭を取り出しては「となりと平均して」末尾に戻す。
    これだけで、雑音が一周ごとに丸くなって弦の音になる。deque(maxlen=) が弦そのもの。
    平均するたびに高い成分から消えるので、はじいた直後は明るく、あとは太く残る。
    """
    count = int(RATE * seconds)
    length = max(2, int(RATE / pitch_hz(note)))     # 一周が音の高さになる
    luck = random.Random(1)                         # 種を固定。端末とブラウザで同じ雑音にする
    string = deque((luck.uniform(-1, 1) for _ in range(length)), maxlen=length)
    last = 0.0

    def wave_at(i: int) -> float:
        nonlocal last
        first = string[0]
        value = damp * (blend * first + (1 - blend) * last)
        string.append(value)
        last = first
        return shape(i, count, attack=1, release=RATE / 20) * value

    return pack(count, 1.0, wave_at)


def guitar(note: int, seconds: float) -> array:
    """ギター。弦をはじく。"""
    return pluck(note, seconds, damp=0.995, blend=0.5)


def bass(note: int, seconds: float) -> array:
    """ベース。1 オクターブ下の弦を、平均を強めにして太く。"""
    return pluck(note - 12, seconds, damp=0.998, blend=0.7)


def xylophone(note: int, seconds: float) -> array:
    """木琴。基音と、4 倍近くの高い倍音を 1 つだけ、短く減衰させる。"""
    count, hz = int(RATE * seconds), pitch_hz(note)

    def wave_at(i: int) -> float:
        t = i / RATE
        value = (math.sin(math.tau * hz * t) * math.exp(-9 * t)
                 + 0.35 * math.sin(math.tau * hz * 3.98 * t) * math.exp(-22 * t))
        return shape(i, count, attack=1, release=RATE / 50) * value

    return pack(count, 0.8, wave_at)


VOICES: tuple[tuple[str, Voice], ...] = (
    ("ピアノ", piano), ("エレクトーン", organ), ("ギター", guitar), ("ベース", bass), ("木琴", xylophone),
)


def wav_bytes(samples: array) -> bytes:
    """標本の列を wav の bytes にする。ファイルにはしない。

    io.BytesIO は「メモリの上のファイル」。wave はファイルのように書き込むので、
    これを渡せば、書き出す先を選ばずに済む（端末はファイル、ブラウザは Audio）。
    """
    buffer = io.BytesIO()
    with wave.open(buffer, "wb") as out:
        out.setnchannels(1)                         # 1 本（モノラル）
        out.setsampwidth(2)                         # 1 標本 2 バイト = 16 bit
        out.setframerate(RATE)
        out.writeframes(samples.tobytes())
    return buffer.getvalue()


def melody(notes: list[int], seconds: float = NOTE, voice: Voice = piano) -> array:
    """音を並べて 1 本の列にする。楽器は外から渡す。"""
    line = array("h")
    for note in notes:
        line.extend(voice(note, seconds))
    return line


@dataclass(frozen=True)
class Song:
    """1 曲。notes は「いちばん低い音を 0 とした半音の番号」。

    どの高さで鳴らすかは OFFSET で決めるので、曲の側は形だけを持つ。
    """

    name: str
    who: str
    notes: list[int]

    def on_board(self) -> list[int]:
        """鍵盤の上に置いた音の番号にする。"""
        return [note + OFFSET for note in self.notes]


SONGS = [
    Song("うれしいひなまつり", "河村光陽（1946 没）", [0, 0, 0, 2, 3, 2, 0]),
    Song("雀の学校", "弘田龍太郎（1952 没）", [0, 3, 3, 3, 0, 3, 3, 3]),
    Song("かごめかごめ", "わらべうた", [0, 0, 2, 0, 2, 3, 2, 0, 0, 2, 0]),
    Song("アルプス一万尺", "アメリカ民謡", [0, 0, 2, 4, 0, 4, 2]),
    Song("メリーさんのひつじ", "アメリカ民謡", [4, 2, 0, 2, 4, 4, 4]),
    Song("あんたがたどこさ", "わらべうた", [0, 0, 2, 4, 4, 2, 0, 2, 4, 0]),
    Song("あめふり", "中山晋平（1952 没）", [3, 3, 0, 3, 5, 5, 3]),
    Song("かえるの合唱", "ドイツ民謡", [0, 2, 4, 5, 4, 2, 0]),
    Song("かなりや", "成田為三（1945 没）", [0, 0, 1, 3, 5, 3, 0]),
    Song("こいのぼり", "作曲者不詳（1931）", [0, 3, 3, 5, 3, 0, 3]),
    Song("月の沙漠", "佐々木すぐる（1966 没）", [2, 2, 4, 5, 4, 2, 0]),
    Song("浦島太郎", "文部省唱歌", [3, 3, 0, 3, 5, 3, 0]),
    Song("茶摘み", "文部省唱歌", [0, 0, 3, 3, 5, 3, 0]),
    Song("草競馬", "S.フォスター", [3, 3, 0, 3, 5, 3, 0]),
    Song("夕焼小焼", "草川信（1948 没）", [3, 3, 5, 3, 0, 3, 5, 3]),
    Song("春が来た", "岡野貞一（1941 没）", [3, 0, 3, 5, 3, 0, 3, 5, 3, 0]),
    Song("桃太郎", "岡野貞一（1941 没）", [3, 3, 0, 3, 5, 3, 3, 3, 0, 3, 5, 3]),
    Song("かもめの水兵さん", "河村光陽（1946 没）", [0, 4, 7, 4, 0, 4, 7]),
    Song("富士山", "文部省唱歌", [0, 4, 7, 4, 0, 4, 2]),
    Song("春よ来い", "弘田龍太郎（1952 没）", [5, 2, 5, 7, 5, 2, 0]),
    Song("証城寺の狸囃子", "中山晋平（1952 没）", [5, 5, 5, 7, 5, 2, 0]),
    Song("金太郎", "田村虎蔵（1943 没）", [4, 4, 2, 0, 4, 7, 7]),
    Song("てるてる坊主", "中山晋平（1952 没）", [0, 2, 4, 5, 7, 7, 4, 0]),
    Song("聖者の行進", "アメリカ賛美歌", [0, 4, 5, 7, 0, 4, 5, 7]),
    Song("荒城の月", "滝廉太郎（1903 没）", [0, 2, 3, 2, 0, 3, 7, 5, 3]),
    Song("ロンドン橋", "イギリス伝承", [5, 7, 5, 3, 2, 3, 5, 0, 2, 3]),
    Song("ぶんぶんぶん", "ボヘミア民謡", [7, 4, 4, 5, 2, 2, 0, 2, 4, 5, 7]),
    Song("ジングルベル", "J.ピアポント", [4, 4, 4, 4, 4, 4, 4, 7, 0, 2, 4]),
    Song("山の音楽家", "ドイツ民謡", [0, 0, 4, 4, 7, 7, 4, 5, 5, 4, 4, 2]),
    Song("ちょうちょう", "ドイツ民謡", [7, 4, 4, 5, 2, 2, 0, 2, 4, 5, 7, 7, 7]),
    Song("さくらさくら", "日本古謡", [4, 4, 6, 4, 4, 6, 4, 6, 7, 6, 4, 6, 4, 0]),
    Song("うさぎとかめ", "納所弁次郎（1936 没）", [3, 3, 0, 3, 8, 5, 3]),
    Song("七つの子", "本居長世（1945 没）", [5, 3, 0, 3, 5, 8, 5, 3]),
    Song("春の小川", "岡野貞一（1941 没）", [3, 3, 5, 3, 0, 3, 5, 8]),
    Song("きらきら星", "フランス民謡", [0, 0, 7, 7, 9, 9, 7]),
    Song("シャボン玉", "中山晋平（1952 没）", [4, 7, 9, 7, 4, 2, 0]),
    Song("赤い靴", "本居長世（1945 没）", [4, 7, 9, 7, 4, 2, 0]),
    Song("この道", "山田耕筰（1965 没）", [0, 4, 7, 9, 7, 4, 2, 0]),
    Song("もみじ", "岡野貞一（1941 没）", [4, 7, 7, 9, 7, 4, 2, 0]),
    Song("峠の我が家", "アメリカ民謡", [0, 5, 5, 7, 9, 5, 4, 2]),
    Song("浜辺の歌", "成田為三（1945 没）", [0, 2, 4, 7, 9, 7, 4, 2]),
    Song("蛍の光", "スコットランド民謡", [0, 5, 5, 9, 7, 5, 7, 9]),
    Song("靴が鳴る", "弘田龍太郎（1952 没）", [0, 4, 7, 7, 9, 7, 5, 4, 2]),
    Song("朧月夜", "岡野貞一（1941 没）", [0, 4, 7, 9, 7, 4, 2, 4, 2, 0]),
    Song("どんぐりころころ", "梁田貞（1959 没）", [4, 4, 7, 7, 9, 9, 7, 4, 4, 2, 2, 0]),
    Song("おおスザンナ", "S.フォスター", [0, 2, 4, 7, 7, 9, 7, 4, 0, 2, 4, 4, 2, 0, 2]),
    Song("赤とんぼ", "山田耕筰（1965 没）", [4, 7, 9, 12, 9, 7, 4, 2, 0]),
]


WHITE = [0, 2, 4, 5, 7, 9, 11, 12, 14, 16, 17, 19, 21, 23, 24]      # 白鍵の音


BLACK_AFTER = [0, 1, 3, 4, 5, 7, 8, 10, 11, 12]                     # この白鍵の右上に黒鍵


BLACK = [WHITE[w] + 1 for w in BLACK_AFTER]                         # 黒鍵の音


WHITE_CHARS = "zxcvbnmqwertyui"                                     # 白鍵のキー


BLACK_CHARS = "sd" "ghj" "23" "567"                                 # 黒鍵のキー


CHARS = dict(zip(WHITE_CHARS, WHITE)) | dict(zip(BLACK_CHARS, BLACK))


def white_box(index: int) -> tuple[int, int, int, int]:
    """白鍵の左上と大きさ。"""
    return LEFT + index * WHITE_W, BOARD_TOP, WHITE_W, WHITE_H


def black_box(index: int) -> tuple[int, int, int, int]:
    """黒鍵の左上と大きさ。白鍵の境目にまたがって載る。"""
    left = LEFT + (BLACK_AFTER[index] + 1) * WHITE_W - BLACK_W // 2
    return left, BOARD_TOP, BLACK_W, BLACK_H


def key_at(x: int, y: int) -> int | None:
    """画面の点を鍵盤の音にする。黒鍵が上に載っているので、黒を先に見る。"""
    for index, note in enumerate(BLACK):
        left, top, w, h = black_box(index)
        if left <= x < left + w and top <= y < top + h:
            return note
    for index, note in enumerate(WHITE):
        left, top, w, h = white_box(index)
        if left <= x < left + w and top <= y < top + h:
            return note
    return None


class Screen:
    """WIDTH × HEIGHT のドットの板。1 ドットは RGB か None（黒）。"""

    def __init__(self):
        self.pixels: list[list[tuple[int, int, int] | None]] = [[None] * WIDTH for _ in range(HEIGHT)]

    def clear(self) -> None:
        for row in self.pixels:
            row[:] = [None] * WIDTH

    def plot(self, x: int, y: int, color: tuple[int, int, int]) -> None:
        if 0 <= x < WIDTH and 0 <= y < HEIGHT:
            self.pixels[y][x] = color

    def box(self, x: int, y: int, w: int, h: int, color: tuple[int, int, int]) -> None:
        for row in range(y, y + h):
            for col in range(x, x + w):
                self.plot(col, row, color)

    def frame(self, x: int, y: int, w: int, h: int, color: tuple[int, int, int]) -> None:
        for i in range(w):
            self.plot(x + i, y, color)
            self.plot(x + i, y + h - 1, color)
        for i in range(h):
            self.plot(x, y + i, color)
            self.plot(x + w - 1, y + i, color)


def roll(screen: "Screen", game: "Game") -> None:
    """お題の音を横に並べる。

    1 列が 1 音。色でその音がどうなっているかを出す。
      青  決まった（もう動かない）
      橙  いま打ち込んでいる
      赤  さっきの答え合わせで違っていた（次に何か押すと消える）
    いま打ち込む列は縦の帯で示す。**どこに入るのかが見えないと、耳より先に迷う。**

    縦は半音 1 つが SEMI ドット。窓の高さは曲によらず SPAN 半音で固定してある——
    曲に合わせて伸び縮みさせると、目盛りの間隔が「その曲の音の幅」を教えてしまう。
    位置だけは曲の真ん中に合わせる（せまい曲が鍵盤に張りつかないように）。
    """
    count = len(game.answer)
    step = (WIDTH - LEFT * 2) // count
    goal = game.goal
    high = OFFSET + game.shift + SPAN - (SPAN - max(game.song.notes)) // 2
    tall = SPAN * SEMI + SEMI
    base = ROLL_TOP + tall            # 印を置く行。鍵盤とぶつからない高さ
    here = game.at()
    if here is not None:                            # いま打ち込む列を先に塗る（目盛りの下）
        screen.box(LEFT + here * step, ROLL_TOP, step - 2, tall, BAND)
    for line in range(SPAN + 1):                    # 半音ごとの目盛り。空いた所も「音の高さ」だと分かる
        y = ROLL_TOP + line * SEMI + SEMI - 1
        color = FAINT if (high - line) == goal[0] else GRID
        for x in range(LEFT, LEFT + count * step - 2):
            screen.plot(x, y, color)
    for i in range(count):
        x = LEFT + i * step
        if game.fixed[i]:
            note, color, mark = goal[i], SURE, SURE
        elif game.missed[i] is not None:
            note, color, mark = game.missed[i], MISS, MISS
        elif game.typed[i] is not None:
            note, color, mark = game.typed[i], TRY, TRY
        elif i == 0:
            note, color, mark = goal[0], HINT, TRY if i == here else GRID
        else:
            note, color, mark = None, None, TRY if i == here else GRID
        screen.box(x + step // 2 - 1, base + 2, 2, 2, mark)
        if note is None:
            continue
        if high - SPAN <= note <= high:
            screen.box(x, ROLL_TOP + (high - note) * SEMI, step - 2, SEMI, color)
        else:                                       # 窓の外の音。端に細く出して「外にある」と示す
            edge = ROLL_TOP if note > high else ROLL_TOP + tall - 1
            screen.box(x, edge, step - 2, 1, color)
    for x in range(LEFT, LEFT + count * step - 2):
        screen.plot(x, base, LINE)


def board(screen: "Screen", game: "Game") -> None:
    """25 鍵の鍵盤。黒鍵をあとに描いて白鍵の上に載せる。

    **決まった音の鍵は青く塗る。** 上のバーと鍵盤がこれでつながる——
    「青いバーはこの鍵のこと」が一目で分かるようにするため。
    もう分かっている音なので、これで教えすぎになることもない。
    """
    goal = game.goal
    done = {goal[i] for i in range(len(goal)) if game.fixed[i]}
    start = goal[0] if game.at() == 0 else None     # おすすめの始まりの鍵
    for index, note in enumerate(WHITE):
        x, y, w, h = white_box(index)
        screen.box(x, y, w, h, LIT if note == game.lit else SURE_W if note in done
                   else HINT_W if note == start else INK)
        screen.frame(x, y, w, h, SHADE)
    for index, note in enumerate(BLACK):
        x, y, w, h = black_box(index)
        screen.box(x, y, w, h, LIT if note == game.lit else SURE_B if note in done
                   else HINT if note == start else EBONY)


def draw(screen: "Screen", game: "Game") -> None:
    screen.clear()
    screen.box(0, 0, WIDTH, HEIGHT, BACK)
    roll(screen, game)
    board(screen, game)


@dataclass
class Game:
    """遊びの状態。端末もブラウザもこれ 1 つを進める。"""

    index: int = 0                                  # 何曲目
    typed: list[int | None] = field(default_factory=list)
    fixed: list[bool] = field(default_factory=list)
    missed: list[int | None] = field(default_factory=list)  # 答え合わせで違っていた音
    heard: int = 0                                  # お題を聞いた回数
    tries: int = 0                                  # 答え合わせをした回数
    lit: int | None = None                          # いま光っている鍵
    shift: int = 0                                  # 弾いている高さのずれ（半音）
    voice: int = 0                                  # 楽器（VOICES の番号）
    call: str = ""                                  # GOOD / ALMOST / BAD
    stars: list[int] = field(default_factory=list)
    cleared: bool = False
    message: str = ""

    def __post_init__(self):
        if not self.typed:
            self.start()

    @property
    def song(self) -> Song:
        return SONGS[self.index]

    @property
    def answer(self) -> list[int]:
        """お題として鳴らす音。ここは動かさない。"""
        return self.song.on_board()

    @property
    def goal(self) -> list[int]:
        """答え合わせの相手。弾いている高さに合わせてずらしたもの。

        耳コピは「音の形」を写す遊びなので、**どの高さから弾いても正解**にする。
        絶対音感がないと出だしの高さは決められないし、決められないと全部はずれる。
        """
        return [note + self.shift for note in self.answer]

    @property
    def sound(self) -> Voice:
        """いま選んでいる楽器。お題も鍵盤もこれで鳴る。"""
        return VOICES[self.voice][1]

    def switch(self) -> str:
        """次の楽器へ。最後まで行ったら最初へ戻る。曲や打ち込みには触らない。"""
        self.voice = (self.voice + 1) % len(VOICES)
        return VOICES[self.voice][0]

    def shifts(self) -> range:
        """25 鍵に収まる範囲で、曲を置ける高さのずれ。"""
        return range(-OFFSET, 25 - OFFSET - max(self.song.notes) + 1)

    def start(self) -> None:
        """新しい曲を出す。1 音目の高さは**下書きとして見せるだけ**にする。

        見せないと、絶対音感が無いかぎり出だしの高さを当てられない。
        かといって埋めてしまうと、**聞いたとおりに全部打つと 1 つずれる**。
        だから見せるが埋めない。打ち込むのは 1 音目から。
        """
        answer = self.answer
        self.typed = [None] * len(answer)
        self.fixed = [False] * len(answer)
        self.missed = [None] * len(answer)
        self.shift = 0
        self.call = ""
        self.heard = 0
        self.tries = 0
        self.lit = None
        self.cleared = False
        self.message = "まずお題を聞いてください"

    def at(self) -> int | None:
        """次に打ち込む場所。確定していなくて、まだ空いているところの左から。"""
        for i, (note, done) in enumerate(zip(self.typed, self.fixed)):
            if not done and note is None:
                return i
        return None

    def press(self, note: int) -> bool:
        """鍵を押す。打ち込む場所があれば入れる。"""
        self.lit = note
        self.forget()
        spot = self.at()
        if spot is None or self.cleared:
            return False
        self.typed[spot] = note
        self.message = self.left()
        return True

    def left(self) -> str:
        """あと何音そろえればいいか。**押すべきときが分かるように、いつも出す。**"""
        rest = sum(1 for i, note in enumerate(self.typed) if not self.fixed[i] and note is None)
        if rest == 0:
            return "そろった。リターンで答え合わせ"
        return f"{len(self.typed)} 音の曲。あと {rest} 音"

    def forget(self) -> None:
        """さっきの答え合わせの跡（赤い印と GOOD/BAD）を消す。何か打ったら消える。"""
        if self.call or any(note is not None for note in self.missed):
            self.missed = [None] * len(self.typed)
            self.call = ""

    def erase(self) -> None:
        """最後に打ち込んだ音を消す。確定した音は消せない。"""
        self.forget()
        for i in reversed(range(len(self.typed))):
            if not self.fixed[i] and self.typed[i] is not None:
                self.typed[i] = None
                self.message = self.left()
                return

    def judge(self) -> bool:
        """答え合わせ。合っていた音だけを確定させ、違った音は消す。

        位置も高さも教えない。合っていたかどうかだけ。
        耳で確かめれば分かることを字で教えると、耳を使わずに解けてしまう。

        違っていた音は**赤のまま残す**。黙って消すと、何が起きたのか分からない。
        次に鍵を押したときに消える。
        """
        if self.at() is not None or self.cleared:
            self.message = "まだ全部そろっていない"
            return False
        self.tries += 1
        if not any(self.fixed):                     # まだ何も決まっていないうちは高さを選び直す
            answer = self.answer
            self.shift = max(self.shifts(), key=lambda d: (
                sum(1 for i, note in enumerate(self.typed) if note == answer[i] + d), -abs(d)))
        goal = self.goal
        self.missed = [None] * len(goal)
        for i in range(len(goal)):
            if self.typed[i] == goal[i]:
                self.fixed[i] = True
            else:
                self.missed[i] = self.typed[i]      # 何を押したかを赤で残す
                self.typed[i] = None
        wrong = sum(1 for note in self.missed if note is not None)
        if all(self.fixed):
            self.cleared = True
            self.call = "GOOD！"
            self.stars.append(self.score())
            how = f"（{self.shift:+d} 半音の高さで弾きましたが、形が同じなので正解）" if self.shift else ""
            self.message = f"★ {self.score()}{how}"
        else:
            self.call = "ALMOST！" if wrong <= ALMOST else "BAD！"
            self.message = (f"青が {sum(self.fixed)} 音そろった。"
                            f"赤い {wrong} 音をもう一度")
        return self.cleared

    def score(self) -> int:
        """星の数。聞いた回数と答え合わせの回数が少ないほど多い。"""
        if self.heard <= 3 and self.tries <= 2:
            return 3
        if self.heard <= 6 and self.tries <= 4:
            return 2
        return 1

    def advance(self) -> bool:
        """次の曲へ。最後まで行ったら False。"""
        if self.index + 1 >= len(SONGS):
            return False
        self.index += 1
        self.start()
        return True


def obey(game: Game, key: str) -> tuple[list[int], float, Voice] | None:
    """キーを 1 つ受け取ってゲームを進め、鳴らすものがあれば (音, 長さ, 楽器) で返す。

    端末もブラウザもここを通る。鳴らし方は違っても、判断はここ 1 か所。
    """
    if key == "voice":                                  # 楽器を変える。遊びには触らない
        game.message = f"楽器: {game.switch()}"
        return [game.goal[0]], TAP, game.sound          # 変わった音をひとつ鳴らして聞かせる
    if key == "space":                                  # いつでも「お題を聞く」だけ
        if game.cleared:
            return game.answer, NOTE, game.sound        # クリア後は聞き直すだけ。進まない
        game.heard += 1
        game.message = game.left()
        return game.answer, NOTE, game.sound
    if key == "enter":                                  # 「答え合わせ」、クリア後は「次の曲へ」
        if game.cleared:
            if not game.advance():
                game.message = "最後の曲です"
            return None
        game.judge()
        return None
    if key == "back":
        game.erase()
        return None
    note = CHARS.get(key)
    if note is None:
        return None
    game.press(note)
    return [note], TAP, game.sound


# --- ここから下はブラウザ版だけ。CLI 版の run() / Screen.render() / Speaker にあたる ---

canvas = document.querySelector("#screen")
ctx = canvas.getContext("2d")
ctx.imageSmoothingEnabled = False
image = ctx.createImageData(WIDTH, HEIGHT)
title_label = document.querySelector("#title")
count_label = document.querySelector("#count")
heard_label = document.querySelector("#heard")
tries_label = document.querySelector("#tries")
stars_label = document.querySelector("#stars")
message = document.querySelector("#message")
hear_button = document.querySelector('#pad [data-key="space"]')
next_button = document.querySelector('#pad [data-key="enter"]')
erase_button = document.querySelector('#pad [data-key="back"]')
verdict = document.querySelector("#verdict")
voice_label = document.querySelector("#voice")
who_label = document.querySelector("#who")


class CanvasScreen(Screen):
    """CLI 版の Screen をそのまま使い、描き終えた画素をまとめて canvas へ送る。"""

    def flush(self) -> None:
        buf = bytearray(WIDTH * HEIGHT * 4)
        i = 0
        for row in self.pixels:
            for color in row:
                if color is not None:
                    buf[i], buf[i + 1], buf[i + 2] = color
                buf[i + 3] = 255
                i += 4
        image.data.assign(bytes(buf))
        ctx.putImageData(image, 0, 0)


class Speaker:
    """ブラウザで音を出す係。CLI 版の Speaker と役目は同じ。

    wav_bytes() が返した **同じ bytes** を data URI にして Audio に渡すだけ。
    作るのに少し時間がかかるので、一度作った音は取っておく。
    """

    def __init__(self):
        self.made: dict[tuple, object] = {}

    def say(self, notes: list[int], seconds: float, voice: Voice) -> None:
        want = (tuple(notes), seconds, voice.__name__)
        if want not in self.made:
            data = wav_bytes(melody(notes, seconds, voice))
            uri = "data:audio/wav;base64," + base64.b64encode(data).decode()
            self.made[want] = window.Audio.new(uri)
        sound = self.made[want]
        sound.pause()
        sound.currentTime = 0
        sound.play()


screen = CanvasScreen()
game = Game()
speaker = Speaker()


def refresh() -> None:
    """CLI 版の show() にあたる。draw() を canvas へ、様子を HTML へ。"""
    draw(screen, game)
    screen.flush()
    title_label.textContent = game.song.name        # 曲名は最初から出す（曲そのものを覚えるため）
    title_label.className = "song done" if game.cleared else "song"
    who_label.textContent = game.song.who
    count_label.textContent = f"{game.index + 1} / {len(SONGS)}"
    heard_label.textContent = f"{game.heard} 回"
    tries_label.textContent = f"{game.tries} 回"
    stars_label.textContent = "".join("★" * n + "・" for n in game.stars[-14:]) or "—"
    verdict.textContent = game.call
    verdict.hidden = not game.call
    verdict.className = "verdict " + {"GOOD！": "good", "ALMOST！": "almost"}.get(game.call, "bad")
    message.textContent = game.message
    voice_label.textContent = VOICES[game.voice][0]
    for button in document.querySelectorAll(".voices button"):
        button.className = "on" if int(button.getAttribute("data-voice")) == game.voice else ""
    # ボタンの意味は 1 つずつ。クリアしたら文字を変えて、できないことは押せなくする
    hear_button.textContent = "▶ もう一度聞く" if game.cleared else "▶ お題を聞く"
    next_button.textContent = "次の曲へ →" if game.cleared else "答え合わせ"
    next_button.className = "go" if game.cleared else ""
    erase_button.disabled = game.cleared


def act(key: str) -> None:
    """キー 1 つぶん進める。判断は CLI と同じ obey()。"""
    want = obey(game, key)
    if want is not None:
        speaker.say(*want)
    refresh()


@when("click", "#screen")
def tap(event) -> None:
    """canvas の上を押したら、その場所の鍵を鳴らす。"""
    box = canvas.getBoundingClientRect()
    x = int((event.clientX - box.left) / box.width * WIDTH)
    y = int((event.clientY - box.top) / box.height * HEIGHT)
    note = key_at(x, y)
    if note is not None:
        act(WHITE_CHARS[WHITE.index(note)] if note in WHITE else BLACK_CHARS[BLACK.index(note)])


@when("click", ".pad button")                        # 2 つ目以降の pad も拾う
def button(event) -> None:
    act(event.target.getAttribute("data-key"))


@when("click", ".voices button")
def pick_voice(event) -> None:
    """楽器のボタン。どれを押したかで決める（Tab は順送り、こちらは直接）。"""
    game.voice = int(event.target.getAttribute("data-voice"))
    game.message = f"楽器: {VOICES[game.voice][0]}"
    speaker.say([game.goal[0]], TAP, game.sound)     # 変わった音をひとつ鳴らして聞かせる
    refresh()


KEYS = {" ": "space", "Enter": "enter", "Backspace": "back", "Tab": "voice"}


@when("keydown", "body")
def typed(event) -> None:
    name = KEYS.get(event.key, event.key.lower())
    if name in CHARS or name in ("space", "enter", "back", "voice"):
        event.preventDefault()
        act(name)


document.querySelector("#loading").hidden = True
refresh()
