Pad Grid¶

a playable synth instrument with record, replay and training modes.

â–¶ Run in browser

Tags: 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:

  • AudioSynth bakes each note once, the first time it is played, from Oscillator.sine partials shaped by Exponential and ADSR envelopes, plus WhiteNoise through a LowPass for the percussion’s attack. The plucked string is a custom AudioSource: a Karplus-Strong delay line, written as a render method returning NumPy samples.

  • A pool of AudioPlayer voices 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.pointers holds 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 with Input.just_pressed and just_released.

  • The control panel is an anchored Panel holding a VBoxContainer of Button rows, a DropDown and Slider widgets. Each exclusive choice (instrument, velocity, mode) is a ButtonGroup of 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())