There is a long history of making 1-bit music that is tied to obsolete technology and the exploration of limitations of piezo speakers in the context of music-making.
A piezo transducer is a simple audio transducer that can be used as a contact microphone / pickup and also as a small, inexpensive speaker.
The piezo transducer is a crystal element that expands and contracts if a voltage is applied, and generates a voltage depending on whether its shape is forcibly changed (e.g. pressed and depressed).
The former characteristic makes it a speaker (when a changing voltage originating from an audio frequency is applied, the piezo expands and contracts in response) whilst the latter characteristic makes it a pickup (when the piezo expands and contracts in response to vibrations such as sound vibrations through a solid medium, then a voltage that corresponds to these vibrations is created by the piezo).
We will be using the piezo as an output transducer, and we will be creating simple 1-bit "beeper" tones using the Teensy.
In terms of 1-bit music, there is still a vibrant scene of music sequencers, emulators, music engines and trackers that are being used today.
Some standout examples include:
• Tristan Perich's minimal hardware odyssey as art and product, "1-Bit Symphony"
• Houston tracker, the first 1-bit sequencer for TI-82 graphic calculator
• 1tracker, a new cross-platform music sequencer that can use a wide variety of 1-bit music engines and routines to generate polyphonic tones using only a mono 1-bit output
For more 1-bit music news and releases (both technical and creative) from the 1-bit music community, please see Shiru's 1-Bit Music News website.
To see a more complex end-point for the material presented in this post, please see my own 1-Bit Beeper USB Physical Instrument MIDI Plugin.
Hardware Setup
As you can imagine, the hardware setup for this is really very simple.
You will need:
1 x Teensy board
2 x alligator leads
1 x piezo transducer
Let's start with a blank Teensy board.
Connect an alligator lead to the ground pin.
Connect an alligator lead to digital pin 10.
Connect the ground alligator lead to the black wire of the piezo.
Connect the alligator lead from digital pin 10 to read wire of the piezo.
One Bit Waveform Anatomy
We will be using a digital pin to drive the piezo either high (5V) or low (ground). Let's investigate the properties of such a waveform.
The resulting waveform is shown above as a general diagram of amplitude (in terms of voltage) against time. The waveform is split into two parts - an "on" portion and an "off portion. The amplitude of the "on" portion of the waveform will always be 5V, whilst the "off" portion of the waveform will always be ground.
This is because our digital pin can only either be "on" (i.e. 5V) or "off" (i.e. ground).
The period length determines the frequency, whilst the duty cycle length as a ratio to the period length determines the timbre of the pulse wave. In the diagram shown, the duty cycle is approximately 60% as a ratio to the period length being 100%.
A duty cycle of 50% will result in a "bassy" and "full" type tone whilst a duty cycle of 25% will result in a more "nasal" type tone.
Keep in mind that as our ears cannot hear phase inversion as an artifact, (i.e. an inverted waveform will sound the same as a non-inverted waveform if heard in isolation) a duty cycle of 25% will sound identical to a duty cycle of 75%!
Frequency, Period Length and Duty Cycle
Frequency and period length are connected and are directly proportionally:
period length in seconds = 1 / frequency
This formula can be re-written as:
frequency = 1 / period length in seconds
As we are dealing with a microcontroller, we might like to see the value of seconds expressed in either milliseconds (1 / 1000th of a second) or microseconds (1 / 1000000th of a second). We can then re-write both of these formulas as:
period length in milliseconds = 1000 / frequency in Hz
frequency in Hz = 1000 / period length in milliseconds
And
period length in microseconds = 1000000 / frequency in Hz
frequency in Hz = 1000000 / period length in microseconds
The reason for expressing these formulas in milliseconds and microseconds is that Teensy has two delay functions: delay() - which gives a pause in our code in terms of milliseconds - and delayMicroseconds() - which gives a pause in our code in terms of microseconds.
We can then imagine a construction of a pulse wave with a frequency of 440 Hz and a duty cycle of 50% as follows:
• Set digital pin to HIGH
• Pause for (1000000 / 440) * 0.5 microseconds
• Set digital pin to LOW
• Pause for (1000000 / 440) * 0.5 microseconds
The multiplication of 0.5 is used to express a duty cycle of 50%. First we have the total period length derived from the frequency, then we have the expression of duty cycle for when the pin is high versus when the pin is low.
If we wanted to have a wave with a frequency of 880 Hz and a duty cycle of 25%, we would express this as follows:
• Set digital pin to HIGH
• Pause for (1000000 / 440) * 0.25 microseconds
• Set digital pin to LOW
• Pause for (1000000 / 440) * 0.75 microseconds
Note that the duty cycle for the HIGH portion is 25%, which means that in order for the LOW portion to form the correct period length, we must make the ratio the remaining 75%.
My First Beep!
Let's put all of this together as a simple code example. Copy and past the following code into Arduino and upload it to the Teensy. You should hear a waveform of (roughly but not quite) 440 Hz.
The first two lines of code declare two variables. A variable in Arduino / Teensy is a small space in memory, where a value is equated with a symbol that we can reuse. As the name suggests, the value of a variable can change. In our first example, these two variables are static and are of the data "type" int as in integer, a whole number between -32,768 to 32,767 and takes up two bytes of our RAM.
The setup() function uses the variable "piezo", which holds the digital pin number of our piezo transducer. This pin - pin number 10 - is set as an OUTPUT using the pinMode function.
In our main loop() function, we have the part of the code that generates the waveform. The frequency is a variable, but the duty cycle is static and hardcoded as 50%.
• Try changing the frequency value and reupload to listen.
Variable Duty Cycle
We can extend this very simple example by adding a new variable for the duty cycle. The new code is as follows. Copy and paste-replace the first block of code in the Arduino software.
This code is almost identical to our first example with the difference being the addition of the duty cycle variable. Note how for the HIGH portion of the waveform, the duty cycle is a decimal point below 1.0, whilst for the LOW portion of the waveform, the duty cycle is given as 1 - duty_cycle. This is so that the total time for our period waveform adds up to 100% of the expected time to form the correct frequency.
• Try changing the duty_cycle value and reupload to listen.
Encapsulation
We can write our very own function that encapsulates our beeping, so that we can write a program that features multiple beeps of different frequency and time length.
Our encapsulation will feature function variables for:
• Frequency, which in the function is converted to period length
• Time in milliseconds, which in the function is converted to x number of repetitions of the period length to add up to the amount of time in milliseconds specified.
Once again, the code looks very familiar.• Pause for (1000000 / 440) * 0.5 microseconds
• Set digital pin to LOW
• Pause for (1000000 / 440) * 0.5 microseconds
The multiplication of 0.5 is used to express a duty cycle of 50%. First we have the total period length derived from the frequency, then we have the expression of duty cycle for when the pin is high versus when the pin is low.
If we wanted to have a wave with a frequency of 880 Hz and a duty cycle of 25%, we would express this as follows:
• Set digital pin to HIGH
• Pause for (1000000 / 440) * 0.25 microseconds
• Set digital pin to LOW
• Pause for (1000000 / 440) * 0.75 microseconds
Note that the duty cycle for the HIGH portion is 25%, which means that in order for the LOW portion to form the correct period length, we must make the ratio the remaining 75%.
My First Beep!
Let's put all of this together as a simple code example. Copy and past the following code into Arduino and upload it to the Teensy. You should hear a waveform of (roughly but not quite) 440 Hz.
int piezo = 10; int frequency = 440; void setup() { pinMode(piezo, OUTPUT); } void loop() { digitalWrite(piezo, HIGH); delayMicroseconds(1000000 / frequency * 0.5); digitalWrite(piezo, LOW); delayMicroseconds(1000000 / frequency * 0.5); }
The first two lines of code declare two variables. A variable in Arduino / Teensy is a small space in memory, where a value is equated with a symbol that we can reuse. As the name suggests, the value of a variable can change. In our first example, these two variables are static and are of the data "type" int as in integer, a whole number between -32,768 to 32,767 and takes up two bytes of our RAM.
The setup() function uses the variable "piezo", which holds the digital pin number of our piezo transducer. This pin - pin number 10 - is set as an OUTPUT using the pinMode function.
In our main loop() function, we have the part of the code that generates the waveform. The frequency is a variable, but the duty cycle is static and hardcoded as 50%.
• Try changing the frequency value and reupload to listen.
Variable Duty Cycle
We can extend this very simple example by adding a new variable for the duty cycle. The new code is as follows. Copy and paste-replace the first block of code in the Arduino software.
int piezo = 10; int frequency = 220; int duty_cycle = 0.7; void setup() { pinMode(piezo, OUTPUT); } void loop() { digitalWrite(piezo, HIGH); delayMicroseconds(1000000 / frequency * duty_cycle); digitalWrite(piezo, LOW); delayMicroseconds(1000000 / frequency * (1.0 - duty_cycle)); }
This code is almost identical to our first example with the difference being the addition of the duty cycle variable. Note how for the HIGH portion of the waveform, the duty cycle is a decimal point below 1.0, whilst for the LOW portion of the waveform, the duty cycle is given as 1 - duty_cycle. This is so that the total time for our period waveform adds up to 100% of the expected time to form the correct frequency.
• Try changing the duty_cycle value and reupload to listen.
Encapsulation
We can write our very own function that encapsulates our beeping, so that we can write a program that features multiple beeps of different frequency and time length.
Our encapsulation will feature function variables for:
• Frequency, which in the function is converted to period length
• Time in milliseconds, which in the function is converted to x number of repetitions of the period length to add up to the amount of time in milliseconds specified.
int piezo = 10; int duty_cycle = 0.7; void setup() { pinMode(piezo, OUTPUT); } void loop() { beep(440, 100); beep(880, 200); } void beep(float frequency, float time) { for(int i = 0; i < (time / (1000 / frequency)); i ++) { digitalWrite(piezo, HIGH); delayMicroseconds(1000000 / frequency * duty_cycle); digitalWrite(piezo, LOW); delayMicroseconds(1000000 / frequency * (1.0 - duty_cycle)); } }
In our main loop, we are referencing a new function called "beep". This function is user created, and and is defined further down after the end of the loop() function. Take note of the syntax of how "beep" is defined and created.
Let's take a closer look at "beep". beep() is void because it does not calculate and return any value. Instead, it simply executes the actions that are encapsulated within. beep() has two variables - a frequency variable of the data type float, and a time variable of the data type float. Look at how beep is called and referenced in the main loop(), something like beep(frequency, time in milliseconds to play that frequency).
beep() also contains a "for loop". A for loop is a small, repeating chunk of code that usually takes the form of:
for(int i; i < some number that is the number of repetitions; i ++)
{
insert code here to loop
}
for() is a structural function, and has a few main parts. First, we declare our index variable that is used as a counter to count the number of repetitions.
Then we set a maximum number of repetitions - if our counter hits this maximum number, then our program leaves the for() loop. Then we tell our for loop that for every repetition, we want to increment our index (i) by one (given as i++).
After this, we have two curly brackets that contain the code that should be looped. An in depth discussion of loops in Arduino can be found here: http://arduino.cc/en/Reference/for
The important thing with our for() loop in the beep() user function is that it automatically calculates how many repetitions of the period length of a particular frequenced are required in order to be a pre-defined length of time in milliseconds.
This might sound confusing, but consider the following:
• We have a frequency that we want to play in Hz
• We have a length of time that we want that frequency to play for in milliseconds
• We can deduce how long one period length of this frequency would take in terms of milliseconds
• We can then deduce how repetitions of this particular period length would be needed to achieve the length of time in milliseconds that we desire our sound to be played for
All of the above is found in the declaration of the for() loop:
for(int i = 0; i < (time / (1000 / frequency)); i ++) {
The key is:
time / (1000 / frequency)
This gives us the number of repetitions of the period length required in order to achieve the note length in terms of milliseconds.
Let's deal with a concrete example. Imagine that we have the function beep(440, 1000) in our main loop() function. This means that 1) we want to play a sound with a frequency of 440 Hz and 2) we want this sound of 440 Hz to be played for 2000 milliseconds
2000 milliseconds / (1000 / 440 Hz) = 880
Therefore, it would take 880 repetitions of a waveform with a frequency of 440 Hz to make a tone that is 2000 milliseconds long. This is the number of repetitions thus defined in our for() loop in the beep() function.
• Try sequencing a series of frequencies by using many beep() function calls in the main loop()
Added Complexity: Drums, MIDI Note Conversions, Note and Song Format
I will not go through the following code beyond saying the following:
• This code is an extension of the above
• It features it's own song format, made up of notes
• It features it's own note format, made up of note type (noise / periodic), pitch (in terms of MIDI notes), length (in sixteenths) and duty cycle per note
• It features a percussive noise sound with variable noise shape
• It features MIDI note to pitch conversion using the Math.h library
• It converts rhythmic values of sixteenths to time length values of milliseconds relative to the BPM
• Read through this code and see if you can follow it to some degree.
• Try changing the song[] array, which holds all of the note and pitch data.
Download code as text file here: http://milkcrate.com.au/_other/downloads/arduino/complex_beeper_example/complex_beeper_example.txt
The song is a poor transcription of the first eight bars of Bach's prelude to the first solo cello suite.
Demo Video
0 comments:
Post a Comment