PWM, broadly speaking, created a pulse that's a specific frequency, but gives you the ability to vary the amount it's on and the amount it's off. To create a square wave (which we'll use to drive audio), we want a pluse that's a specific amount on and off (50% on and 50% off in our case), but with a variable frequency.
As a recap, here's the PWM program for PIO:
@asm_pio(sideset_init=PIO.OUT_LOW) def pwm_prog(): pull(noblock) .side(0) mov(x, osr) mov(y, isr) label("pwmloop") jmp(x_not_y, "skip") nop() .side(1) label("skip") jmp(y_dec, "pwmloop")
As you can see, this has a loop that runs for a specific number of cycles (the total number is held in the ISR -- take a look at the previous article for details of the little tweak that let's us load a number into the ISR). After a certain number of cycles, it uses side-setting (again, see previous article for details) to turn a bit on, but it contiues with the loop.
For our square wave, we want to do a similar thing, but instead of just turning a bit on, we want to turn it on, then start a count-down so after the same number of cycles we turn it off.
#mirror the above loop, but with the pin high to form the second #half of the square wave label("down") mov(y, isr) label("down_loop") jmp(x_not_y, "skip_down") nop() .side(0) jmp("restart") label("skip_down") jmp(y_dec, "down_loop")
@asm_pio(sideset_init=PIO.OUT_LOW) def square_prog(): label("restart") pull(noblock) .side(0) mov(x, osr) mov(y, isr) #start loop #here, the pin is low, and it will count down y #until y=x, then put the pin high and jump to the next secion label("uploop") jmp(x_not_y, "skip_up") nop() .side(1) jmp("down") label("skip_up") jmp(y_dec, "uploop")
We've descended into spaghetti code a little bit with jumps taking you here, there and everywhere. This is always a risk with code like this as we don't have any loop syntax to keep things tidy. We also have only a limited number of options for jump conditions. For example, we can jump on x does not equal y, but not x equals y. The limited options here help keep the PIO blocks small and fast, and with a little creative thought, we can usually structure our program to work with what's available. The full range of conditions are:
(no condition): Always jump
not_x jump is x is 0
x_dec: decrement x by one and jump if x is not 0
not_y: jump if y is 0
y_dec: decrement y by one and jump if y is not 0
x_not_y: jump if x is not y
pin: jump if pin is not low (we'll cover this more in the next article)
not_osr: jump is the output shift register is not empty
As you can see, in our loops, we really want something to happen when x equals y, so we have to create a little path where we jump over some code if x is not y, letting it run only when x is equal to y.
Once we've got this PIO program, the only thing left to do is calculate what value we want to load into X to play a particular note. We do this in the following function:
def calc_pitch(self, hertz): return int( -1 * (((1000000/hertz) - 20000)/4))
We run it at a frequency of 1000000, there are (usually) two instructions per little loop and two little loops. The number loaded into the ISR in our example, is 5000. So this is, ((frequency / desired pitch) - (number of instructions * ISR value)) / number of instructions.
The full code for this example is at https://github.com/benevpi/pico_pio_buzz, and you can get your Pico to play Happy Birthday using PIO.
In this article, we've seen how PIO lets you tweak programs to get behaviour that's similar, but not quite the same, as other programs. Had we just had access to a hardware PWM peripheral, we wouldn't have been able to do this, but because we can reprogram PIO, we can use it as PWM, or with a little tweak, use it to make tones.