My attempts to imitate different bird calls ended up in a highly parameterizable function that can also serve as a sweep generator for other experiments. I also discovered that a lambda expression is useful to make the code more concise.
For my experiments I used a piezo disk buzzer directly connected to a pin of an ESP32 DevKit V1.
------------. ESP32 | | ____|| GPIO_NUM_4 o-------------| || piezo | |-¦¦-|| buzzer GND o-------------|____|| | ||
The basic function for sound generation is to turn a buzzer on and off. For simplicity, the on time and the off time should be the same. If we pack this functionality into a lambda expression and call it buz, we get:
// lambda expression to toggle a buzzer once // with a duty cycle of 50% |¨¨¨|___| // T0 T0 auto buz = [](uint32_t usT0){ digitalWrite(PIN_BUZZER, HIGH); delayMicroseconds(usT0); digitalWrite(PIN_BUZZER, LOW); delayMicroseconds(usT0);};
If we call this function n times, we get a tone consisting of n square waves of the period 2*T0 microseconds. A bird call, however, is not a sound of a single frequency, but a periodic chirping with rising and falling frequencies.
So the chirp function I want to design surely needs the parameters "start frequency" and "end frequency". Additionally I want to be able to define in how many steps the end frequency should be reached. And as already mentioned, I can specify how many periods the tone of a single frequency should consist of.
I could do this by adding a small frequency difference at each step. However, it is also easy to divide the frequency range into equal intervals, as in the chromatic scale, and multiply the frequency of successive notes by a constant factor.
A chirp function that sweeps a certain frequency range once in n steps could therefore look like this:
chirp(fStart, fEnd, nSteps, nPeriods)
But wait, a bird chirps several times with small interruptions between each peep. So the function gets 2 more parameters, namely nChirps and msPause, which specify how many chirps should sound and in which time interval.
The whole conversion of frequencies into periods and the determination of the frequency multiplier are hidden in the function body and the "birdcall designer" can fully concentrate on the selection of the function parameters to imitate a specific bird.
After all these considerations I decided to make the duty cycle variable as well (1 .. 99%) and finally the chirp function looked like this:
Because successive frequencies are multiplied by a constant factor at each step, the frequencies increase exponentially.
void chirp(uint32_t fStart, uint32_t fStop, int nSteps, int nPeriods, int nChirps, int duty, uint32_t msPause) { double pStart = 1000000.0 / (double)fStart; double pStop = 1000000.0 / (double)fStop; // We calculate the multiplicator k to get fStop in nSteps // fStop = fStart * k ^ nSteps ---> 1/pStop = 1/pStart * k ^ nSteps ---> pStart/pStop = k ^ nSteps // But we use the periods, because that fits better to toggle the buzzer double k = log(pStart / pStop) / (double)nSteps; k = exp(-k); // here we get actually 1/k, because we will multiply in the buzzer loop (see beloW) ❗ // lambda expression to toggle a buzzer once // with a duty cycle of duty% |¨¨¨|______| // tOn tOff auto buz = [](uint32_t usTon, uint32_t usToff){ digitalWrite(PIN_BUZZER, HIGH); delayMicroseconds(usTon); digitalWrite(PIN_BUZZER, LOW); delayMicroseconds(usToff);}; for (int n = 0; n < nChirps; n++) // output nChirps { uint32_t p = (uint32_t)round((double)pStart); for (int s = 0; s <= nSteps; s++) { uint32_t tOn = period * duty / 100; uint32_t tOff = period - tOn; // output nPulses with same pitch for (int n = 0; n < nPeriods; n++) buz(tOn, tOff); period *= k; // calculate next period ❗ } delay(msPause); } }
The diagram below shows the generated frequencies with a chirp over 3 octaves from 440 Hz to 3520 Hz in 12 steps per octave.
Start and end frequency are each 1 Hz. The end frequency is reached in one step and the two frequencies each consist of one period and the chirp is executed once. The duty cycle is 50%. At the end of this chirp there is a 2 sec. wait. But because I call the function repeatedly in the main loop of the program, the pulse train also appears repeatedly in the screenshot from my scope (see below).
Same as above, but the final frequency is 2 Hz. We see one pulse of 1Hz and one of 2Hz. The off-time of the second pulse is merged with the wait of 2 seconds.
The start frequency is 1 HZ, the end frequency is 4 Hz. This is reached in 4 steps and each intermediate frequency consists of 1 period and 1 chirp is executed. At the end also 2000 ms are waited.
Generates the 12 semitones of the chromatic scale starting at 880 Hz and ending at the concert pitch 440 Hz. Each tone consists of 10 periods. To hear the individual tones better, increase the number of periods from 10 to 1000.
Let's combine several chirps to imitate different birds, e.g. the cuckoo.
void cuckoo() { const float third = 1.222; // minorThird = 1.18 ... majorThird = 1.25 const float cuc = 667; // E4 const float koo = cuc / third; // ~C#4 for (int i = 1; i < random(1,5); i++) { chirp(cuc, cuc, 1, 46, 1, 50, 200); chirp(koo, koo, 1, 52, 1, 50, 830); } }
Start and end frequency of "cuc" as well as of "koo" are the same. Therefore, the two calls sound 2 * 46 * 1000/667 = 138 ms and 2 * 52 * 1000/546 = 190 ms long with a pause of 200 ms in between, summing up to a total of ~530ms.
This artificial cuckoo does not sound very natural, because the feathered cuckoo calls not with rectangular but with almost pure sine tones.
The cawing of a raven comes quite close to the real call if we reduce the duty cycle to 20% and call the function like this:
void raven() { chirp(75,65,8,4,random(2, 6), 20, 350); }
Why would a bird change the frequencies of its beeps only exponentially? Interesting effects can be created if the frequencies of a chirp are changed sinusoidally.
We map the step interval (0 .. nSteps) to the range (0 .. 2Pi), so we run through this range in n steps. For each step we now calculate the corresponding frequency in the following way:
f = fm + fa * sin(k * s) with fm = (fStart + fStop) / 2 The "middle" frequency fa = (fStop - fStart) / 2 The "frequency swing (amplitude)" around fm k = TWO_PI / nSteps; s = step number, 0 .. nSteps
But why should we limit ourselves to sinusoidally graduated frequencies? We can imagine any other mathematical rules to get from one frequency to the next.
If we pass the "frequency generator" as a parameter to our chirp function, we get a generalized chirp function. But first we define a type for the frequency generator function as follows:
typedef double (*FreqGen)(int stepNbr, double fStart, double fStop,int nSteps);
The generalized chirp function now looks like this:
void chirp(double fStart, double fStop, int nSteps, int nPeriods, int nChirps, FreqGen fgen, int duty, uint32_t msPause) { auto buz = [](uint32_t usTon, uint32_t usToff){ digitalWrite(PIN_BUZZER, HIGH); delayMicroseconds(usTon); digitalWrite(PIN_BUZZER, LOW); delayMicroseconds(usToff);}; for (int n = 0; n < nChirps; n++) // output nChirps { for (int s = 0; s <= nSteps; s++) { double fNext = fgen(s, fStart, fStop, nSteps); // ❗ call the frequency generator double p = 1000000.0 / fNext; uint32_t tOn = p * duty / 100.0; uint32_t tOff = p - tOn; //log_i("%2d: f = %5.2f, ton = %d, toff = %d", s, fNext, tOn, tOff); for (int n = 0; n < nPeriods; n++) buz(tOn, tOff); } delay(msPause); } }
Now we only have to program generators with different mathematical rules:
double linearScale(int stepNbr, double fStart, double fStop,int nSteps) { double df = (fStop - fStart) / nSteps; double fNext = fStart + stepNbr * df; return fNext; }
double chromaticScale(int stepNbr, double fStart, double fStop,int nSteps) { // We calculate the multiplicator k to get fStop in nSteps // fStop = fStart * k ^ nSteps double k = log(fStop / fStart) / (double)nSteps; double fNext = fStart * exp(k * stepNbr); return fNext; }
double sinePiScale(int stepNbr, double fStart, double fStop,int nSteps) { double fa = (fStop - fStart); // max. frequency swing const double k = PI / nSteps; double fNext = fStart + fa * sin(k * stepNbr); // get next frequency return fNext; }
double sine2PiScale(int stepNbr, double fStart, double fStop,int nSteps) { double fm = (fStart + fStop) / 2.0; // arithmetic mean double fa = (fStop - fStart) / 2.0; // max. frequency swing around fm const double k = TWO_PI / nSteps; double fNext = fm + fa * sin(k * stepNbr); // get next frequency return fNext; }
double cosinePiScale(int stepNbr, double fStart, double fStop,int nSteps) { double fm = (fStart + fStop) / 2.0; // arithmetic mean double fa = (fStop - fStart) / 2.0; // max. frequency swing around fm const double k = PI / nSteps; double fNext = fm - fa * cos(k * stepNbr); // get next frequency return fNext; }
double cosine2PiScale(int stepNbr, double fStart, double fStop,int nSteps) { double fm = (fStart + fStop) / 2.0; // arithmetic mean double fa = (fStop - fStart) / 2.0; // max. frequency swing around fm const double k = TWO_PI / nSteps; double fNext = fm - fa * cos(k * stepNbr); // get next frequency return fNext; }
double atanPiScale(int stepNbr, double fStart, double fStop,int nSteps) { double k = (fStop - fStart)/atan(PI); double fNext = fStart + k * atan(PI/nSteps * stepNbr); return fNext; }
double atan2PiScale(int stepNbr, double fStart, double fStop,int nSteps) { double k = (fStop - fStart)/atan(TWO_PI); double fNext = fStart + k * atan(TWO_PI/nSteps * stepNbr); return fNext; }
We have implemented a universal chirp function that can generate frequencies that follow a mathemathical rule. For comparison, we call all generators and plot the generated frequencies all together in one diagram. The frequency range is swept in 5 steps.
The first diagram shows the frequencies when fStart is smaller than fStop so that all chirps start with increasing frequencies.
The second diagram shows the frequencies when fStart and fStop are swapped so that all chirps start with decreasing frequencies.
I had always been fascinated by the function sinc(x) = sin(x)/x with its increasing and decreasing waveform. How does an artificial bird sound whose beep starts with an increasing vibrato, changes into a sharp beep and fades out with a decreasing vibrato? But remember, it's not the volume, it's the pitch.
To which number range should I map my number of frequency steps? -5π..+5π or -3π..+3π maybe also 0..7π or -4π..0? Obviously, I need another parameter in my generator function to specify the multiples of π.
typedef double (*FreqGenSinc)(int stepNbr, double fStart, double fStop,int nSteps, int nPi);
However, this function has a different signature than the previous definition and compiling the chirp function with a generator with this definition will result in an error message. However, thanks to polymorphism in C++ we can define a second chirp function and thus avoid the problem (see chirpmaker.h).
double sincScaleNpi_Npi(int stepNbr, double fStart, double fStop,int nSteps, int nPi) { double halfRange = nPi * PI; double range = 2 * halfRange; auto sinc = [](double x) { return fabs(x) < 0.001 ? 1.0 : sin(x)/x; }; double fa = (fStop - fStart); // max. frequency swing double k = range / nSteps; double fNext = fStart + fa * sinc(k * stepNbr - halfRange); // get next frequency return fNext; }
double sincScaleNpi_0(int stepNbr, double fStart, double fStop,int nSteps, int nPi) { double range = nPi * PI; auto sinc = [](double x) { return fabs(x) < 0.001 ? 1.0 : sin(x)/x; }; double fa = (fStop - fStart); // max. frequency swing double k = range / nSteps; double fNext = fStart + fa * sinc(k * stepNbr - range); // get next frequency return fNext; }
double sincScale0_Npi(int stepNbr, double fStart, double fStop, int nSteps, int nPi) { double range = nPi * PI; auto sinc = [](double x) { return fabs(x) < 0.001 ? 1.0 : sin(x)/x; }; double swap = fStart; fStart = fStop; fStop = swap; double fa = (fStop - fStart); // max. frequency swing double k = range / nSteps; double fNext = fStart + fa * sinc(k * stepNbr); // get next frequency return fNext; }
void phaser(uint32_t freq, int nPeriods, int dutyStart, int dutyEnd, int nChirps, uint32_t msPause) { uint32_t p = 1000000/freq; auto buz = [](uint32_t usTon, uint32_t usToff){ digitalWrite(PIN_BUZZER, HIGH); delayMicroseconds(usTon); digitalWrite(PIN_BUZZER, LOW); delayMicroseconds(usToff);}; for (int n = 0; n < nChirps; n++) // output nChirps { for (int d = dutyStart; d <= dutyEnd; d++) { uint32_t tOn = p * d / 100; uint32_t tOff = p - tOn; for (int n = 0; n < nPeriods; n++) buz(tOn, tOff); } delay(msPause); } }
If we call the function with phaser(500, 20, 1, 99, 1, 2000)
,
we hear a tone of 500 Hz whose duty cycle is changed from 1 ... 99 %.
Each of the 99 tones consists of 20 periods.
If we listen carefully, we notice how the timbre of the selected frequency
and also the volume changes. This is due to the fact that as the duty cycle
changes, the composition of the harmonic frequencies of the sound changes.
With all this knowledge, I implemented various "birds" in the program that are called to sing in random order. Now I wanted to let the birds whistle in another program. Soon I realized that my few lines of code would get lost in the extensive code of the sound generation. Therefore I put this part into a universal class Chirpmaker. Now the application of the sound functions was reduced to a few lines:
#include "Chirpmaker.h" const uint8_t PIN_BUZZER = GPIO_NUM_4; Chirpmaker cm(PIN_BUZZER); // Creates an object that emits sounds at pin 4 void setup() { // Your initialization code cm.signet(); // Call a signet to signal the end of setup } void loop() { cm.phoneCall(7); cm.birdConcert(3000); cm.chirp(1000, 3000, 70, 100, 5, sincScaleNpi_Npi, 50, 500); }
I hope you enjoyed this little excursion into sound generation. Maybe a reader programs an alarm clock which greets him in the morning with a bird concert.
Interested? Please download the entire program code. The zip-file contains the complete PlatformIO project.
My programming environment is not the native Arduino™ IDE but PlatformIO™ on top of Microsoft's Visual Studio Code™. This combination offers many advantages and allows a much better structuring of the code into several modules especially when we adopt The Object Oriented way.