This is a personal project exploring the concepts of simple digital sound/music,
code generation, and microcontrollers.
The main goal of this project was to have an Arduino Uno play music
written initially contained in an .abc
file.
I originally chose to write it in C, but I've re-written the entire stack in
Rust.
Notes on the C implementation can be found
here.
- sequences of sounds can be played via passive buzzer on the Arduino
- use interrupts and internal clock for note timing
- high-precision time keeping between notes
-
.abc
files can be parsed using a host computer- pitch parsing
- length parsing
- switch to
regex?parser expression grammar - test coverage including sample
.abc
files - ABC time signature
- ABC key signature
- C header files (
.h
) can be generated on the host computer - Arduino C/C++ program can include the generated header file and play its contents
- user-friendly command line interface
- one command to parse a file, generate code, then build and upload the Arduino program
- host computer program can play and test parsing/generation without the need of an Arduino
- parse MusicXML files
- allow for multiple buzzer to play harmonies/chords
- re-write in Rust 🦀
- Windows support for build and upload scripts
Note this has only been tested on Linux (Debian 10 and 11), though it should work on any Unix-like system (BSD, MacOS, Solaris, etc.).
- Ensure a nightly Rust toolchain is installed (e.g. using rustup).
- (Optional) Set up rust-analyzer for your
editor/IDE. If you do, make sure to add
ard-r-sound-embedded
(the Arduino executable crate) torust-analyzer.files.excludeDirs
. This is necessary because the crate must be onnightly
and has a specific forced build target. This also means that if you want to userust-analyzer
with this crate, you need to open another instance of your editor/IDE from inside of its directory.
Build via cargo build
- add
--release
if you want an optimized build - the
ard-r-sound
binary will be intarget/debug
ortarget/release/
Usage:
ard-r-sound <input_file.abc> [-f <output format>] [-o <output_file_path>]
-f
options (all require a-o
exceptplay
)
The ard-r-sound-embedded
crate builds into an Arduino executable.
Using arduino_hal
, it can be built and uploaded in a single command
(assuming you've done cd ard-r-sound-embedded/
first!):
cargo run -- -P <device_file>
(probably /dev/ttyACM0)
The ard_r_sound_macros::static_from_file!{variable_name, file_path}
procedural macro parses and optimizes an abc file.
variable_name
must be a valid Rust identifier.file_path
is a file path -- not aString
or&str
orPath
- This is implemented by concatenating the rest of the syntax tokens passed to the macro.
For example,
static_from_file!{SONG, ../misc/example-abcs/mary.abc}
expands to something like:
static SONG: ard_r_sound_base::OptimizedStatic<...> =
ard_r_sound_base::OptimizedStatic<...> {
// unique notes
uniques: [...],
// the song itself, stored as indexes of the uniques array
list: [...],
};
The OptimizedStatic
struct is const-generic over the amount of unique
notes in the song and total number of notes in the song.
The Arduino should wired up similarly to the following diagram:
(diagram made with Fritzing and GIMP)
The toggle switch is there in order to disable the sound output without unplugging the Arduino. Additionally, the potentiometer is put in-line to reduce the volume of the buzzer. These are useful for testing but not necessary. A resistor could be used in place of the potentiometer if a constant volume reduction is needed.
The specific digital pin that is used isn't particularly important, but it is currently hardcoded to be pin 5.
The abc
file is parsed into an internal representation, then converted
into a more space-efficient format, all at compile-time.
The main goal of this approach is to reduce executable size. Via the Arduino website:
The ATmega328 chip found on the Uno has the following amounts of memory:
Flash 32k bytes (of which .5k is used for the bootloader)
SRAM 2k bytes
EEPROM 1k byte
By pre-processing the song, we are able to de-duplicate the notes and save space. Thus, the song in Arduino memory is not an array of "full-fat" notes, but two arrays: one that contains unique notes and another that contains indexes/references to those unique notes (the sequence of notes to play the song). Since this is all done before the program is actually compiled, this data can reside in read-only memory instead of RAM. The actual compression/efficiency ratio of this technique depends on the number of repeated notes of a specific song.
Audio is generated by setting the buzzer's pin to high for
some amount of time, once per period.
For example, if we want to play middle A, which has a frequency of 440Hz
(or 432Hz if you are insane),
we would need to pulse to the pin 440 times a second,
or once every 0.0022727...
seconds (the period of the wave).
This could be done in a naïve way by busy-waiting,
but it would be better to utilize the Arduino's internal clocks
to trigger interrupts.
As specified in the ATmega328p datasheet, we have to set up several registers
on the Arduino to do this.
In Rust, arduino_hal
gives convenient bindings to do this.
Essentially, the the Arduino's clock will trigger an interrupt whenever
its internal counter register matches a number that we set.
Whenever this interrupt is triggered, the execution of the program switches
to a service routine, then back to whatever it was doing before.
In this interrupt service routine, we need to pulse the buzzer pin.
This becomes a bit more complicated when we strive to minimize the length of the interrupt service routine (ISR). This is desirable because interrupts are disabled for the duration of the ISR. This means that any other interrupt can't be handled during that time. Since we have to hold the buzzer's pin high for several clock cycles in order to actually generate some noise, it would be best not to do that inside of the ISR. Thus, we need to set up the clock to cyclically trigger an interrupt on two different periods -- one for the active duration and one for the inactive duration.
The Arduino's internal clock also allows for the clock to directly toggle some pins whenever there's a compare match, but each clock is linked to specific pins (see the "Timers and the Arduino" section of Secrets of Arduino PWM). Because we have 2 different timings and need to alternate between them (by writing to specific registers), we need to use an interrupt anyway. Thus, I'm not sure that setting the pin to toggle on compare match would be that useful, given the pin restriction that it brings.
Unless otherwise noted, all files in this repository are released under the terms of the GNU General Public License version 3 or, at your choice, any later version. (GPLv3+). See COPYING for more details.
.abc
notation: ABC Notation Homepage, ABC examples, the ABC Standard, the ABC Plus Project- Formula for note frequency: One explanation, another explanation
- Using the Arduino internal clocks: in Rust, in C++
- The ATmega328P Datasheet
- Secrets of Arduino PWM