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