Thursday, August 6, 2015

Wavetable Synthesis on the ATtiny84

Yes, it is actually possible. I wouldn't recommend it; the built-in 8 mHz oscillator is slow enough that even Interrupt calls take enough clock cycles to be heard as "grit" or "chatter" in the sound.

But I've got a code hacked to the point where I can control frequency, volume, and get an audible difference between sawtooth and square wave. (Haven't plugged in the wavetable for a sine wave yet...but hopefully that will sound a little purer).

I figured it out from Jon Thompson's excellent article over at Make. Although it took quite a lot of mental struggle before it all became clear! Thus this post; I'm going to attempt to explain what it is I figured out in the almost certainly quixotic hope that someone else will find my fumbling explanation easier.




Skipping over hardware solutions like the resistor-ladder DAC, Jon's big trick is 1-bit synthesis. More-or-less. The shortest explanation for the trick is it leverages the built-in PWM of the AVR chip, setting the PWM level at intervals that are a multiple of the intended frequency.

Whew! Let me try to put that even simpler. In Arduino parlance, PWM is also known as "analog out" and can be thought of as a variable voltage between 0 and supply. My first successful experiment -- the square wave -- toggles the "analog out" between 0 and full at the rate of the desired frequency.

Actually, I set up an arbitrary wave resolution of 16, meaning I set PWM to "5v" for 8 ticks, and to "0v" for 8 ticks.

For a sawtooth wave, with each "tick" I increase the analog out by a fraction, resetting to zero again when 16 ticks have passed (once again, this sets the frequency of the resulting wave).

For more complex waves, you use a lookup table; an array, with each cell holding a value (or a complex derived value) that is read in, resulting in a continuously variable voltage in a pattern that repeats each time the table is completed and you return to index=0.

(And, yes...you could in theory program an entire complex sound, like a voice sample or a symphony, in one looooong table. Which is basically what an audio file or a CD track is. But there isn't enough memory on the little AVRs to make this a good approach!)




Time to get a little more granular. How this is achieved on the actual chip is by using two timers. Every AVR chip has several on-board hardware timers. The advantage to these is that they run independent of program flow, in the background.

For the ATtiny84, there are two hardware timers available, both can output to hardware pins, and both are capable of Fast PWM.

And unfortunately, although you can use an external crystal with all the AVRs, I chose for this circuit to use the built-in oscillator which runs at a brisk but not spectacular 8 mHz. Fast PWM mode counts to 256 with each cycle, which technically puts me above the threshold of pitch perception, but aliasing and other (unknown) effects made it necessary to increase the PWM frequency above that.

Thus I have Timer0 running in CTC mode, meaning it refreshes whenever it hits the value in OCR0A; which I've set to a mere 64 (4x the frequency of Fast PWM). OCR0B contains the match value; when the timer reaches this value it clears output pin OC0B (which is the pin I've wired to my amp and speaker). Thus, the ratio of OCR0B to OCR0A is the ratio of analog output to supply voltage.

As an aside, if you use both OC0A and OC0B, and set the latter to inverting mode, you can make a true AC signal. I thought I might need to do this, which is why I've been doing all this programming with the Raygun only partially soldered together.

Anyhow. This is relatively straight-forward; enable output to hardware pins on timer control register TCCR0A, set up for CTC (unfortunately split between the two control registers. Sigh). And enable clock input with no pre-scaling in TCCR0B.

And yes, you can use helpful macros, but I'm lazy and just set everything using binary:

TCCR0A = 0b10100011;
TCCR0B = 0b00001001;

Oh, and you also need to configure the output pin as, well, output. I just used the DDR for this, but since I'm programming this within the Arduino IDE, I could just use pinMode(7, OUTPUT).



As another aside; the Arduino IDE is smart. If it doesn't recognize something as being one of its own macros, it passes it on to the C compiler (gcc, which is at the root of the Arduino install). So you can write more-or-less standard C directives, and the compiler will make a good effort at interpreting them.

So. You could just put the routine that changes the analog out (or, rather, sets OCR0A) in the main loop, but it runs a lot smoother if this also goes into a hardware timer. So we set up Timer1 to trigger an interrupt vector at the frequency divided by the horizontal resolution of the wave. For me, it worked out to set the pre-scaler to none, the horizontal resolution at 16, and an OCR1A value of 200 put me square in the audio range.

So the Timer1 control registers look almost identical. I don't need any outputs enabled, and I'm using CTC mode again.

The fun part is enabling interrupts.

This has multiple steps. First is to tell the compile to look for the c interrupts when it compiles your code:

#include <avr/interrupt.h>

Next is to globally enable interrupts (this is a very useful little function, as it and its mate can be used to disable interrupts during time-critical processes):

sei(); 

Last is to enable the desired interrupt vector. Timer1 has a control register just for this, and fortunately, the compiler recognizes the name of the actual flag I'm trying to set:

TIMSK1 |= (1 << OCIE1A);

Yeah, this might need a refresher. TIMSK1 is the name of the register that enables interrupts to be sent on various Timer1 events. OCIE is a trigger set when comparator match is achieved between the value carried in OCR1A and the current value of the timer/counter. << causes the "1" to be shifted the number of steps represented by the position of the OCIE1A flag into a binary mask, and |= binary-or's the mask with a copy of the register's original value and puts that into the register.

Whew! Now we just have to write the interrupt vector itself.



Within the program flow of AVR code written in C from within the Arduino IDE, compiler directives appear first. These are things that don't actually run as code per se, but are used to create various values and initialize variables and so on that the compiler will then figure out how to distribute into program memory.

void setup() runs once, every time the Arduino/AVR resets. This is a good place to set up volatile values and flags.

void loop() is the main body; once it has finished with setup() the AVR will go into here and stay until reset. However!

Anything placed in the format of a function call outside of loop() will run whenever, well, the function is called. And Interrupts are basically function calls. The difference is that the hardware calls them, and not any line within your loop() code.

  ISR(TIM1_COMPA_vect)
{
}
Is one of the Interrupt Service Register vectors, and the mnemonic here is pretty straight-forward; TIM1 is our Timer1, COMPA is the comparator flag set when OC1A is reached, and vect is, well...

And then we finally come to the synthesis itself. As I said above, you can do a complete wavetable, you can get even more complicated than that to implement FM synthesis or a volume control, or you can mathematically derive a waveform on the fly with a few simple lines:

 OCR0B = wave;
wave++;
if (wave >= 16)
{
  wave = 0;
}
  TCNT1 = 80; 

This should be pretty clear. I'm increasing the output level of the PWM each time I step through, and resetting every time I reach 16 (this is kept consistent whatever the wave generation method, because this allows a consistent frequency to be set via Timer1's OCR1A).  The volume in this example is a mere fraction of the potential 64 of peak output, but it is plenty loud enough for testing purposes.

Oh, and the last line there? ISR may be called from hardware, but they still take time to resolve when you are actually running code. This code advances Timer1 (the register name here is pretty obviously Timer Count 1) to make up for the clock cycles lost during the ISR.

And that's enough for this entry! Next time, I'll try to work in some sound samples, as I develop the framework here to synthesize the various sounds the raygun is going to need.

(Incidentally, OCR0B = int(random(0,20)) worked okay as a white noise generator. At lower Timer1 frequencies it was pretty crackly and gritty, though.)



And, alas, although I was getting some really interesting sounds, it wasn't a precise process. Worse was that I really need, for the raygun, a "chirp" that starts from very high frequency and glissades down sharply. And that wasn't really compatible with the lookup table; when I got close to the frequencies desired, artifacts occurred, and when I tried to gliss, it stair-stepped a little too audibly. Plus the volume was all over the map.

So I've dropped back to using Timer0 as an audio oscillator. And since I'm using it essentially straight, I can attach to both outputs (running one in inverting mode). That gives me the higher frequencies I needed and a nice strong audio signal. After that the "chirp" code is easy.

I will still experiment, though. I might try using a resistor network to add in some of Timer1, to get richer tones. I can generate some very nice beat and hetrodyne effects, I imagine, by using CTC and setting the refresh to an odd number. I'll need some kind of complex warble for the "heat ray" setting (aka on for as long as the trigger is held down).

And I might still use the more complex synthesis ...next experiment is to see if I can reset the timer configuration on the fly. 

No comments:

Post a Comment