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