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