Skip to content

Commit

Permalink
Merge pull request #231 from shorepine/amychip-combined
Browse files Browse the repository at this point in the history
Audio in, memorypcm, noclone merged for AMYchip support
  • Loading branch information
bwhitman authored Oct 15, 2024
2 parents abceaa6 + e02bb63 commit 74d61d8
Show file tree
Hide file tree
Showing 31 changed files with 1,389 additions and 762 deletions.
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,13 @@ all: default

SOURCES = src/algorithms.c src/amy.c src/envelope.c src/examples.c \
src/filters.c src/oscillators.c src/pcm.c src/partials.c src/custom.c \
src/delay.c src/log2_exp2.c src/patches.c
src/delay.c src/log2_exp2.c src/patches.c src/transfer.c

OBJECTS = $(patsubst %.c, %.o, src/algorithms.c src/amy.c src/envelope.c \
src/delay.c src/partials.c src/custom.c src/patches.c \
src/examples.c src/filters.c src/oscillators.c src/pcm.c src/log2_exp2.c \
src/libminiaudio-audio.c)

src/libminiaudio-audio.c src/transfer.c)
HEADERS = $(wildcard src/*.h) src/amy_config.h
HEADERS_BUILD := $(filter-out src/patches.h,$(HEADERS))

Expand Down
50 changes: 40 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

AMY is a fast, small and accurate music synthesizer library written in C with Python and Arduino bindings that deals with combinations of many oscillators very well. It can easily be embedded into almost any program, architecture or microcontroller. We've run AMY on [the web](https://shorepine.github.io/amy/), Mac, Linux, ESP32, ESP32S3 and ESP32P4, Teensy 3.6, Teensy 4.1, the Raspberry Pi, the Pi Pico RP2040, the Pi Pico 2 RP2530, iOS devices, the Electro-Smith Daisy (ARM Cortex M7), and more to come. It is highly optimized for polyphony and poly-timbral operation on even the lowest power and constrained RAM microcontroller but can scale to as many cores as you want.

It can be used as a very good analog-type synthesizer (Juno-6 style) a FM synthesizer (DX7 style), a partial breakpoint synthesizer (Alles machine or Atari AMY), a drum machine (PCM samples included), or as a lower level toolkit to make your own combinations of oscillators, filters, LFOs and effects.
It can be used as a very good analog-type synthesizer (Juno-6 style) a FM synthesizer (DX7 style), a partial breakpoint synthesizer (Alles machine or Atari AMY), a sampler (where you load in your own PCM data), a drum machine (808-style PCM samples are included), or as a lower level toolkit to make your own combinations of oscillators, filters, LFOs and effects.

AMY powers the multi-speaker mesh synthesizer [Alles](https://github.com/shorepine/alles), as well as the [Tulip Creative Computer](https://github.com/shorepine/tulipcc). Let us know if you use AMY for your own projects and we'll add it here!

Expand All @@ -19,11 +19,12 @@ It supports
* saw (up and down)
* triangle
* noise
* PCM, reading from a baked-in buffer of percussive and misc samples
* PCM, reading from a baked-in buffer of percussive and misc samples, or by loading samples with looping and base midi note
* karplus-strong string with adjustable feedback
* Stereo audio input can be used as an oscillator for real time audio effects
* An operator / algorithm-based frequency modulation (FM) synth
* Biquad low-pass, bandpass or hi-pass filters with cutoff and resonance, can be assigned to any oscillator
* Reverb and chorus effects, set globally
* Reverb, echo and chorus effects, set globally
* Stereo pan or mono operation
* An additive partial synthesizer with an analysis front end to play back long strings of breakpoint-based sine waves
* Oscillators can be specified by frequency in floating point or midi note
Expand Down Expand Up @@ -97,7 +98,7 @@ You can also start a thread playing live audio:

```python
>>> import amy
>>> amy.live() # can optinally pass in audio device ID, amy.live(2)
>>> amy.live() # can optinally pass in playback and capture audio device IDs, amy.live(2, 1)
>>> amy.send(voices='0', load_patch=130, note=50, vel=1)
>>> amy.stop()
```
Expand Down Expand Up @@ -125,7 +126,7 @@ void bleep() {
}

void main() {
amy_start(/* cores= */ 1, /* reverb= */ 0, /* chorus= */ 0); // initializes amy
amy_start(/* cores= */ 1, /* reverb= */ 0, /* chorus= */ 0, /* echo */ 1); // initializes amy
amy_live_start(); // render live audio
bleep();
}
Expand All @@ -137,7 +138,7 @@ Or in C, sending the wire protocol directly:
#include "amy.h"

void main() {
amy_start(/* cores= */ 1, /* reverb= */ 0, /* chorus= */ 0);
amy_start(/* cores= */ 1, /* reverb= */ 0, /* chorus= */ 0, /* echo */ 1);
amy_live_start();
amy_play_message("v0n50l1K130r0Z");
}
Expand All @@ -148,7 +149,7 @@ If you want to receive buffers of samples, or have more control over the renderi
```c
#include "amy.h"
...
amy_start(/* cores= */ 2, /* reverb= */ 1, /* chorus= */ 1);
amy_start(/* cores= */ 2, /* reverb= */ 1, /* chorus= */ 1, /* echo */ 1);
...
... {
// For each sample block:
Expand Down Expand Up @@ -191,7 +192,6 @@ Here's the full list:
| `b` | `feedback` | float 0-1 | Use for the ALGO synthesis type in FM or for karplus-strong, or to indicate PCM looping (0 off, >0, on) |
| `B` | `bp1` | string | Breakpoints for Envelope Generator 1. See bp0 |
| `c` | `chained_osc` | uint 0 to OSCS-1 | Chained oscillator. Note/velocity events to this oscillator will propagate to chained oscillators. VCF is run only for first osc in chain, but applies to all oscs in chain. |
| `C` | `clone_osc` | uint 0 to OSCS-1 | Clone oscillator. Most parameters from the named other oscillator are copied into this one. |
| `d` | `duty` | float[,float...] | Duty cycle for pulse wave, ControlCoefficients, defaults to 0.5 |
| `D` | `debug` | uint, 2-4 | 2 shows queue sample, 3 shows oscillator data, 4 shows modified oscillator. Will interrupt audio! |
| `f` | `freq` | float[,float...] | Frequency of oscillator, set of ControlCoefficients. Default is 0,1,0,0,0,0,1 (from `note` pitch plus `pitch_bend`) |
Expand Down Expand Up @@ -221,9 +221,10 @@ Here's the full list:
| `u` | `store_patch` | number,string | Store up to 32 patches in RAM with ID number (1024-1055) and AMY message after a comma. Must be sent alone |
| `v` | `osc` | uint 0 to OSCS-1 | Which oscillator to control |
| `V` | `volume` | float 0-10 | Volume knob for entire synth, default 1.0 |
| `w` | `wave` | uint 0-11 | Waveform: [0=SINE, PULSE, SAW_DOWN, SAW_UP, TRIANGLE, NOISE, KS, PCM, ALGO, PARTIAL, PARTIALS, BYO_PARTIALS, OFF]. default: 0/SINE |
| `w` | `wave` | uint 0-15 | Waveform: [0=SINE, PULSE, SAW_DOWN, SAW_UP, TRIANGLE, NOISE, KS, PCM, ALGO, PARTIAL, PARTIALS, BYO_PARTIALS, AUDIO_IN0, AUDIO_IN1, CUSTOM, OFF]. default: 0/SINE |
| `x` | `eq` | float,float,float | Equalization in dB low (~800Hz) / med (~2500Hz) / high (~7500Gz) -15 to 15. 0 is off. default 0. |
| `X` | `eg1_type` | uint 0-3 | Type for Envelope Generator 1 - 0: Normal (RC-like) / 1: Linear / 2: DX7-style / 3: True exponential. |
| `z` | `load_sample` | uint x 6 | Signal to start loading sample. patch, length(samples), samplerate, midinote, loopstart, loopend. All subsequent messages are base64 encoded WAVE-style frames of audio until `length` is reached. Set `patch` and `length=0` to unload a sample from RAM. |



Expand Down Expand Up @@ -419,6 +420,16 @@ amy.send(osc=0, bp0=',,,0.9') # No trailing commas.
```
.. we effectively end up with `bp0='0,1,1000,0.9`, i.e. the 4 elements in the second `bp0` string change the first breakpoint set to have only 2 breakpoints, meaning a constant amplitude during note-on, then a final slow release to 0.9 -- not at all like the first form, and likely not what we wanted.

## Audio input and effects

By setting `wave` to `AUDIO_IN0` or `AUDIO_IN1`, you can have either channel of a stereo input act as an AMY oscillator. You can use this oscillator like you would any other in AMY, apply global effects to it, add filters, change amplitude, etc.

```
amy.send(osc=0, wave=amy.AUDIO_IN0, vel=1)
amy.echo(1, 250, 250, 0.5, 0.5)
```

If you are building your own audio system around AMY you will want to fill in the buffer `amy_in_block` before rendering. Our included `miniaudio`-based system does this for you. See [`amychip`](https://github.com/shorepine/amychip) for a demo of this in hardware.

## FM & ALGO type

Expand Down Expand Up @@ -571,7 +582,7 @@ Note that the default `bp0` amplitude envelope of the `PARTIALS` osc is a gate,

## PCM

AMY comes with a set of 67 drum-like and instrument PCM samples to use as well, as they are normally hard to render with additive, subtractive or FM synthesis. You can use the type `PCM` and patch numbers 0-66 to explore them. Their native pitch is used if you don't give a frequency or note parameter. You can update the PCM sample bank using `amy_headers.py`.
AMY comes with a set of 67 drum-like and instrument PCM samples to use as well, as they are normally hard to render with additive, subtractive or FM synthesis. You can use the type `PCM` and patch numbers 0-66 to explore them. Their native pitch is used if you don't give a frequency or note parameter. You can update the baked-in PCM sample bank using `amy_headers.py`.


```python
Expand All @@ -588,6 +599,25 @@ amy.send(vel=0) # note off
amy.send(wave=amy.PCM,vel=1,patch=35,feedback=1) # nice violin
```

## Sampler (aka Memory PCM)

You can also load your own samples into AMY at runtime. We support sending PCM data over the wire protocol. Use `load_sample` in `amy.py` as an example:

```python
amy.load_sample("G1.wav", patch=3)
amy.send(osc=0, wave=amy.PCM, patch=3, vel=1) # plays the sample
```

You can use any patch number. If it overlaps with an existing PCM baked in number, it will play the memory sample instead of the baked in sample until you `unload_sample` the patch.

If the WAV file has sampler metadata like loop points or base MIDI note, we use that in AMY. You can set it directly as well using `loopstart`, `loopend`, `midinote` or `length` in the `load_sample` call. To unload a sample:

```python
amy.unload_sample(3) # unloads the RAM for patch 3
```

Under the hood, if AMY receives a `load_sample` message (with patch number and nonzero length), it will then pause all other message parsing until it has received `length` amount of base64 encoded bytes over the wire protocol. Each individual message must be base64 encoded. Since AMY's maximum message length is 255 bytes, there is logic in `load_sample` in `amy.py` to split the sample data into 188 byte chunks, which generates 252 bytes of base64 text. Please see `amy.load_sample` if you wish to load samples on other platforms.

## <a name="voices_and_patches"></a>Voices and patches (DX7, Juno-6, custom) support

Up until now, we have been directly controlling the AMY oscillators, which are the fundamental building blocks for sound production. However, as we've seen, most interesting tones involve multiple oscillators. AMY provides a second layer of organization, **voices**, to make it easier to configure and use groups of oscillators in coordination. And you configure a voice by using a **patch**, which is simply a stored list of AMY commands that set up one or more oscillators.
Expand Down
61 changes: 51 additions & 10 deletions amy.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,11 @@
AMY_NCHANS = 2
AMY_OSCS = 120
MAX_QUEUE = 400
SINE, PULSE, SAW_DOWN, SAW_UP, TRIANGLE, NOISE, KS, PCM, ALGO, PARTIAL, PARTIALS, BYO_PARTIALS, CUSTOM, OFF = range(14)
SINE, PULSE, SAW_DOWN, SAW_UP, TRIANGLE, NOISE, KS, PCM, ALGO, PARTIAL, PARTIALS, BYO_PARTIALS, AUDIO_IN0, AUDIO_IN1, CUSTOM, OFF = range(16)
FILTER_NONE, FILTER_LPF, FILTER_BPF, FILTER_HPF, FILTER_LPF24 = range(5)
ENVELOPE_NORMAL, ENVELOPE_LINEAR, ENVELOPE_DX7, ENVELOPE_TRUE_EXPONENTIAL = range(4)
RESET_ALL_OSCS, RESET_TIMEBASE, RESET_AMY = (8192, 16384, 32768)
AMY_LATENCY_MS = 0
AMY_MAX_DRIFT_MS = 20000

override_send = None
mess = []
Expand Down Expand Up @@ -158,8 +157,8 @@ def message(**kwargs):
# I=int, F=float, S=str, L=list, C=ctrl_coefs
kw_map = {'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': 'cI', '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', 'clone_osc': 'CI',
'eq': 'xL', 'filter_type': 'GI', 'algorithm': 'oI', 'ratio': 'IF', 'latency_ms': 'NI', 'algo_source': 'OL',
'bp0': 'AL', 'bp1': 'BL', 'eg0_type': 'TI', 'eg1_type': 'XI', 'debug': 'DI', 'chained_osc': 'cI', 'mod_source': 'LI',
'eq': 'xL', 'filter_type': 'GI', 'algorithm': 'oI', 'ratio': 'IF', 'latency_ms': 'NI', 'algo_source': 'OL', 'load_sample': 'zL',
'chorus': 'kL', 'reverb': 'hL', 'echo': 'ML', 'load_patch': 'KI', 'store_patch': 'uS', 'voices': 'rL',
'external_channel': 'WI', 'portamento': 'mI',
'patch': 'pI', 'num_partials': 'pI', # Note alaising.
Expand Down Expand Up @@ -233,10 +232,6 @@ def stop_store_patch(patch_number):
global saved_override, override_send
override_send = saved_override

if(patch_number<1023 or patch_number>1055):
print("invalid memory patch number (1024-1055)")
return

m = "u"+str(patch_number)+retrieve_patch()
send_raw(m)

Expand Down Expand Up @@ -295,9 +290,9 @@ def render(seconds):
def start():
live()

def live(audio_device=-1):
def live(audio_playback_device=-1, audio_capture_device=-1):
import libamy
libamy.live(audio_device)
libamy.live(audio_playback_device, audio_capture_device)

# Stops live mode
def pause():
Expand All @@ -310,7 +305,53 @@ def stop():
def restart():
import libamy
libamy.restart()

def unload_sample(patch=0):
s= "%d,%d" % (patch, 0)
send(load_sample=s)
print("Patch %d unloaded from RAM" % (patch))

def load_sample(wavfilename, patch=0, midinote=0, loopstart=0, loopend=0):
from math import ceil
import amy_wave # our version of a wave file reader that looks for sampler metadata
# tulip has ubinascii, normal has base64
try:
import base64
def b64(b):
return base64.b64encode(b)
except ImportError:
import ubinascii
def b64(b):
return ubinascii.b2a_base64(b)[:-1]

w = amy_wave.open(wavfilename, 'r')

if(w.getnchannels()>1):
# de-interleave and just choose the first channel
f = bytes([f[j] for i in range(0,len(f),4) for j in (i,i+1)])
if(loopstart==0):
if(hasattr(w,'_loopstart')):
loopstart = w._loopstart
if(loopend==0):
if(hasattr(w,'_loopend')):
loopend = w._loopend
if(midinote==0):
if(hasattr(w,'_midinote')):
midinote = w._midinote
else:
midinote=60

# Tell AMY we're sending over a sample
s = "%d,%d,%d,%d,%d,%d" % (patch, w.getnframes(), w.getframerate(), midinote, loopstart, loopend)
send(load_sample=s)
# Now generate the base64 encoded segments, 188 bytes / 94 frames at a time
# why 188? that generates 252 bytes of base64 text. amy's max message size is currently 255.
for i in range(ceil(w.getnframes()/94)):
message = b64(w.readframes(94))
send_raw(message.decode('ascii'))
print("Loaded sample over wire protocol. Patch #%d. %d bytes, %d frames, midinote %d" % (patch, w.getnframes()*2, w.getnframes(), midinote))


"""
Convenience functions
"""
Expand Down
Loading

0 comments on commit 74d61d8

Please sign in to comment.