Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
11307cb
checkpoint for #497
bwhitman Dec 15, 2025
f58139d
fixing functions
bwhitman Dec 16, 2025
17e3762
transfer layer
bwhitman Dec 16, 2025
e38016d
.
bwhitman Dec 16, 2025
b2e9d72
checkpoint for pcm 497
bwhitman Dec 20, 2025
47572ad
working, but an octave too low?
bwhitman Dec 20, 2025
942b9e8
fixing log2sr
bwhitman Dec 20, 2025
03db748
fixing PCM ROM bug, transfer file
bwhitman Dec 21, 2025
05fe50a
sampling
bwhitman Dec 21, 2025
8684d8d
corrupted frames, but sampling working
bwhitman Dec 21, 2025
80f906e
.
bwhitman Dec 21, 2025
4735370
fix pcm_left bug
bwhitman Dec 21, 2025
fbd0ea6
everything seems to work
bwhitman Dec 21, 2025
7d34b3f
adding ref
bwhitman Dec 21, 2025
ac134d9
Merge branch 'main' into pcm_497
bwhitman Dec 21, 2025
85df330
fopen/fclose on note on. no looping for disk samples. fixing wav reader
bwhitman Dec 22, 2025
0f2d011
docs
bwhitman Dec 22, 2025
99fe201
weird typo in sequencer.c -- new?
bwhitman Dec 22, 2025
abae965
fixing transfer to not do reboot (the hook can)
bwhitman Dec 22, 2025
5181f05
posix only
bwhitman Dec 22, 2025
b6e6774
adding restart ref
bwhitman Dec 22, 2025
0686b61
removing .DS_Store
bwhitman Dec 22, 2025
c097dcd
fixing review comment
bwhitman Dec 22, 2025
73dce3a
fixing stereo sample WAV
bwhitman Dec 22, 2025
25d73d5
bps = 16
bwhitman Dec 22, 2025
726caa4
fixing parsing
bwhitman Dec 22, 2025
3b2dd8e
bytes, not frames, everywhere
bwhitman Dec 23, 2025
ea7a4fd
removing amyboard #warning and also fixing ESP compiler error about c…
bwhitman Dec 23, 2025
a98cb4d
adding amyboard sysex writer
bwhitman Dec 24, 2025
940da9f
Merge branch 'pcm_497' of github.com:shorepine/amy into pcm_497
bwhitman Dec 24, 2025
4a94fba
fixing up midi sysex sending for amy/tulip
bwhitman Dec 25, 2025
f0d1355
Merge branch 'pcm_497' of https://github.com/shorepine/amy into pcm_497
bwhitman Dec 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ AMY supports
* MIDI input support and synthesizer voice management, including voice stealing, controllers and per-channel multi-timbral operation
* A strong Juno-6 style analog synthesizer
* An operator / algorithm-based frequency modulation (FM) synth, modeled after the DX-7
* PCM sampler, reading from a baked-in buffer of percussive and misc samples, or by loading samples with looping and base midi note
* PCM sampler, reading from a baked-in buffer of percussive and misc samples, or by loading samples into RAM, or playing from files on disk directly, with loop points and base midi note
* karplus-strong string with adjustable feedback
* An arbitrary number of band-limited oscillators, each with adjustable frequency, pan, phase, amplitude:
* pulse (+ adjustable duty cycle), sine, saw (up and down), triangle, noise
Expand All @@ -41,6 +41,7 @@ AMY supports
* A front end for DX7 and Juno-6 SYSEX patches and conversion setup commands
* Built-in event clock and pattern sequencer, using hardware real time timers on microcontrollers
* Multi-core (including microcontrollers) for rendering if available
* File transfer to the host

The FM synth provides a Python library, [`fm.py`](https://github.com/shorepine/amy/blob/main/amy/fm.py) that can convert any DX7 patch into an AMY patch.

Expand Down
66 changes: 59 additions & 7 deletions amy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,15 +127,17 @@ def str_of_int(arg):


_KW_MAP_LIST = [ # Order matters because patch_string must come last.
('osc', 'vI'), ('wave', 'wI'), ('note', 'nF'), ('vel', 'lF'), ('amp', 'aC'), ('freq', 'fC'), ('duty', 'dC'), ('feedback', 'bF'), ('time', 'tI'),
('reset', 'SI'), ('phase', 'PF'), ('pan', 'QC'), ('client', 'gI'), ('volume', 'VF'), ('pitch_bend', 'sF'), ('filter_freq', 'FC'), ('resonance', 'RF'),
('bp0', 'AL'), ('bp1', 'BL'), ('eg0_type', 'TI'), ('eg1_type', 'XI'), ('debug', 'DI'), ('chained_osc', 'cI'), ('mod_source', 'LI'),
('eq', 'xL'), ('filter_type', 'GI'), ('ratio', 'IF'), ('latency_ms', 'NI'), ('algo_source', 'OL'), ('load_sample', 'zL'),
('osc', 'vI'), ('wave', 'wI'), ('note', 'nF'), ('vel', 'lF'), ('amp', 'aC'), ('freq', 'fC'), ('duty', 'dC'),
('feedback', 'bF'), ('time', 'tI'), ('reset', 'SI'), ('phase', 'PF'), ('pan', 'QC'), ('client', 'gI'),
('volume', 'VF'), ('pitch_bend', 'sF'), ('filter_freq', 'FC'), ('resonance', 'RF'), ('bp0', 'AL'),
('bp1', 'BL'), ('eg0_type', 'TI'), ('eg1_type', 'XI'), ('debug', 'DI'), ('chained_osc', 'cI'),
('mod_source', 'LI'), ('eq', 'xL'), ('filter_type', 'GI'), ('ratio', 'IF'), ('latency_ms', 'NI'),
('algo_source', 'OL'), ('load_sample', 'zL'), ('transfer_file', 'zTL'), ('disk_sample', 'zFL'),
('algorithm', 'oI'), ('chorus', 'kL'), ('reverb', 'hL'), ('echo', 'ML'), ('patch', 'KI'), ('voices', 'rL'),
('external_channel', 'WI'), ('portamento', 'mI'), ('sequence', 'HL'), ('tempo', 'jF'),
('synth', 'iI'), ('pedal', 'ipI'), ('synth_flags', 'ifI'), ('num_voices', 'ivI'), ('to_synth', 'itI'),
('grab_midi_notes', 'imI'), ('synth_delay', 'idI'), # 'i' is prefix for some two-letter synth-level codes.
('preset', 'pI'), ('num_partials', 'pI'), # Note alaising.
('grab_midi_notes', 'imI'), ('synth_delay', 'idI'), ('preset', 'pI'), ('num_partials', 'pI'), # note aliasing
('start_sample', 'zSL'), ('stop_sample', 'zOI'),
('patch_string', 'uS'), # patch_string MUST be last because we can't identify when it ends except by end-of-message.
]
_KW_PRIORITY = {k: i for i, (k, _) in enumerate(_KW_MAP_LIST)} # Maps each key to its index within _KW_MAP_LIST.
Expand Down Expand Up @@ -305,6 +307,29 @@ def unload_sample(patch=0):
send(load_sample=s)
print("Patch %d unloaded from RAM" % (patch))

# For AMYBoard and other AMYs that can get messages over MIDI sysex
# AMYboard is the name of the default AMYboard USB over MIDI device.
# If you're using another MIDI device, set output_name to it
# Use this like amy.override_send = sysex_write
def sysex_write(message, output_name='AMYboard'):
import mido
outputs = mido.get_output_names()
target_name = None
for name in outputs:
if output_name in name:
target_name = name
break
if target_name is None:
print("Could not find %s MIDI")
if isinstance(message, str):
payload = message.encode('ascii')
elif isinstance(message, bytes):
payload = message
# AMY sysex message
data = [0x00, 0x03, 0x45] + list(payload)
with midi.open_output(target_name) as out:
m = mido.Message('sysex', data=data)
out.send(m)

try:
import base64
Expand All @@ -315,6 +340,13 @@ def b64(b):
def b64(b):
return ubinascii.b2a_base64(b)[:-1]

def start_sample(preset=0, bus=1, max_frames=0, midinote=60, loopstart=0, loopend=0):
s = "%d,%d,%d,%d,%d,%d" % (preset, bus, max_frames, midinote, loopstart, loopend)
send(start_sample=s)

def stop_sample():
send(stop_sample=1)

def load_sample_bytes(b, stereo=False, preset=0, midinote=60, loopstart=0, loopend=0, sr=AMY_SAMPLE_RATE):
# takes in a python bytes obj instead of filename
from math import ceil
Expand All @@ -330,7 +362,27 @@ def load_sample_bytes(b, stereo=False, preset=0, midinote=60, loopstart=0, loope
message = b64(frames_bytes)
send_raw(message.decode('ascii'))
last_f = last_f + 188
print("Loaded sample over wire protocol. Preset #%d. %d bytes, %d frames, midinote %d" % (preset, n_frames*2, n_frames, midinote))

def disk_sample(wavfilename, preset=0, midinote=60):
s = "%d,%s,%d" % (preset, wavfilename, midinote)
send(disk_sample=s)

def transfer_file(source_filename, dest_filename=None):
import os
from math import ceil
if(dest_filename is None):
dest_filename = source_filename
file_size = os.path.getsize(source_filename)
s = "%s,%d" % (dest_filename, file_size)
send(transfer_file=s)
# Now generate the base64 encoded segments, 188 bytes at a time
# why 188? that generates 252 bytes of base64 text. amy's max message size is currently 255.
w = open(source_filename, 'rb')
for i in range(ceil(file_size/188)):
file_bytes = w.read(188)
message = b64(file_bytes)
send_raw(message.decode('ascii'))
w.close()

def load_sample(wavfilename, preset=0, midinote=0, loopstart=0, loopend=0):
from math import ceil
Expand Down
18 changes: 16 additions & 2 deletions amy/constants.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
MAX_FILENAME_LEN=127
AMY_BLOCK_SIZE=128
BLOCK_SIZE_BITS=7
AMY_BLOCK_SIZE=256
Expand All @@ -6,6 +7,16 @@
AMY_SAMPLE_RATE=48000
AMY_SAMPLE_RATE=44100
PCM_AMY_SAMPLE_RATE=22050
AMY_TRANSFER_TYPE_NONE=0
AMY_TRANSFER_TYPE_AUDIO=1
AMY_TRANSFER_TYPE_FILE=2
AMY_TRANSFER_TYPE_SAMPLE=3
AMY_PCM_TYPE_ROM=0
AMY_PCM_TYPE_FILE=1
AMY_PCM_TYPE_MEMORY=2
PCM_FILE_BUFFER_MULT=8
AMY_BUS_OUTPUT=1
AMY_BUS_AUDIO_IN=2
AMY_MAX_CORES=2
AMY_MAX_CHANNELS=2
AMY_NCHANS=2
Expand Down Expand Up @@ -72,8 +83,11 @@
AUDIO_EXT0=14
AUDIO_EXT1=15
AMY_MIDI=16
CUSTOM=17
WAVE_OFF=18
PCM_LEFT=17
PCM_RIGHT=18
PCM_MIX=7
CUSTOM=19
WAVE_OFF=20
SYNTH_OFF=0
SYNTH_AUDIBLE=1
SYNTH_AUDIBLE_SUSPENDED=2
Expand Down
63 changes: 62 additions & 1 deletion amy/test.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import sys
import os
import random
import string
import tempfile

import numpy as np
import scipy.io.wavfile as wav
Expand Down Expand Up @@ -831,6 +834,61 @@ def run(self):
amy.send(time=900, osc=0, vel=0)


class TestFileTransfer(AmyTest):

def test(self):
_amy.stop()
_amy.start_no_default()
payload = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(2048))
with tempfile.NamedTemporaryFile(mode='w', delete=True) as f:
with tempfile.NamedTemporaryFile(mode='w+', delete=True) as g:
f.write(payload)
f.flush()
os.fsync(f.fileno())
amy.transfer_file(f.name, g.name)
amy.render(0.1)
g.seek(0)
transferred = g.read()
if transferred != payload:
raise AssertionError('transfer file contents mismatch')
print('TestFileTransfer: ok')


class TestDiskSample(AmyTest):
def run(self):
amy.disk_sample('sounds/partial_sources/CL SHCI A3.wav', preset=1024, midinote=57)
amy.send(time=50, osc=0, preset=1024, wave=amy.PCM_MIX, vel=2, note=57)

class TestRestartFileSample(AmyTest):
def run(self):
amy.disk_sample('sounds/partial_sources/CL SHCI A3.wav', preset=1024, midinote=60)
amy.send(time=50, osc=0, preset=1024, wave=amy.PCM_MIX, vel=2, note=72)
amy.send(time=500, osc=0, preset=1024, wave=amy.PCM_MIX, vel=2, note=50)

class TestDiskSampleStereo(AmyTest):

def run(self):
amy.disk_sample('sounds/220_440_stereo.wav', preset=1024, midinote=60)
amy.disk_sample('sounds/220_440_stereo.wav', preset=1025, midinote=60)
amy.send(time=50, osc=0, preset=1024, wave=amy.PCM_LEFT, pan=0, vel=1, note=60)
amy.send(time=500, osc=1, preset=1025, wave=amy.PCM_RIGHT, pan=1, vel=1, note=60)

class TestSample(AmyTest):
def run(self):
amy.start_sample(preset=1024,bus=1,max_frames=22050, midinote=60)
amy.send(time=0, synth=1, num_voices=4, patch=20)
amy.send(time=50, synth=1, note=48, vel=1)
amy.send(time=150, synth=1, note=60, vel=1)
amy.send(time=250, synth=1, note=63, vel=1)
# notes off
amy.send(time=400, synth=1, note=48, vel=0)
amy.send(time=400, synth=1, note=60, vel=0)
amy.send(time=400, synth=1, note=63, vel=0)

# play a pitched up version
amy.send(osc=116, time=400, preset=1024, wave=amy.PCM_MIX, vel=1, note=72)
amy.send(osc=116, time=700, preset=1024, wave=amy.PCM_MIX, vel=2, note=84)


def main(argv):
if len(argv) > 1:
Expand Down Expand Up @@ -864,7 +922,10 @@ def main(argv):
#TestVoiceStealing().test()
#TestSustainPedal().test()
#TestPatchFromEvents().test()
TestVoiceStealDecay().test()
#TestVoiceStealDecay().test()
#TestRestartFileSample().test()
#TestDiskSample().test()
TestFileTransfer().test()

amy.send(debug=0)
print("tests done.")
Expand Down
24 changes: 20 additions & 4 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ void (*amy_external_midi_input_hook)(uint8_t * bytes, uint16_t len, uint8_t is_s

// Called every sequencer tick
void (*amy_external_sequencer_hook)(uint32_t) = NULL;

// Hooks for file reading / writing / opening if your AMY host supports that
// We provide this for POSIX platforms (mac, linux, etc)
uint32_t (*amy_external_fopen_hook)(char * filename, char * mode) = NULL;
uint32_t (*amy_external_fwrite_hook)(uint32_t fptr, uint8_t * bytes, uint32_t len) = NULL;
uint32_t (*amy_external_fread_hook)(uint32_t fptr, uint8_t * bytes, uint32_t len) = NULL;
void (*amy_external_fclose_hook)(uint32_t fptr) = NULL;

// Called when a file transfer is done (used in Micropython platforms to unpack)
void (*amy_external_file_transfer_done_hook)(const char *filename) = NULL;
```


Expand Down Expand Up @@ -153,7 +163,7 @@ Please see [AMY synthesizer details](synth.md) for more explanation on the synth
| Wire code | C/JS `amy_event` | Python `amy.send` | Type-range | Notes |
| ------ | -------- | ---------- | ---------- | ------------------------------------- |
| `v` | `osc` | `osc` | uint 0 to OSCS-1 | Which oscillator to control |
| `w` | `wave` | `wave` | uint 0-16 | Waveform: [0=SINE, PULSE, SAW_DOWN, SAW_UP, TRIANGLE, NOISE, KS, PCM, ALGO, PARTIAL, BYO_PARTIALS, INTERP_PARTIALS, AUDIO_IN0, AUDIO_IN1, CUSTOM, OFF]. default: 0/SINE |
| `w` | `wave` | `wave` | uint 0-20 | Waveform: [0=SINE, PULSE, SAW_DOWN, SAW_UP, TRIANGLE, NOISE, KS, PCM, ALGO, PARTIAL, BYO_PARTIALS, INTERP_PARTIALS, AUDIO_IN0, AUDIO_IN1, AUDIO_EXT0, AUDIO_EXT1, AMY_MIDI, PCM_LEFT, PCM_RIGHT, CUSTOM, OFF]. default: 0/SINE |
| `S` | `reset_osc`| `reset` | uint | Resets given oscillator. set to RESET_ALL_OSCS to reset all oscillators, gain and EQ. RESET_TIMEBASE resets the clock (immediately, ignoring `time`). RESET_AMY restarts AMY. RESET_SEQUENCER clears the sequencer.|
| `A` | `bp0` | `bp0` | string | Envelope Generator 0's comma-separated breakpoint pairs of time(ms) and level, e.g. `100,0.5,50,0.25,200,0`. The last pair triggers on note off (release) |
| `B` | `bp1` | `bp1` | string | Breakpoints for Envelope Generator 1. See bp0 |
Expand Down Expand Up @@ -185,6 +195,14 @@ These per-oscillator parameters use [CtrlCoefs](synth.md) notation
| `f` | `freq_coefs[]` | `freq` | float[,float...] | Frequency of oscillator, set of ControlCoefficients. Default is 0,1,0,0,0,0,1 (from `note` pitch plus `pitch_bend`) |
| `F` | `filter_freq_coefs[]` | `filter_freq` | float[,float...] | Center/break frequency for variable filter, set of ControlCoefficients |

### PCM sampling

| Wire code | C/JS `amy_event` | Python `amy.send` | Type-range | Notes |
| ------ | -------- | ---------- | ---------- | ------------------------------------- |
| `z` | **TODO**| `load_sample` | uint x 6 | Signal to start loading sample. preset number, length(frames), samplerate, channels, midinote, loopstart, loopend. All subsequent messages are base64 encoded WAVE-style frames of audio until `length` is reached. Set `preset` and `length=0` to unload a sample from RAM. |
| `zF` | **TODO**| `disk_sample` | uint,string,uint | Set a PCM preset to play live from a WAV filename on AMY host disk. Params: preset number, filename, midinote. See `hooks` for reading files on host disk. **Only one file sample can be played at once per preset number. Use multiple presets if you want polyphony from a single sample.** |
| `zS` | **TODO**| `start_sample` | uint x 6 | Start sampling to a stereo PCM preset from bus. Params: preset number, bus, max length in frames, midinote, loopstart, loopend. bus = 1 is AMY mixed output. bus = 2 is AUDIO_IN0 + 1. Will sample until max length is reached, `stop_sample` is issued, or a new `start_sample` is issued. |
| `zO` | **TODO**| `stop_sample` | uint | Stop sampling. Does nothing if no sampling active. param ignored. |

### Other

Expand All @@ -200,7 +218,5 @@ These per-oscillator parameters use [CtrlCoefs](synth.md) notation
| `t` | `time` | `time` | uint | Request playback time relative to some fixed start point on your host, in ms. Allows precise future scheduling. |
| `V` | `volume`| `volume` | float 0-10 | Volume knob for entire synth, default 1.0 |
| `x` | `eq_l, eq_m, eq_h` |`eq` | float,float,float | Equalization in dB low (~800Hz) / med (~2500Hz) / high (~7500Gz) -15 to 15. 0 is off. default 0. |
| `z` | **TODO**| `load_sample` | uint x 6 | Signal to start loading sample. preset number, length(samples), samplerate, midinote, loopstart, loopend. All subsequent messages are base64 encoded WAVE-style frames of audio until `length` is reached. Set `preset` and `length=0` to unload a sample from RAM. |
| `D` | **TODO** | `debug` | uint, 2-4 | 2 shows queue sample, 3 shows oscillator data, 4 shows modified oscillator. Will interrupt audio! |


| `zT` | **TODO**| `transfer_file` | string,uint | Transfer a file to the host. Params: destination filename, file size. See `hooks` for writing files on host disk. |
Loading