From 2af2a379c02a7d3b4bb596dbadf5212ad377720c Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:09:33 -0500 Subject: [PATCH 1/2] ElectronicDrums5: a five-channel electronic drum kit --- docs/instruments.md | 96 +++ examples/electronic_drums5_demo.py | 346 ++++++++ lib/audioinstruments/_names.py | 2 + lib/audioinstruments/electronic_drums5.py | 47 ++ lib/vrack/adapter.py | 1 + lib/vrack/dsp/drums.py | 37 +- lib/vrack/instruments/electronic_drums5.py | 289 +++++++ lib/vrack/modules/__init__.py | 1 + .../modules/electronic_drums5/__init__.py | 7 + lib/vrack/modules/electronic_drums5/voice.py | 174 ++++ tests/mpy/run.py | 1 + tests/mpy/test_electronic_drums5.py | 198 +++++ tests/test_allocation.py | 1 + tests/test_dsp_drums.py | 64 ++ tests/test_synth_electronic_drums5.py | 411 ++++++++++ tests/traits/electronic_drums5.json | 745 ++++++++++++++++++ tools/traits.py | 9 + 17 files changed, 2424 insertions(+), 5 deletions(-) create mode 100644 examples/electronic_drums5_demo.py create mode 100644 lib/audioinstruments/electronic_drums5.py create mode 100644 lib/vrack/instruments/electronic_drums5.py create mode 100644 lib/vrack/modules/electronic_drums5/__init__.py create mode 100644 lib/vrack/modules/electronic_drums5/voice.py create mode 100644 tests/mpy/test_electronic_drums5.py create mode 100644 tests/test_synth_electronic_drums5.py create mode 100644 tests/traits/electronic_drums5.json diff --git a/docs/instruments.md b/docs/instruments.md index 4f9e065..7cfe951 100644 --- a/docs/instruments.md +++ b/docs/instruments.md @@ -3944,6 +3944,102 @@ When idle, parked voices take 1.11 ms per buffer on CPython (factor 19.3) and 0.48 ms on MicroPython (factor 44.8). On MicroPython, all fourteen voices running simultaneously execute comfortably in real time (4.71x at default 128-frame blocks). +## Playing the ElectronicDrums5 + +The ElectronicDrums5 is a five-channel electronic drum kit with swept analog tones, +inspired by the Simmons SDS-V (1981). Its iconic hexagonal pads, sweeping tom fills, +and punchy transients powered early 1980s pop, new wave, and electronic rock. +A drummer plays it dynamically from velocity-sensitive pads, with separate analog +voice channels for the bass drum, snare drum, and three toms. + +```python +import vrack.adapter as adapter + +inst = adapter.create(48000, voice="electronic_drums5") # 5 voices, 1 per channel +audio_out.play(inst.output) +inst.program_change(0) # Classic +inst.note_on(36, 127) # Accented Bass Drum +inst.note_on(38, 100) # Snare Drum +inst.note_on(48, 127) # High Tom +``` + +### Sounds and circuits + +Each channel combines an oscillator whose pitch dives rapidly after the strike +(triangle shaped toward a sine), white noise through a low-pass filter (modeled on the +SSM2044 VCF), and an initial strike click pulse, all governed by one exponential +amplitude decay envelope. + +The five channels map to General MIDI percussion keys: + +| voice | channel | General MIDI keys | panel controls | +|---|---|---|---| +| 0 | bass drum (BD) | 35, 36 | bd_tone, bd_noise, bd_pitch, bd_bend, bd_decay, bd_filter, bd_click | +| 1 | snare drum (SD) | 37, 38, 39, 40 | sd_tone, sd_noise, sd_pitch, sd_bend, sd_decay, sd_filter, sd_click | +| 2 | tom 1 / high tom (T1) | 48, 50 | t1_tone, t1_noise, t1_pitch, t1_bend, t1_decay, t1_filter, t1_click | +| 3 | tom 2 / mid tom (T2) | 45, 47 | t2_tone, t2_noise, t2_pitch, t2_bend, t2_decay, t2_filter, t2_click | +| 4 | tom 3 / low tom (T3) | 41, 43 | t3_tone, t3_noise, t3_pitch, t3_bend, t3_decay, t3_filter, t3_click | + +The **bass drum** tone rests near 42-78 Hz (60 Hz at the middle setting) with a short 35 ms downward +bend from an octave above, a strong strike click, subtle filtered noise, and an +amplitude decay of 300 to 500 ms. + +The **snare drum** rests near 150-270 Hz (210 Hz at the middle setting) with a tight 25 ms bend and a +prominent burst of white noise through a high-cutoff low-pass filter (1.5 to 9.5 kHz) +for an aggressive, bright crack decaying in 200 to 300 ms. + +The **toms** (high, mid, low) rest near 220 Hz (T1), 160 Hz (T2), and 110 Hz (T3). Each +tom strikes an octave to an octave and a half higher and bends downward over 200 to 240 ms, +blended with a touch of filtered noise and moderate attack click over a 400 to 800 ms decay. +A fill rolling down the three toms produces the signature diving pitch sweep of 1980s drums. + +### Velocity, choke, and output + +Strikes respond dynamically to MIDI velocity, scaled by `sensitivity` (0 to 10, default 5.0). +At sensitivity 10, a soft hit at velocity 40 sits about 18 dB below velocity 127 and bends +half as far in pitch, capturing the expressive response of the original velocity-sensitive +pads. At sensitivity 0, level and bend depth remain constant across velocities. + +There is no voice choking (`CHOKE = ()`). Striking a ringing channel again takes the new +strike over the sound that is still ringing: the oscillator keeps its phase, the level +carries on from where it was, the pitch bends down again from the top, and the strike +click sounds again, as it does on every hit. Master volume +(`volume`, default 7.0) and a soft ceiling tanh saturation stage protect the shared mono +output stage against hard clipping when all five channels are struck simultaneously. +`inst.latency_samples` is 0. + +### Patches and macros + +Five presets cover classic electronic drum voicings: +- **Classic**: The factory 1984 kit with sweeping toms and balanced punch. +- **Deep Toms**: Lower resting pitches with extended pitch bends and longer decays. +- **Electro**: Fast decays, heavy strike clicks, and a tight snare crack. +- **Syndrum**: High tone mix, minimal noise, and deep bends for disco toms. +- **Gated**: Short, abrupt decays on snare and toms evoking 1980s gated room reverb. + +The eight macros are `t1_bend`, `t1_decay`, `sd_noise`, `sd_filter`, `bd_click`, +`bd_decay`, `sensitivity`, and `volume`. `t1_bend` and `t1_decay` move all three toms +together: tom 1 goes to the knob's value and the other two move by the same amount, so a +patch whose toms differ keeps those differences. A tom that reaches 0 or 10 stops there and +comes back to its offset when you turn the knob back. + +`examples/electronic_drums5_demo.py` demonstrates all five presets across five 16-step +grooves in 55 seconds, streaming audio to `wav_exports/electronic_drums5_demo.wav`. + +### What it costs + +`examples/poly_bench.py electronic_drums5` benchmarks all five channels sounding +simultaneously (struck every four buffers) across block sizes: + +| block | CPython ms | factor | MicroPython ms | factor | +| ---: | ---: | ---: | ---: | ---: | +| 64 | 18.4 | 1.16 | 8.1 | 2.65 | +| 128 (default) | 10.1 | 2.11 | 4.1 | 5.18 | + +Measured on an otherwise idle machine; a busy one reads lower. The kit runs faster than +real time on both interpreters at the default 128-frame block (2.1x on CPython, 5.2x on +MicroPython), with about a factor of 1.2 to spare on CPython at 64 frames. + ## Traits `tools/traits.py` plays an instrument's fixed phrase and measures its sound: diff --git a/examples/electronic_drums5_demo.py b/examples/electronic_drums5_demo.py new file mode 100644 index 0000000..b183b31 --- /dev/null +++ b/examples/electronic_drums5_demo.py @@ -0,0 +1,346 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# SPDX-License-Identifier: MIT +"""Play ElectronicDrums5: five classic 1980s electronic drum patterns across its five patches. + + python examples/electronic_drums5_demo.py # writes wav_exports/electronic_drums5_demo.wav + +One five-channel drum kit plays the whole piece and changes patches between sections, +demonstrating the swept analog tones, punchy strike clicks, and filtered noise of the SDS-V: +1. Classic: 1984 rock beat with signature swept tom fills diving down high, mid, and low toms. +2. Deep Toms: a slow tribal tom groove with low resting pitches and long decaying bends. +3. Electro: a tight sixteenth-note electro groove with fast snare cracks and clicky hits. +4. Syndrum: a driving disco beat with off-beat diving tom fills and minimal noise. +5. Gated: big heavy backbeat with a tom 1 roll crescendoing from soft ghost hits to hard strikes. + +Each section is written as a 16-step grid per bar so the rhythmic patterns +are visible in code. The script streams audio directly to the WAV file and +prints a summary table of start time, peak, RMS, clipping, and spectrum checks. +""" + +import math +import sys + +if "lib" not in sys.path: + sys.path.insert(0, "lib") + +import audiocore +import vrack.adapter as adapter +from demo_check import Section, feed_sections, open_wav, section_ok + +RATE = 48000 +BUFFER = 256 +CLIP = 27000 +BPM = 110 +STEP = 60.0 / (BPM * 4) # 16th note step duration in seconds (~0.136 s) +BAR = STEP * 16 # 4/4 bar duration (~2.182 s) + +# Voice pad note assignments +BD = 36 # Bass Drum +SD = 38 # Snare Drum +T1 = 48 # High Tom +T2 = 45 # Mid Tom +T3 = 41 # Low Tom + +VELOCITIES = { + "X": 127, # Accented hit + "x": 100, # Standard hit + "o": 65, # Soft hit + "g": 40, # Ghost hit + "1": 35, + "2": 50, + "3": 65, + "4": 80, + "5": 95, + "6": 110, + "7": 127, +} + + +def parse_grid(t_start, bars): + """Convert a sequence of 16-step bar grids into note events. + + Each bar is a dict of {pad_note: "16-character string"}. + Characters: 'X' (127), 'x' (100), 'o' (65), 'g' (40), digits 1-7, '.' (rest). + """ + events = [] + t = t_start + for bar in bars: + for pad, pattern in bar.items(): + for step_idx, ch in enumerate(pattern): + if ch in VELOCITIES: + v = VELOCITIES[ch] + hit_time = t + step_idx * STEP + events.append((hit_time, "on", pad, v)) + events.append((hit_time + 0.02, "off", pad, 0)) + t += BAR + return events, t + + +def classic(t): + """Classic: 1984 rock beat with a tom fill down all three toms at the end of each four bars.""" + # Bar 0: Main groove + b0 = { + BD: "X...X...X.......", + SD: "....X.......X...", + T1: "................", + T2: "................", + T3: "................", + } + # Bar 1: Variation + b1 = { + BD: "X.......X...X...", + SD: "....X.......X...", + } + # Bar 2: Groove + b2 = { + BD: "X...X...X.......", + SD: "....X.......X.o.", + } + # Bar 3: Tom fill down all three toms + b3 = { + BD: "X...............", + SD: "....X...........", + T1: "........X.X.....", + T2: "............X.X.", + T3: "................", + } + # Bar 4: Fill resolves into low tom and return hit + b4 = { + BD: "X.......X...X...", + SD: "....X.......X...", + T3: "X.X.............", + } + return parse_grid(t, [b0, b1, b2, b3, b4]) + + +def deep_toms(t): + """Deep Toms: a slow tom groove with tribal interplay across the low-tuned toms.""" + b0 = { + BD: "X.......X.......", + SD: "....o.......o...", + T1: "..x.......x.....", + T2: "......x.......x.", + T3: "x...x...x...x...", + } + b1 = { + BD: "X.......X...X...", + SD: "....o.......o...", + T1: "....x.......x...", + T2: "..x...x...x...x.", + T3: "x.......x.......", + } + b2 = { + BD: "X.......X.......", + SD: "....o.......o...", + T1: "..x.......x...x.", + T2: "......x.........", + T3: "x...x...x...x...", + } + b3 = { + BD: "X.......X.......", + SD: "....o.......o...", + T1: "........x.x.....", + T2: "............x.x.", + T3: "x...x...x.......", + } + b4 = { + BD: "X.......X...X...", + SD: "....o.......o...", + T3: "x.x.....x...x...", + } + return parse_grid(t, [b0, b1, b2, b3, b4]) + + +def electro(t): + """Electro: tight sixteenth beat with snappy snare and fast clicks.""" + b0 = { + BD: "X...X.X...X...X.", + SD: "....X..g....X...", + T1: "..........x...x.", + T2: "......x.........", + T3: "..x.........x...", + } + b1 = { + BD: "X...X...X...X.X.", + SD: "....X..g....X.g.", + T1: "........x...x...", + T2: "..x...x.........", + T3: "..........x...x.", + } + b2 = { + BD: "X...X.X...X...X.", + SD: "....X..g....X...", + T1: "..........x...x.", + T2: "......x.........", + T3: "..x.........x...", + } + b3 = { + BD: "X...X...X.......", + SD: "....X.......X.X.", + T1: "........x.x.....", + T2: "............x.x.", + } + b4 = { + BD: "X...X.X...X...X.", + SD: "....X..g....X...", + T3: "x.x.............", + } + return parse_grid(t, [b0, b1, b2, b3, b4]) + + +def syndrum(t): + """Syndrum: classic disco beat with off-beat diving tom hits.""" + b0 = { + BD: "X...X...X...X...", + SD: "....X.......X...", + T1: "..X.......X.....", + T2: "......X.........", + T3: "............X...", + } + b1 = { + BD: "X...X...X...X...", + SD: "....X.......X...", + T1: "....X.......X...", + T2: "..X.......X.....", + T3: "......X.......X.", + } + b2 = { + BD: "X...X...X...X...", + SD: "....X.......X...", + T1: "..X.......X.....", + T2: "......X.........", + T3: "............X...", + } + b3 = { + BD: "X...X...X...X...", + SD: "....X.......X...", + T1: "........X.X.....", + T2: "............X.X.", + } + b4 = { + BD: "X...X...X...X...", + SD: "....X.......X...", + T3: "X.X.............", + } + return parse_grid(t, [b0, b1, b2, b3, b4]) + + +def gated(t): + """Gated: big 80s backbeat with a rising crescendo tom roll from soft to hard.""" + b0 = { + BD: "X.X.....X.......", + SD: "....X.......X...", + T1: "................", + T2: "................", + T3: "................", + } + b1 = { + BD: "X.X.....X...X...", + SD: "....X.......X...", + } + b2 = { + BD: "X.X.....X.......", + SD: "....X.......X...", + } + # Bar 3: Fast tom roll with velocities rising from soft (35) to hard (127) + b3 = { + BD: "X...............", + T1: "1234567X1234567X", + } + # Bar 4: Big backbeat crash resolution + b4 = { + BD: "X.X.....X.......", + SD: "....X.......X...", + T2: "X...............", + T3: "....X...........", + } + return parse_grid(t, [b0, b1, b2, b3, b4]) + + +SECTIONS = ( + ("Classic", 0, classic), + ("Deep Toms", 1, deep_toms), + ("Electro", 2, electro), + ("Syndrum", 3, syndrum), + ("Gated", 4, gated), +) + + +def render(sections, wav_path=None): + """Play all sections on one kit, streaming a WAV and metering each section.""" + on_cpython = sys.implementation.name == "cpython" + inst = adapter.create( + RATE, voice="electronic_drums5", output="pull" if on_cpython else "ring", ring_blocks=2 + ) + events = [] + starts = [] + t = 0.2 + for name, patch, write in sections: + starts.append((name, t, int(t * RATE))) + events.append((t - 0.05, "patch", patch, 0)) + sec_events, t = write(t) + for ev in sec_events: + events.append(ev) + events.sort(key=lambda e: (e[0], e[1] != "patch", e[1] == "on")) + buffers = int((t + 0.6) * RATE) // BUFFER + frames = buffers * BUFFER + bounds = [start[-1] for start in starts] + [frames] + sections_out = [Section(RATE, CLIP) for _ in starts] + wav = open_wav(wav_path, RATE, frames * 4) if wav_path else None + k = 0 + for b in range(buffers): + end = (b + 1) * BUFFER + while k < len(events) and events[k][0] * RATE < end: + ev = events[k] + kind = ev[1] + if kind == "patch": + inst.program_change(ev[2]) + elif kind == "on": + inst.note_on(ev[2], ev[3]) + elif kind == "off": + inst.note_off(ev[2]) + k += 1 + inst.fill() + _, data = audiocore.get_buffer(inst.output) + data = bytes(data) + if wav is not None: + wav.write(data) + feed_sections(data, b * BUFFER, bounds, sections_out) + stolen = inst.voice_pool.stolen + inst.deinit() + if wav is not None: + wav.close() + print("wrote %s (%.1f s)" % (wav_path, frames / RATE)) + return starts, sections_out, stolen + + +def main(): + starts, meters, stolen = render(SECTIONS, "wav_exports/electronic_drums5_demo.wav") + frames = starts[0][2] + sum(m.count for m in meters) + total_sec = frames / RATE + ok = 50.0 <= total_sec <= 65.0 + print("\nstart section peak rms clipped audible upper") + for (name, t_sec, _), m in zip(starts, meters): + mins = int(t_sec) // 60 + secs = t_sec - mins * 60 + time_str = "%02d:%05.2f" % (mins, secs) + share, upper = m.share(), m.upper_share() + print( + "%-11s %-16s %6d %6.0f %8d %7.0f%% %6.1f%%" + % (time_str, name, m.peak, m.rms(), m.clipped, 100 * share, 100 * upper) + ) + ok = ok and section_ok(m.rms(), m.clipped, share, upper) + rms_vals = [m.rms() for m in meters] + rms_ratio_db = 20.0 * math.log10(max(rms_vals) / min(rms_vals)) if min(rms_vals) > 0 else 999.0 + print( + "\nRMS range: min %.0f, max %.0f (spread: %.1f dB)" + % (min(rms_vals), max(rms_vals), rms_ratio_db) + ) + print("voices stolen: %d" % stolen) + assert stolen == 0, "No voices should be stolen" + print("result:", "ok" if ok else "FAILED") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/lib/audioinstruments/_names.py b/lib/audioinstruments/_names.py index eddff3f..f946b4e 100644 --- a/lib/audioinstruments/_names.py +++ b/lib/audioinstruments/_names.py @@ -14,6 +14,7 @@ "duosynth", "electric_grand70", "electric_piano", + "electronic_drums5", "hybrid_mono", "mono101", "mono_one", @@ -53,6 +54,7 @@ "duosynth", "electric_grand70", "electric_piano", + "electronic_drums5", "hybrid_mono", "mono101", "mono_one", diff --git a/lib/audioinstruments/electronic_drums5.py b/lib/audioinstruments/electronic_drums5.py new file mode 100644 index 0000000..375c81b --- /dev/null +++ b/lib/audioinstruments/electronic_drums5.py @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# SPDX-License-Identifier: MIT +# Generated by tools/make_faces.py from vrack.instruments.electronic_drums5.ElectronicDrums5: edit that, then run it again. +"""ElectronicDrums5, a five-channel electronic drum kit with swept tones, inspired by the Simmons SDS-V.""" + +NAME = "electronic_drums5" +DISPLAY_NAME = "electronic_drums5" +CATEGORIES = ("Synth",) +VERSION = "0.5.0.dev0" +VENDOR = "PyDevices" + +MACRO_LABELS = ( + "t1_bend", + "t1_decay", + "sd_noise", + "sd_filter", + "bd_click", + "bd_decay", + "sensitivity", + "volume", +) + +MACRO_MODES = { + 0: "UNIPOLAR", + 1: "UNIPOLAR", + 2: "UNIPOLAR", + 3: "UNIPOLAR", + 4: "UNIPOLAR", + 5: "UNIPOLAR", + 6: "UNIPOLAR", + 7: "UNIPOLAR", +} + +PATCHES = { + 0: ("Classic", (64, 64, 95, 89, 89, 64, 64, 89)), + 1: ("Deep Toms", (95, 95, 95, 89, 89, 83, 64, 89)), + 2: ("Electro", (64, 38, 114, 108, 114, 38, 64, 89)), + 3: ("Syndrum", (121, 83, 95, 89, 89, 64, 64, 89)), + 4: ("Gated", (64, 28, 108, 114, 89, 38, 64, 89)), +} + + +def create(sample_rate, channel_count=2, transport=None, **options): + """A live electronic_drums5. See ``audioinstruments.create`` for the options.""" + from . import _build + + return _build(NAME, sample_rate, channel_count, transport, options) diff --git a/lib/vrack/adapter.py b/lib/vrack/adapter.py index 05d5cc4..04e2020 100644 --- a/lib/vrack/adapter.py +++ b/lib/vrack/adapter.py @@ -70,6 +70,7 @@ "duosynth": ("duosynth", "DuoSynth"), "electric_grand70": ("electric_grand70", "ElectricGrand70"), "electric_piano": ("electric_piano", "ElectricPiano"), + "electronic_drums5": ("electronic_drums5", "ElectronicDrums5"), "mono101": ("mono101", "Mono101"), "mono_synth": ("mono_synth", "MonoSynth"), "poly106": ("poly106", "Poly106"), diff --git a/lib/vrack/dsp/drums.py b/lib/vrack/dsp/drums.py index b1231cc..6f176b6 100644 --- a/lib/vrack/dsp/drums.py +++ b/lib/vrack/dsp/drums.py @@ -884,7 +884,12 @@ class SweptOscillatorDrumCore: strike ramp, and an optional noise mix with its own filter and decay. """ - def __init__(self, sample_rate=DEFAULT_SAMPLE_RATE, block_size=DEFAULT_BLOCK_SIZE): + def __init__( + self, + sample_rate=DEFAULT_SAMPLE_RATE, + block_size=DEFAULT_BLOCK_SIZE, + noise_filter_mode="HP", + ): self.sample_rate = sample_rate self.block_size = block_size self.edges = EdgeDetector(block_size) @@ -898,16 +903,21 @@ def __init__(self, sample_rate=DEFAULT_SAMPLE_RATE, block_size=DEFAULT_BLOCK_SIZ self.sweep_time_sec = 0.030 self.click_level = 0.35 self.click_tone_hz = 3200.0 + self.tone_mix = 1.0 self.noise_mix = 0.0 self.noise_decay_sec = 0.05 self.shape = 1.0 self.accent = 0.5 + self.noise_filter_mode = noise_filter_mode + self.noise_cutoff_hz = 2000.0 self.click_filter = BiquadFilter(block_size, sample_rate, mode="HP", stages=1, q=1.0) self.click_filter.set(self.click_tone_hz) - self.noise_filter = BiquadFilter(block_size, sample_rate, mode="HP", stages=1, q=1.0) - self.noise_filter.set(2000.0) + self.noise_filter = BiquadFilter( + block_size, sample_rate, mode=self.noise_filter_mode, stages=1, q=1.0 + ) + self.noise_filter.set(self.noise_cutoff_hz) self.amp = 0.0 self.rise = 0.0 @@ -941,8 +951,10 @@ def set_block_size(self, block_size): self.ramp = Ramp(block_size) self.click_filter = BiquadFilter(block_size, self.sample_rate, mode="HP", stages=1, q=1.0) self.click_filter.set(self.click_tone_hz) - self.noise_filter = BiquadFilter(block_size, self.sample_rate, mode="HP", stages=1, q=1.0) - self.noise_filter.set(2000.0) + self.noise_filter = BiquadFilter( + block_size, self.sample_rate, mode=self.noise_filter_mode, stages=1, q=1.0 + ) + self.noise_filter.set(self.noise_cutoff_hz) self._sweep_buf = create_buffer(block_size) self._pitch_cv = create_buffer(block_size) @@ -962,6 +974,19 @@ def set_block_size(self, block_size): self._noise_env_buf = create_buffer(block_size) self._tmp = create_buffer(block_size) + def set_noise_filter(self, cutoff_hz, mode=None): + if mode is not None and mode != self.noise_filter_mode: + self.noise_filter_mode = mode + self.noise_filter = BiquadFilter( + self.block_size, self.sample_rate, mode=mode, stages=1, q=1.0 + ) + self.noise_cutoff_hz = cutoff_hz + self.noise_filter.set(cutoff_hz) + + def set_click_tone(self, tone_hz): + self.click_tone_hz = tone_hz + self.click_filter.set(tone_hz) + def reset(self): self.amp = 0.0 self.rise = 0.0 @@ -1035,6 +1060,8 @@ def render(self, gate, dst, velocity=1.0): else: self.rise = 0.0 mul(dst, self._amp_buf) + if self.tone_mix != 1.0: + scale(dst, self.tone_mix) if self.click_level > 0.0 and (self.click_env > 1e-4 or self.click_lpf > 1e-4): click_alpha = 1.0 - math.exp( diff --git a/lib/vrack/instruments/electronic_drums5.py b/lib/vrack/instruments/electronic_drums5.py new file mode 100644 index 0000000..192085f --- /dev/null +++ b/lib/vrack/instruments/electronic_drums5.py @@ -0,0 +1,289 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# SPDX-License-Identifier: MIT +"""ElectronicDrums5, a five-channel electronic drum kit with swept tones, inspired by the Simmons SDS-V. + + import vrack.adapter as adapter + + inst = adapter.create(48000, voice="electronic_drums5") + audio_out.play(inst.output) + inst.note_on(36, 100) # bass drum + inst.note_on(38, 100) # snare + +Five dedicated analog drum channels: bass drum, snare, and low, mid, and high toms. +Each channel mixes a swept tone (an oscillator diving in pitch after the strike), +filtered white noise, and a strike click, shaped under one amplitude envelope. +The pads are velocity-sensitive: harder hits sound louder and bend further in pitch, +scaled by the kit's pad sensitivity control. + +The sounds play from General MIDI drum keys on pads 35 through 50: +- Voice 0: Bass Drum (keys 35, 36) +- Voice 1: Snare Drum (keys 37, 38, 39, 40) +- Voice 2: Tom 1 / High Tom (keys 48, 50) +- Voice 3: Tom 2 / Mid Tom (keys 45, 47) +- Voice 4: Tom 3 / Low Tom (keys 41, 43) + +No voice choking is used; each voice rings freely over repeated strikes. +Master volume and soft ceiling saturation protect the shared mono output stage. +""" + +from ..adapter import VoicePatch + +VOICE_NAMES = ("bd", "sd", "t1", "t2", "t3") + +#: All five voice channels and their panel controls on a 0..10 scale, plus sensitivity and volume. +SETTINGS = { + # Bass Drum + "bd_tone": 7.0, + "bd_noise": 2.0, + "bd_pitch": 5.0, + "bd_bend": 5.0, + "bd_decay": 5.0, + "bd_filter": 5.0, + "bd_click": 7.0, + # Snare + "sd_tone": 6.0, + "sd_noise": 7.5, + "sd_pitch": 5.0, + "sd_bend": 4.0, + "sd_decay": 5.0, + "sd_filter": 7.0, + "sd_click": 5.0, + # Tom 1 (High) + "t1_tone": 7.0, + "t1_noise": 2.0, + "t1_pitch": 5.0, + "t1_bend": 5.0, + "t1_decay": 5.0, + "t1_filter": 5.0, + "t1_click": 5.0, + # Tom 2 (Mid) + "t2_tone": 7.0, + "t2_noise": 2.0, + "t2_pitch": 5.0, + "t2_bend": 5.0, + "t2_decay": 5.0, + "t2_filter": 5.0, + "t2_click": 5.0, + # Tom 3 (Low) + "t3_tone": 7.0, + "t3_noise": 2.0, + "t3_pitch": 5.0, + "t3_bend": 5.0, + "t3_decay": 5.0, + "t3_filter": 5.0, + "t3_click": 5.0, + # Global controls + "sensitivity": 5.0, + "volume": 7.0, +} + +#: The five factory patches: (name, difference from SETTINGS). +PRESETS = ( + ("Classic", {}), + ( + "Deep Toms", + { + "bd_pitch": 4.0, + "bd_decay": 6.5, + "t1_pitch": 3.0, + "t1_bend": 7.5, + "t1_decay": 7.5, + "t2_pitch": 3.0, + "t2_bend": 7.5, + "t2_decay": 7.5, + "t3_pitch": 3.0, + "t3_bend": 7.5, + "t3_decay": 7.5, + }, + ), + ( + "Electro", + { + "bd_click": 9.0, + "bd_decay": 3.0, + "sd_click": 8.0, + "sd_noise": 9.0, + "sd_filter": 8.5, + "sd_decay": 2.5, + "t1_click": 8.0, + "t1_decay": 3.0, + "t2_click": 8.0, + "t2_decay": 3.0, + "t3_click": 8.0, + "t3_decay": 3.0, + }, + ), + ( + "Syndrum", + { + "t1_tone": 9.0, + "t1_noise": 0.2, + "t1_bend": 9.5, + "t1_decay": 6.5, + "t2_tone": 9.0, + "t2_noise": 0.2, + "t2_bend": 9.5, + "t2_decay": 6.5, + "t3_tone": 9.0, + "t3_noise": 0.2, + "t3_bend": 9.5, + "t3_decay": 6.5, + }, + ), + ( + "Gated", + { + "bd_decay": 3.0, + "sd_noise": 8.5, + "sd_filter": 9.0, + "sd_decay": 2.0, + "t1_decay": 2.2, + "t2_decay": 2.2, + "t3_decay": 2.2, + }, + ), +) + +#: The eight macros, in panel order. +MACROS = ( + "t1_bend", + "t1_decay", + "sd_noise", + "sd_filter", + "bd_click", + "bd_decay", + "sensitivity", + "volume", +) + +#: Pad mapping: (MIDI key, voice index). +PADS = ( + (35, 0), + (36, 0), # BD + (37, 1), + (38, 1), + (39, 1), + (40, 1), # SD + (48, 2), + (50, 2), # T1 + (45, 3), + (47, 3), # T2 + (41, 4), + (43, 4), # T3 +) + +#: No voice choking; each channel rings freely. +CHOKE = () + + +def settings(index): + """Every control of patch ``index``: SETTINGS with the patch's own on top.""" + values = dict(SETTINGS) + values.update(PRESETS[index][1]) + return values + + +_TOMS = ("t1", "t2", "t3") + + +def _macro_value(name, values): + """A control's value as a macro, 0..127.""" + return int(round(values[name] * 12.7)) + + +class ElectronicDrums5(VoicePatch): + """The ElectronicDrums5 electronic drum kit as a voice patch for ``vrack.adapter.create()``.""" + + NAME = "electronic_drums5" + VOICES = 5 + LATENCY_SAMPLES = 0 + PADS = PADS + CHOKE = CHOKE + MACRO_LABELS = MACROS + MACRO_MODES = {i: "UNIPOLAR" for i in range(len(MACROS))} + PATCHES = { + i: (PRESETS[i][0], tuple(_macro_value(m, settings(i)) for m in MACROS)) + for i in range(len(PRESETS)) + } + + def __init__(self): + self.output = None + self.midi = None + # Each tom's bend and decay as the patch (or a control) last set them. + # The shared tom macros move all three by the same amount from here, + # so a patch's per-tom offsets survive turning them. + self._toms = {"bend": [5.0, 5.0, 5.0], "decay": [5.0, 5.0, 5.0]} + + def build_global(self, rack, midi): + self.midi = midi + + def build(self, voice): + idx = voice.index + if not 0 <= idx < 5: + raise ValueError("voice index %d out of range (VOICES = 5)" % idx) + + from ..modules.electronic_drums5.voice import ElectronicDrums5Voice + + which = VOICE_NAMES[idx] + m = voice.add(ElectronicDrums5Voice, which) + m.set_which(which) + + voice.connect(voice.gate, m.gate_in) + voice.connect(voice.velocity, m.vel_in) + voice.connect(voice.pitch, m.pitch_in) + return m.audio_out + + def build_shared(self, rack, left, right): + from ..modules.rhythm808.output import Rhythm808Output + + out = rack.add_module( + Rhythm808Output("output", sample_rate=rack.sample_rate, block_size=rack.block_size) + ) + self.output = out + rack.connect(left, out.left_in) + rack.connect(right, out.right_in) + return out.left_out, out.right_out + + # -- controls ------------------------------------------------------------- + + def set_control(self, pool, name, value): + """Set one control by its SETTINGS name.""" + if name == "volume": + if self.output is not None: + self.output.set_param("volume", float(value)) + return + + if name == "sensitivity": + val = float(value) + for mod_name in VOICE_NAMES: + pool.set_param(mod_name, "sensitivity", val) + return + + # Individual channel controls: _ + parts = name.split("_", 1) + if len(parts) == 2: + mod_prefix, param = parts + if mod_prefix in VOICE_NAMES: + pool.set_param(mod_prefix, param, float(value)) + if param in self._toms and mod_prefix in _TOMS: + self._toms[param][_TOMS.index(mod_prefix)] = float(value) + + def apply_patch(self, pool, index): + if not 0 <= index < len(PRESETS): + return + for name, value in settings(index).items(): + self.set_control(pool, name, value) + + def set_macro(self, pool, index, norm): + name = MACROS[index] + val = norm * 10.0 + if name in ("t1_bend", "t1_decay"): + # Shared tom macro: tom 1 goes to the knob's value and the other + # two move by the same amount, keeping their offsets from tom 1. + param = name[3:] + base = self._toms[param] + delta = val - base[0] + for i, tom in enumerate(_TOMS): + pool.set_param(tom, param, min(10.0, max(0.0, base[i] + delta))) + else: + self.set_control(pool, name, val) diff --git a/lib/vrack/modules/__init__.py b/lib/vrack/modules/__init__.py index 2cb9b3c..2bd3df7 100644 --- a/lib/vrack/modules/__init__.py +++ b/lib/vrack/modules/__init__.py @@ -30,6 +30,7 @@ ("ElectricGrand70Voice", "ElectricGrand70Voice", "electric_grand70.voice"), ("ElectricPianoOutput", "ElectricPianoOutput", "electric_piano.output"), ("ElectricPianoVoice", "ElectricPianoVoice", "electric_piano.voice"), + ("ElectronicDrums5Voice", "ElectronicDrums5Voice", "electronic_drums5.voice"), ("FunctionGenerator", "FunctionGenerator", "function_generator"), ("GranularProcessor", "GranularProcessor", "granular_processor"), ("HybridMono", "HybridMono", "hybrid_mono"), diff --git a/lib/vrack/modules/electronic_drums5/__init__.py b/lib/vrack/modules/electronic_drums5/__init__.py new file mode 100644 index 0000000..7d889d1 --- /dev/null +++ b/lib/vrack/modules/electronic_drums5/__init__.py @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# SPDX-License-Identifier: MIT +"""Voice module for ElectronicDrums5, inspired by the Simmons SDS-V.""" + +from .voice import ElectronicDrums5Voice + +__all__ = ["ElectronicDrums5Voice"] diff --git a/lib/vrack/modules/electronic_drums5/voice.py b/lib/vrack/modules/electronic_drums5/voice.py new file mode 100644 index 0000000..c51e130 --- /dev/null +++ b/lib/vrack/modules/electronic_drums5/voice.py @@ -0,0 +1,174 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# SPDX-License-Identifier: MIT +"""Channel voice module for ElectronicDrums5, inspired by the Simmons SDS-V.""" + +from ...core.jack import SignalType +from ...core.module import Module +from ...core.signal import DEFAULT_BLOCK_SIZE, DEFAULT_SAMPLE_RATE, scale +from ...dsp.drums import SweptOscillatorDrumCore + +CHANNEL_SPECS = { + "bd": { + "pitch_range": (42.0, 78.0), + "bend_max_cents": 2800.0, + "bend_time_sec": 0.035, + "decay_range": (0.12, 0.75), + "noise_scale": 0.35, + "filter_range": (400.0, 3600.0), + "click_scale": 0.85, + "click_tone_hz": 2800.0, + "gain": 3.2, + }, + "sd": { + "pitch_range": (150.0, 270.0), + "bend_max_cents": 1200.0, + "bend_time_sec": 0.025, + "decay_range": (0.08, 0.45), + "noise_scale": 1.25, + "filter_range": (1500.0, 9500.0), + "click_scale": 0.65, + "click_tone_hz": 3400.0, + "gain": 2.8, + }, + "t1": { + "pitch_range": (160.0, 280.0), + "bend_max_cents": 3200.0, + "bend_time_sec": 0.20, + "decay_range": (0.15, 1.05), + "noise_scale": 0.35, + "filter_range": (1000.0, 6000.0), + "click_scale": 0.55, + "click_tone_hz": 3000.0, + "gain": 2.8, + }, + "t2": { + "pitch_range": (115.0, 205.0), + "bend_max_cents": 3200.0, + "bend_time_sec": 0.22, + "decay_range": (0.15, 1.10), + "noise_scale": 0.35, + "filter_range": (800.0, 5200.0), + "click_scale": 0.55, + "click_tone_hz": 2800.0, + "gain": 2.8, + }, + "t3": { + "pitch_range": (78.0, 142.0), + "bend_max_cents": 3200.0, + "bend_time_sec": 0.24, + "decay_range": (0.15, 1.15), + "noise_scale": 0.35, + "filter_range": (600.0, 4500.0), + "click_scale": 0.55, + "click_tone_hz": 2500.0, + "gain": 2.8, + }, +} + + +class ElectronicDrums5Voice(Module): + """An analog electronic drum channel combining swept tone, filtered noise, and click. + + Inspired by the channel modules of the Simmons SDS-V drum synthesizer. + """ + + NAME = "ElectronicDrums5Voice" + DISPLAY_NAME = "ElectronicDrums5 Voice" + HP = 6 + + def __init__( + self, name, which="bd", sample_rate=DEFAULT_SAMPLE_RATE, block_size=DEFAULT_BLOCK_SIZE + ): + self.which = which + self.spec = CHANNEL_SPECS.get(which, CHANNEL_SPECS["bd"]) + super().__init__(name, sample_rate=sample_rate, block_size=block_size) + + def set_which(self, which): + self.which = which + self.spec = CHANNEL_SPECS.get(which, CHANNEL_SPECS["bd"]) + self.core.set_click_tone(self.spec["click_tone_hz"]) + self.core.sweep_time_sec = self.spec["bend_time_sec"] + + def _build(self): + self.gate_in = self.add_input("gate_in", "Gate In", SignalType.GATE) + self.vel_in = self.add_input("vel_in", "Velocity In", SignalType.CV) + self.pitch_in = self.add_input("pitch_in", "Pitch In", SignalType.CV) + self.audio_out = self.add_output("audio_out", "Audio Out", SignalType.AUDIO) + + sr, bs = self.sample_rate, self.block_size + self.core = SweptOscillatorDrumCore(sr, bs, noise_filter_mode="LP") + self.core.set_click_tone(self.spec["click_tone_hz"]) + self.core.sweep_time_sec = self.spec["bend_time_sec"] + + self.add_param("tone", default=7.0, min_val=0.0, max_val=10.0) + self.add_param("noise", default=2.0, min_val=0.0, max_val=10.0) + self.add_param("pitch", default=5.0, min_val=0.0, max_val=10.0) + self.add_param("bend", default=5.0, min_val=0.0, max_val=10.0) + self.add_param("decay", default=5.0, min_val=0.0, max_val=10.0) + self.add_param("filter", default=5.0, min_val=0.0, max_val=10.0) + self.add_param("click", default=5.0, min_val=0.0, max_val=10.0) + self.add_param("sensitivity", default=5.0, min_val=0.0, max_val=10.0) + + self.tone = 7.0 + self.noise = 2.0 + self.pitch = 5.0 + self.bend = 5.0 + self.decay = 5.0 + self.filter = 5.0 + self.click = 5.0 + self.sensitivity = 5.0 + + def on_param(self, name, value): + setattr(self, name, value) + + def set_block_size(self, block_size): + super().set_block_size(block_size) + self.core.set_block_size(block_size) + + def reset(self): + super().reset() + self.core.reset() + + def process(self, block_size): + gate = self.gate_in.get_buffer() + vel = self.vel_in.get_buffer() + v = float(vel[0]) if vel is not None else 100.0 / 127.0 + v = max(0.0, min(1.0, v)) + + s = self.sensitivity / 10.0 + # Velocity level response: at sensitivity 10, v=40/127 sits about 18 dB below 127 + vel_gain = (1.0 - s) + s * (v**1.8) + # Velocity bend response: at sensitivity 10, v=40/127 bends about half as far + bend_factor = (1.0 - s * 0.73) + (s * 0.73) * v + + spec = self.spec + p_lo, p_hi = spec["pitch_range"] + p_norm = max(0.0, min(1.0, self.pitch / 10.0)) + self.core.frequency = p_lo + p_norm * (p_hi - p_lo) + + b_norm = max(0.0, min(1.0, self.bend / 10.0)) + self.core.pitch_drop_cents = b_norm * spec["bend_max_cents"] * bend_factor + + d_lo, d_hi = spec["decay_range"] + d_norm = max(0.0, min(1.0, self.decay / 10.0)) + decay_sec = d_lo + d_norm * (d_hi - d_lo) + self.core.decay = decay_sec + # ``decay`` is the time the tone takes to fall 40 dB; the noise envelope is + # set by its time constant, so give it the same fall time (ln 100 = 4.605). + self.core.noise_decay_sec = decay_sec / 4.60517 + + self.core.tone_mix = max(0.0, min(1.0, self.tone / 10.0)) + self.core.noise_mix = max(0.0, min(1.0, self.noise / 10.0)) * spec["noise_scale"] + + f_lo, f_hi = spec["filter_range"] + f_norm = max(0.0, min(1.0, self.filter / 10.0)) + cutoff = f_lo + f_norm * (f_hi - f_lo) + self.core.set_noise_filter(cutoff) + + c_norm = max(0.0, min(1.0, self.click / 10.0)) + self.core.click_level = c_norm * spec["click_scale"] + + out = self.audio_out.buffer + self.core.render(gate, out, velocity=vel_gain) + + scale(out, spec["gain"]) diff --git a/tests/mpy/run.py b/tests/mpy/run.py index 3cd6fc9..18238c6 100644 --- a/tests/mpy/run.py +++ b/tests/mpy/run.py @@ -71,6 +71,7 @@ "test_rhythm78", "test_rhythm808", "test_rhythm909", + "test_electronic_drums5", "test_face", ) diff --git a/tests/mpy/test_electronic_drums5.py b/tests/mpy/test_electronic_drums5.py new file mode 100644 index 0000000..92f5441 --- /dev/null +++ b/tests/mpy/test_electronic_drums5.py @@ -0,0 +1,198 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# SPDX-License-Identifier: MIT +"""ElectronicDrums5 on MicroPython: 5 voices, 5 presets, every pad sounds, macros, re-strike, flat allocation.""" + +import array +import gc + +import audiocore +import vrack.adapter as adapter +from vrack.core import signal + +SLACK = 64 +CASTS = 0 if getattr(signal, "_cast_into", None) is not None else 0 + +VOICE_PADS = ( + 36, # BD + 38, # SD + 48, # T1 + 45, # T2 + 41, # T3 +) + + +def pulled(inst, buffers): + out = array.array("h") + for _ in range(buffers): + inst.fill() + result, data = audiocore.get_buffer(inst.output) + assert result == 1 + out.extend(array.array("h", bytes(data))) + return out + + +def peak(x): + top = 0 + for v in x: + if abs(v) > top: + top = abs(v) + return top + + +def test_five_voices_zero_latency_and_five_patches(): + inst = adapter.create(48000, voice="electronic_drums5", engine="rack") + assert inst.voices == 5 and inst.latency_samples == 0 + assert len(inst.patches) == 5 + assert len(inst.macro_labels) == 8 + inst.deinit() + + +def test_every_pad_sounds(): + inst = adapter.create(48000, voice="electronic_drums5", engine="rack") + for pad in (35, 36, 37, 38, 39, 40, 48, 50, 45, 47, 41, 43): + inst.note_on(pad, 100) + x = pulled(inst, 15) + top = peak(x) + assert top > 300, "Pad %d is too quiet: %d" % (pad, top) + inst.all_notes_off() + pulled(inst, 20) + inst.deinit() + + +def test_every_patch_plays(): + inst = adapter.create(48000, voice="electronic_drums5", engine="rack") + for patch in range(5): + inst.program_change(patch) + inst.note_on(36, 110) + inst.note_on(38, 100) + x = pulled(inst, 25) + top = peak(x) + assert 300 < top < 32000, "Patch %d peak: %d" % (patch, top) + inst.all_notes_off() + pulled(inst, 30) + inst.deinit() + + +def test_restrike_re_pings_without_click(): + inst = adapter.create(48000, voice="electronic_drums5", engine="rack") + inst.note_on(48, 100) + x1 = pulled(inst, 8) + inst.note_on(48, 100) + x2 = pulled(inst, 8) + inst.deinit() + top1 = peak(x1) + top2 = peak(x2) + assert top1 > 500 and top2 > 500 + biggest = 0 + for i in range(2, len(x1), 2): + biggest = max(biggest, abs(x1[i] - x1[i - 2])) + for i in range(2, len(x2), 2): + biggest = max(biggest, abs(x2[i] - x2[i - 2])) + assert biggest < 0.35 * max(top1, top2), "restrike jumped: %d of %d" % (biggest, top2) + + +def test_shared_tom_macros(): + inst = adapter.create(48000, voice="electronic_drums5", engine="rack") + inst.set_macro(0, 127) # t1_bend macro to max + for mod_name in ("t1", "t2", "t3"): + mod = next( + m + for m in inst.rack.modules + if getattr(m, "id", "").endswith(mod_name) and hasattr(m, "bend") + ) + assert mod.bend == 10.0 + inst.deinit() + + +def test_allocation_flat_over_strike_of_every_pad(): + inst = adapter.create(48000, voice="electronic_drums5", engine="rack") + for pad in VOICE_PADS: + inst.note_on(pad, 100) + for _ in range(4): + inst.fill() + gc.collect() + before = gc.mem_alloc() + inst.fill() + allocated = gc.mem_alloc() - before + assert allocated <= SLACK, "pad %d allocated %d B during fill (budget %d)" % ( + pad, + allocated, + SLACK, + ) + inst.deinit() + + +KEYS = {"bd": 36, "sd": 38, "t1": 48, "t2": 45, "t3": 41} + + +def strike(channel, buffers=6, velocity=100, **controls): + inst = adapter.create(48000, voice="electronic_drums5", engine="rack") + for name, value in controls.items(): + inst.patch.set_control(inst.voice_pool, name, float(value)) + inst.note_on(KEYS[channel], velocity) + x = pulled(inst, buffers) + inst.deinit() + return x + + +def energy(x, start=0): + total = 0.0 + for i in range(start, len(x), 2): + total += x[i] * x[i] + return total + + +def test_tone_noise_and_click_make_the_sound_and_zero_is_silent(): + for ch in KEYS: + silent = {ch + "_tone": 0, ch + "_noise": 0, ch + "_click": 0} + assert peak(strike(ch, 3, **silent)) == 0, ch + for part in ("tone", "noise", "click"): + only = dict(silent) + only[ch + "_" + part] = 10 + assert peak(strike(ch, 3, **only)) > 100, "%s_%s" % (ch, part) + + +def test_decay_lengthens_the_fall(): + for ch in KEYS: + short = energy(strike(ch, 12, **{ch + "_decay": 1}), 6000) + long = energy(strike(ch, 12, **{ch + "_decay": 9}), 6000) + assert long > 4 * short, ch + + +def test_velocity_and_sensitivity_scale_the_level(): + soft = peak(strike("t2", 3, velocity=40, sensitivity=10)) + hard = peak(strike("t2", 3, velocity=127, sensitivity=10)) + assert hard > 4 * soft + flat_soft = peak(strike("t2", 3, velocity=40, sensitivity=0)) + flat_hard = peak(strike("t2", 3, velocity=127, sensitivity=0)) + assert abs(flat_hard - flat_soft) < 0.1 * flat_hard + + +def test_volume_zero_is_silent(): + assert peak(strike("bd", 3, volume=0)) == 0 + assert peak(strike("bd", 3, volume=10)) > peak(strike("bd", 3, volume=3)) + + +def _toms(mods, param): + return [round(getattr(mods[t], param), 3) for t in ("t1", "t2", "t3")] + + +def test_tom_macros_keep_each_toms_offset(): + for param in ("bend", "decay"): + inst = adapter.create(48000, voice="electronic_drums5", engine="rack") + for tom, value in (("t1", 5.0), ("t2", 3.0), ("t3", 8.0)): + inst.patch.set_control(inst.voice_pool, tom + "_" + param, value) + mods = {} + for m in inst.rack.modules: + mods[getattr(m, "which", None)] = m + + index = inst.macro_labels.index("t1_" + param) + inst.set_macro(index, 76) + assert _toms(mods, param) == [5.984, 3.984, 8.984], _toms(mods, param) + inst.set_macro(index, 127) + assert _toms(mods, param) == [10.0, 8.0, 10.0], _toms(mods, param) + inst.set_macro(index, 0) + assert _toms(mods, param) == [0.0, 0.0, 3.0], _toms(mods, param) + inst.set_macro(index, 76) + assert _toms(mods, param) == [5.984, 3.984, 8.984], _toms(mods, param) + inst.deinit() diff --git a/tests/test_allocation.py b/tests/test_allocation.py index e94c29e..5783dcc 100644 --- a/tests/test_allocation.py +++ b/tests/test_allocation.py @@ -72,6 +72,7 @@ def traced_buffers(): vrack.ElectricGrand70Output, vrack.ElectricPianoVoice, vrack.ElectricPianoOutput, + vrack.ElectronicDrums5Voice, vrack.ClavichordVoice, vrack.ClavichordOutput, vrack.ReedPianoVoice, diff --git a/tests/test_dsp_drums.py b/tests/test_dsp_drums.py index 005f209..7da2223 100644 --- a/tests/test_dsp_drums.py +++ b/tests/test_dsp_drums.py @@ -475,3 +475,67 @@ def test_guiro_core_short_and_long_modes(): span = int(0.03 * SAMPLE_RATE) assert np.max(np.abs(audio_long[mid_idx : mid_idx + span])) > 0.05 assert np.max(np.abs(audio_short[mid_idx : mid_idx + span])) < 0.005 + + +def test_swept_oscillator_drum_tone_mix_and_noise_filter(): + # Setting tone_mix to 0 mutes the tone completely + drum = SweptOscillatorDrumCore(SAMPLE_RATE, BLOCK) + drum.tone_mix = 0.0 + drum.click_level = 0.0 + drum.noise_mix = 0.0 + audio = render_blocks(drum, 20, strike_at=0) + assert np.max(np.abs(audio)) == 0.0 + + # Low-pass noise filter mode + drum_lp = SweptOscillatorDrumCore(SAMPLE_RATE, BLOCK, noise_filter_mode="LP") + drum_lp.tone_mix = 0.0 + drum_lp.click_level = 0.0 + drum_lp.noise_mix = 0.5 + drum_lp.set_noise_filter(1000.0, mode="LP") + audio_lp = render_blocks(drum_lp, 40, strike_at=0) + fft_lp = np.abs(np.fft.rfft(audio_lp)) + freqs = np.fft.rfftfreq(len(audio_lp), 1.0 / SAMPLE_RATE) + hi_lp = np.sum(fft_lp[freqs > 5000]) + + drum_bright = SweptOscillatorDrumCore(SAMPLE_RATE, BLOCK, noise_filter_mode="LP") + drum_bright.tone_mix = 0.0 + drum_bright.click_level = 0.0 + drum_bright.noise_mix = 0.5 + drum_bright.set_noise_filter(8000.0, mode="LP") + audio_bright = render_blocks(drum_bright, 40, strike_at=0) + fft_bright = np.abs(np.fft.rfft(audio_bright)) + hi_bright = np.sum(fft_bright[freqs > 5000]) + + assert hi_bright > hi_lp * 3.0 + + +def test_swept_oscillator_drum_defaults_keep_the_earlier_machines_sound(): + # The tone level and the noise filter are additions: a core that leaves them + # alone must render exactly what it did before they existed (tone at unity, + # noise through the 2 kHz high-pass). + drum = SweptOscillatorDrumCore(SAMPLE_RATE, BLOCK) + assert drum.tone_mix == 1.0 + assert drum.noise_filter_mode == "HP" + assert drum.noise_cutoff_hz == 2000.0 + + def run(core): + core.noise_mix = 0.3 + core.pitch_drop_cents = 1200.0 + return render_blocks(core, 30, strike_at=0) + + plain = run(SweptOscillatorDrumCore(SAMPLE_RATE, BLOCK)) + spelled = SweptOscillatorDrumCore(SAMPLE_RATE, BLOCK, noise_filter_mode="HP") + spelled.tone_mix = 1.0 + spelled.set_noise_filter(2000.0) + assert np.array_equal(plain, run(spelled)) + + # And the tone level scales the tone alone, exactly. + quiet = SweptOscillatorDrumCore(SAMPLE_RATE, BLOCK) + quiet.tone_mix = 0.5 + loud = SweptOscillatorDrumCore(SAMPLE_RATE, BLOCK) + for core in (quiet, loud): + core.click_level = 0.0 + core.noise_mix = 0.0 + assert np.allclose( + render_blocks(quiet, 20, strike_at=0) * 2.0, render_blocks(loud, 20, strike_at=0) + ) diff --git a/tests/test_synth_electronic_drums5.py b/tests/test_synth_electronic_drums5.py new file mode 100644 index 0000000..fa29f79 --- /dev/null +++ b/tests/test_synth_electronic_drums5.py @@ -0,0 +1,411 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# SPDX-License-Identifier: MIT +"""ElectronicDrums5: every pad sounds, presets load, controls move circuits, +velocity scales level and bend, and re-strike re-pings cleanly. +""" + +import audiocore +import numpy as np +import pytest +import vrack.adapter as adapter +from vrack.instruments import electronic_drums5 + + +def make(preset=0, **options): + inst = adapter.create(48000, voice="electronic_drums5", output="pull", engine="rack", **options) + inst.program_change(preset) + return inst + + +def pull(inst, buffers=20): + chunks = [] + for _ in range(buffers): + _, data = audiocore.get_buffer(inst.output) + chunks.append(np.frombuffer(bytes(data), dtype=np.int16)) + return np.concatenate(chunks).reshape(-1, 2).astype(np.float64) + + +def play(inst, note, buffers=20, velocity=100): + inst.note_on(note, velocity) + return pull(inst, buffers) + + +def rms(x): + return float(np.sqrt(np.mean(x * x))) + + +def peak(x): + return float(np.max(np.abs(x))) + + +def centroid(x, sr=48000): + mono = x[:, 0] if x.ndim > 1 else x + mag = np.abs(np.fft.rfft(mono * np.hanning(len(mono)))) + freqs = np.fft.rfftfreq(len(mono), 1.0 / sr) + s = np.sum(mag) + return float(np.sum(freqs * mag) / s) if s > 0 else 0.0 + + +def test_registration_and_properties(): + assert "electronic_drums5" in adapter.ALL + inst = make() + assert inst.voices == 5 + assert inst.latency_samples == 0 + assert len(inst.patches) == len(electronic_drums5.PRESETS) + assert len(inst.macro_labels) == len(electronic_drums5.MACROS) + inst.deinit() + + +def test_pad_map_matches_the_published_table(): + table = { + 0: (35, 36), + 1: (37, 38, 39, 40), + 2: (48, 50), + 3: (45, 47), + 4: (41, 43), + } + got = {} + for key, voice in electronic_drums5.PADS: + got.setdefault(voice, []).append(key) + assert {v: tuple(sorted(k)) for v, k in got.items()} == table + assert electronic_drums5.CHOKE == () + + +ALL_DRUM_PADS = ( + 35, # BD alt + 36, # BD + 37, # SD alt (side stick) + 38, # SD + 39, # SD alt (clap) + 40, # SD alt + 41, # Low Tom + 43, # Low Tom alt + 45, # Mid Tom + 47, # Mid Tom alt + 48, # High Tom + 50, # High Tom alt +) + + +@pytest.mark.parametrize("note", ALL_DRUM_PADS) +def test_every_pad_sounds_its_voice(note): + inst = make() + x = play(inst, note, buffers=20, velocity=100) + assert np.all(np.isfinite(x)) + assert peak(x) > 500, "Pad %d is too quiet" % note + assert rms(x) > 20, "Pad %d energy too low" % note + inst.deinit() + + +@pytest.mark.parametrize("preset", range(len(electronic_drums5.PRESETS))) +def test_presets_load_and_play(preset): + inst = make(preset) + assert inst.patch_index == preset + # Play kick + snare + tom 1 + x = play(inst, 36, buffers=15, velocity=100) + assert np.all(np.isfinite(x)) + assert peak(x) > 500 + y = play(inst, 38, buffers=15, velocity=100) + assert np.all(np.isfinite(y)) + assert peak(y) > 500 + z = play(inst, 48, buffers=15, velocity=100) + assert np.all(np.isfinite(z)) + assert peak(z) > 500 + inst.deinit() + + +SR = 48000 +KEYS = {"bd": 36, "sd": 38, "t1": 48, "t2": 45, "t3": 41} +CHANNELS = tuple(KEYS) + + +def ctl(inst, **controls): + for name, value in controls.items(): + inst.patch.set_control(inst.voice_pool, name, float(value)) + + +def strike(controls, channel, velocity=100, seconds=1.0, preset=0): + """One strike of ``channel`` with ``controls`` set on top of the preset; left channel.""" + inst = make(preset) + ctl(inst, **controls) + inst.note_on(KEYS[channel], velocity) + frames = 0 + chunks = [] + while frames < seconds * SR: + chunks.append(pull(inst, 1)[:, 0]) + frames += len(chunks[-1]) + inst.deinit() + return np.concatenate(chunks) + + +def peak_hz(x): + mag = np.abs(np.fft.rfft(x * np.hanning(len(x)), 1 << 17)) + return float(np.fft.rfftfreq(1 << 17, 1.0 / SR)[np.argmax(mag)]) + + +def fall_ms(x, db=40.0): + """Milliseconds from the loudest 5 ms to the first 5 ms window ``db`` below it.""" + w = 240 + env = np.sqrt(np.convolve(x * x, np.ones(w) / w, "same")) + top = int(np.argmax(env)) + below = np.where(env[top:] < env[top] * 10.0 ** (-db / 20.0))[0] + assert len(below), "never fell %g dB" % db + return below[0] * 1000.0 / SR + + +def only(channel, *keep): + """Controls that silence every source of ``channel`` except those named in ``keep``.""" + return {channel + "_" + c: (10.0 if c in keep else 0.0) for c in ("tone", "noise", "click")} + + +@pytest.mark.parametrize("channel", CHANNELS) +def test_tone_and_noise_and_click_levels_scale(channel): + for source in ("tone", "noise", "click"): + quiet = only(channel, source) + lo = rms(strike({**quiet, channel + "_" + source: 2.0}, channel, seconds=0.2)) + hi = rms(strike({**quiet, channel + "_" + source: 8.0}, channel, seconds=0.2)) + assert hi > 2.5 * lo, "%s_%s does not scale its level" % (channel, source) + off = strike({**quiet, channel + "_" + source: 0.0}, channel, seconds=0.2) + assert peak(off) < 1.0, "%s_%s at 0 still sounds" % (channel, source) + + +@pytest.mark.parametrize("channel", CHANNELS) +def test_pitch_moves_the_resting_tone(channel): + base = {**only(channel, "tone"), channel + "_bend": 0.0} + freqs = [ + peak_hz(strike({**base, channel + "_pitch": p}, channel, seconds=0.3)[2400:12000]) + for p in (0.0, 5.0, 10.0) + ] + assert freqs[0] + 10 < freqs[1] < freqs[2] - 10, freqs + + +@pytest.mark.parametrize("channel", CHANNELS) +def test_bend_sets_how_far_the_pitch_falls(channel): + base = only(channel, "tone") + drop = [] + for b in (0.0, 5.0, 10.0): + x = strike({**base, channel + "_bend": b}, channel, seconds=0.5) + drop.append(peak_hz(x[:2400]) / peak_hz(x[14400:24000])) + assert drop[0] < 1.1, "bend 0 still falls: %s" % drop + assert drop[0] + 0.1 < drop[1] < drop[2] - 0.05, drop + + +@pytest.mark.parametrize("channel", CHANNELS) +def test_decay_lengthens_the_fall(channel): + times = [ + fall_ms(strike({channel + "_decay": d}, channel, seconds=2.5)) for d in (1.0, 5.0, 9.0) + ] + assert times[0] * 1.5 < times[1] < times[2] / 1.3, times + + +@pytest.mark.parametrize("channel", CHANNELS) +def test_filter_moves_the_noise_brightness(channel): + base = only(channel, "noise") + dull = centroid(strike({**base, channel + "_filter": 1.0}, channel, seconds=0.2)) + bright = centroid(strike({**base, channel + "_filter": 9.0}, channel, seconds=0.2)) + assert bright > dull * 1.8, (dull, bright) + + +@pytest.mark.parametrize( + "channel,low,high", + (("bd", 300, 500), ("sd", 200, 300), ("t1", 400, 800), ("t2", 400, 800), ("t3", 400, 800)), +) +def test_default_decay_is_in_the_documented_range(channel, low, high): + ms = fall_ms(strike({}, channel, seconds=2.5)) + assert low <= ms <= high, "%s falls 40 dB in %.0f ms" % (channel, ms) + + +def test_snare_noise_does_not_outlast_its_tone(): + # The crack is noise; its tail must follow sd_decay as the tone does. + noise = fall_ms(strike(only("sd", "noise"), "sd", seconds=2.5)) + tone = fall_ms(strike(only("sd", "tone"), "sd", seconds=2.5)) + assert noise < 1.4 * tone, (noise, tone) + + +def test_toms_are_distinct_and_dive(): + rest = {} + for ch in ("t1", "t2", "t3"): + x = strike({**only(ch, "tone")}, ch, seconds=0.8) + rest[ch] = peak_hz(x[24000:36000]) + assert peak_hz(x[:2400]) > 1.5 * rest[ch], "%s does not dive" % ch + assert rest["t1"] > 1.2 * rest["t2"] > 1.2 * 1.2 * rest["t3"], rest + + +def test_velocity_scales_level_and_bend_as_documented(): + soft = strike({"sensitivity": 10.0, **only("t1", "tone")}, "t1", velocity=40, seconds=0.5) + loud = strike({"sensitivity": 10.0, **only("t1", "tone")}, "t1", velocity=127, seconds=0.5) + db = 20 * np.log10(peak(loud) / peak(soft)) + assert 16.0 < db < 20.0, db + + # bend: 40 falls about half as far (in octaves) as 127 + def octaves(x): + return np.log2(peak_hz(x[:1500]) / peak_hz(x[14000:22000])) + + ratio = octaves(soft) / octaves(loud) + assert 0.35 < ratio < 0.7, ratio + + +def test_sensitivity_trims_the_velocity_response(): + def level(sens, vel): + return peak(strike({"sensitivity": sens}, "t2", velocity=vel, seconds=0.3)) + + flat = level(0.0, 40) / level(0.0, 127) + mid = level(5.0, 40) / level(5.0, 127) + full = level(10.0, 40) / level(10.0, 127) + assert flat > 0.95 and full < 0.2 and full + 0.1 < mid < flat - 0.1, (flat, mid, full) + + +def test_volume_scales_the_output_and_zero_is_silent(): + quiet = peak(strike({"volume": 2.0}, "bd", seconds=0.3)) + loud = peak(strike({"volume": 8.0}, "bd", seconds=0.3)) + assert loud > 2.0 * quiet + assert peak(strike({"volume": 0.0}, "bd", seconds=0.3)) < 1.0 + + +@pytest.mark.parametrize("preset", range(len(electronic_drums5.PRESETS))) +def test_each_patch_loads_every_control(preset): + inst = make(preset) + want = electronic_drums5.settings(preset) + seen = {} + for m in inst.rack.modules: + which = getattr(m, "which", None) + if which in KEYS: + for p in ("tone", "noise", "pitch", "bend", "decay", "filter", "click"): + seen[which + "_" + p] = getattr(m, p) + assert abs(m.sensitivity - want["sensitivity"]) < 0.05 + inst.deinit() + for name, value in want.items(): + if name not in ("sensitivity", "volume"): + assert abs(seen[name] - value) < 0.05, name + + +def test_patches_sound_different(): + levels = { + tuple(round(rms(strike({}, ch, seconds=0.6, preset=i)) / 50) for ch in CHANNELS) + for i in range(len(electronic_drums5.PRESETS)) + } + assert len(levels) >= 4 + + +def test_macros_move_what_their_labels_say(): + labels = electronic_drums5.MACROS + + def render(macro, value, channel): + inst = make() + inst.set_macro(labels.index(macro), value) + inst.note_on(KEYS[channel], 100) + x = np.concatenate([pull(inst, 1)[:, 0] for _ in range(300)]) + inst.deinit() + return x + + # t1_bend: all three toms fall further + for ch in ("t1", "t2", "t3"): + lo = render("t1_bend", 0, ch) + hi = render("t1_bend", 127, ch) + assert peak_hz(hi[:1500]) > 1.5 * peak_hz(lo[:1500]), ch + # t1_decay: all three toms ring longer + for ch in ("t1", "t2", "t3"): + assert fall_ms(render("t1_decay", 120, ch)) > 1.5 * fall_ms(render("t1_decay", 30, ch)), ch + # sd_noise louder, sd_filter brighter, bd_click bigger, bd_decay longer + assert rms(render("sd_noise", 127, "sd")) > 1.2 * rms(render("sd_noise", 0, "sd")) + assert centroid(render("sd_filter", 127, "sd")) > 1.3 * centroid(render("sd_filter", 0, "sd")) + assert peak(render("bd_click", 127, "bd")[:480]) > 1.3 * peak(render("bd_click", 0, "bd")[:480]) + assert fall_ms(render("bd_decay", 127, "bd")) > 1.5 * fall_ms(render("bd_decay", 20, "bd")) + # sensitivity and volume + soft = peak(render("sensitivity", 127, "t2")[:4800]) + assert soft > 0 + assert peak(render("volume", 127, "bd")) > 1.5 * peak(render("volume", 30, "bd")) + + +def test_macro_leaves_other_channels_alone(): + inst = make() + inst.set_macro(electronic_drums5.MACROS.index("sd_noise"), 127) + mods = {getattr(m, "which", None): m for m in inst.rack.modules} + assert mods["sd"].noise == 10.0 + assert mods["bd"].noise == electronic_drums5.SETTINGS["bd_noise"] + assert mods["t1"].noise == electronic_drums5.SETTINGS["t1_noise"] + inst.deinit() + + +@pytest.mark.parametrize("param", ("bend", "decay")) +def test_tom_macros_keep_each_toms_offset(param): + inst = make() + ctl(inst, **{"t1_" + param: 5.0, "t2_" + param: 3.0, "t3_" + param: 8.0}) + index = electronic_drums5.MACROS.index("t1_" + param) + + def toms(): + mods = {getattr(m, "which", None): m for m in inst.rack.modules} + return [round(getattr(mods[t], param), 3) for t in ("t1", "t2", "t3")] + + inst.set_macro(index, 76) # 76 / 127 * 10 = 5.984: one step up from tom 1's 5 + assert toms() == [5.984, 3.984, 8.984] + inst.set_macro(index, 127) # tom 3 stops at 10 + assert toms() == [10.0, 8.0, 10.0] + inst.set_macro(index, 0) # tom 2 stops at 0 + assert toms() == [0.0, 0.0, 3.0] + inst.set_macro(index, 76) # turned back, every offset returns + assert toms() == [5.984, 3.984, 8.984] + inst.deinit() + + +def test_restrike_keeps_the_oscillator_phase(): + # With the click and noise off, a re-strike over a ringing tom must not jump: + # the oscillator keeps its phase and the new strike takes over the ringing sound. + inst = make() + ctl(inst, **only("t1", "tone")) + inst.note_on(48, 100) + first = pull(inst, 60)[:, 0] + inst.note_off(48) + inst.note_on(48, 100) + second = pull(inst, 8)[:, 0] + inst.deinit() + x = np.concatenate([first, second]) + step = np.max(np.abs(np.diff(x[len(first) - 64 :]))) + assert step < 0.15 * peak(x), step + + +def test_restrike_click_stays_a_click_not_a_jump(): + inst = make() + inst.note_on(36, 100) + x1 = pull(inst, 30) + inst.note_on(36, 100) + x2 = pull(inst, 8) + inst.deinit() + full = np.concatenate([x1, x2])[:, 0] + assert np.max(np.abs(np.diff(full))) < 0.7 * peak(full) + + +def test_five_accented_strikes_do_not_clip_and_leave_no_dc(): + for volume in (7.0, 10.0): + inst = make() + ctl(inst, volume=volume) + for key in KEYS.values(): + inst.note_on(key, 127) + x = np.concatenate([pull(inst, 1) for _ in range(600)]) + inst.deinit() + assert peak(x) < 32760, "clipped at volume %g" % volume + assert abs(np.mean(x[-9600:])) < 2.0 + assert abs(np.mean(x[:, 0] - x[:, 1])) < 1e-9 + + +@pytest.mark.parametrize("channel", CHANNELS) +def test_a_strike_leaves_no_dc(channel): + x = strike({}, channel, seconds=3.0) + assert abs(np.mean(x[-24000:])) < 1.0 + assert abs(np.mean(x)) < 0.02 * rms(x) + + +def test_a_drum_does_not_depend_on_how_long_the_key_is_held(): + held = strike({}, "t1", seconds=0.6) + inst = make() + inst.note_on(48, 100) + a = pull(inst, 2)[:, 0] + inst.note_off(48) + rest = [] + n = len(a) + while n < 0.6 * SR: + rest.append(pull(inst, 1)[:, 0]) + n += len(rest[-1]) + inst.deinit() + short = np.concatenate([a] + rest)[: len(held)] + assert np.max(np.abs(held[: len(short)] - short)) < 0.01 * peak(held) diff --git a/tests/traits/electronic_drums5.json b/tests/traits/electronic_drums5.json new file mode 100644 index 0000000..44af3ee --- /dev/null +++ b/tests/traits/electronic_drums5.json @@ -0,0 +1,745 @@ +{ + "instrument": "electronic_drums5", + "spec": { + "drums": true, + "notes": [ + 36, + 38, + 48, + 45, + 41 + ], + "patches": [ + 0, + 1 + ], + "ring": 10.0, + "tail": 0.3, + "velocities": [ + 40, + 100, + 127 + ], + "velocity": 100 + }, + "traits": { + "p0.d36.band_high_db": { + "kind": "band_db", + "tol": 2.0, + "value": -26.118 + }, + "p0.d36.band_low_db": { + "kind": "band_db", + "tol": 2.0, + "value": -0.093 + }, + "p0.d36.band_mid_db": { + "kind": "band_db", + "tol": 2.0, + "value": -17.277 + }, + "p0.d36.centroid_hz": { + "kind": "hz", + "tol": 142.774, + "value": 2855.485 + }, + "p0.d36.decay20_ms": { + "kind": "ms", + "tol": 30.9, + "value": 206.0 + }, + "p0.d36.decay40_ms": { + "kind": "ms", + "tol": 63.6, + "value": 424.0 + }, + "p0.d36.fundamental_hz": { + "kind": "pitch_hz", + "tol": 1.921, + "value": 64.036 + }, + "p0.d36.length_ms": { + "kind": "ms_long", + "tol": 164.0, + "value": 656.0 + }, + "p0.d36.noise_tone_db": { + "kind": "ratio_db", + "tol": 3.0, + "value": -7.103 + }, + "p0.d36.pitch_drop_cents": { + "kind": "drop_cents", + "tol": 130.149, + "value": 650.745 + }, + "p0.d36.v100.centroid_hz": { + "kind": "hz", + "tol": 142.774, + "value": 2855.485 + }, + "p0.d36.v100.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -5.104 + }, + "p0.d36.v127.centroid_hz": { + "kind": "hz", + "tol": 137.667, + "value": 2753.343 + }, + "p0.d36.v127.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -4.352 + }, + "p0.d36.v40.centroid_hz": { + "kind": "hz", + "tol": 151.363, + "value": 3027.263 + }, + "p0.d36.v40.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -8.394 + }, + "p0.d38.band_high_db": { + "kind": "band_db", + "tol": 2.0, + "value": -4.904 + }, + "p0.d38.band_low_db": { + "kind": "band_db", + "tol": 2.0, + "value": -12.481 + }, + "p0.d38.band_mid_db": { + "kind": "band_db", + "tol": 2.0, + "value": -2.074 + }, + "p0.d38.centroid_hz": { + "kind": "hz", + "tol": 258.051, + "value": 5161.016 + }, + "p0.d38.decay20_ms": { + "kind": "ms", + "tol": 21.0, + "value": 140.0 + }, + "p0.d38.decay40_ms": { + "kind": "ms", + "tol": 39.6, + "value": 264.0 + }, + "p0.d38.fundamental_hz": { + "kind": "pitch_hz", + "tol": 6.844, + "value": 228.127 + }, + "p0.d38.length_ms": { + "kind": "ms_long", + "tol": 100.5, + "value": 402.0 + }, + "p0.d38.noise_tone_db": { + "kind": "ratio_db", + "tol": 3.0, + "value": 0.179 + }, + "p0.d38.pitch_drop_cents": { + "kind": "drop_cents", + "tol": 50.0, + "value": 93.862 + }, + "p0.d38.v100.centroid_hz": { + "kind": "hz", + "tol": 258.051, + "value": 5161.016 + }, + "p0.d38.v100.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -5.783 + }, + "p0.d38.v127.centroid_hz": { + "kind": "hz", + "tol": 260.488, + "value": 5209.756 + }, + "p0.d38.v127.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -4.883 + }, + "p0.d38.v40.centroid_hz": { + "kind": "hz", + "tol": 265.529, + "value": 5310.574 + }, + "p0.d38.v40.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -9.35 + }, + "p0.d41.band_high_db": { + "kind": "band_db", + "tol": 2.0, + "value": -29.875 + }, + "p0.d41.band_low_db": { + "kind": "band_db", + "tol": 2.0, + "value": -6.386 + }, + "p0.d41.band_mid_db": { + "kind": "band_db", + "tol": 2.0, + "value": -1.14 + }, + "p0.d41.centroid_hz": { + "kind": "hz", + "tol": 80.033, + "value": 1600.654 + }, + "p0.d41.decay20_ms": { + "kind": "ms", + "tol": 48.9, + "value": 326.0 + }, + "p0.d41.decay40_ms": { + "kind": "ms", + "tol": 97.2, + "value": 648.0 + }, + "p0.d41.fundamental_hz": { + "kind": "pitch_hz", + "tol": 7.033, + "value": 234.441 + }, + "p0.d41.length_ms": { + "kind": "ms_long", + "tol": 245.0, + "value": 980.0 + }, + "p0.d41.noise_tone_db": { + "kind": "ratio_db", + "tol": 3.0, + "value": -12.551 + }, + "p0.d41.pitch_drop_cents": { + "kind": "drop_cents", + "tol": 50.0, + "value": 202.033 + }, + "p0.d41.v100.centroid_hz": { + "kind": "hz", + "tol": 80.033, + "value": 1600.654 + }, + "p0.d41.v100.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -10.386 + }, + "p0.d41.v127.centroid_hz": { + "kind": "hz", + "tol": 86.559, + "value": 1731.172 + }, + "p0.d41.v127.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -8.359 + }, + "p0.d41.v40.centroid_hz": { + "kind": "hz", + "tol": 91.793, + "value": 1835.856 + }, + "p0.d41.v40.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -12.606 + }, + "p0.d45.band_high_db": { + "kind": "band_db", + "tol": 2.0, + "value": -29.012 + }, + "p0.d45.band_low_db": { + "kind": "band_db", + "tol": 2.0, + "value": -17.218 + }, + "p0.d45.band_mid_db": { + "kind": "band_db", + "tol": 2.0, + "value": -0.089 + }, + "p0.d45.decay20_ms": { + "kind": "ms", + "tol": 47.1, + "value": 314.0 + }, + "p0.d45.decay40_ms": { + "kind": "ms", + "tol": 95.1, + "value": 634.0 + }, + "p0.d45.length_ms": { + "kind": "ms_long", + "tol": 237.0, + "value": 948.0 + }, + "p0.d45.noise_tone_db": { + "kind": "ratio_db", + "tol": 3.0, + "value": 40.0 + }, + "p0.d45.v100.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -9.557 + }, + "p0.d45.v127.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -7.923 + }, + "p0.d45.v40.centroid_hz": { + "kind": "hz", + "tol": 88.153, + "value": 1763.058 + }, + "p0.d45.v40.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -12.45 + }, + "p0.d48.band_high_db": { + "kind": "band_db", + "tol": 2.0, + "value": -28.319 + }, + "p0.d48.band_low_db": { + "kind": "band_db", + "tol": 2.0, + "value": -17.463 + }, + "p0.d48.band_mid_db": { + "kind": "band_db", + "tol": 2.0, + "value": -0.085 + }, + "p0.d48.centroid_hz": { + "kind": "hz", + "tol": 85.5, + "value": 1710.005 + }, + "p0.d48.decay20_ms": { + "kind": "ms", + "tol": 44.7, + "value": 298.0 + }, + "p0.d48.decay40_ms": { + "kind": "ms", + "tol": 90.3, + "value": 602.0 + }, + "p0.d48.length_ms": { + "kind": "ms_long", + "tol": 227.0, + "value": 908.0 + }, + "p0.d48.noise_tone_db": { + "kind": "ratio_db", + "tol": 3.0, + "value": 40.0 + }, + "p0.d48.v100.centroid_hz": { + "kind": "hz", + "tol": 85.5, + "value": 1710.005 + }, + "p0.d48.v100.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -9.27 + }, + "p0.d48.v127.centroid_hz": { + "kind": "hz", + "tol": 82.013, + "value": 1640.263 + }, + "p0.d48.v127.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -7.915 + }, + "p0.d48.v40.centroid_hz": { + "kind": "hz", + "tol": 86.574, + "value": 1731.485 + }, + "p0.d48.v40.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -12.418 + }, + "p0.noise_floor_db": { + "kind": "floor_db", + "tol": 6.0, + "value": -120.0 + }, + "p1.d36.band_high_db": { + "kind": "band_db", + "tol": 2.0, + "value": -26.221 + }, + "p1.d36.band_low_db": { + "kind": "band_db", + "tol": 2.0, + "value": -0.142 + }, + "p1.d36.band_mid_db": { + "kind": "band_db", + "tol": 2.0, + "value": -15.254 + }, + "p1.d36.centroid_hz": { + "kind": "hz", + "tol": 126.595, + "value": 2531.902 + }, + "p1.d36.decay20_ms": { + "kind": "ms", + "tol": 38.7, + "value": 258.0 + }, + "p1.d36.decay40_ms": { + "kind": "ms", + "tol": 78.6, + "value": 524.0 + }, + "p1.d36.length_ms": { + "kind": "ms_long", + "tol": 200.5, + "value": 802.0 + }, + "p1.d36.noise_tone_db": { + "kind": "ratio_db", + "tol": 3.0, + "value": -8.273 + }, + "p1.d36.pitch_drop_cents": { + "kind": "drop_cents", + "tol": 148.906, + "value": 744.528 + }, + "p1.d36.v100.centroid_hz": { + "kind": "hz", + "tol": 126.595, + "value": 2531.902 + }, + "p1.d36.v100.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -4.412 + }, + "p1.d36.v127.centroid_hz": { + "kind": "hz", + "tol": 135.662, + "value": 2713.246 + }, + "p1.d36.v127.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -4.548 + }, + "p1.d36.v40.centroid_hz": { + "kind": "hz", + "tol": 149.256, + "value": 2985.123 + }, + "p1.d36.v40.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -8.399 + }, + "p1.d38.band_high_db": { + "kind": "band_db", + "tol": 2.0, + "value": -4.904 + }, + "p1.d38.band_low_db": { + "kind": "band_db", + "tol": 2.0, + "value": -12.481 + }, + "p1.d38.band_mid_db": { + "kind": "band_db", + "tol": 2.0, + "value": -2.074 + }, + "p1.d38.centroid_hz": { + "kind": "hz", + "tol": 258.051, + "value": 5161.016 + }, + "p1.d38.decay20_ms": { + "kind": "ms", + "tol": 21.0, + "value": 140.0 + }, + "p1.d38.decay40_ms": { + "kind": "ms", + "tol": 39.6, + "value": 264.0 + }, + "p1.d38.fundamental_hz": { + "kind": "pitch_hz", + "tol": 6.844, + "value": 228.127 + }, + "p1.d38.length_ms": { + "kind": "ms_long", + "tol": 100.5, + "value": 402.0 + }, + "p1.d38.noise_tone_db": { + "kind": "ratio_db", + "tol": 3.0, + "value": 0.179 + }, + "p1.d38.pitch_drop_cents": { + "kind": "drop_cents", + "tol": 50.0, + "value": 93.862 + }, + "p1.d38.v100.centroid_hz": { + "kind": "hz", + "tol": 258.051, + "value": 5161.016 + }, + "p1.d38.v100.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -5.783 + }, + "p1.d38.v127.centroid_hz": { + "kind": "hz", + "tol": 260.488, + "value": 5209.756 + }, + "p1.d38.v127.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -4.883 + }, + "p1.d38.v40.centroid_hz": { + "kind": "hz", + "tol": 265.533, + "value": 5310.665 + }, + "p1.d38.v40.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -9.353 + }, + "p1.d41.band_high_db": { + "kind": "band_db", + "tol": 2.0, + "value": -30.278 + }, + "p1.d41.band_low_db": { + "kind": "band_db", + "tol": 2.0, + "value": -8.767 + }, + "p1.d41.band_mid_db": { + "kind": "band_db", + "tol": 2.0, + "value": -0.624 + }, + "p1.d41.centroid_hz": { + "kind": "hz", + "tol": 69.699, + "value": 1393.976 + }, + "p1.d41.decay20_ms": { + "kind": "ms", + "tol": 69.0, + "value": 460.0 + }, + "p1.d41.decay40_ms": { + "kind": "ms", + "tol": 134.7, + "value": 898.0 + }, + "p1.d41.length_ms": { + "kind": "ms_long", + "tol": 335.5, + "value": 1342.0 + }, + "p1.d41.noise_tone_db": { + "kind": "ratio_db", + "tol": 3.0, + "value": 40.0 + }, + "p1.d41.v100.centroid_hz": { + "kind": "hz", + "tol": 69.699, + "value": 1393.976 + }, + "p1.d41.v100.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -9.284 + }, + "p1.d41.v40.centroid_hz": { + "kind": "hz", + "tol": 77.625, + "value": 1552.499 + }, + "p1.d41.v40.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -12.767 + }, + "p1.d45.band_high_db": { + "kind": "band_db", + "tol": 2.0, + "value": -29.618 + }, + "p1.d45.band_low_db": { + "kind": "band_db", + "tol": 2.0, + "value": -17.777 + }, + "p1.d45.band_mid_db": { + "kind": "band_db", + "tol": 2.0, + "value": -0.078 + }, + "p1.d45.decay20_ms": { + "kind": "ms", + "tol": 66.3, + "value": 442.0 + }, + "p1.d45.decay40_ms": { + "kind": "ms", + "tol": 132.0, + "value": 880.0 + }, + "p1.d45.length_ms": { + "kind": "ms_long", + "tol": 328.0, + "value": 1312.0 + }, + "p1.d45.noise_tone_db": { + "kind": "ratio_db", + "tol": 3.0, + "value": 40.0 + }, + "p1.d45.v100.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -9.437 + }, + "p1.d45.v127.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -7.882 + }, + "p1.d45.v40.centroid_hz": { + "kind": "hz", + "tol": 74.814, + "value": 1496.27 + }, + "p1.d45.v40.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -12.446 + }, + "p1.d48.band_high_db": { + "kind": "band_db", + "tol": 2.0, + "value": -28.559 + }, + "p1.d48.band_low_db": { + "kind": "band_db", + "tol": 2.0, + "value": -18.298 + }, + "p1.d48.band_mid_db": { + "kind": "band_db", + "tol": 2.0, + "value": -0.071 + }, + "p1.d48.decay20_ms": { + "kind": "ms", + "tol": 61.5, + "value": 410.0 + }, + "p1.d48.decay40_ms": { + "kind": "ms", + "tol": 123.0, + "value": 820.0 + }, + "p1.d48.length_ms": { + "kind": "ms_long", + "tol": 306.5, + "value": 1226.0 + }, + "p1.d48.noise_tone_db": { + "kind": "ratio_db", + "tol": 3.0, + "value": 40.0 + }, + "p1.d48.v100.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -9.402 + }, + "p1.d48.v127.centroid_hz": { + "kind": "hz", + "tol": 69.264, + "value": 1385.274 + }, + "p1.d48.v127.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -7.756 + }, + "p1.d48.v40.centroid_hz": { + "kind": "hz", + "tol": 74.61, + "value": 1492.197 + }, + "p1.d48.v40.peak_db": { + "kind": "db", + "tol": 1.0, + "value": -12.362 + }, + "p1.noise_floor_db": { + "kind": "floor_db", + "tol": 6.0, + "value": -120.0 + } + }, + "unsteady": [ + "p0.d45.centroid_hz", + "p0.d45.v100.centroid_hz", + "p0.d45.v127.centroid_hz", + "p1.d36.fundamental_hz", + "p1.d41.v127.centroid_hz", + "p1.d41.v127.peak_db", + "p1.d45.centroid_hz", + "p1.d45.v100.centroid_hz", + "p1.d45.v127.centroid_hz", + "p1.d48.centroid_hz", + "p1.d48.v100.centroid_hz" + ] +} diff --git a/tools/traits.py b/tools/traits.py index b12fbcc..b07ec7a 100644 --- a/tools/traits.py +++ b/tools/traits.py @@ -245,6 +245,15 @@ "ring": 1.0, "tail": 0.3, }, + "electronic_drums5": { + "patches": (0, 1), + "drums": True, + "notes": (36, 38, 48, 45, 41), + "velocity": 100, + "velocities": (40, 100, 127), + "ring": 10.0, + "tail": 0.3, + }, "reed_piano": { "patches": (0, 1, 2, 3, 4, 5), "notes": (36, 48, 60, 72, 84), From 0a67326e1f0d0494a38f34b5bcd8f3f2de85c531 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:13:26 -0500 Subject: [PATCH 2/2] ElectronicDrums5 face: version 0.5.0.dev1 --- lib/audioinstruments/electronic_drums5.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/audioinstruments/electronic_drums5.py b/lib/audioinstruments/electronic_drums5.py index 375c81b..0d2ccee 100644 --- a/lib/audioinstruments/electronic_drums5.py +++ b/lib/audioinstruments/electronic_drums5.py @@ -6,7 +6,7 @@ NAME = "electronic_drums5" DISPLAY_NAME = "electronic_drums5" CATEGORIES = ("Synth",) -VERSION = "0.5.0.dev0" +VERSION = "0.5.0.dev1" VENDOR = "PyDevices" MACRO_LABELS = (