As I continue my learning experience with Elixir, having a fun project to stay motivated is always a good idea.

I saw an interesting project called AMY on Hacker News for sound generation (which has NOTHING to do with Elixir, BTW).

AMY provides a software audio synthesizer with 64 simultaneous “voices” (aka oscillators) that can generate sound via several different methods:

  • Periodic pure waveforms
  • Short audio samples
  • FM/DX7 style synthesizer patches
  • Cool stuff I don’t quite understand yet with “partials”

For more details, the AMY github project is at github and has all the details.

AMY’s authors ( Brian Whitman and DAn Ellis ) use it for another interesting project called the Alles mesh synthesizer .

AMY and Alles are loving nods to sound experiments of the past, one of which I’d heard about (Atari’s 1980’s AMY sound chip ) and the other I’d not (Hal Alles’ 1970’s Bell Labs Digital Synthesizer ), but apparently the Atari AMY chip was inspired by the Alles synthesizer.

While Brian’s and DAn’s projects aren’t direct copies of their namesakes, the inspiration is evident the more I learn.

Anyway, after playing with AMY (the software package) for several days, I wanted to get back to the Elixir part of this project, and thought it would be interesting to connect the two.

Looking at the existing AMY code, there are three obvious methods that Elixir might be able to use for control.

  • A standalone test tool using the C-API that generates three tones using data structures and function calls.
  • A WASM module with the entire C-API, used by their demo webpage.
  • A Python module with wrappers for the C-API.

None of these methods quite fit my mental picture, with the closest being the Python module.

This was slightly more complicated than I wanted however, and would require me to write data structure conversion and method invocation code.

Rather, I wanted something that was somewhat like a black-box, kind of how the POKEY chip of the Atari 400 or SID chip of the Commodore 64 worked:

Joe in 1981

graph LR; A[Atari 400 basic] B[POKEY sound chip] C[square wave sounds] A-- "magic
POKE" -->B; B-->C;

Joe in 1982

graph LR; A[Commodore 64 basic &
6502 code] B[SID sound chip] C[more kinds of wave sounds] A-- "write to magic
addresses" -->B; B-->C;

Joe in 2023

graph LR; A[Elixir code] B[AMY audio
synthesizer library] C[even more
amazing sounds] A-- some kind of
magic dust -->B; B-->C;

One enticing option the API exposes is a function for what they call a “wire protocol” that doesn’t require sharing complex data structures or functions.

The wire protocol uses short ASCII strings to control AMY, which the authors describe as a way to “make using AMY from any sort of environment as easy as possible”.

Wire protocol messages are handled via a function named amy_play_message that accepts a char * parameter.

Seems like this was exactly the magic dust I was looking for!

Using this, my strategy is to create a standalone binary to pass wire protocol via standard input into the amy_play_message function.

sequenceDiagram participant iex as Iex shell participant amy as amy-message participant out as speaker iex ->>+ amy : launch binary as child process amy -->> iex : open stdin and stdout channels iex ->> amy : send "v0w0f440l1" to stdout amy -->> iex : send response amy -->> out : generate 440 Hz sine wave

I contributed the amy-message code to the AMY github codebase and have been using it to learn the protocol and the behavior of AMY’s voices.

I have an uncommited change that enables command-line history and editing when amy-message is run from a shell; reach out if you’d like more details.

Another rationale for creating amy-message was to use it as a “port driver” inside Elixir.

Port drivers are discussed at hex.pm, but the short version is Elixir runs an external program uses stdin/stdout as a communication channel.

Port drivers provide a relatively safe way to extend Elixir functionality without introducing unsafe code into Elixir’s BEAM VM.

Since AMY and the associated sound libraries start threads and have some amount of dynamic memory allocation that might be “dangerous”, I feel better about isolating things in a child process to keep BEAM safe.

With this work in place, it’s just a matter of putting the amy-message binary somewhere Elixir can find it, and sending wire procotol strings through Port functions.

  • Here’s a summary of use
Port.open({:spawn, "./amy-message -l"}, [:binary])
flush() # see the available audio backends
p = Port.open({:spawn, "./amy-message -d 0"}, [:binary])
send(p, {self(), {:command, "v5w7p60"}})  # set osc5 to PCM "bird" patch
send(p, {self(), {:command, "v5l1"}})  # play osc5
  • Here’s a video of me building and running it…
  • Here’s a hastily written web app inspired from an AMY example to try the wire protocol.

  • Joe’s AMY explorer

After all this, it might seem like very little Elixir learning happened, which is kind of true, but this is organically growing towards an audio project for Elixir I hope to write more of and about.

So stay tuned…


Notes:

AMY currently runs on Windows, macOS, and Linux systems (including Raspberry Pi’s), so this same approach should work equally well on these platforms. I’ve mostly run on x86_64 Linux, but lightly tested on macOS and a Raspberry Pi 3B.

AMY also targets ESP32 and other minimal horsepower platforms. It’s conceivable that a variation of the port example above could send wire protocol via UART or Bluetooth, or something else.


Links: