Pad Grid

Play Demo

PadGrid — Polished pad instrument with recording, loop, and training modes.

An 8×8 grid of velocity-sensitive pads with multiple synthesized instruments, musical scale mapping, bloom glow, particle bursts, and recording/loop/training modes.

Controls: Keyboard rows map to pad grid (bottom-up): Row 0: Z X C V B N M , Row 1: A S D F G H J K Row 2: Q W E R T Y U I Row 3: 1 2 3 4 5 6 7 8 Row 4+: mouse only (click pads) Mouse: Click any pad (supports simultaneous keyboard + mouse) PageUp/PageDown: Octave shift F1-F6: Quick instrument select ESC: Quit

Source Code

   1"""PadGrid — Polished pad instrument with recording, loop, and training modes.
   2
   3An 8×8 grid of velocity-sensitive pads with multiple synthesized instruments,
   4musical scale mapping, bloom glow, particle bursts, and recording/loop/training
   5modes.
   6
   7Controls:
   8    Keyboard rows map to pad grid (bottom-up):
   9        Row 0: Z X C V B N M ,
  10        Row 1: A S D F G H J K
  11        Row 2: Q W E R T Y U I
  12        Row 3: 1 2 3 4 5 6 7 8
  13        Row 4+: mouse only (click pads)
  14    Mouse: Click any pad (supports simultaneous keyboard + mouse)
  15    PageUp/PageDown: Octave shift
  16    F1-F6: Quick instrument select
  17    ESC: Quit
  18"""
  19
  20
  21import logging
  22import math
  23import time
  24from collections import deque
  25from dataclasses import dataclass, field
  26from enum import Enum, auto
  27
  28import numpy as np
  29
  30from simvx.core import (
  31    AudioStream,
  32    AudioStreamPlayer,
  33    Button,
  34    DropDown,
  35    HBoxContainer,
  36    Input,
  37    Key,
  38    Label,
  39    Node,
  40    Panel,
  41    Property,
  42    Slider,
  43    VBoxContainer,
  44    Vec2,
  45)
  46from simvx.graphics import App
  47
  48# ============================================================================
  49# Constants
  50# ============================================================================
  51
  52SAMPLE_RATE = 44100
  53WINDOW_W, WINDOW_H = 1280, 720
  54
  55# Keyboard layout: rows of 8 keys mapping to pad grid rows (bottom-up)
  56PAD_KEY_ROWS = [
  57    [Key.Z, Key.X, Key.C, Key.V, Key.B, Key.N, Key.M, Key.COMMA],
  58    [Key.A, Key.S, Key.D, Key.F, Key.G, Key.H, Key.J, Key.K],
  59    [Key.Q, Key.W, Key.E, Key.R, Key.T, Key.Y, Key.U, Key.I],
  60    [Key.KEY_1, Key.KEY_2, Key.KEY_3, Key.KEY_4, Key.KEY_5, Key.KEY_6, Key.KEY_7, Key.KEY_8],
  61]
  62
  63log = logging.getLogger(__name__)
  64
  65
  66# ============================================================================
  67# Musical scales
  68# ============================================================================
  69
  70class Scale(Enum):
  71    CHROMATIC = auto()
  72    MAJOR = auto()
  73    MINOR = auto()
  74    PENTATONIC = auto()
  75    BLUES = auto()
  76
  77
  78# Semitone intervals from root for each scale
  79SCALE_INTERVALS = {
  80    Scale.CHROMATIC: list(range(12)),
  81    Scale.MAJOR: [0, 2, 4, 5, 7, 9, 11],
  82    Scale.MINOR: [0, 2, 3, 5, 7, 8, 10],
  83    Scale.PENTATONIC: [0, 3, 5, 7, 10],
  84    Scale.BLUES: [0, 3, 5, 6, 7, 10],
  85}
  86
  87SCALE_NAMES = {
  88    Scale.CHROMATIC: "Chromatic",
  89    Scale.MAJOR: "Major",
  90    Scale.MINOR: "Minor",
  91    Scale.PENTATONIC: "Pentatonic",
  92    Scale.BLUES: "Blues",
  93}
  94
  95
  96def note_name(semitones_from_c: int) -> str:
  97    """Return note name like C4, D#5 for a given semitone offset from C0."""
  98    names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
  99    octave = semitones_from_c // 12
 100    note = semitones_from_c % 12
 101    return f"{names[note]}{octave}"
 102
 103
 104def pad_to_semitones(index: int, scale: Scale, base_semitone: int = 36, grid_cols: int = 8) -> int:
 105    """Convert pad index to absolute semitone number.
 106
 107    Layout: columns are scale degrees, rows are octaves.
 108    Bottom-left = lowest note, right = higher in scale, up = higher octave.
 109    """
 110    col = index % grid_cols
 111    row = index // grid_cols
 112    intervals = SCALE_INTERVALS[scale]
 113    step = col % len(intervals)
 114    col_octave = col // len(intervals)
 115    return base_semitone + (row + col_octave) * 12 + intervals[step]
 116
 117
 118def semitone_to_freq(semitone: int) -> float:
 119    """Convert absolute semitone (C0=0) to frequency in Hz."""
 120    return 16.3516 * (2.0 ** (semitone / 12.0))
 121
 122
 123# ============================================================================
 124# Tone generation — multiple instrument types
 125# ============================================================================
 126
 127class InstrumentType(Enum):
 128    BELLS = auto()
 129    PAD = auto()
 130    PLUCK = auto()
 131    SYNTH = auto()
 132    GLASS = auto()
 133    PERC = auto()
 134
 135
 136INSTRUMENT_NAMES = {
 137    InstrumentType.BELLS: "Bells",
 138    InstrumentType.PAD: "Pad",
 139    InstrumentType.PLUCK: "Pluck",
 140    InstrumentType.SYNTH: "Synth",
 141    InstrumentType.GLASS: "Glass",
 142    InstrumentType.PERC: "Perc",
 143}
 144
 145# Colour palettes per instrument (gradient from pad 0 → pad N)
 146INSTRUMENT_COLOURS = {
 147    InstrumentType.BELLS: ((0.2, 0.5, 1.0), (0.6, 0.9, 1.0)),
 148    InstrumentType.PAD: ((0.2, 0.1, 0.5), (0.7, 0.3, 0.9)),
 149    InstrumentType.PLUCK: ((0.1, 0.5, 0.3), (0.4, 1.0, 0.6)),
 150    InstrumentType.SYNTH: ((0.6, 0.1, 0.4), (1.0, 0.4, 0.7)),
 151    InstrumentType.GLASS: ((0.3, 0.6, 0.7), (0.7, 1.0, 1.0)),
 152    InstrumentType.PERC: ((0.7, 0.2, 0.1), (1.0, 0.6, 0.2)),
 153}
 154
 155
 156def _apply_envelope(sig: np.ndarray, attack_ms: float = 5.0, release_ms: float = 20.0) -> np.ndarray:
 157    """Apply smooth fade-in/fade-out to prevent clicks. Always ends at zero."""
 158    n = len(sig)
 159    attack = min(int(SAMPLE_RATE * attack_ms / 1000), n // 4)
 160    release = min(int(SAMPLE_RATE * release_ms / 1000), n // 4)
 161    if attack > 0:
 162        sig[:attack] *= np.linspace(0, 1, attack, dtype=np.float32) ** 2  # Squared for smoother curve
 163    if release > 0:
 164        sig[-release:] *= np.linspace(1, 0, release, dtype=np.float32) ** 2
 165    return sig
 166
 167
 168def _normalize(sig: np.ndarray, volume: float = 0.3) -> np.ndarray:
 169    """Normalize peak to volume and ensure float32."""
 170    peak = np.abs(sig).max()
 171    if peak > 0:
 172        sig = sig * (volume / peak)
 173    return sig.astype(np.float32)
 174
 175
 176def _generate_bells(freq: float, duration: float = 1.0) -> np.ndarray:
 177    """Warm kalimba/music-box: soft sine with gentle harmonics and chorus."""
 178    n = int(SAMPLE_RATE * duration)
 179    t = np.linspace(0, duration, n, dtype=np.float32)
 180    env = np.exp(-5.0 * t)
 181    sig = np.sin(2 * np.pi * freq * t) * env
 182    sig += 0.15 * np.sin(2 * np.pi * freq * 2.0 * t) * np.exp(-8.0 * t)
 183    sig += 0.08 * np.sin(2 * np.pi * freq * 3.0 * t) * np.exp(-12.0 * t)
 184    sig += 0.12 * np.sin(2 * np.pi * freq * 1.002 * t) * env  # Detuned chorus
 185    return _normalize(_apply_envelope(sig, attack_ms=5, release_ms=30), 0.3)
 186
 187
 188def _generate_pad(freq: float, duration: float = 1.5) -> np.ndarray:
 189    """Warm ambient pad: soft harmonics, slow attack/release."""
 190    n = int(SAMPLE_RATE * duration)
 191    t = np.linspace(0, duration, n, dtype=np.float32)
 192    sig = np.sin(2 * np.pi * freq * t)
 193    sig += 0.3 * np.sin(2 * np.pi * freq * 2 * t)  # Octave
 194    sig += 0.1 * np.sin(2 * np.pi * freq * 3 * t)  # Fifth
 195    sig += 0.15 * np.sin(2 * np.pi * freq * 1.003 * t)  # Chorus detune
 196    return _normalize(_apply_envelope(sig, attack_ms=80, release_ms=200), 0.25)
 197
 198
 199def _generate_pluck(freq: float, duration: float = 0.8) -> np.ndarray:
 200    """Karplus-Strong pluck: harp-like with natural decay."""
 201    n = int(SAMPLE_RATE * duration)
 202    period = max(2, int(SAMPLE_RATE / freq))
 203    rng = np.random.default_rng(int(freq * 100))
 204    buf = rng.uniform(-1, 1, period).astype(np.float32)
 205    # Pre-filter the initial noise to soften the attack
 206    for _ in range(3):
 207        buf = 0.5 * (buf + np.roll(buf, 1))
 208    out = np.zeros(n, dtype=np.float32)
 209    decay = 0.995
 210    for i in range(n):
 211        idx = i % period
 212        out[i] = buf[idx]
 213        buf[idx] = decay * 0.5 * (buf[idx] + buf[(idx + 1) % period])
 214    return _normalize(_apply_envelope(out, attack_ms=3, release_ms=30), 0.3)
 215
 216
 217def _generate_synth(freq: float, duration: float = 1.0) -> np.ndarray:
 218    """Soft synth: band-limited pulse with gentle harmonics (not harsh square)."""
 219    n = int(SAMPLE_RATE * duration)
 220    t = np.linspace(0, duration, n, dtype=np.float32)
 221    # Band-limited square: sum of odd harmonics with strong rolloff
 222    sig = np.sin(2 * np.pi * freq * t)
 223    sig += 0.25 * np.sin(2 * np.pi * freq * 3 * t)
 224    sig += 0.10 * np.sin(2 * np.pi * freq * 5 * t)
 225    # Detuned second voice for width
 226    sig += 0.5 * np.sin(2 * np.pi * freq * 1.005 * t)
 227    sig *= np.exp(-2.5 * t)
 228    return _normalize(_apply_envelope(sig, attack_ms=8, release_ms=40), 0.25)
 229
 230
 231def _generate_glass(freq: float, duration: float = 1.5) -> np.ndarray:
 232    """Crystal glass: pure sine with gentle vibrato, smooth attack."""
 233    n = int(SAMPLE_RATE * duration)
 234    t = np.linspace(0, duration, n, dtype=np.float32)
 235    # Gentle vibrato that fades in
 236    vib_depth = 3.0 * (1 - np.exp(-3.0 * t))
 237    vib = vib_depth * np.sin(2 * np.pi * 4.5 * t)
 238    sig = np.sin(2 * np.pi * (freq + vib) * t) * np.exp(-2.0 * t)
 239    return _normalize(_apply_envelope(sig, attack_ms=10, release_ms=40), 0.25)
 240
 241
 242def _generate_perc(freq: float, duration: float = 0.4) -> np.ndarray:
 243    """Soft percussion: pitch-swept sine with gentle transient."""
 244    n = int(SAMPLE_RATE * duration)
 245    t = np.linspace(0, duration, n, dtype=np.float32)
 246    sweep_freq = freq * (1.0 + 2.0 * np.exp(-40.0 * t))
 247    phase = np.cumsum(sweep_freq / SAMPLE_RATE) * 2 * np.pi
 248    sig = np.sin(phase) * np.exp(-10.0 * t)
 249    # Soft filtered noise (not raw noise)
 250    noise_len = min(int(SAMPLE_RATE * 0.01), n)
 251    rng = np.random.default_rng(int(freq * 100))
 252    noise = rng.uniform(-0.3, 0.3, noise_len).astype(np.float32)
 253    # Low-pass the noise
 254    for _ in range(3):
 255        noise = 0.5 * (noise + np.roll(noise, 1))
 256    noise *= np.linspace(1, 0, noise_len, dtype=np.float32) ** 2
 257    sig[:noise_len] += noise
 258    return _normalize(_apply_envelope(sig, attack_ms=2, release_ms=20), 0.3)
 259
 260
 261GENERATORS = {
 262    InstrumentType.BELLS: _generate_bells,
 263    InstrumentType.PAD: _generate_pad,
 264    InstrumentType.PLUCK: _generate_pluck,
 265    InstrumentType.SYNTH: _generate_synth,
 266    InstrumentType.GLASS: _generate_glass,
 267    InstrumentType.PERC: _generate_perc,
 268}
 269
 270
 271class ToneCache:
 272    """Caches generated AudioStreams keyed by (instrument, semitone)."""
 273
 274    def __init__(self):
 275        self._cache: dict[tuple[InstrumentType, int], AudioStream] = {}
 276
 277    def get(self, instrument: InstrumentType, semitone: int) -> AudioStream:
 278        key = (instrument, semitone)
 279        if key not in self._cache:
 280            freq = semitone_to_freq(semitone)
 281            generator = GENERATORS[instrument]
 282            mono = generator(freq)
 283            # Interleave to stereo
 284            stereo = np.empty(len(mono) * 2, dtype=np.float32)
 285            stereo[0::2] = mono
 286            stereo[1::2] = mono
 287            stream = AudioStream(f"tone:{INSTRUMENT_NAMES[instrument]}:{note_name(semitone)}")
 288            stream.backend_data = stereo
 289            self._cache[key] = stream
 290        return self._cache[key]
 291
 292
 293# ============================================================================
 294# Velocity estimation
 295# ============================================================================
 296
 297class VelocityMode(Enum):
 298    FIXED = auto()
 299    WOBBLE = auto()
 300    HOLD = auto()
 301
 302
 303@dataclass
 304class VelocityTracker:
 305    """Tracks mouse wobble and key hold timing for velocity estimation."""
 306    mode: VelocityMode = VelocityMode.WOBBLE
 307    fixed_velocity: float = 0.8
 308    sensitivity: float = 1.0  # Wobble sensitivity multiplier
 309
 310    # Wobble tracking
 311    _mouse_history: deque = field(default_factory=lambda: deque(maxlen=30))
 312    _last_mouse_pos: tuple[float, float] = (0.0, 0.0)
 313
 314    # Hold tracking: key -> press timestamp
 315    _key_press_times: dict = field(default_factory=dict)
 316
 317    def update(self, mouse_pos: tuple[float, float]):
 318        """Call each frame to update mouse history."""
 319        dx = mouse_pos[0] - self._last_mouse_pos[0]
 320        dy = mouse_pos[1] - self._last_mouse_pos[1]
 321        self._mouse_history.append(math.sqrt(dx * dx + dy * dy))
 322        self._last_mouse_pos = mouse_pos
 323
 324    def on_key_press(self, pad_id: int):
 325        """Record key press time for hold-duration velocity."""
 326        self._key_press_times[pad_id] = time.perf_counter()
 327
 328    def on_key_release(self, pad_id: int):
 329        """Remove key press tracking."""
 330        self._key_press_times.pop(pad_id, None)
 331
 332    def get_velocity(self, pad_id: int, is_mouse: bool = False) -> float:
 333        """Return velocity 0.0–1.0 for a pad trigger."""
 334        if self.mode == VelocityMode.FIXED:
 335            return self.fixed_velocity
 336        if self.mode == VelocityMode.WOBBLE and is_mouse:
 337            # Sum recent mouse movement
 338            if not self._mouse_history:
 339                return 0.5
 340            total = sum(self._mouse_history)
 341            # Map path length to velocity (0–1), scaled by sensitivity
 342            raw = min(1.0, (total / 50.0) * self.sensitivity)
 343            return max(0.15, raw)
 344        if self.mode == VelocityMode.HOLD:
 345            press_time = self._key_press_times.get(pad_id)
 346            if press_time is None:
 347                return 0.7
 348            held = time.perf_counter() - press_time
 349            # Shorter hold = louder (percussive). 0–200ms maps to 1.0–0.3
 350            return max(0.3, 1.0 - held * 3.5)
 351        # Fallback for keyboard when wobble mode
 352        return 0.7
 353
 354
 355# ============================================================================
 356# Recording / sequencer
 357# ============================================================================
 358
 359class Mode(Enum):
 360    PERFORM = auto()
 361    RECORD = auto()
 362    REPLAY = auto()
 363    TRAIN_WAIT = auto()
 364    TRAIN_FOLLOW = auto()
 365
 366
 367@dataclass
 368class PadEvent:
 369    """A single recorded pad press."""
 370    time: float  # Seconds from recording start
 371    pad_index: int
 372    velocity: float
 373    instrument: InstrumentType
 374
 375
 376@dataclass
 377class Sequencer:
 378    """Records and plays back pad events with loop support."""
 379    events: list[PadEvent] = field(default_factory=list)
 380    loop_enabled: bool = False
 381    bpm: float = 120.0
 382    quantize: bool = False
 383
 384    _recording: bool = False
 385    _playing: bool = False
 386    _start_time: float = 0.0
 387    _playback_cursor: int = 0
 388    _loop_duration: float = 0.0
 389    _record_bpm: float = 120.0  # BPM at the time recording started
 390
 391    # Training
 392    _train_cursor: int = 0
 393    _waiting_for_input: bool = False
 394
 395    def start_recording(self):
 396        self.events.clear()
 397        self._recording = True
 398        self._record_bpm = self.bpm
 399        self._start_time = time.perf_counter()
 400
 401    def stop_recording(self):
 402        self._recording = False
 403        if self.events:
 404            self._loop_duration = self.events[-1].time + 0.5  # Pad end
 405
 406    def record_event(self, pad_index: int, velocity: float, instrument: InstrumentType):
 407        if not self._recording:
 408            return
 409        t = time.perf_counter() - self._start_time
 410        if self.quantize and self.bpm > 0:
 411            beat_dur = 60.0 / self.bpm / 4  # 16th note
 412            t = round(t / beat_dur) * beat_dur
 413        self.events.append(PadEvent(t, pad_index, velocity, instrument))
 414
 415    def start_playback(self):
 416        if not self.events:
 417            return
 418        self._playing = True
 419        self._start_time = time.perf_counter()
 420        self._playback_cursor = 0
 421
 422    def stop_playback(self):
 423        self._playing = False
 424        self._playback_cursor = 0
 425
 426    def start_training(self):
 427        if not self.events:
 428            return
 429        self._train_cursor = 0
 430        self._waiting_for_input = True
 431
 432    def _tempo_ratio(self) -> float:
 433        """Playback speed multiplier: >1 = faster, <1 = slower."""
 434        if self._record_bpm > 0:
 435            return self.bpm / self._record_bpm
 436        return 1.0
 437
 438    def get_pending_events(self) -> list[PadEvent]:
 439        """Return events that should trigger this frame during replay."""
 440        if not self._playing or not self.events:
 441            return []
 442        # Scale real elapsed time by tempo ratio so BPM slider affects playback speed
 443        ratio = self._tempo_ratio()
 444        now = (time.perf_counter() - self._start_time) * ratio
 445        if self.loop_enabled and self._loop_duration > 0:
 446            now = now % self._loop_duration
 447            # Reset cursor on loop wrap
 448            if self._playback_cursor >= len(self.events):
 449                self._playback_cursor = 0
 450
 451        result = []
 452        while self._playback_cursor < len(self.events):
 453            ev = self.events[self._playback_cursor]
 454            if ev.time <= now:
 455                result.append(ev)
 456                self._playback_cursor += 1
 457            else:
 458                break
 459
 460        if not self.loop_enabled and self._playback_cursor >= len(self.events):
 461            self._playing = False
 462        return result
 463
 464    @property
 465    def progress(self) -> float:
 466        """0-1 playback/training progress."""
 467        if not self.events:
 468            return 0.0
 469        if self._playing and self._loop_duration > 0:
 470            ratio = self._tempo_ratio()
 471            now = (time.perf_counter() - self._start_time) * ratio
 472            if self.loop_enabled:
 473                return (now % self._loop_duration) / self._loop_duration
 474            return min(1.0, now / self._loop_duration)
 475        return self._train_cursor / max(1, len(self.events))
 476
 477    def get_training_target(self) -> PadEvent | None:
 478        """Return the next event the user should hit in training mode."""
 479        if self._train_cursor < len(self.events):
 480            return self.events[self._train_cursor]
 481        return None
 482
 483    def advance_training(self) -> PadEvent | None:
 484        """Move to next training target. Returns the new target or None."""
 485        self._train_cursor += 1
 486        if self._train_cursor >= len(self.events):
 487            self._train_cursor = 0  # Loop training
 488        return self.get_training_target()
 489
 490
 491
 492# ============================================================================
 493# Visual pad state
 494# ============================================================================
 495
 496@dataclass
 497class PadState:
 498    """Per-pad visual/audio state."""
 499    pressed: bool = False
 500    brightness: float = 0.0  # 0 = idle, 1 = fully lit
 501    press_time: float = 0.0
 502    velocity: float = 0.0
 503    # Training highlight
 504    is_target: bool = False
 505    # Particle burst countdown
 506    particle_timer: float = 0.0
 507
 508
 509# ============================================================================
 510# Main PadGrid node
 511# ============================================================================
 512
 513class PadGridDemo(Node):
 514    """Root node for the pad grid instrument demo."""
 515
 516    grid_size = Property(8, range=(4, 16), hint="Grid dimensions (NxN)")
 517    master_volume = Property(0.0, range=(-40.0, 12.0), hint="Master volume dB")
 518    octave_offset = Property(-1, range=(-3, 3), hint="Octave shift")
 519
 520    def __init__(self, **kwargs):
 521        super().__init__(**kwargs)
 522        self._grid_n = 8
 523        self._instrument = InstrumentType.BELLS
 524        self._musical_scale = Scale.PENTATONIC
 525        self._mode = Mode.PERFORM
 526        self._tone_cache = ToneCache()
 527        self._sequencer = Sequencer()
 528        self._velocity = VelocityTracker()
 529        self._pads: list[PadState] = []
 530        self._players: list[AudioStreamPlayer] = []
 531        self._next_player = 0
 532        self._time = 0.0
 533        self._retrigger = False  # False = smooth (stop previous note), True = layer
 534
 535        # UI refs
 536        self._control_panel: Panel | None = None
 537        self._control_vbox: VBoxContainer | None = None
 538        self._mode_label: Label | None = None
 539        self._info_label: Label | None = None
 540        self._progress_label: Label | None = None
 541
 542        # Named button refs for highlighting active states
 543        self._inst_buttons: dict[InstrumentType, Button] = {}
 544        self._vel_buttons: dict[VelocityMode, Button] = {}
 545        self._mode_buttons: dict[Mode, Button] = {}
 546        self._loop_btn: Button | None = None
 547        self._quantize_btn: Button | None = None
 548        self._retrigger_btn: Button | None = None
 549
 550        # Track which player is assigned to each pad (for stop-on-release)
 551        self._pad_player: dict[int, AudioStreamPlayer] = {}
 552
 553        # Ripple state: list of (pad_index, start_time, velocity)
 554        self._ripples: list[tuple[int, float, float]] = []
 555
 556        # Build key-to-pad mapping
 557        self._key_to_pad: dict[int, int] = {}
 558        self._rebuild_key_map()
 559
 560        # Mouse pad tracking for multi-press
 561        self._mouse_pressed_pad: int = -1
 562
 563        # Multitouch: tracking_id -> pad_index
 564        self._touch_pads: dict[int, int] = {}
 565
 566    def _rebuild_key_map(self):
 567        """Map keyboard keys to pad indices."""
 568        self._key_to_pad.clear()
 569        for row_idx, keys in enumerate(PAD_KEY_ROWS):
 570            for col_idx, key in enumerate(keys):
 571                if col_idx < self._grid_n and row_idx < self._grid_n:
 572                    pad_idx = row_idx * self._grid_n + col_idx
 573                    self._key_to_pad[int(key)] = pad_idx
 574
 575    def ready(self):
 576        n = self._grid_n
 577        self._pads = [PadState() for _ in range(n * n)]
 578
 579        # Audio player pool (16 voices for polyphony)
 580        for i in range(16):
 581            p = AudioStreamPlayer(name=f"Voice{i}")
 582            p.bus = "master"
 583            self.add_child(p)
 584            self._players.append(p)
 585
 586        self._build_ui()
 587
 588        # Play startup chime to verify audio works
 589        self._play_startup_chime()
 590
 591        # Multitouch is handled via SDL3's Input.touches_just_pressed — no setup needed
 592
 593    def _build_ui(self):
 594        """Build the control panel on the right side."""
 595        sw, sh = self._screen_size()
 596        panel = Panel(name="ControlPanel")
 597        panel.position = Vec2(sw - 220, 10)
 598        panel.size = Vec2(210, sh - 20)
 599        panel.bg_colour = (0.08, 0.08, 0.10, 0.92)
 600        panel.border_colour = (0.2, 0.2, 0.25, 1.0)
 601        self.add_child(panel)
 602        self._control_panel = panel
 603
 604        vbox = VBoxContainer(name="Controls")
 605        vbox.position = Vec2(10, 10)
 606        vbox.size = Vec2(190, sh - 40)
 607        vbox.separation = 8
 608        panel.add_child(vbox)
 609        self._control_vbox = vbox
 610
 611        # Title
 612        title = Label("PAD GRID", name="Title")
 613        title.font_size = 18.0
 614        title.text_colour = (0.8, 0.9, 1.0, 1.0)
 615        title.size = Vec2(190, 24)
 616        title.alignment = "center"
 617        vbox.add_child(title)
 618
 619        # -- Instrument section --
 620        sec_inst = Label("INSTRUMENT", name="SecInst")
 621        sec_inst.font_size = 10.0
 622        sec_inst.text_colour = (0.5, 0.5, 0.6, 1.0)
 623        sec_inst.size = Vec2(190, 14)
 624        vbox.add_child(sec_inst)
 625
 626        inst_row = HBoxContainer(name="InstRow")
 627        inst_row.size = Vec2(190, 28)
 628        inst_row.separation = 3
 629        vbox.add_child(inst_row)
 630
 631        for itype in InstrumentType:
 632            iname = INSTRUMENT_NAMES[itype]
 633            c0, _ = INSTRUMENT_COLOURS[itype]
 634            btn = Button(iname[:3], name=f"Inst_{iname}")
 635            btn.size = Vec2(30, 26)
 636            btn.font_size = 10.0
 637            btn.bg_colour = (*c0, 0.6)
 638            btn.hover_colour = (*c0, 0.8)
 639            btn.pressed_colour = (*c0, 1.0)
 640            btn.border_width = 0
 641            btn.pressed.connect(lambda it=itype: self._set_instrument(it))
 642            inst_row.add_child(btn)
 643            self._inst_buttons[itype] = btn
 644
 645        # -- Scale section --
 646        sec_scale = Label("SCALE", name="SecScale")
 647        sec_scale.font_size = 10.0
 648        sec_scale.text_colour = (0.5, 0.5, 0.6, 1.0)
 649        sec_scale.size = Vec2(190, 14)
 650        vbox.add_child(sec_scale)
 651
 652        scale_items = [SCALE_NAMES[s] for s in Scale]
 653        scale_dd = DropDown(items=scale_items, selected=list(Scale).index(self._musical_scale), name="ScaleDD")
 654        scale_dd.size = Vec2(190, 26)
 655        scale_dd.font_size = 11.0
 656        scale_dd.item_selected.connect(self._on_scale_selected)
 657        vbox.add_child(scale_dd)
 658
 659        # -- Volume --
 660        sec_vol = Label("VOLUME", name="SecVol")
 661        sec_vol.font_size = 10.0
 662        sec_vol.text_colour = (0.5, 0.5, 0.6, 1.0)
 663        sec_vol.size = Vec2(190, 14)
 664        vbox.add_child(sec_vol)
 665
 666        vol_slider = Slider(-40, 12, value=0, name="VolSlider")
 667        vol_slider.size = Vec2(190, 22)
 668        vol_slider.step = 1.0
 669        vol_slider.fill_colour = (0.3, 0.5, 0.8, 1.0)
 670        vol_slider.value_changed.connect(lambda v: setattr(self, "master_volume", v))
 671        vbox.add_child(vol_slider)
 672
 673        # -- Velocity --
 674        sec_vel = Label("VELOCITY", name="SecVel")
 675        sec_vel.font_size = 10.0
 676        sec_vel.text_colour = (0.5, 0.5, 0.6, 1.0)
 677        sec_vel.size = Vec2(190, 14)
 678        vbox.add_child(sec_vel)
 679
 680        vel_row = HBoxContainer(name="VelRow")
 681        vel_row.size = Vec2(190, 26)
 682        vel_row.separation = 3
 683        vbox.add_child(vel_row)
 684
 685        for vm in VelocityMode:
 686            btn = Button(vm.name.capitalize(), name=f"Vel_{vm.name}")
 687            btn.size = Vec2(60, 24)
 688            btn.font_size = 10.0
 689            btn.bg_colour = (0.2, 0.2, 0.25, 1.0)
 690            btn.border_width = 0
 691            btn.pressed.connect(lambda m=vm: self._set_velocity_mode(m))
 692            vel_row.add_child(btn)
 693            self._vel_buttons[vm] = btn
 694
 695        # -- Mode section --
 696        sec_mode = Label("MODE", name="SecMode")
 697        sec_mode.font_size = 10.0
 698        sec_mode.text_colour = (0.5, 0.5, 0.6, 1.0)
 699        sec_mode.size = Vec2(190, 14)
 700        vbox.add_child(sec_mode)
 701
 702        mode_names = [
 703            ("Perform", Mode.PERFORM),
 704            ("Record", Mode.RECORD),
 705            ("Replay", Mode.REPLAY),
 706            ("Train", Mode.TRAIN_WAIT),
 707            ("Follow", Mode.TRAIN_FOLLOW),
 708        ]
 709        mode_row1 = HBoxContainer(name="ModeRow1")
 710        mode_row1.size = Vec2(190, 26)
 711        mode_row1.separation = 3
 712        vbox.add_child(mode_row1)
 713
 714        mode_row2 = HBoxContainer(name="ModeRow2")
 715        mode_row2.size = Vec2(190, 26)
 716        mode_row2.separation = 3
 717        vbox.add_child(mode_row2)
 718
 719        for i, (mname, mval) in enumerate(mode_names):
 720            btn = Button(mname, name=f"Mode_{mname}")
 721            btn.size = Vec2(60, 24)
 722            btn.font_size = 10.0
 723            btn.bg_colour = (0.2, 0.2, 0.25, 1.0)
 724            btn.border_width = 0
 725            btn.pressed.connect(lambda m=mval: self._set_mode(m))
 726            (mode_row1 if i < 3 else mode_row2).add_child(btn)
 727            self._mode_buttons[mval] = btn
 728
 729        # Loop / Quantize / Retrigger toggles
 730        toggle_row = HBoxContainer(name="ToggleRow")
 731        toggle_row.size = Vec2(190, 26)
 732        toggle_row.separation = 3
 733        vbox.add_child(toggle_row)
 734
 735        loop_btn = Button("Loop", name="LoopBtn")
 736        loop_btn.size = Vec2(50, 24)
 737        loop_btn.font_size = 10.0
 738        loop_btn.bg_colour = (0.2, 0.2, 0.25, 1.0)
 739        loop_btn.border_width = 0
 740        loop_btn.pressed.connect(self._toggle_loop)
 741        toggle_row.add_child(loop_btn)
 742        self._loop_btn = loop_btn
 743
 744        quantize_btn = Button("Quant", name="QuantBtn")
 745        quantize_btn.size = Vec2(50, 24)
 746        quantize_btn.font_size = 10.0
 747        quantize_btn.bg_colour = (0.2, 0.2, 0.25, 1.0)
 748        quantize_btn.border_width = 0
 749        quantize_btn.pressed.connect(self._toggle_quantize)
 750        toggle_row.add_child(quantize_btn)
 751        self._quantize_btn = quantize_btn
 752
 753        retrig_btn = Button("Retrig", name="RetrigBtn")
 754        retrig_btn.size = Vec2(50, 24)
 755        retrig_btn.font_size = 10.0
 756        retrig_btn.bg_colour = (0.2, 0.2, 0.25, 1.0)
 757        retrig_btn.border_width = 0
 758        retrig_btn.pressed.connect(self._toggle_retrigger)
 759        toggle_row.add_child(retrig_btn)
 760        self._retrigger_btn = retrig_btn
 761
 762        # BPM
 763        bpm_row = HBoxContainer(name="BPMRow")
 764        bpm_row.size = Vec2(190, 26)
 765        bpm_row.separation = 5
 766        vbox.add_child(bpm_row)
 767
 768        bpm_label = Label("BPM", name="BPMLbl")
 769        bpm_label.font_size = 10.0
 770        bpm_label.text_colour = (0.5, 0.5, 0.6, 1.0)
 771        bpm_label.size = Vec2(30, 22)
 772        bpm_row.add_child(bpm_label)
 773
 774        bpm_slider = Slider(60, 200, value=120, name="BPMSlider")
 775        bpm_slider.size = Vec2(150, 22)
 776        bpm_slider.step = 1.0
 777        bpm_slider.fill_colour = (0.4, 0.4, 0.5, 1.0)
 778        bpm_slider.value_changed.connect(lambda v: setattr(self._sequencer, "bpm", v))
 779        bpm_row.add_child(bpm_slider)
 780
 781        # Status labels
 782        self._mode_label = Label("PERFORM", name="ModeLbl")
 783        self._mode_label.font_size = 14.0
 784        self._mode_label.text_colour = (0.3, 1.0, 0.5, 1.0)
 785        self._mode_label.size = Vec2(190, 18)
 786        self._mode_label.alignment = "center"
 787        vbox.add_child(self._mode_label)
 788
 789        self._info_label = Label("Bells | Pentatonic", name="InfoLbl")
 790        self._info_label.font_size = 10.0
 791        self._info_label.text_colour = (0.6, 0.6, 0.7, 1.0)
 792        self._info_label.size = Vec2(190, 14)
 793        self._info_label.alignment = "center"
 794        vbox.add_child(self._info_label)
 795
 796        self._progress_label = Label("", name="ProgressLbl")
 797        self._progress_label.font_size = 10.0
 798        self._progress_label.text_colour = (0.5, 0.5, 0.6, 1.0)
 799        self._progress_label.size = Vec2(190, 14)
 800        self._progress_label.alignment = "center"
 801        vbox.add_child(self._progress_label)
 802
 803        # -- Octave display --
 804        self._octave_label = Label("Octave: C3", name="OctLbl")
 805        self._octave_label.font_size = 10.0
 806        self._octave_label.text_colour = (0.5, 0.5, 0.6, 1.0)
 807        self._octave_label.size = Vec2(190, 14)
 808        self._octave_label.alignment = "center"
 809        vbox.add_child(self._octave_label)
 810
 811    # ---- State setters ----
 812
 813    def _set_instrument(self, inst: InstrumentType):
 814        self._instrument = inst
 815        self._update_info()
 816
 817    def _on_scale_selected(self, index: int):
 818        self._musical_scale = list(Scale)[index]
 819        self._update_info()
 820
 821    def _set_velocity_mode(self, mode: VelocityMode):
 822        self._velocity.mode = mode
 823
 824    def _set_mode(self, mode: Mode):
 825        # Stop previous mode
 826        if self._mode == Mode.RECORD:
 827            self._sequencer.stop_recording()
 828        if self._mode in (Mode.REPLAY, Mode.TRAIN_FOLLOW):
 829            self._sequencer.stop_playback()
 830
 831        self._mode = mode
 832        # Clear target highlights
 833        for ps in self._pads:
 834            ps.is_target = False
 835
 836        if mode == Mode.RECORD:
 837            self._sequencer.start_recording()
 838        elif mode == Mode.REPLAY:
 839            self._sequencer.start_playback()
 840        elif mode == Mode.TRAIN_WAIT:
 841            self._sequencer.start_training()
 842            self._highlight_target()
 843        elif mode == Mode.TRAIN_FOLLOW:
 844            self._sequencer.start_playback()
 845            self._sequencer.start_training()
 846            self._highlight_target()
 847
 848        if self._mode_label:
 849            names = {
 850                Mode.PERFORM: "PERFORM",
 851                Mode.RECORD: "RECORD",
 852                Mode.REPLAY: "REPLAY",
 853                Mode.TRAIN_WAIT: "TRAIN (WAIT)",
 854                Mode.TRAIN_FOLLOW: "TRAIN (FOLLOW)",
 855            }
 856            colours = {
 857                Mode.PERFORM: (0.3, 1.0, 0.5, 1.0),
 858                Mode.RECORD: (1.0, 0.3, 0.3, 1.0),
 859                Mode.REPLAY: (0.3, 0.6, 1.0, 1.0),
 860                Mode.TRAIN_WAIT: (1.0, 0.8, 0.2, 1.0),
 861                Mode.TRAIN_FOLLOW: (1.0, 0.6, 0.2, 1.0),
 862            }
 863            self._mode_label.text = names[mode]
 864            self._mode_label.text_colour = colours[mode]
 865
 866    def _toggle_loop(self):
 867        self._sequencer.loop_enabled = not self._sequencer.loop_enabled
 868
 869    def _toggle_quantize(self):
 870        self._sequencer.quantize = not self._sequencer.quantize
 871
 872    def _toggle_retrigger(self):
 873        self._retrigger = not self._retrigger
 874
 875    def _update_button_states(self):
 876        """Update button colours to reflect active instrument, mode, velocity, and toggles."""
 877        _ON = (0.3, 0.55, 0.9, 1.0)
 878        _OFF = (0.2, 0.2, 0.25, 1.0)
 879
 880        # Instrument buttons — active one gets a bright border
 881        for itype, btn in self._inst_buttons.items():
 882            c0, _ = INSTRUMENT_COLOURS[itype]
 883            if itype == self._instrument:
 884                btn.bg_colour = (*c0, 1.0)
 885                btn.border_width = 2
 886                btn.border_colour = (1.0, 1.0, 1.0, 0.8)
 887            else:
 888                btn.bg_colour = (*c0, 0.5)
 889                btn.border_width = 0
 890
 891        # Velocity mode buttons
 892        for vm, btn in self._vel_buttons.items():
 893            btn.bg_colour = _ON if vm == self._velocity.mode else _OFF
 894
 895        # Mode buttons
 896        for m, btn in self._mode_buttons.items():
 897            btn.bg_colour = _ON if m == self._mode else _OFF
 898
 899        # Toggle buttons
 900        if self._loop_btn:
 901            self._loop_btn.bg_colour = _ON if self._sequencer.loop_enabled else _OFF
 902        if self._quantize_btn:
 903            self._quantize_btn.bg_colour = _ON if self._sequencer.quantize else _OFF
 904        if self._retrigger_btn:
 905            self._retrigger_btn.bg_colour = _ON if self._retrigger else _OFF
 906
 907    def _update_info(self):
 908        if self._info_label:
 909            self._info_label.text = f"{INSTRUMENT_NAMES[self._instrument]} | {SCALE_NAMES[self._musical_scale]}"
 910
 911    def _highlight_target(self):
 912        """Highlight the current training target pad."""
 913        for ps in self._pads:
 914            ps.is_target = False
 915        target = self._sequencer.get_training_target()
 916        if target and 0 <= target.pad_index < len(self._pads):
 917            self._pads[target.pad_index].is_target = True
 918
 919    # ---- Pad geometry helpers ----
 920
 921    def _screen_size(self) -> tuple[int, int]:
 922        """Current window size from scene tree."""
 923        tree = self._tree
 924        if tree and hasattr(tree, "screen_size"):
 925            return tree.screen_size
 926        return WINDOW_W, WINDOW_H
 927
 928    def _grid_origin(self) -> tuple[float, float]:
 929        """Top-left of the pad grid area."""
 930        sw, sh = self._screen_size()
 931        n = self._grid_n
 932        pad_area_w = sw - 240  # Leave room for control panel
 933        pad_area_h = sh - 20
 934        pad_size = min(pad_area_w / n, pad_area_h / n)
 935        total_w = pad_size * n
 936        total_h = pad_size * n
 937        ox = (pad_area_w - total_w) / 2 + 10
 938        oy = (sh - total_h) / 2
 939        return ox, oy
 940
 941    def _pad_size(self) -> float:
 942        sw, sh = self._screen_size()
 943        n = self._grid_n
 944        pad_area_w = sw - 240
 945        pad_area_h = sh - 20
 946        return min(pad_area_w / n, pad_area_h / n)
 947
 948    def _pad_rect(self, pad_index: int) -> tuple[float, float, float, float]:
 949        """Return (x, y, w, h) for a pad, with gap."""
 950        n = self._grid_n
 951        col = pad_index % n
 952        row = pad_index // n
 953        # Rows go bottom-up visually: row 0 is at the bottom
 954        visual_row = (n - 1) - row
 955        ox, oy = self._grid_origin()
 956        ps = self._pad_size()
 957        gap = max(2.0, ps * 0.08)
 958        x = ox + col * ps + gap / 2
 959        y = oy + visual_row * ps + gap / 2
 960        return x, y, ps - gap, ps - gap
 961
 962    def _pad_at_mouse(self, mx: float, my: float) -> int:
 963        """Return pad index at mouse position, or -1."""
 964        n = self._grid_n
 965        for i in range(n * n):
 966            x, y, w, h = self._pad_rect(i)
 967            if x <= mx <= x + w and y <= my <= y + h:
 968                return i
 969        return -1
 970
 971    # ---- Pad colour ----
 972
 973    def _pad_colour(self, pad_index: int) -> tuple[float, float, float]:
 974        """Gradient colour for a pad based on instrument palette."""
 975        n = self._grid_n
 976        total = n * n
 977        t = pad_index / max(1, total - 1)
 978        c0, c1 = INSTRUMENT_COLOURS[self._instrument]
 979        return (
 980            c0[0] + (c1[0] - c0[0]) * t,
 981            c0[1] + (c1[1] - c0[1]) * t,
 982            c0[2] + (c1[2] - c0[2]) * t,
 983        )
 984
 985    # ---- Audio ----
 986
 987    def _play_startup_chime(self):
 988        """Play a short rising chime on startup to verify audio works."""
 989        for i, semi in enumerate([60, 64, 67]):  # C4, E4, G4
 990            stream = self._tone_cache.get(self._instrument, semi)
 991            p = self._players[i % len(self._players)]
 992            p.stream = stream
 993            p.volume_db = -6.0
 994            p.play()
 995
 996    def _play_pad(self, pad_index: int, velocity: float):
 997        """Play the tone for a given pad."""
 998        base_semitone = 36 + int(self.octave_offset) * 12  # C3 default
 999        semitone = pad_to_semitones(pad_index, self._musical_scale, base_semitone, self._grid_n)
1000        stream = self._tone_cache.get(self._instrument, semitone)
1001
1002        # Smooth mode: stop any existing sound on this same pad to avoid layered echo
1003        if not self._retrigger:
1004            self._stop_pad_audio(pad_index)
1005
1006        # Pick next voice from round-robin pool
1007        player = self._players[self._next_player % len(self._players)]
1008        self._next_player += 1
1009        if player._playing:
1010            player.set_pan_and_gain(0.0, -80.0)
1011            player.stop()
1012        player.stream = stream
1013        player.volume_db = float(self.master_volume) + 20 * math.log10(max(0.01, velocity))
1014        player.pitch_scale = 1.0
1015        player.loop = False
1016        player.play()
1017        self._pad_player[pad_index] = player
1018
1019    def _stop_pad_audio(self, pad_index: int):
1020        """Fade out audio for a pad on release (avoids click from abrupt stop)."""
1021        player = self._pad_player.pop(pad_index, None)
1022        if player and player._playing:
1023            # Silent + center pan so the channel cleans up without a click.
1024            player.set_pan_and_gain(0.0, -80.0)
1025            player.stop()
1026
1027    # ---- Input processing ----
1028
1029    def process(self, dt: float):
1030        self._time += dt
1031        n = self._grid_n
1032        total = n * n
1033
1034        # Update layout on resize
1035        sw, sh = self._screen_size()
1036        if self._control_panel:
1037            self._control_panel.position = Vec2(sw - 220, 10)
1038            self._control_panel.size = Vec2(210, sh - 20)
1039        if self._control_vbox:
1040            self._control_vbox.size = Vec2(190, sh - 40)
1041
1042        # Update velocity tracker
1043        mouse_pos = Input.mouse_position
1044        self._velocity.update(mouse_pos)
1045
1046        # Octave shift
1047        if Input.is_key_just_pressed(Key.PAGE_UP):
1048            self.octave_offset = min(3, int(self.octave_offset) + 1)
1049        if Input.is_key_just_pressed(Key.PAGE_DOWN):
1050            self.octave_offset = max(-3, int(self.octave_offset) - 1)
1051
1052        # Quick instrument select: F1-F6
1053        fkeys = [Key.F1, Key.F2, Key.F3, Key.F4, Key.F5, Key.F6]
1054        for i, fk in enumerate(fkeys):
1055            if Input.is_key_just_pressed(fk):
1056                self._set_instrument(list(InstrumentType)[i])
1057
1058        # ---- Keyboard pad input ----
1059        for key_int, pad_idx in self._key_to_pad.items():
1060            if pad_idx >= total:
1061                continue
1062            key = Key(key_int)
1063            if Input.is_key_just_pressed(key):
1064                self._velocity.on_key_press(pad_idx)
1065                vel = self._velocity.get_velocity(pad_idx, is_mouse=False)
1066                self._trigger_pad(pad_idx, vel)
1067            if Input.is_key_just_released(key):
1068                self._velocity.on_key_release(pad_idx)
1069                self._release_pad(pad_idx)
1070
1071        # ---- Mouse pad input (string-based API — reliable with GLFW) ----
1072        mx, my = mouse_pos
1073        mouse_down = Input._keys_just_pressed.get("mouse_1", False)
1074        mouse_up = Input._keys_just_released.get("mouse_1", False)
1075        mouse_held = Input._keys.get("mouse_1", False)
1076
1077        if mouse_down:
1078            pad = self._pad_at_mouse(mx, my)
1079            if pad >= 0:
1080                vel = self._velocity.get_velocity(pad, is_mouse=True)
1081                self._trigger_pad(pad, vel)
1082                self._mouse_pressed_pad = pad
1083
1084        if mouse_up:
1085            if self._mouse_pressed_pad >= 0:
1086                self._release_pad(self._mouse_pressed_pad)
1087            self._mouse_pressed_pad = -1
1088
1089        # Mouse drag across pads (only while button is held)
1090        if mouse_held and self._mouse_pressed_pad >= 0:
1091            pad = self._pad_at_mouse(mx, my)
1092            if pad >= 0 and pad != self._mouse_pressed_pad:
1093                self._release_pad(self._mouse_pressed_pad)
1094                vel = self._velocity.get_velocity(pad, is_mouse=True)
1095                self._trigger_pad(pad, vel)
1096                self._mouse_pressed_pad = pad
1097
1098        # ---- Multitouch input (via SDL3 backend) ----
1099        for tid, (tx, ty, tp) in Input.touches_just_pressed.items():
1100            pad = self._pad_at_mouse(tx, ty)
1101            if pad >= 0:
1102                vel = min(1.0, max(0.2, tp))
1103                self._trigger_pad(pad, vel)
1104                self._touch_pads[tid] = pad
1105
1106        for tid in Input.touches_just_released:
1107            pad = self._touch_pads.pop(tid, -1)
1108            if pad >= 0:
1109                self._release_pad(pad)
1110
1111        # Touch drag across pads
1112        for tid, (tx, ty, tp) in Input.touches.items():
1113            if tid in self._touch_pads:
1114                pad = self._pad_at_mouse(tx, ty)
1115                if pad >= 0 and pad != self._touch_pads[tid]:
1116                    self._release_pad(self._touch_pads[tid])
1117                    vel = min(1.0, max(0.2, tp))
1118                    self._trigger_pad(pad, vel)
1119                    self._touch_pads[tid] = pad
1120
1121        # ---- Replay mode ----
1122        if self._mode == Mode.REPLAY or self._mode == Mode.TRAIN_FOLLOW:
1123            for ev in self._sequencer.get_pending_events():
1124                if self._mode == Mode.REPLAY:
1125                    self._trigger_pad(ev.pad_index, ev.velocity, from_replay=True)
1126                    # Auto-release after short duration
1127                elif self._mode == Mode.TRAIN_FOLLOW:
1128                    # Only visual — sound is user-triggered
1129                    if ev.pad_index < total:
1130                        self._pads[ev.pad_index].is_target = True
1131
1132        # ---- Update pad visuals ----
1133        for _i, ps in enumerate(self._pads):
1134            if ps.pressed:
1135                ps.brightness = min(1.0, ps.brightness + dt * 12.0)
1136            else:
1137                ps.brightness = max(0.0, ps.brightness - dt * 4.0)
1138            # Particle timer countdown
1139            if ps.particle_timer > 0:
1140                ps.particle_timer -= dt
1141
1142        # Fade ripples
1143        self._ripples = [(p, t, v) for p, t, v in self._ripples if self._time - t < 0.4]
1144
1145        # Update active button highlights
1146        self._update_button_states()
1147
1148        # Update status labels
1149        if self._progress_label:
1150            if self._mode in (Mode.REPLAY, Mode.TRAIN_WAIT, Mode.TRAIN_FOLLOW):
1151                prog = self._sequencer.progress
1152                evts = len(self._sequencer.events)
1153                loop_str = " [LOOP]" if self._sequencer.loop_enabled else ""
1154                self._progress_label.text = f"{int(prog * 100)}% ({evts} events){loop_str}"
1155            elif self._mode == Mode.RECORD:
1156                evts = len(self._sequencer.events)
1157                q = " [Q]" if self._sequencer.quantize else ""
1158                self._progress_label.text = f"Recording: {evts} events{q}"
1159            else:
1160                self._progress_label.text = ""
1161
1162        if self._octave_label:
1163            base = 36 + int(self.octave_offset) * 12
1164            self._octave_label.text = f"Octave: {note_name(base)} | {SCALE_NAMES[self._musical_scale]}"
1165
1166    def _trigger_pad(self, pad_index: int, velocity: float, from_replay: bool = False):
1167        """Trigger a pad press — play sound and update visuals."""
1168        n = self._grid_n
1169        total = n * n
1170        if pad_index < 0 or pad_index >= total:
1171            return
1172
1173        ps = self._pads[pad_index]
1174
1175        # Training mode: check correctness
1176        if not from_replay and self._mode in (Mode.TRAIN_WAIT, Mode.TRAIN_FOLLOW):
1177            target = self._sequencer.get_training_target()
1178            if target and target.pad_index != pad_index:
1179                if self._mode == Mode.TRAIN_FOLLOW:
1180                    # Wrong pad — flash red but no sound
1181                    ps.brightness = 0.5
1182                    return
1183                # TRAIN_WAIT: just ignore wrong presses
1184                return
1185            elif target:
1186                # Correct! Advance training
1187                for p in self._pads:
1188                    p.is_target = False
1189                next_target = self._sequencer.advance_training()
1190                if next_target and next_target.pad_index < total:
1191                    self._pads[next_target.pad_index].is_target = True
1192                velocity = target.velocity  # Use original velocity
1193
1194        ps.pressed = True
1195        ps.velocity = velocity
1196        ps.press_time = self._time
1197        ps.particle_timer = 0.3
1198
1199        # Play audio
1200        self._play_pad(pad_index, velocity)
1201
1202        # Record event
1203        if self._mode == Mode.RECORD and not from_replay:
1204            self._sequencer.record_event(pad_index, velocity, self._instrument)
1205
1206        # Start ripple
1207        self._ripples.append((pad_index, self._time, velocity))
1208
1209    def _release_pad(self, pad_index: int):
1210        if 0 <= pad_index < len(self._pads):
1211            self._pads[pad_index].pressed = False
1212            # Only stop sustained instruments (pad). Others have natural decay.
1213            if self._instrument == InstrumentType.PAD:
1214                self._stop_pad_audio(pad_index)
1215
1216    # ---- Drawing ----
1217
1218    def draw(self, renderer):
1219        n = self._grid_n
1220        total = n * n
1221        ps_size = self._pad_size()
1222
1223        for i in range(total):
1224            x, y, w, h = self._pad_rect(i)
1225            ps = self._pads[i]
1226            base_colour = self._pad_colour(i)
1227
1228            # Idle breathing animation (subtle)
1229            breath = 0.03 * math.sin(self._time * 1.5 + i * 0.3)
1230            idle_mult = 0.25 + breath
1231
1232            # Brightness from press/release
1233            bright = ps.brightness
1234
1235            # Training target: pulsing highlight
1236            if ps.is_target:
1237                pulse = 0.5 + 0.5 * math.sin(self._time * 6.0)
1238                idle_mult = max(idle_mult, 0.4 + 0.3 * pulse)
1239
1240            # Final colour: lerp from dim to bright, boosted by velocity
1241            intensity = idle_mult + bright * (0.75 + 0.25 * ps.velocity)
1242            r = min(1.0, base_colour[0] * intensity)
1243            g = min(1.0, base_colour[1] * intensity)
1244            b = min(1.0, base_colour[2] * intensity)
1245
1246            # Pad body (main rect)
1247            pad_colour = (r, g, b, 1.0)
1248            renderer.draw_rect((x, y), (w, h), colour=pad_colour, filled=True)
1249
1250            # Rounded corners: draw 4 filled circles at corners over a slightly inset rect
1251            corner_r = max(2.0, w * 0.08)
1252
1253            # Corner circles for rounded appearance
1254            for (cx, cy) in (
1255                (x + corner_r, y + corner_r),
1256                (x + w - corner_r, y + corner_r),
1257                (x + w - corner_r, y + h - corner_r),
1258                (x + corner_r, y + h - corner_r),
1259            ):
1260                renderer.draw_circle((cx, cy), corner_r, colour=pad_colour, filled=True, segments=12)
1261
1262            # 3D bevel: top edge highlight, bottom edge shadow
1263            if w > 10:
1264                highlight = (min(1.0, r + 0.15), min(1.0, g + 0.15), min(1.0, b + 0.15), 0.4)
1265                shadow = (r * 0.3, g * 0.3, b * 0.3, 0.5)
1266                renderer.draw_rect((x + corner_r, y), (w - corner_r * 2, 2), colour=highlight, filled=True)
1267                renderer.draw_rect((x + corner_r, y + h - 2), (w - corner_r * 2, 2), colour=shadow, filled=True)
1268
1269            # Glow halo when pressed (larger, semi-transparent circle behind)
1270            if bright > 0.05:
1271                glow_r = w * 0.7 * bright
1272                glow_alpha = 0.2 * bright * ps.velocity
1273                renderer.draw_circle(
1274                    (x + w / 2, y + h / 2), glow_r,
1275                    colour=(r, g, b, glow_alpha), filled=True, segments=16,
1276                )
1277
1278            # Particle sparks
1279            if ps.particle_timer > 0 and ps.velocity > 0.1:
1280                self._draw_particles(renderer, x + w / 2, y + h / 2, ps, base_colour)
1281
1282            # Note label
1283            if w > 30:
1284                base_semi = 36 + int(self.octave_offset) * 12
1285                semi = pad_to_semitones(i, self._musical_scale, base_semi, self._grid_n)
1286                label = note_name(semi)
1287                text_scale = max(0.5, min(1.0, w / 80))
1288                tw = renderer.text_width(label, text_scale)
1289                tx = x + (w - tw) / 2
1290                ty = y + h - 14 * text_scale - 2
1291                text_alpha = 0.4 + 0.6 * bright
1292                renderer.draw_text(label, (tx, ty), colour=(1.0, 1.0, 1.0, text_alpha), scale=text_scale)
1293
1294        # Ripple effects
1295        for pad_idx, start_time, vel in self._ripples:
1296            age = self._time - start_time
1297            self._draw_ripple(renderer, pad_idx, age, vel)
1298
1299        # Playback progress bar (thin line at bottom of grid)
1300        if self._mode in (Mode.REPLAY, Mode.TRAIN_FOLLOW) and self._sequencer._playing:
1301            ox, oy = self._grid_origin()
1302            grid_w = ps_size * n
1303            prog = self._sequencer.progress
1304            bar_y = oy + ps_size * n + 4
1305            renderer.draw_rect((ox, bar_y), (grid_w * prog, 3), colour=(0.3, 0.7, 1.0, 0.8), filled=True)
1306
1307    def _draw_particles(self, renderer, cx: float, cy: float, ps: PadState, base_colour: tuple):
1308        """Draw sparkle particles emanating from pad center."""
1309        t = 1.0 - ps.particle_timer / 0.3  # 0→1 over lifetime
1310        count = int(6 + 8 * ps.velocity)
1311        for j in range(count):
1312            angle = (j / count) * math.tau + ps.press_time * 5
1313            dist = 8 + 40 * t * (0.5 + 0.5 * ps.velocity)
1314            px = cx + math.cos(angle) * dist
1315            py = cy + math.sin(angle) * dist
1316            size = max(1.0, 3.0 * (1.0 - t) * ps.velocity)
1317            alpha = max(0.0, 1.0 - t * 1.2)
1318            r = min(1.0, base_colour[0] + 0.3)
1319            g = min(1.0, base_colour[1] + 0.3)
1320            b = min(1.0, base_colour[2] + 0.3)
1321            renderer.draw_circle((px, py), size, colour=(r, g, b, alpha), filled=True, segments=6)
1322
1323    def _draw_ripple(self, renderer, pad_index: int, age: float, velocity: float):
1324        """Draw expanding ring ripple from a pad."""
1325        x, y, w, h = self._pad_rect(pad_index)
1326        cx, cy = x + w / 2, y + h / 2
1327        t = age / 0.4  # Normalize to 0–1 over 0.4s
1328        if t >= 1.0:
1329            return
1330        radius = w * 0.5 + w * 1.5 * t
1331        alpha = max(0.0, 0.3 * (1.0 - t) * velocity)
1332        base = self._pad_colour(pad_index)
1333        # Draw ring as circle outline (thick line circle approximation)
1334        segments = 20
1335        step = math.tau / segments
1336        colour = (*base, alpha)
1337        for s in range(segments):
1338            a1 = s * step
1339            a2 = (s + 1) * step
1340            x1 = cx + math.cos(a1) * radius
1341            y1 = cy + math.sin(a1) * radius
1342            x2 = cx + math.cos(a2) * radius
1343            y2 = cy + math.sin(a2) * radius
1344            renderer.draw_thick_line(x1, y1, x2, y2, width=2.0, colour=colour)
1345
1346
1347# ============================================================================
1348# Entry point
1349# ============================================================================
1350
1351if __name__ == "__main__":
1352    # Use SDL3 for multitouch support (GLFW has zero touch support on Wayland)
1353    backend = "sdl3"
1354    try:
1355        import sdl3 as _sdl3_check  # noqa: F401
1356    except ImportError:
1357        backend = "glfw"
1358        log.warning("SDL3 not available, falling back to GLFW (no touch support)")
1359
1360    app = App(width=WINDOW_W, height=WINDOW_H, title="SimVX Pad Grid", backend=backend)
1361    app.run(PadGridDemo())