The arbitrary waveform capability along with ADSR envelopes and filters has made it very easy to create custom musical voices with synthio. And if you use an additive synthesis tool like WaveBuilder, you'll begin to quickly amass quite a few custom musical voices as you experiment with the nearly infinite number of oscillator combinations.
Hearing Voices
Up to this point, I've been keeping track of the numeric specifications for WaveBuilder oscillators and synthio.Envelopes in project code or scribbled on a note pad. That means that when building a new project that needs to use a previously created voice, I'll cut and paste the voice definition code into the new project. The process works, but isn't ideal. What if a synthio musical voice could be loaded from a collection in a file folder, kind of like working with a font file?
So a concept for the WaveStore project began to develop. Here are the initial requirements.
- Library File Management -- We'll make it easy at first and just work with a collection stored on an SD card. No need to tax the brain to conjure up a way to write to the CircuitPython root directory just yet. After creating and storing a voice to a file, we'll manually copy the files from the SD card in order to use and share between projects.
- Files -- In the spirit of keeping it simple, only files representing waveforms and envelopes will be created. Those objects form the fundamental elements of a musical voice. Perhaps we can add filters and other effects later.
-
Library File Names -- Waveforms will be stored in a standard wave file with a
.wav
extension, thanks to the awesome Adafruit_CircuitPython_Wave library. Envelopes and filters definitions will be stored in plain text files with.adsr
and.fltr
extensions but will transform to synthio.Envelope and synthio.BiQuad objects when retrieved. - Icons -- Imagine being able to select a musical voice waveform or envelope by touching a screen icon. Functions will be provided to save and retrieve bitmap images of waveforms and envelopes as well as capturing the contents of an entire screen. We'll start with 64x64 pixel bitmaps created by WaveViz. A graphical frequency response representation of a bi-quad filter's coefficients is in the works but is a bit beyond today's skillset. There's a potential workaround, but I'd rather derive it directly from the numbers. Hoping for an epiphany soon.
In the future, more features will likely be added to WaveStore such as support for filters and on-screen icon buttons -- as my experience and Python skill set grows. What would you add?
Enter WaveStore
WaveStore is under development but is available for testing. The current alpha version of the WaveStore class provides the following helpers:
- Wavetables
- read_wavetable -- Read a
.wav
file and return a memory view object. - read_wavetable_ulab -- Read a
.wav
file and return a ulab.numpy array object. The ulab array can be mixed with other wavetable array files. - write_wavetable -- Format a wavetable and write it as a standard
.wav
file.
- read_wavetable -- Read a
- Envelopes
- read_envelope -- Read an
.adsr
file and return a synthio.Envelope object. - write_envelope -- Write a text interpretation of a synthio.Envelope object to an
.adsr
file.
- read_envelope -- Read an
- Bitmaps
- read_bitmap -- Read a
.bmp
file and return the bitmap as a TileGrid object that can be added to a displayio.Group object. - write_bitmap -- Write a bitmap image to a
.bmp
file. - write_screen -- Write the screen contents to a
.bmp
file on the SD card.
- read_bitmap -- Read a
- Utilities
- get_catalog -- Returns a list of files in a specified folder.
WaveStore needs to work with other libraries to build and create voices and graphic icons, so developing a simple test example isn't so simple. Here's a comprehensive test example (still named "wavestore_simplest.py" for Community Bundle compatibility) that demonstrates how to store and retrieve icons, waveforms, and envelopes.
# SPDX-FileCopyrightText: Copyright (c) 2024 JG for Cedar Grove Maker Studios # SPDX-License-Identifier: MIT # # wavestore_simpletest.py import gc import time import board import displayio import synthio import adafruit_ili9341 from cedargrove_wavebuilder import WaveBuilder, WaveShape from cedargrove_waveviz import WaveViz from cedargrove_wavestore import WaveStore DEBUG = True SAMP_RATE = 22_050 # samples per second TEST_LIST = list(range(0, 20)) # List of tests to perform FOLDER = "/sd/voices" # The storage folder path (no trailing slash) synth = synthio.Synthesizer(sample_rate=SAMP_RATE) # Instantiate WaveStore to manage SD card contents w_store = WaveStore(board.SPI(), board.D20, debug=DEBUG) # Instantiate the 2.4-inch TFT Wing attached to FeatherS2 displayio.release_displays() # Release display resources display_bus = displayio.FourWire( board.SPI(), command=board.D6, chip_select=board.D5, reset=None ) display = adafruit_ili9341.ILI9341(display_bus, width=320, height=240) display.rotation = 0 splash = displayio.Group() display.root_group = splash # pylint: disable=no-member m0 = gc.mem_free() t0 = time.monotonic() # Create two waveforms, icons, and two envelopes for the tests harp_tone = [ (WaveShape.Sine, 1.00, 0.10), (WaveShape.Sine, 2.00, 0.48), (WaveShape.Sine, 3.00, 0.28), (WaveShape.Sine, 4.00, 0.02), (WaveShape.Sine, 5.00, 0.12), ] harp = WaveBuilder(oscillators=harp_tone, table_length=512) harp_icon = WaveViz(harp.wave_table, 10, 10, 64, 64) chime_tone = [ (WaveShape.Sine, 1.00, -0.60), (WaveShape.Sine, 2.76, 0.20), (WaveShape.Sine, 5.40, 0.10), (WaveShape.Sine, 8.93, 0.07), (WaveShape.Sine, 11.34, 0.01), (WaveShape.Sine, 18.64, 0.01), (WaveShape.Sine, 31.87, 0.01), ] chime = WaveBuilder(oscillators=chime_tone, table_length=512) chime_icon = WaveViz(chime.wave_table, 10, 80, 64, 64) string_envelope = synthio.Envelope( attack_time=0.0001, attack_level=1.0, decay_time=0.977, release_time=0.200, sustain_level=0.500, ) string_env_icon = WaveViz(string_envelope, 80, 26, 64, 32) chime_steel_envelope = synthio.Envelope( attack_time=0.02, attack_level=1.0, decay_time=0.1, release_time=2.0, sustain_level=0.0, ) chime_steel_env_icon = WaveViz(chime_steel_envelope, 80, 96, 64, 32) # Start testing # Test 1: Get the SD directory and print list to REPL if 1 in TEST_LIST: print("\nTest 1: Get the SD directory and print list to REPL") print(f"SD directory: {w_store.get_catalog(path=FOLDER)}") print(" completed") # Test 2: Write waveform bitmap images to files if 2 in TEST_LIST: print("\nTest 2: Write bitmap image to a file") w_store.write_bitmap( harp_icon.bitmap, harp_icon.pixel_shader, filename="harp_icon.bmp", path=FOLDER, overwrite=True, ) w_store.write_bitmap( chime_icon.bitmap, chime_icon.pixel_shader, filename="chime_steel_icon.bmp", path=FOLDER, overwrite=True, ) print(" completed") # Test 3: Read and display saved bitmap if 3 in TEST_LIST: print("\nTest 3: Read and display saved bitmap") new_bitmap = w_store.read_bitmap("harp_icon.bmp", path=FOLDER) new_bitmap.x = 10 new_bitmap.y = 10 splash.append(new_bitmap) print(" completed") # Test 4: Add second icon and envs; save screen in root directory if 4 in TEST_LIST: print("\nTest 4: Test 4: Add second icon and envs; save screen in root directory") splash.append(chime_icon) splash.append(string_env_icon) splash.append(chime_steel_env_icon) w_store.write_screen(display, "screenshot.bmp", path="/sd", overwrite=True) print(" completed") # Test 5: Clear the screen and read and display saved screenshot from the root directory if 5 in TEST_LIST: print( "\nTest 5: Clear the screen and read and display saved screenshot from the root directory" ) splash.pop() splash.pop() time.sleep(1) # Wait for a moment to show blank screen splash.append(w_store.read_bitmap("screenshot.bmp", path="/sd")) print(" completed") # Test 6: Write wave tables to files if 6 in TEST_LIST: print("\nTest 6: Write wave tables to files") print(harp_icon.wave_table) w_store.write_wavetable( harp_icon.wave_table, "harp.wav", path=FOLDER, samp_rate=SAMP_RATE, overwrite=True, ) print(chime_icon.wave_table) w_store.write_wavetable( chime_icon.wave_table, "chime_steel.wav", path=FOLDER, samp_rate=SAMP_RATE, overwrite=True, ) print(" completed") # Test 7: Read wavetable as memory_view object from a file and display if 7 in TEST_LIST: print("\nTest 7: Read wavetable as memory_view object from a file and display") wave_table = w_store.read_wavetable("harp.wav", path=FOLDER) print(f"w_store.read_wavetable: {wave_table}") harp_icon.wave_table = w_store.read_wavetable("harp.wav", path=FOLDER) print(" completed") # Test 8: Read wave table as ulab array from a file and display if 8 in TEST_LIST: pass # Test 9: Write envelope objects to files if 9 in TEST_LIST: print("\nTest 9: Write envelope objects to files") w_store.write_envelope(string_envelope, "string.adsr", path=FOLDER, overwrite=True) w_store.write_envelope( chime_steel_envelope, "chime_steel.adsr", path=FOLDER, overwrite=True ) print(" completed") # Test 10: Read envelope object from a file if 10 in TEST_LIST: print("\nTest 10: Read envelope object from a file") new_env = w_store.read_envelope("string.adsr", path=FOLDER) print(" completed") # Test 11: Write envelope bitmap image to a file if 11 in TEST_LIST: print("\nTest 11: Write envelope bitmap image to a file") w_store.write_bitmap( string_env_icon.bitmap, string_env_icon.pixel_shader, filename="string_adsr_icon.bmp", path=FOLDER, overwrite=True, ) w_store.write_bitmap( chime_steel_env_icon.bitmap, chime_steel_env_icon.pixel_shader, filename="chime_steel_adsr_icon.bmp", path=FOLDER, overwrite=True, ) print(" completed") # Test 12: Write filter object to a file if 12 in TEST_LIST: pass # Test 13: Read filter object from file if 13 in TEST_LIST: pass # Test 14: Display wave table bitmap with transparency if 14 in TEST_LIST: print("\nTest 14: Display wave table bitmap with transparency") new_bitmap = w_store.read_bitmap("streetchicken.bmp", path="/sd") splash.append( displayio.TileGrid( new_bitmap.bitmap, pixel_shader=new_bitmap.pixel_shader, x=170, y=15 ) ) print(" completed") # All tests completed print("\n*** All tests completed ***") # pylint: disable=no-member print(f"mem_free delta: {gc.mem_free() - m0}") print(f"time delta: {time.monotonic() - t0}") while True: pass
SD Card Contents
Before:
After: