Lab 4: Digital Audio

Introduction

In this lab, I learned how to use my MCU to play music by using timers. These timers generated square waves by toggling a GPIO pin at a specific frequency for specified durations. This lab also served as an introduction to working with the STM32 microcontroller boards. I gained a lot of experience with reading proper documentation (reference manual and datasheet) to understand clocks, timers, register, I/O, and more. The end result consisted of playing “Fur Elise” and my chosen song of “Hedwig’s Theme” from Harry Potter!

Design and Testing

Writing the Library in C

I used the Timer 15 and Timer 16 registers in order to output a square wave signal from the microcontroller. For this lab, we had to read the datasheet carefully and write our own library in C from scratch. One of the reasons I chose Timers 15 and 16 is because they have the same register map. This meant I could take advantage of only having to write one struct in my timer header file. Another reason I chose these timers is that they both have the ability to be configured to a PWM mode and be connected to a GPIO pin in alternate function mode in order to have the desired functionality of producing a square wave output.

After writing my header file, I wrote my timer source code, which consisted of four functions: initTIM to initialize my timer, delay_millis to provide a millisecond delay to set my note durations, initPWM to initialize the PWM mode of Timer 16, and setFreq to set the square wave to the desired frequency. Writing these four functions was the most difficult part of the lab, as they consisted of hours reading the reference manual to understand configuration, registers, and precisely which bits to set in which order to produce the desired functionality.

Software and Timing Calculations

To go into more details on how the timers work, the timer configured in the PWM mode had the auto reload register (ARR) and the capture/compare register (CCR1) set in order to produce a specific period and duty cycle. The ARR value is set such that when the counter is equal to this value, it resets to 0, meaning this value sets the period of the signal. When the counter is less than the CCR1 value, it outputs a low (0), and when the counter is higher, it outputs a high (1), meaning the CCR1 value sets teh duty cycle. I set the CCR1 value to be half of the ARR value to have a duty cycle of 0.5. For the timer configured to set the delay, I utilized the ARR to control the delay, as it was set depending on the millisecond input.

In this lab, I used a phase-locked-loop (PLL) to set the microcontroller clock at 80 MHz, and to produce lower frequencies, I used prescalers. The timing calculations and theory behind my prescaler values are shown below in Figure 1.

Figure 1. Timing calculations for frequency of sound output

In order to make sure that the minimum and maximum frequency and duration were supported with my chosen prescaler values, I did the calculations shown below in Figure 2.

Figure 2. Max and min duration and frequency calculations

One of the specs for this lab was to produce individual pitches calculated to be accurate within 1% across the frequency range of 220-1000 Hz. Using the derived formula in figure 1 and careful unit conversion, I ran a timing analysis across this frequency range to make sure the spec was met. The calculations used to program the spreadsheet, showing examples of the pitches and durations being correct, as well as the resulting data is shown below in Figure 3 and 4, respectively.

Figure 3. Calculations for spreadsheet and examples

Figure 4. Pitch accuracy spreadsheet

Software Testing

In order to test the software’s functionality first, I uploaded the code to my MCU and used an oscilloscope to read the values of the GPIO pin to check that I was seeing the correct duty cycle and frequency. There was a lot of troubleshooting here, as I originlly did not see any square waves. I used Segger’s built in Debug mode to step through my code and ensure that all my registers were getting the value they were supposed to. After debugging and changing the way I configured/enabled things in my code, I started to see the correct frequency square waves in the oscilloscope.

Technical Documentation

The source code for the project can be found in the associated Github repository.

Schematic

The circuit for this lab was simple. It contained an LM386 audio amplifier to drive the 8 ohm speaker and a potentiometer for volume control. The schematic is shown below in Figure 5.

Figure 5. Schematic for audio amplifier circuit

Results and Discussion

The design met all the intended objectives. The hardware for this lab, which was tested after making sure the software worked properly, worked on the first try. To encode a new song, I found the site music for music from Harry Potter, and transcribed the notes into frequencies using the table from the class website. A video of the final result, including Fur Elise and Hedwig’s Theme from Harry Potter, is shown below.

Conclusion

I was successfully able to use my MCU to play music in this lab! The learning curve was steep, as this was my first time writing libraries in C and using a microcontroller in depth. However, I got really comfortable reading the datasheet and reference manual, and I gained a better understanding of the STM32 overall. In total, I spent 19 hours on this lab.

AI Prototype

This was the prompt I entered into ChatGPT: What timers should I use on the STM32L432KC to generate frequencies ranging from 220Hz to 1kHz? What’s the best choice of timer if I want to easily connect it to a GPIO pin? What formulae are relevant, and what registers need to be set to configure them properly?

This is the answer it gave me:

Great question. On the STM32L432KC you’ve got several timers that can do this cleanly.

Which timer(s) to use

Easiest “hook to a GPIO and forget it” (50% square wave):
TIM2 (general-purpose, 32-bit) using Output Compare Toggle mode. It routes to lots of pins (e.g., PA0…PA3 for CH1…CH4 on most L4s), and since it’s 32-bit you’ll never worry about running out of counts.

If you might want PWM duty control now or later:
TIM1 (advanced) or TIM2 / TIM15 / TIM16 / TIM17 in PWM mode. TIM1 has “advanced” features (BDTR/MOE), but TIM2 is simplest and widely pinned out. Pick whichever has a channel available on a pin you’ve broken out; PA8 (TIM1_CH1) and PA0 (TIM2_CH1) are common.
(Exact pin/AF depends on your board; check the “Alternate function mapping” table for the L432KC to pick the cleanest pin.)

Core formulas (you’ll use these every time)

Let f_tim be the timer’s input clock after the prescaler:

f_tim = f_clk / (PSC + 1)

PWM / update frequency (ARR reload):
f_out = f_tim / (ARR + 1)

PWM duty (edge-aligned):
duty = (CCR + 1) / (ARR + 1) → for 50%, set CCR = ARR / 2.

Output-compare TOGGLE (pure 50% square):
The pin toggles each compare event, so period is 2×(CCR+1) ticks →
f_out = f_tim / (2 * (CCR + 1))

These let you hit anything from 220 Hz to 1 kHz by choosing (PSC, ARR) for PWM or (PSC, CCR) for toggle.

Quick worked choices (two common clock setups)

Assume the timer clock (f_clk) is:

16 MHz (HSI default)

PWM at 1 kHz: pick PSC = 15 ⇒ f_tim = 1 MHz. Need ARR = 999 for 1 kHz. 50%: CCR = 500 - 1 = 499 (or just 500 if you’re not using “+1” convention).

PWM at 220 Hz: same PSC = 15 ⇒ f_tim = 1 MHz. ARR ≈ 1,000,000/220 − 1 = 4545 − 1 = 4544. 50%: CCR = 2272.

Toggle at 440 Hz (A4): PSC = 15 ⇒ f_tim = 1 MHz. Need CCR = f_tim/(2*f) − 1 = 1e6/(880) − 1 ≈ 1136 − 1 = 1135.

80 MHz (PLL to 80 MHz, common on L4)

PWM at 1 kHz: PSC = 79 ⇒ f_tim = 1 MHz. ARR = 999.

PWM at 220 Hz: PSC = 79 ⇒ ARR = 4544.

Toggle at 1 kHz: PSC = 79 ⇒ CCR = 1e6/(2*1000) − 1 = 499.

All of these fit in 16-bit easily; you don’t need TIM2’s 32-bit width, but it’s convenient.

Registers you’ll touch
RCC (enable clocks)

GPIO: RCC->AHB2ENR |= RCC_AHB2ENR_GPIOxEN;

Timer:

TIM2/TIM6/7/15/16/17 → RCC->APB1ENR1 or APB2ENR (TIM1/15/16/17 are on APB2).

Example: RCC->APB1ENR1 |= RCC_APB1ENR1_TIM2EN;
RCC->APB2ENR |= RCC_APB2ENR_TIM1EN;

GPIO (put the pin in Alternate Function)

MODER: set to AF for your pin (10b).

AFRL/AFRH: set AF# for the chosen timer channel (e.g., AF1 for TIM1/TIM2 on many L4 pins).

Optional: OSPEEDR medium/high, PUPDR none, OTYPER push-pull.

Timer core (common)

PSC – prescaler

ARR – auto-reload (period); set before enabling counter

CR1 – ARPE=1 (preload ARR), CEN=1 to start; DIR=0 for upcount

Optional: EGR |= UG to force an update event so new PSC/ARR latch immediately

PWM mode specifics

CCMRx: set OCxM = 110 (PWM mode 1); OCxPE=1 (preload enable)

CCR x: duty value (0…ARR)

CCER: CCxE=1 (enable channel), choose polarity (CCxP=0 for active high)

TIM1 only (advanced timer): BDTR: set MOE=1 (Main Output Enable) or nothing will come out

Output Compare TOGGLE mode specifics

CCMRx: OCxM = 011 (Toggle), OCxPE=0 (typically no preload), CCR x = half-period − 1 in ticks

CCER: **CCxE=1`

ARR: can be large; in toggle mode you don’t need it to set the frequency (but keep it ≥ CCR)

Minimal LL-style pseudo-sequence
A) TIM2 PWM @ 1 kHz, 50% on PA0 (TIM2_CH1 as an example)
// clocks
RCC->AHB2ENR  |= RCC_AHB2ENR_GPIOAEN;
RCC->APB1ENR1 |= RCC_APB1ENR1_TIM2EN;

// PA0 -> AF1 (TIM2_CH1)
GPIOA->MODER   = (GPIOA->MODER & ~(3U << (0*2))) | (2U << (0*2));
GPIOA->AFR[0]  = (GPIOA->AFR[0] & ~(0xF << (0*4))) | (0x1 << (0*4)); // AF1
GPIOA->OSPEEDR = (GPIOA->OSPEEDR & ~(3U << (0*2))) | (2U << (0*2));  // medium/high

// timer base: f_clk=80MHz -> PSC=79 => f_tim=1MHz
TIM2->PSC = 79;
TIM2->ARR = 999;          // 1 kHz
TIM2->CCR1 = 500;         // ~50%

// PWM mode 1 on CH1
TIM2->CCMR1 = (TIM2->CCMR1 & ~TIM_CCMR1_OC1M) | (6U << TIM_CCMR1_OC1M_Pos);
TIM2->CCMR1 |= TIM_CCMR1_OC1PE;  // preload
TIM2->CCER  |= TIM_CCER_CC1E;    // enable output

TIM2->CR1 |= TIM_CR1_ARPE;       // ARR preload
TIM2->EGR |= TIM_EGR_UG;         // latch PSC/ARR/CCR
TIM2->CR1 |= TIM_CR1_CEN;        // go

B) TIM2 TOGGLE @ 440 Hz on PA0 (exact 50% square, set-and-forget)
// clocks & pin same as above...

TIM2->PSC = 79;            // 80MHz/80 = 1MHz tick
TIM2->CCR1 = 1135;         // f_out = 1e6 / (2*(1135+1)) ≈ 440 Hz
TIM2->ARR = 0xFFFF;        // big enough (not critical)

// OC toggle on CH1
TIM2->CCMR1 = (TIM2->CCMR1 & ~TIM_CCMR1_OC1M) | (3U << TIM_CCMR1_OC1M_Pos); // toggle
TIM2->CCER  |= TIM_CCER_CC1E;

TIM2->EGR |= TIM_EGR_UG;
TIM2->CR1 |= TIM_CR1_CEN;

C) TIM1 PWM (remember BDTR/MOE)
RCC->APB2ENR |= RCC_APB2ENR_TIM1EN;
// ...GPIO to AF for TIM1_CH1 (e.g., PA8/AF1)...
TIM1->PSC = 79; TIM1->ARR = 999; TIM1->CCR1 = 500;
TIM1->CCMR1 = (TIM1->CCMR1 & ~TIM_CCMR1_OC1M) | (6U << TIM_CCMR1_OC1M_Pos);
TIM1->CCMR1 |= TIM_CCMR1_OC1PE;
TIM1->CCER  |= TIM_CCER_CC1E;
TIM1->BDTR  |= TIM_BDTR_MOE;   // **required** for TIM1
TIM1->CR1   |= TIM_CR1_ARPE;
TIM1->EGR   |= TIM_EGR_UG;
TIM1->CR1   |= TIM_CR1_CEN;

Practical picking guidance

Just tones (always 50%)? Use TIM2 + OC Toggle. One register (CCR) sets the frequency; rock-solid 50% duty.

Want variable duty / envelopes / volume via duty? Use PWM mode on TIM2 (simplest routing) or TIM1 (if you need advanced features, dead-time, etc.).

Pin choice: Favour PA0..PA3 (TIM2 CH1..4) or PA8..PA11 (TIM1 CH1..4) because they’re commonly available on L432KC boards. Confirm the AF number for your exact pin in the datasheet’s AF table.

If you tell me your exact pin and your current system clock (16 MHz HSI vs 80 MHz PLL), I can drop in the exact (PSC, ARR, CCR) values for 220 Hz, 261.63 Hz, … up to 1 kHz in whichever mode you prefer.

It was honestly pretty useful. It walked through why you would want to use certain timers, what kinds of bits you need to enable, and the general formula for the thought process. It gave a more confident, even better answer after inputting the reference manual. It’s good for understanding general process flow, but I think I still wouldn’t trust it for the exact bits because a lot of precision and thought specific to your design is needed for the code.