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