Pad Grid¶
a playable synth instrument with record, replay and training modes.
â–¶ Run in browserTags: 2d audio ui touch
An 8x8 grid of pads where every sound is synthesised at runtime, with no audio
files. Columns step through the chosen musical scale and rows climb by octaves,
so anything you play stays in key. Each press lights its pad with a glow, a
burst of sparks and a ripple, all drawn in on_draw.
How it is built:
AudioSynthbakes each note once, the first time it is played, fromOscillator.sinepartials shaped byExponentialandADSRenvelopes, plusWhiteNoisethrough aLowPassfor the percussion’s attack. The plucked string is a customAudioSource: a Karplus-Strong delay line, written as arendermethod returning NumPy samples.A pool of
AudioPlayervoices plays the notes, so several ring at once. A new note takes the voice that falls silent soonest, cutting it short only when every voice is still ringing.Input.pointersholds the mouse, every finger and a pen, each with its own press, drag and release, so eight fingers play eight pads and dragging across the grid is a glissando. A touchscreen or pen that measures pressure sets the note’s velocity; otherwise the panel’s velocity mode does: Fixed, or Wobble, where the faster the mouse was moving the louder the note.The keys are actions in the class’s
input_context: one action per keyboard pad, polled withInput.just_pressedandjust_released.The control panel is an anchored
Panelholding aVBoxContainerofButtonrows, aDropDownandSliderwidgets. Each exclusive choice (instrument, velocity, mode) is aButtonGroupof toggle buttons, and Loop, Quant and Retrig are single toggle buttons.
Modes: Perform just plays. Record captures what you play, snapped to 16th notes at the BPM slider’s tempo when Quant is on. Replay plays the phrase back, looping when Loop is on, and the BPM slider retimes it. Train lights the next note of the phrase and waits for you to hit it; Follow runs the phrase in time and lights each pad as it comes due, leaving every note for you to play. With Retrig on, pressing a pad again layers the new note over the ringing one.
Controls: click, tap or drag across the pads. The bottom four rows are also the keyboard rows Z to comma, A to K, Q to I and 1 to 8. Page Up and Page Down, or the panel’s OCT buttons, shift the octave; F1 to F6 pick an instrument.
Source¶
1# /// simvx
2# tags = ["2d", "audio", "ui", "touch"]
3# web = { width = 1280, height = 720 }
4# ///
5"""Pad Grid: a playable synth instrument with record, replay and training modes.
6
7An 8x8 grid of pads where every sound is synthesised at runtime, with no audio
8files. Columns step through the chosen musical scale and rows climb by octaves,
9so anything you play stays in key. Each press lights its pad with a glow, a
10burst of sparks and a ripple, all drawn in ``on_draw``.
11
12How it is built:
13
14- ``AudioSynth`` bakes each note once, the first time it is played, from
15 ``Oscillator.sine`` partials shaped by ``Exponential`` and ``ADSR``
16 envelopes, plus ``WhiteNoise`` through a ``LowPass`` for the percussion's
17 attack. The plucked string is a custom ``AudioSource``: a Karplus-Strong
18 delay line, written as a ``render`` method returning NumPy samples.
19- A pool of ``AudioPlayer`` voices plays the notes, so several ring at once.
20 A new note takes the voice that falls silent soonest, cutting it short only
21 when every voice is still ringing.
22- ``Input.pointers`` holds the mouse, every finger and a pen, each with its
23 own press, drag and release, so eight fingers play eight pads and dragging
24 across the grid is a glissando. A touchscreen or pen that measures pressure
25 sets the note's velocity; otherwise the panel's velocity mode does: Fixed,
26 or Wobble, where the faster the mouse was moving the louder the note.
27- The keys are actions in the class's ``input_context``: one action per
28 keyboard pad, polled with ``Input.just_pressed`` and ``just_released``.
29- The control panel is an anchored ``Panel`` holding a ``VBoxContainer`` of
30 ``Button`` rows, a ``DropDown`` and ``Slider`` widgets. Each exclusive
31 choice (instrument, velocity, mode) is a ``ButtonGroup`` of toggle buttons,
32 and Loop, Quant and Retrig are single toggle buttons.
33
34Modes: Perform just plays. Record captures what you play, snapped to 16th
35notes at the BPM slider's tempo when Quant is on. Replay plays the phrase back,
36looping when Loop is on, and the BPM slider retimes it. Train lights the next
37note of the phrase and waits for you to hit it; Follow runs the phrase in time
38and lights each pad as it comes due, leaving every note for you to play. With
39Retrig on, pressing a pad again layers the new note over the ringing one.
40
41Controls: click, tap or drag across the pads. The bottom four rows are also
42the keyboard rows Z to comma, A to K, Q to I and 1 to 8. Page Up and Page Down,
43or the panel's OCT buttons, shift the octave; F1 to F6 pick an instrument.
44"""
45
46import math
47from bisect import bisect_right
48from collections import deque
49from dataclasses import dataclass, field
50from enum import StrEnum
51from functools import cache
52from operator import attrgetter
53from typing import NamedTuple
54
55import numpy as np
56
57from simvx.core import (
58 ADSR,
59 Anchor,
60 AudioClip,
61 AudioPlayer,
62 AudioSynth,
63 Button,
64 ButtonGroup,
65 DropDown,
66 Exponential,
67 HBoxContainer,
68 Input,
69 Key,
70 Label,
71 LowPass,
72 Node,
73 Oscillator,
74 Panel,
75 PenAxis,
76 PointerState,
77 PointerType,
78 Property,
79 SizingMode,
80 Slider,
81 VBoxContainer,
82 Vec3,
83 WhiteNoise,
84)
85from simvx.core.audio_synth import AudioSource
86from simvx.graphics import App
87
88GRID = 8
89VOICES = 16 # how many notes can ring at once
90PANEL_WIDTH, MARGIN = 210, 10
91DIM = (0.5, 0.5, 0.6, 1.0)
92
93PAD_KEYS = [ # bottom row first
94 [Key.Z, Key.X, Key.C, Key.V, Key.B, Key.N, Key.M, Key.COMMA],
95 [Key.A, Key.S, Key.D, Key.F, Key.G, Key.H, Key.J, Key.K],
96 [Key.Q, Key.W, Key.E, Key.R, Key.T, Key.Y, Key.U, Key.I],
97 [Key.KEY_1, Key.KEY_2, Key.KEY_3, Key.KEY_4, Key.KEY_5, Key.KEY_6, Key.KEY_7, Key.KEY_8],
98]
99INSTRUMENT_KEYS = [Key.F1, Key.F2, Key.F3, Key.F4, Key.F5, Key.F6]
100
101
102class Scale(StrEnum):
103 CHROMATIC = "Chromatic"
104 MAJOR = "Major"
105 MINOR = "Minor"
106 PENTATONIC = "Pentatonic"
107 BLUES = "Blues"
108
109
110SCALE_STEPS = {
111 Scale.CHROMATIC: range(12),
112 Scale.MAJOR: (0, 2, 4, 5, 7, 9, 11),
113 Scale.MINOR: (0, 2, 3, 5, 7, 8, 10),
114 Scale.PENTATONIC: (0, 3, 5, 7, 10),
115 Scale.BLUES: (0, 3, 5, 6, 7, 10),
116}
117
118
119class Instrument(StrEnum):
120 BELLS = "Bells"
121 PAD = "Pad"
122 PLUCK = "Pluck"
123 SYNTH = "Synth"
124 GLASS = "Glass"
125 PERC = "Perc"
126
127
128# The colour of the first pad and of the last, per instrument; the grid blends between them.
129PALETTES = {
130 Instrument.BELLS: (Vec3(0.2, 0.5, 1.0), Vec3(0.6, 0.9, 1.0)),
131 Instrument.PAD: (Vec3(0.2, 0.1, 0.5), Vec3(0.7, 0.3, 0.9)),
132 Instrument.PLUCK: (Vec3(0.1, 0.5, 0.3), Vec3(0.4, 1.0, 0.6)),
133 Instrument.SYNTH: (Vec3(0.6, 0.1, 0.4), Vec3(1.0, 0.4, 0.7)),
134 Instrument.GLASS: (Vec3(0.3, 0.6, 0.7), Vec3(0.7, 1.0, 1.0)),
135 Instrument.PERC: (Vec3(0.7, 0.2, 0.1), Vec3(1.0, 0.6, 0.2)),
136}
137# Long enough for the slowest partial to fade to near silence, so no note ends with a click.
138SECONDS = {
139 Instrument.BELLS: 1.0,
140 Instrument.PAD: 1.5,
141 Instrument.PLUCK: 0.8,
142 Instrument.SYNTH: 2.0,
143 Instrument.GLASS: 2.5,
144 Instrument.PERC: 0.5,
145}
146
147
148class VelocityMode(StrEnum):
149 FIXED = "Fixed"
150 WOBBLE = "Wobble"
151
152
153class Mode(StrEnum):
154 PERFORM = "Perform"
155 RECORD = "Record"
156 REPLAY = "Replay"
157 TRAIN = "Train"
158 FOLLOW = "Follow"
159
160
161MODE_COLOURS = {
162 Mode.PERFORM: (0.3, 1.0, 0.5, 1.0),
163 Mode.RECORD: (1.0, 0.3, 0.3, 1.0),
164 Mode.REPLAY: (0.3, 0.6, 1.0, 1.0),
165 Mode.TRAIN: (1.0, 0.8, 0.2, 1.0),
166 Mode.FOLLOW: (1.0, 0.6, 0.2, 1.0),
167}
168
169
170def note_name(semitone: int) -> str:
171 """C0 is semitone 0, so 57 is A4."""
172 return f"{('C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B')[semitone % 12]}{semitone // 12}"
173
174
175# Sound
176
177
178class Pluck(AudioSource):
179 """A Karplus-Strong string: a burst of soft noise circulating in a delay line that loses energy each lap."""
180
181 def __init__(self, freq: float):
182 self.freq = freq
183
184 def render(self, cursor_samples: int, n_samples: int, sample_rate: int) -> np.ndarray:
185 period = max(2, round(sample_rate / self.freq))
186 string = np.random.default_rng(round(self.freq)).uniform(-1.0, 1.0, period)
187 for _ in range(3): # smooth the noise so the attack is soft
188 string = (string + np.roll(string, 1)) / 2
189 laps = []
190 for _ in range(-(-(cursor_samples + n_samples) // period)):
191 laps.append(string)
192 string = 0.4975 * (string + np.roll(string, -1)) # average neighbours and damp
193 return np.concatenate(laps)[cursor_samples : cursor_samples + n_samples].astype(np.float32)
194
195
196@cache
197def tone(instrument: Instrument, semitone: int) -> AudioClip:
198 """One note of an instrument, baked the first time it is asked for."""
199 freq = 440.0 * 2 ** ((semitone - 57) / 12)
200 duration = SECONDS[instrument]
201 synth = AudioSynth()
202
203 def fading(rate: float) -> Exponential:
204 return Exponential(end=math.exp(-rate * duration)) # exp(-rate * t) over the clip
205
206 def sine(ratio: float, gain: float, envelope) -> None:
207 synth.add(Oscillator.sine(freq * ratio), envelope=envelope, gain=gain)
208
209 match instrument:
210 case Instrument.BELLS: # a music box: quick-fading overtones and a slow chorus beat
211 for ratio, gain, rate in ((1, 0.2, 5), (2, 0.03, 8), (3, 0.016, 12), (1.002, 0.024, 5)):
212 sine(ratio, gain, fading(rate))
213 case Instrument.PAD: # a held chord of soft harmonics that swells in and out
214 swell = ADSR(attack=0.08, decay=0.0, sustain=1.0, release=0.2)
215 for ratio, gain in ((1, 0.16), (2, 0.05), (3, 0.016), (1.003, 0.024)):
216 sine(ratio, gain, swell)
217 case Instrument.PLUCK:
218 synth.add(Pluck(freq), envelope=ADSR(attack=0.003, decay=0.0, release=0.03), gain=0.3)
219 case Instrument.SYNTH: # odd harmonics make a soft square wave; a detuned voice widens it
220 for ratio, gain in ((1, 0.14), (3, 0.035), (5, 0.014), (1.005, 0.07)):
221 sine(ratio, gain, fading(2.5))
222 case Instrument.GLASS: # two nearly equal sines beat against each other and shimmer
223 sine(1, 0.15, fading(2))
224 sine(1.004, 0.1, fading(2))
225 case Instrument.PERC: # a thud, a click an octave and a fifth up, and a breath of noise
226 sine(1, 0.25, fading(10))
227 sine(3, 0.1, fading(40))
228 synth.add(WhiteNoise(seed=semitone), envelope=fading(300), filter=LowPass(2000), gain=0.15)
229 return synth.bake(duration, channels=1)
230
231
232@dataclass
233class Voice:
234 player: AudioPlayer
235 ends: float = 0.0 # tree time at which its note has faded out
236 pad: int = -1 # the pad whose note it is playing
237
238
239# Recording
240
241
242class Note(NamedTuple):
243 time: float # seconds from the start of the recording, at the recorded tempo
244 pad: int
245 velocity: float
246 instrument: Instrument
247
248
249@dataclass
250class Phrase:
251 """A recorded run of pad presses that can be replayed, looped and practised."""
252
253 notes: list[Note] = field(default_factory=list)
254 bpm: float = 120.0
255 loop: bool = False
256 quantize: bool = False
257 playing: bool = False
258 recorded_bpm: float = 120.0
259 started: float = 0.0
260 clock: float = 0.0
261 cursor: int = 0 # the next note to replay
262 target: int = 0 # the next note to practise
263
264 @property
265 def length(self) -> float:
266 return self.notes[-1].time + 0.5 if self.notes else 0.0
267
268 @property
269 def progress(self) -> float:
270 if not self.notes:
271 return 0.0
272 return self.clock / self.length if self.playing else self.target / len(self.notes)
273
274 def record(self, now: float) -> None:
275 self.notes.clear()
276 self.recorded_bpm, self.started = self.bpm, now
277
278 def add(self, now: float, pad: int, velocity: float, instrument: Instrument) -> None:
279 t = now - self.started
280 if self.quantize:
281 sixteenth = 15.0 / self.bpm
282 t = round(t / sixteenth) * sixteenth
283 self.notes.append(Note(t, pad, velocity, instrument))
284
285 def play(self) -> None:
286 self.playing, self.clock, self.cursor, self.target = bool(self.notes), 0.0, 0, 0
287
288 def advance(self, dt: float) -> list[Note]:
289 """Move the playhead on, retimed by the tempo, and return the notes it passed."""
290 if not self.playing:
291 return []
292 self.clock += dt * self.bpm / self.recorded_bpm
293 due = self._take_due()
294 if self.clock >= self.length:
295 if self.loop:
296 self.clock -= self.length
297 self.cursor = 0
298 due += self._take_due()
299 else:
300 self.playing = False
301 return due
302
303 def _take_due(self) -> list[Note]:
304 end = bisect_right(self.notes, self.clock, key=attrgetter("time"))
305 due, self.cursor = self.notes[self.cursor : end], end
306 return due
307
308 def next_target(self) -> Note | None:
309 return self.notes[self.target] if self.notes else None
310
311 def hit_target(self) -> None:
312 self.target = (self.target + 1) % len(self.notes)
313
314
315@dataclass
316class PadState:
317 pressed: bool = False
318 brightness: float = 0.0 # 0 idle, 1 fully lit
319 velocity: float = 0.0
320 pressed_at: float = -1.0
321 release_at: float = 0.0 # a replayed press lets itself go at this time; 0 for a held one
322 target: bool = False
323
324
325class PadGrid(Node):
326 """An 8x8 synth pad instrument with a control panel on the right."""
327
328 dynamic = True # breathing pads, sparks and ripples animate every frame
329
330 input_context = {
331 **{f"pad_{row * GRID + col}": [key] for row, keys in enumerate(PAD_KEYS) for col, key in enumerate(keys)},
332 **{f"instrument_{i}": [key] for i, key in enumerate(INSTRUMENT_KEYS)},
333 "octave_up": [Key.PAGE_UP],
334 "octave_down": [Key.PAGE_DOWN],
335 }
336
337 instrument = Property(Instrument.BELLS, on_change="_show_settings")
338 scale = Property(Scale.PENTATONIC, on_change="_show_settings")
339 octave_offset = Property(-1, range=(-3, 3), on_change="_show_settings")
340 master_volume = Property(0.0, range=(-40.0, 12.0), hint="Master volume dB")
341 velocity_mode = Property(VelocityMode.WOBBLE)
342 retrigger = Property(False, hint="Layer a repeated pad's notes instead of cutting the last")
343
344 def __init__(self, **kwargs):
345 super().__init__(**kwargs)
346 self.pads = [PadState() for _ in range(GRID * GRID)]
347 self.phrase = Phrase()
348 self.mode = Mode.PERFORM
349 self.voices = [Voice(self.add_child(AudioPlayer())) for _ in range(VOICES)]
350 self.mouse_motion = deque(maxlen=30) # how far the mouse moved in each recent frame
351 self.last_mouse = (0.0, 0.0)
352 self.held = {} # pointer id -> the pad it is holding down
353 self._build_panel()
354
355 def on_ready(self):
356 for semitone in (60, 64, 67): # a C major chime says the audio works
357 self._sound(Instrument.BELLS, semitone, 0.5)
358
359 # Notes
360
361 def semitone(self, pad: int) -> int:
362 """Columns step through the scale, spilling into the next octave; rows climb an octave each."""
363 row, col = divmod(pad, GRID)
364 steps = SCALE_STEPS[self.scale]
365 octave, step = divmod(col, len(steps))
366 return 36 + (self.octave_offset + row + octave) * 12 + steps[step]
367
368 def _sound(self, instrument: Instrument, semitone: int, velocity: float, pad: int = -1) -> None:
369 clip = tone(instrument, semitone)
370 now = self.tree.now
371 voice = min(self.voices, key=attrgetter("ends")) # a silent one if any, else the nearest to silence
372 voice.player.stop()
373 voice.player.stream = clip
374 voice.player.volume_db = self.master_volume + 20 * math.log10(max(0.01, velocity))
375 voice.player.play()
376 voice.ends, voice.pad = now + clip.duration, pad
377
378 def _silence(self, pad: int) -> None:
379 for voice in self.voices:
380 if voice.pad == pad:
381 voice.pad = -1
382 if voice.player.is_playing():
383 voice.player.fade_out(0.08)
384
385 def press(self, pad: int, velocity: float, *, replayed: bool = False, instrument: Instrument | None = None):
386 """Play a pad; in Train and Follow only the phrase's next note counts."""
387 state = self.pads[pad]
388 target = self.phrase.next_target() if self.mode in (Mode.TRAIN, Mode.FOLLOW) and not replayed else None
389 if target:
390 if pad != target.pad:
391 if self.mode is Mode.FOLLOW:
392 state.brightness = 0.5 # a dim flash and no sound marks a wrong pad
393 return
394 velocity = target.velocity
395 self.phrase.hit_target()
396 self._show_target()
397 state.pressed, state.velocity, state.pressed_at = True, velocity, self.tree.now
398 state.release_at = self.tree.now + 0.15 if replayed else 0.0
399 if not self.retrigger:
400 self._silence(pad)
401 self._sound(instrument or self.instrument, self.semitone(pad), velocity, pad)
402 if self.mode is Mode.RECORD and not replayed:
403 self.phrase.add(self.tree.now, pad, velocity, self.instrument)
404
405 def release(self, pad: int) -> None:
406 self.pads[pad].pressed = False
407 self.pads[pad].release_at = 0.0
408 if self.instrument is Instrument.PAD: # the other instruments ring out on their own
409 self._silence(pad)
410
411 def _velocity(self, pointer: PointerState | None = None) -> float:
412 """How hard a pad was struck: measured pressure where the hardware has it, else the panel's mode."""
413 caps = Input.capabilities
414 if pointer is not None and (
415 (pointer.pointer_type is PointerType.TOUCH and caps.touch_pressure)
416 or (pointer.pointer_type is PointerType.PEN and PenAxis.PRESSURE in caps.pen_axes)
417 ):
418 return min(1.0, max(0.2, pointer.pressure))
419 if self.velocity_mode is VelocityMode.FIXED:
420 return 0.8
421 if pointer is None:
422 return 0.7 # a key has no wobble
423 return max(0.15, min(1.0, sum(self.mouse_motion) / 50.0))
424
425 # Modes
426
427 def set_mode(self, mode: Mode) -> None:
428 self.mode = mode
429 self.phrase.playing = False
430 match mode:
431 case Mode.RECORD:
432 self.phrase.record(self.tree.now)
433 case Mode.REPLAY | Mode.FOLLOW:
434 self.phrase.play()
435 case Mode.TRAIN:
436 self.phrase.target = 0
437 self._show_target()
438 self.mode_buttons[mode].checked = True
439 self.mode_label.text = mode.upper()
440 self.mode_label.text_colour = MODE_COLOURS[mode]
441
442 def _show_target(self) -> None:
443 for state in self.pads:
444 state.target = False
445 target = self.phrase.next_target()
446 if target and self.mode in (Mode.TRAIN, Mode.FOLLOW):
447 self.pads[target.pad].target = True
448
449 def select_instrument(self, instrument: Instrument) -> None:
450 self.instrument = instrument
451 self.instrument_buttons[instrument].checked = True
452
453 # Frame
454
455 def on_update(self, dt: float):
456 now = self.tree.now
457 mouse = Input.mouse_position
458 self.mouse_motion.append(math.dist(mouse, self.last_mouse))
459 self.last_mouse = mouse
460
461 if Input.just_pressed("octave_up"):
462 self.octave_offset += 1
463 if Input.just_pressed("octave_down"):
464 self.octave_offset -= 1
465 for i, instrument in enumerate(Instrument):
466 if Input.just_pressed(f"instrument_{i}"):
467 self.select_instrument(instrument)
468 for pad in range(len(PAD_KEYS) * GRID):
469 if Input.just_pressed(f"pad_{pad}"):
470 self.press(pad, self._velocity())
471 if Input.just_released(f"pad_{pad}"):
472 self.release(pad)
473
474 # Every pointer is its own voice. A drag onto a new pad lets the old one go.
475 for pid, pointer in Input.pointers.items():
476 pad = self.pad_at(pointer.position)
477 if pointer.just_pressed and pad is not None:
478 self.press(pad, self._velocity(pointer))
479 self.held[pid] = pad
480 elif pointer.contact and pid in self.held and pad is not None and pad != self.held[pid]:
481 self.release(self.held[pid])
482 self.press(pad, self._velocity(pointer))
483 self.held[pid] = pad
484 if pointer.just_released and pid in self.held:
485 self.release(self.held.pop(pid))
486
487 for note in self.phrase.advance(dt):
488 if self.mode is Mode.REPLAY:
489 self.press(note.pad, note.velocity, replayed=True, instrument=note.instrument)
490 else: # Follow lights the note and leaves playing it to you
491 self.pads[note.pad].target = True
492
493 for pad, state in enumerate(self.pads):
494 if state.release_at and now >= state.release_at:
495 self.release(pad)
496 rate = 12.0 if state.pressed else -4.0
497 state.brightness = min(1.0, max(0.0, state.brightness + dt * rate))
498
499 count = len(self.phrase.notes)
500 match self.mode:
501 case Mode.RECORD:
502 self.progress_label.text = f"Recording: {count} notes{' [Q]' if self.phrase.quantize else ''}"
503 case Mode.PERFORM:
504 self.progress_label.text = ""
505 case _:
506 loop = " [LOOP]" if self.phrase.loop else ""
507 self.progress_label.text = f"{self.phrase.progress:.0%} ({count} notes){loop}"
508
509 # Layout
510
511 def grid_rect(self) -> tuple[float, float, float]:
512 """The grid's top-left corner and the size of one cell, fitted beside the panel."""
513 width, height = self.viewport_size
514 area_w, area_h = width - PANEL_WIDTH - 3 * MARGIN, height - 2 * MARGIN
515 cell = min(area_w, area_h) / GRID
516 return MARGIN + (area_w - cell * GRID) / 2, (height - cell * GRID) / 2, cell
517
518 def pad_rect(self, pad: int) -> tuple[float, float, float]:
519 """A pad's top-left corner and side, row 0 at the bottom."""
520 left, top, cell = self.grid_rect()
521 row, col = divmod(pad, GRID)
522 gap = max(2.0, cell * 0.08)
523 return left + col * cell + gap / 2, top + (GRID - 1 - row) * cell + gap / 2, cell - gap
524
525 def pad_at(self, point) -> int | None:
526 left, top, cell = self.grid_rect()
527 col, row = int((point[0] - left) // cell), GRID - 1 - int((point[1] - top) // cell)
528 return row * GRID + col if 0 <= col < GRID and 0 <= row < GRID else None
529
530 def pad_colour(self, pad: int) -> Vec3:
531 low, high = PALETTES[self.instrument]
532 return low.lerp(high, pad / (GRID * GRID - 1))
533
534 # Drawing
535
536 def on_draw(self, renderer):
537 now = self.tree.now
538 for pad, state in enumerate(self.pads):
539 x, y, side = self.pad_rect(pad)
540 centre = (x + side / 2, y + side / 2)
541 base = self.pad_colour(pad)
542 glow = 0.25 + 0.03 * math.sin(now * 1.5 + pad * 0.3) # a slow idle breath
543 if state.target:
544 glow = max(glow, 0.55 + 0.15 * math.sin(now * 6.0))
545 r, g, b = np.minimum(1.0, base * (glow + state.brightness * (0.75 + 0.25 * state.velocity)))
546 radius = min(max(2.0, side * 0.08), side / 2)
547 rounded_rect(renderer, x, y, side, radius, (r, g, b, 1.0))
548 if side > 10: # a bevel: lit top edge, shaded bottom edge
549 renderer.rect((x + radius, y), (side - 2 * radius, 2), fill=(r + 0.15, g + 0.15, b + 0.15, 0.4))
550 renderer.rect((x + radius, y + side - 2), (side - 2 * radius, 2), fill=(r * 0.3, g * 0.3, b * 0.3, 0.5))
551 if state.brightness > 0.05:
552 halo = (r, g, b, 0.2 * state.brightness * state.velocity)
553 renderer.circle(centre, side * 0.7 * state.brightness, fill=halo, segments=16)
554
555 age = now - state.pressed_at
556 if age < 0.3 and state.velocity > 0.1:
557 draw_sparks(renderer, centre, age / 0.3, state, base)
558 if age < 0.4: # a ripple ring spreading from the pad
559 ring = (*base, 0.3 * (1.0 - age / 0.4) * state.velocity)
560 renderer.circle(centre, side * (0.5 + 1.5 * age / 0.4), stroke=ring, width=2.0, segments=20)
561 if side > 30:
562 label_colour = (1.0, 1.0, 1.0, 0.4 + 0.6 * state.brightness)
563 renderer.text(
564 note_name(self.semitone(pad)),
565 rect=(x, y, side, side - 2),
566 align="centre",
567 valign="bottom",
568 fill=label_colour,
569 scale=max(0.5, min(1.0, side / 80)),
570 )
571
572 if self.phrase.playing: # the playhead, under the grid
573 left, top, cell = self.grid_rect()
574 bar = (0.3, 0.7, 1.0, 0.8)
575 renderer.rect((left, top + cell * GRID + 4), (cell * GRID * self.phrase.progress, 3), fill=bar)
576
577 # Panel
578
579 def _build_panel(self) -> None:
580 panel = self.add_child(Panel(anchor=Anchor.RIGHT_WIDE, inset=MARGIN, size=(PANEL_WIDTH, 0)))
581 panel.bg_colour = (0.08, 0.08, 0.10, 0.92)
582 panel.border_colour = (0.2, 0.2, 0.25, 1.0)
583 box = panel.add_child(VBoxContainer(anchor=Anchor.FULL_RECT, inset=10, separation=8))
584
585 title = box.add_child(Label("PAD GRID", font_size=18.0, alignment="centre"))
586 title.text_colour = (0.8, 0.9, 1.0, 1.0)
587
588 box.add_child(caption("INSTRUMENT"))
589 instruments = ButtonGroup()
590 self.instrument_buttons = {}
591 for instrument in Instrument:
592 button = Button(
593 instrument[:3], lambda i=instrument: self.select_instrument(i), group=instruments, font_size=10.0
594 )
595 low, _ = PALETTES[instrument]
596 button.bg_colour = (*low, 0.5)
597 button.hover_colour = (*low, 0.8)
598 button.checked_colour = (*low, 1.0)
599 self.instrument_buttons[instrument] = button
600 box.add_child(button_row(*self.instrument_buttons.values()))
601 self.instrument_buttons[self.instrument].checked = True
602
603 box.add_child(caption("SCALE"))
604 scales = box.add_child(DropDown(items=list(Scale), selected_index=list(Scale).index(self.scale), font_size=11))
605 scales.item_selected.connect(lambda i: setattr(self, "scale", list(Scale)[i]))
606
607 box.add_child(caption("VOLUME"))
608 volume = box.add_child(Slider(-40, 12, value=self.master_volume))
609 volume.value_changed.connect(lambda db: setattr(self, "master_volume", db))
610
611 box.add_child(caption("VELOCITY"))
612 velocities = ButtonGroup()
613 velocity_buttons = []
614 for mode in VelocityMode:
615 button = Button(mode, lambda m=mode: setattr(self, "velocity_mode", m), group=velocities, font_size=10.0)
616 button.checked = mode is self.velocity_mode
617 velocity_buttons.append(button)
618 box.add_child(button_row(*velocity_buttons))
619
620 box.add_child(caption("MODE"))
621 modes = ButtonGroup()
622 self.mode_buttons = {m: Button(m, lambda m=m: self.set_mode(m), group=modes, font_size=10.0) for m in Mode}
623 buttons = list(self.mode_buttons.values())
624 box.add_child(button_row(*buttons[:3]))
625 box.add_child(button_row(*buttons[3:]))
626
627 toggles = {"Loop": (self.phrase, "loop"), "Quant": (self.phrase, "quantize"), "Retrig": (self, "retrigger")}
628 row = []
629 for text, (target, attr) in toggles.items():
630 button = Button(text, toggle_mode=True, font_size=10.0)
631 button.toggled.connect(lambda on, target=target, attr=attr: setattr(target, attr, on))
632 row.append(button)
633 box.add_child(button_row(*row))
634
635 tempo = box.add_child(HBoxContainer(separation=5, size=(0, 26)))
636 tempo.add_child(caption("BPM"))
637 bpm = tempo.add_child(Slider(60, 200, value=self.phrase.bpm, size=(150, 22)))
638 bpm.value_changed.connect(lambda value: setattr(self.phrase, "bpm", value))
639
640 self.mode_label = box.add_child(Label("", font_size=14.0, alignment="centre"))
641 self.settings_label = box.add_child(Label("", font_size=10.0, alignment="centre"))
642 self.progress_label = box.add_child(Label("", font_size=10.0, alignment="centre"))
643 for label in (self.settings_label, self.progress_label):
644 label.text_colour = DIM
645 box.add_child(
646 button_row(
647 Button("OCT -", lambda: setattr(self, "octave_offset", self.octave_offset - 1), font_size=10.0),
648 Button("OCT +", lambda: setattr(self, "octave_offset", self.octave_offset + 1), font_size=10.0),
649 )
650 )
651 self.set_mode(Mode.PERFORM)
652 self._show_settings()
653
654 # Example help overlay: not part of the technique shown.
655 hint = Label("Z-8 rows: play pads\nPgUp/PgDn: octave\nF1-F6: instrument", font_size=9.0, alignment="centre")
656 hint.text_colour = (0.45, 0.45, 0.55, 1.0)
657 box.add_child(hint)
658
659 def _show_settings(self) -> None:
660 lowest = note_name(36 + self.octave_offset * 12)
661 self.settings_label.text = f"{self.instrument} | {self.scale} | from {lowest}"
662
663
664def caption(text: str) -> Label:
665 label = Label(text, font_size=10.0)
666 label.text_colour = DIM
667 return label
668
669
670def button_row(*buttons: Button) -> HBoxContainer:
671 """A full-width row that shares its width evenly between the buttons."""
672 row = HBoxContainer(separation=3, size=(0, 26))
673 row.sizing = SizingMode.FILL
674 for button in buttons:
675 row.add_child(button)
676 return row
677
678
679def rounded_rect(renderer, x: float, y: float, side: float, radius: float, colour) -> None:
680 """A square with rounded corners: a cross of two rects and a disc in each corner.
681
682 The 2D renderer's rect() has no corner radius, so the shape is assembled from primitives.
683 """
684 renderer.rect((x + radius, y), (side - 2 * radius, side), fill=colour)
685 renderer.rect((x, y + radius), (side, side - 2 * radius), fill=colour)
686 for cx in (x + radius, x + side - radius):
687 for cy in (y + radius, y + side - radius):
688 renderer.circle((cx, cy), radius, fill=colour, segments=12)
689
690
691def draw_sparks(renderer, centre, t: float, state: PadState, base: Vec3) -> None:
692 """A ring of sparks flying out from a struck pad, more and further for a harder strike."""
693 count = int(6 + 8 * state.velocity)
694 distance = 8 + 40 * t * (0.5 + 0.5 * state.velocity)
695 colour = (*np.minimum(1.0, base + 0.3), max(0.0, 1.0 - t * 1.2))
696 size = max(1.0, 3.0 * (1.0 - t) * state.velocity)
697 for j in range(count):
698 angle = j / count * math.tau + state.pressed_at * 5
699 spark = (centre[0] + math.cos(angle) * distance, centre[1] + math.sin(angle) * distance)
700 renderer.circle(spark, size, fill=colour, segments=6)
701
702
703if __name__ == "__main__":
704 App(width=1280, height=720, title="SimVX Pad Grid").run(PadGrid())