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