Pad Grid

A playable synth instrument with record, loop, and training modes.

▶ Run in browser

Tags: 2d audio ui touch

An 8x8 grid of velocity-sensitive pads where every sound is synthesized in Python. Six instruments (bells, ambient pad, Karplus-Strong pluck, soft synth, glass, percussion) are rendered to PCM with NumPy and played through a pool of 16 AudioPlayer voices. Columns step through the selected musical scale and rows jump octaves, so anything you play stays in key. Each press fires glow, particle and ripple feedback drawn on the immediate-mode 2D canvas.

Engine features showcased: Procedural AudioClips built with AudioClip.from_pcm, a polyphonic AudioPlayer voice pool, immediate-mode 2D drawing via on_draw, anchored UI widgets (Panel, VBoxContainer, Button, Slider, DropDown), and unified keyboard, mouse and multitouch input.

How to play: Click or touch a pad, and drag with the button held for a glissando. The right-hand panel picks the instrument, scale, velocity mode and master volume, plus the performance mode: Perform, Record (with optional 16th-note quantize), Replay (loop on/off, BPM slider retimes playback), Train, which lights the next pad of the recorded phrase and waits for you to hit it, or Follow, which runs the phrase in time and lights each pad as it comes due, leaving every note for you to play.

Controls: Keyboard rows map to the bottom four pad rows (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 Rows 4-7: mouse or touch only Mouse/touch: press any pad (keyboard, mouse and touch all work at once) PageUp/PageDown: octave shift F1-F6: quick instrument select ESC: quit

Source

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