Pad Grid

Pad instrument with recording, loop, and training modes.

▶ Run in browser

Tags: 3d audio input

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

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