A rhythm game is a clock
with physical inputs.
Learn the path from a switch contact to a measured timing error—one small experiment at a time. STM32F411RE / NUCLEO-F411RE, bare-metal C and CMSIS.
Poll first. Then EXTI + a free-running TIM2.
Polling makes the electrical behavior visible. A 1 MHz TIM2 counter gives one consistent timebase. EXTI then teaches interrupt routing and captures a timestamp promptly without making main-loop speed the normal measurement limit. Keep debounce and judgment in main. Only move to input capture after measuring the jitter you want to remove.
| Approach | What you actually measure | Rhythm-game trade-off | Place in this course |
|---|---|---|---|
| A · Main-loop polling | First read that notices the changed GPIO level. | Simple and easy to debug. Delay ranges from nearly zero to the maximum time between reads. Display/audio/blocking work makes that interval variable. A tightly bounded fast loop can be entirely adequate for tens-of-ms windows. | Stage 1 baseline; TIM2 still supplies timestamps. |
| B · EXTI + TIM2 | CNT read after the CPU enters the handler. | Flexible GPIO routing, good learning value and usually ample performance for ±25/100 ms windows. Jitter remains from interrupt service, masking and electrical bounce. EXTI cannot recover the original edge time after long masking. | Core project architecture. |
| C · Timer input capture | Hardware-latched count when an input edge is recognized. | Most deterministic edge timestamp here; ISR delay need not change the latched value. Limited channels and AF pins; filters add delay; overcapture loses data; still needs debounce. Great for measuring, not automatically necessary for human taps. | Stage 13 controlled comparison. |
| D · SysTick | Commonly a software millisecond count updated by a core exception. | Easy coarse scheduler, but 1 ms ticks quantize timestamps. Delayed or coalesced interrupts can invalidate naive “one ISR means exactly one elapsed tick” logic. Sub-tick VAL reads are possible but rollover races complicate them. | Understand it; avoid a second game clock initially. |
| E · DWT cycle counter | Core cycles at the point software reads CYCCNT. | Fine resolution for short profiling, but an ISR read still includes interrupt latency. Frequency changes, wrap and sleep/debug behavior complicate song time. No GPIO capture latch. | Measure code cost after the basics. |
SysTick is a 24-bit core down-counter with CTRL/LOAD/VAL and no RCC peripheral gate; a 1 kHz interrupt does not make GPIO edges interrupt-driven. DWT is also a core facility. TIM2 runs independently of ordinary CPU instructions, but its rate still depends on the RCC clock tree. PM0214 · p. 246 · SysTick.
Accuracy has several different meanings
Resolution
1 µs timer steps determine the smallest count difference. They do not eliminate input or audio delay.
Latency & jitter
How late, and how variably, the event is observed. Fixed latency can be calibrated; variable delay cannot be removed with one offset.
Clock accuracy
How closely the time ruler follows real seconds and the audio clock. A fine-resolution oscillator can still drift.
The route you will build
Click a stage to follow the electrical and CPU responsibilities.
How to use the course
Read Hardware checks before connecting the module. Complete each numbered stage in order, test its expected result, and perform the experiment before continuing. Code blocks are incremental exercises, not one finished game to paste into main.c. Stage 1 supplies a tiny standalone main; later stages replace loop fragments or add helpers. A comment naming a later integration step is a deliberate exercise, not a hidden library.
Use a CMSIS device project with the STM32F411xE startup/vector table, linker script and system_stm32f4xx.c. Include stm32f411xe.h. Build for Cortex-M4/Thumb with the project’s consistent floating-point ABI. Keep one main and one definition per ISR; no HAL initialization or Arduino calls are required.
Your board has three jobs.
Visible in the photo
KEY A: K1–K16 and row/column labels, consistent with a 4×4 switch matrix.
KEY B: S1–S4, VCC and GND labels, consistent with four separate switches and bias resistors.
LED: D1–D8 plus VCC, consistent with a common-positive LED bank that is driven by sinking current.
These are photo-based interpretations. Header position, polarity, shared rails and actual resistor values need continuity/resistance/diode tests or the exact module schematic. Do not wire by guessed connector pin numbers.
Unpowered identification checklist
- Disconnect the module and Nucleo from power. Locate header labels by viewing both sides. Mark S1, VCC, GND and D1 on a drawing.
- Measure S1↔GND while pressing/releasing, then S1↔VCC. For this tutorial, confirm a switch closes S1 to GND and releases it. Check S2–S4 similarly; identify any external pull-up resistors.
- Use diode mode to establish LED polarity. Trace each D pin through its LED and series resistor to the shared rail. Confirm one series resistor per LED; measure its resistance, lifting a lead only if in-circuit paths obscure the value.
- Check whether KEY B VCC and LED VCC are already connected. Neither label authorizes 5 V. Supply only 3.3 V for these exercises.
- Before connecting a GPIO, power the verified module from 3.3 V/GND and measure switch released/pressed levels. For a current-limited LED test, use an additional 1 kΩ resistor in the D1-to-ground path. Never put an ammeter directly across a supply.
Proposed four-lane mapping
Board connector labels below are module signal names. CN7/CN10 are Nucleo morpho positions, verified in UM1724 · p. 59 · Table 29. This mapping avoids PA13/PA14 SWD, PA2/PA3 ST-LINK serial, PC13 USER and PA5/LD2. PA0’s later input-capture use is an intentional alternate mode.
| Module signal | STM32 GPIO | Nucleo header | Purpose |
|---|---|---|---|
| S1 / lane 0 | PA0 | CN7-28 | EXTI0 · IRQ 6 |
| S2 / lane 1 | PA1 | CN7-30 | EXTI1 · IRQ 7 |
| S3 / lane 2 | PA4 | CN7-32 | EXTI4 · IRQ 10 |
| S4 / lane 3 | PB5 | CN10-29 | EXTI5 / EXTI9_5 · IRQ 23 |
| D1 | PB0 | CN7-34 | GPIO output |
| D2 | PB1 | CN10-24 | GPIO output |
| D3 | PB6 | CN10-17 | GPIO output |
| D4 | PB7 | CN7-21 | GPIO output |
| D5 | PB12 | CN10-16 | GPIO output; conflicts with I2S2_WS |
| D6 | PB13 | CN10-30 | GPIO output; conflicts with I2S2_CK |
| D7 | PB14 | CN10-28 | GPIO output |
| D8 | PB15 | CN10-26 | GPIO output; conflicts with I2S2_SD |
Use Nucleo 3.3 V (for example CN7-16) and GND (for example CN7-19); verify board orientation and numbering. Connect ground first. A GPIO pin label, package lead number and header position are different coordinate systems.
Current flows; “bits” do not flow down wires
For a 1 kΩ LED resistor, estimate I ≈ (3.3 V − LED forward voltage − GPIO low voltage)/1000 Ω. At a 2 V LED drop this is roughly 1.3 mA. The resistor limits current. Confirm per-pin, aggregate and supply limits in DS10314; absolute maximum ratings are not design targets. Do not assume a module designed for another voltage is safe to drive unchanged.
LED: 3.3 V ─ resistor ─ LED anode → cathode ─ PB0
Timer: RCC ─ TIM2 clock ─ PSC ─ CNT (no wire)
Audio later: I²S / PWM ─ external DAC or filter + amplifier ─ speaker
Why not the 16-key matrix first?
A matrix shares row/column wires. You drive one row low, leave other rows high-impedance, and read columns with pull-ups. The timestamp is quantized by the scan period, and contacts still bounce. Without isolation diodes, some multi-key combinations create ghost keys: that is particularly harmful for rhythm-game chords. Never drive unselected rows high against a selected low row through closed switches. Return to KEY A after the independent S1–S4 path works; confirm its diode arrangement and supported rollover first.
Change one assumption.
These calculators model arithmetic and feedback. Browser keyboard timing is not an STM32 accuracy test.
Beat → timer timestamp
Actual − expected
Center pair = Perfect (±25 ms). Outer buckets = early/late Good (up to ±100 ms). All eight = outside the window; an unpressed expired note uses the same Miss pattern, but has no delta.
Clock → prescaler → counter
Enter the timer kernel clock, not blindly the CPU clock. Example: HCLK=100 MHz, APB1=/2 → PCLK1=50 MHz; with TIMPRE=0, TIM2 clock=100 MHz, so PSC=99 produces 1 MHz.
Match a press to a note
Fresh chart for each click: lane 1 at 1000 ms and 1080 ms; lane 2 at 1000 ms. Matching chooses the nearest same-lane note within ±100 ms, tie toward the earlier entry.
This isolated demonstration resets state; Stage 7’s firmware records judged[] so subsequent presses cannot consume the same note again.
One button → one LED
Make voltage become a bit, then make a bit become light.
The concept & why it matters
A GPIO is a pad with a configurable input buffer and output driver. Input mode listens; output mode drives. An unconnected input can float, so a pull-up supplies a weak path to 3.3 V. A verified switch to ground overpowers that weak pull when pressed.
A memory-mapped register is a hardware control word at an address. CMSIS provides typed names for those addresses; it is not an Arduino layer. GPIOA->IDR reads a physical input. GPIOB->BSRR issues an atomic set/reset command.
Follow the hardware
Verified KEY B S1 → PA0, and verified active-low LED D1 → PB0. The LED board’s VCC is 3.3 V. Current flows from 3.3 V through its resistor and LED into PB0 when PB0 drives low. The CPU continuously polls; no interrupt or AF mux participates.
Register ownership
RCC_AHB1ENR: GPIOAEN bit 0, GPIOBEN bit 1. PA0: MODER[1:0]=00, PUPDR[1:0]=01, IDR[0]. PB0: MODER[1:0]=01, OTYPER[0]=0, OSPEEDR[1:0]=00, PUPDR[1:0]=00. BSRR[0] drives high/off; BSRR[16] drives low/on. GPIOA base 0x40020000; IDR offset 0x10 → address 0x40020010.
A small implementation
#include "stm32f411xe.h"
#include <stdint.h>
#include <stdbool.h>
static void gpio_init(void) {
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN | RCC_AHB1ENR_GPIOBEN;
(void)RCC->AHB1ENR; // clock-enable readback
GPIOA->MODER &= ~(3U << 0); // PA0 input
GPIOA->PUPDR = (GPIOA->PUPDR & ~(3U << 0)) | (1U << 0);
GPIOB->BSRR = 1U << 0; // latch HIGH before enabling output: off
GPIOB->OTYPER &= ~(1U << 0); // push-pull
GPIOB->OSPEEDR &= ~(3U << 0); // low slew
GPIOB->PUPDR &= ~(3U << 0);
GPIOB->MODER = (GPIOB->MODER & ~(3U << 0)) | (1U << 0);
}
static bool button_down(void) { return (GPIOA->IDR & 1U) == 0; }
static void led(bool on) { GPIOB->BSRR = 1U << (on ? 16 : 0); }
int main(void) {
gpio_init();
for (;;) led(button_down());
}Wire & test
Finish the Hardware checks first. Connect common ground; KEY B S1 to CN7-28 (PA0); D1 to CN7-34 (PB0); verified board VCC rails to Nucleo 3.3 V. Do not confuse KEY A matrix pins with S1. Use a loose switch between PA0 and GND plus a known LED/resistor if the module wiring is not verified.
Expected result
Released: PA0 near 3.3 V, IDR[0]=1, PB0 high, LED off. Pressed: PA0 near 0 V, IDR[0]=0, PB0 low, LED on. A visible flash at reset suggests output-latch ordering or wiring is wrong.
One experiment
Watch IDR while holding and releasing. Change PUPDR to 00 only when a verified external pull-up exists: behavior should remain defined. Do not interpret an unconnected, floating input as a reliable button.
Build a microsecond clock
Establish a time ruler before adding debounce or scoring.
The concept & why it matters
A hardware timer counts clock pulses independently of your loop. Its prescaler divides the incoming timer clock by PSC+1. CNT holds the count; ARR defines when it wraps. TIM2 and TIM5 are 32-bit on this MCU. We use TIM2 at 1 MHz: each tick represents 1 µs. This is resolution, not a promise of 1 µs absolute accuracy.
For TIMPRE=0: timer clock = PCLK1 when APB1 division is 1, otherwise 2×PCLK1. TIMPRE=1 changes this rule; the example deliberately requires 0.
Follow the hardware
TIM2 is internal: no GPIO pin, AF setting or external wire is needed for a free-running counter. TIM2EN is APB1ENR bit 0. GPIO and TIM2 clocks are independent. The CPU reads CNT whenever it needs the current time.
Register ownership
RCC_CFGR SWS/HPRE/PPRE1 verify the source and divisors; RCC_DCKCFGR.TIMPRE verifies the timer rule. TIM2_CR1.CEN starts counting. PSC=15 and ARR=0xFFFFFFFF at a verified 16 MHz timer clock. EGR.UG loads the buffered prescaler; SR=0 clears initialization flags while stopped. No update interrupt is required.
A small implementation
// Keep Stage 1 includes. This is a reset-clock teaching profile.
static bool timer_init_1mhz(void) {
SystemCoreClockUpdate();
if (SystemCoreClock != 16000000U ||
(RCC->CFGR & (RCC_CFGR_SWS | RCC_CFGR_HPRE | RCC_CFGR_PPRE1)) ||
(RCC->DCKCFGR & RCC_DCKCFGR_TIMPRE)) return false;
RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
(void)RCC->APB1ENR;
TIM2->CR1 = 0; TIM2->DIER = 0; TIM2->SMCR = 0;
TIM2->PSC = 15U; TIM2->ARR = 0xFFFFFFFFU;
TIM2->EGR = TIM_EGR_UG;
TIM2->SR = 0; TIM2->CNT = 0;
TIM2->CR1 = TIM_CR1_CEN;
return true;
}
static uint32_t now_us(void) { return TIM2->CNT; }
// After gpio_init(); stop with LED off if timer_init_1mhz() fails.
// One replacement main-loop experiment:
uint32_t previous = now_us();
bool lit = false;
for (;;) {
uint32_t now = now_us();
if ((uint32_t)(now - previous) >= 500000U) {
previous += 500000U;
lit = !lit; led(lit);
}
}Wire & test
Keep Stage 1 wiring. Inspect the RCC fields before calling this init. Cube-generated SystemClock_Config may already have enabled a PLL; do not pretend that PCLK1 is 16 MHz. Use a reset-style CMSIS startup for this profile, or derive the real timer input clock before adapting PSC. Measure LED half-period with a logic analyzer if available.
Expected result
The LED changes state every 0.5 s, for a complete 1 s cycle. CNT keeps running during ordinary CPU work. At 1 MHz a 32-bit counter wraps after 4294.967296 s (71.58 min). Unsigned subtraction handles elapsed intervals across one wrap.
One experiment
Set PSC=31 before EGR.UG: the counter runs at 500 kHz and the same 500000-count threshold now takes 1 s. Explain why the divisor is 32, not 31. Restore PSC=15.
Debounce without blocking
One physical press must create one gameplay event.
The concept & why it matters
Mechanical contacts can make and break several times during a press. That bounce is a sequence of real voltage edges, not a software bug. Accept a changed state only after it remains unchanged for 5 ms; require a stable release before accepting another press. Start at 5 ms and measure your switches.
Store the candidate edge time, then validate later. Scoring the validation time would add a predictable debounce delay. This particular policy timestamps the beginning of the final stable low interval, not necessarily the first mechanical contact.
Follow the hardware
Same GPIO input and TIM2. Main samples PA0 regularly, ideally at least once per millisecond. No EXTI yet. Holding the switch stays in the down state and generates no repeat. Releasing rearms the next press after validation.
Register ownership
GPIOA_IDR[0] provides the level. TIM2_CNT measures candidate age. No new clock gate. MODER/PUPDR retain Stage 1 values; no output register is used for debounce itself.
A small implementation
typedef struct {
bool stable, candidate;
uint32_t since;
} Debounce;
static void observe(Debounce *b, bool raw_down, uint32_t t) {
if (raw_down != b->candidate) {
b->candidate = raw_down; b->since = t;
}
}
static bool confirm_press(Debounce *b, uint32_t now, uint32_t *press_t) {
if (b->candidate == b->stable ||
(uint32_t)(now - b->since) < 5000U) return false;
b->stable = b->candidate;
if (!b->stable) return false; // stable release rearms
*press_t = b->since; // not the later confirmation time
return true;
}
// Initialize after GPIO + TIM2; a held-at-boot key needs release first.
Debounce button = {button_down(), button_down(), now_us()};
volatile uint32_t presses = 0, last_press_us = 0;
for (;;) {
uint32_t now = now_us(), stamp;
observe(&button, button_down(), now);
if (confirm_press(&button, now, &stamp)) {
last_press_us = stamp; ++presses;
}
led(button.stable);
}Wire & test
Same wiring. Inspect presses after a group of presses rather than pausing inside the sampling loop. A breakpoint distorts all timing. A scope or logic analyzer across S1/GND reveals raw bounce directly.
Expected result
Ten separate press/release cycles produce ten accepted presses. A long hold produces one. The LED follows the debounced state and therefore changes slightly after the electrical edge.
One experiment
Try 1, 5 and 15 ms validation. Compare false double hits versus the ability to repeat rapidly. A 5 ms press validation plus a 5 ms release validation imposes a minimum accepted cycle; choose it from measured bounce, not habit.
EXTI → NVIC → timestamp queue
Decouple edge detection from main-loop work.
The concept & why it matters
EXTI detects an edge and sets a pending bit. SYSCFG selects which port supplies each numbered EXTI line. EXTI0 can select PA0 or PB0, not both. NVIC decides whether and when the CPU runs the interrupt handler. An ISR briefly interrupts main; hardware timers keep counting.
Reading CNT in an ISR timestamps service, not the exact edge. The delay includes synchronization, masking, higher-priority work and exception entry. A queue prevents later edges from overwriting an unread timestamp.
Follow the hardware
PA0 remains GPIO input: EXTI is not an AF mode. Route Port A to EXTI0, enable both edges so release/bounce are observable, and enqueue time+level. Main drains events into Stage 3 observe(), then checks the current level and confirms stability. No LED delays, formatting or scoring inside the ISR.
Register ownership
RCC_APB2ENR.SYSCFGEN bit 14; SYSCFG_EXTICR1[3:0]=0000. EXTI_IMR[0], RTSR[0], FTSR[0], PR[0]. PR is write-one-to-clear: use assignment, never |=. EXTI itself has no separate RCC gate. EXTI0_IRQn=6; NVIC ISER0[6]. CMSIS NVIC_EnableIRQ writes the core register.
A small implementation
typedef struct { uint32_t t; uint8_t lane, down; } Raw;
#define QN 32U // power of two; usable capacity 31
static volatile Raw q[QN];
static volatile uint32_t wr, rd, dropped;
static void push_raw(uint32_t t, uint8_t lane, bool down) {
uint32_t next = (wr + 1U) & (QN - 1U);
if (next == rd) { ++dropped; return; }
q[wr].t=t; q[wr].lane=lane; q[wr].down=down;
__DMB(); wr=next;
}
static bool pop_raw(Raw *out) {
uint32_t saved = __get_PRIMASK(); __disable_irq();
bool ok = rd != wr;
if (ok) {
out->t=q[rd].t; out->lane=q[rd].lane; out->down=q[rd].down;
rd=(rd+1U)&(QN-1U);
}
__set_PRIMASK(saved); return ok;
}
static void exti0_init(void) {
RCC->APB2ENR |= RCC_APB2ENR_SYSCFGEN;
(void)RCC->APB2ENR;
EXTI->IMR &= ~1U;
SYSCFG->EXTICR[0] &= ~15U; // A=0 for EXTI0
EXTI->RTSR |= 1U; EXTI->FTSR |= 1U;
EXTI->PR = 1U;
NVIC_ClearPendingIRQ(EXTI0_IRQn);
NVIC_SetPriority(EXTI0_IRQn, 2U);
NVIC_EnableIRQ(EXTI0_IRQn); EXTI->IMR |= 1U;
}
void EXTI0_IRQHandler(void) {
if (EXTI->PR & 1U) {
uint32_t t = TIM2->CNT;
bool down = (GPIOA->IDR & 1U) == 0;
EXTI->PR = 1U;
push_raw(t, 0, down);
}
}
// Main-loop fragment, before confirm_press():
Raw e;
while (pop_raw(&e)) observe(&button, e.down, e.t);
uint32_t now = now_us();
observe(&button, button_down(), now); // reconcile actual level
// confirm_press(&button, now, &stamp), then score in main.
// If dropped != 0: mark a timing fault; do not trust that run.Wire & test
Reuse wiring; initialize GPIO, timer, debounce and queue before enabling EXTI. Confirm the startup vector table resolves EXTI0_IRQHandler. Keep the event loop nonblocking. For Stage 8, give every EXTI producer the same preemption priority so they cannot interrupt one another inside push_raw().
Expected result
An edge reaches ISR even when main is doing short unrelated work. Raw edge count can exceed accepted press count. A hold still scores once after main’s debounce. An EXTI pending bit is not an edge FIFO: very fast bounce may merge before service, and sampled IDR reports the current level.
One experiment
Add a controlled 2 ms main-loop workload with interrupts enabled, then compare polling-only and ISR timestamps. Next use a measured short interrupt-masked interval and observe its delay. Do not put that artificial delay into the game.
Measure early / late
Separate the signed error from the quality grade.
The concept & why it matters
The expected note time and actual press time must use the same epoch and units. Define delta = actual − expected. Negative means early; positive means late. “Perfect” is a window around zero, so +7 ms is both 7 ms late and Perfect in this example.
Follow the hardware
TIM2 is the time source; main uses a validated press timestamp. No new peripheral or IRQ. The timer does not know what a song or a score means. Those are software interpretations of measured time.
Register ownership
Read TIM2_CNT through now_us(). GPIO/EXTI still provide input. Use integer microseconds until presentation; dividing by 1000 too early throws away information.
A small implementation
typedef enum { PERFECT, GOOD, MISS } Grade;
// STM32 GCC/Clang signed conversion; intervals must stay within half-wrap.
static int32_t delta_us(uint32_t actual, uint32_t expected) {
return (int32_t)(actual - expected);
}
static Grade grade(int32_t d) {
if (d >= -25000 && d <= 25000) return PERFECT;
if (d >= -100000 && d <= 100000) return GOOD;
return MISS;
}
// Main, after capturing song_start = now_us() once:
uint32_t expected = song_start + 2000000U;
// On one accepted press (stamp from confirm_press):
int32_t error_us = delta_us(stamp, expected);
Grade result = grade(error_us);
// Store error_us/result for inspection; don't printf in the ISR.Wire & test
Schedule one expected hit 2 s after a deliberate start action. Initially inspect the result after the trial; the browser timing lab below lets you test the arithmetic without wiring. Audio is not needed yet.
Expected result
−42000 µs → 42 ms early / Good; +7000 µs → 7 ms late / Perfect; 0 → exact / Perfect. ±25000 is Perfect; ±100000 is Good; beyond that is outside the hit window.
One experiment
Change Perfect to ±15 ms and Good to ±80 ms. Does the timer become less accurate? No: you changed game difficulty, not timer resolution.
Build a beat chart
Describe the song independently of GPIO numbers.
The concept & why it matters
A chart is a sequence of expected events: time plus lane, with judgment state held separately. At 120 BPM, one beat lasts 60/120 = 0.5 s, not 1 s. Beat 3.5 is 1.75 s after beat zero. Add an explicit lead-in/song offset to place beat zero on your timer.
Use integer musical ticks rather than float equality. With 480 ticks per beat, beat 3.5 is tick 1680. Convert each absolute tick from the epoch; repeatedly adding a rounded beat length accumulates drift.
Follow the hardware
TIM2 continues unchanged. Lane 0 is a logical button ID, not EXTI0 by definition. The chart can be tested before multiple inputs are wired.
Register ownership
No new register. PSC=15 still means 1 µs/count only under the verified clock profile. Use 64-bit intermediate arithmetic to avoid multiplying into overflow.
A small implementation
typedef struct { uint32_t at_us; uint8_t lane; } Note;
static const Note ms_chart[] = {
{1000000U,0}, {1500000U,1}, {2000000U,0}, {2500000U,2}
};
static uint32_t beat_ticks_to_us(uint32_t ticks, uint32_t bpm) {
// bpm > 0; restrict songs to <30 minutes in this course.
uint64_t den = (uint64_t)bpm * 480U;
return (uint32_t)(((uint64_t)ticks * 60000000ULL + den/2U) / den);
}
// 120 BPM: {beat 0 lane0, beat1 lane1, beat2 lane2, beat3.5 lane0}
uint32_t ticks[] = {0,480,960,1680};
uint8_t lanes[] = {0,1,2,0};
Note chart[4];
for (unsigned i=0;i<4;++i)
chart[i]=(Note){1000000U+beat_ticks_to_us(ticks[i],120),lanes[i]};
// chart.at_us is relative to song_start; beat zero has a 1-second lead-in.
// Absolute expected timestamp = song_start + chart[i].at_us.Wire & test
Keep single-button wiring and test chart conversion in the debugger or timing lab. To play all entries with one button temporarily set each lane to 0. Do not leave lanes 1/2 silently unplayable.
Expected result
With the 1 s lead-in, the musical example becomes 1000, 1500, 2000, 2750 ms. That differs intentionally from the earlier 1000/1500/2000/2500 ms example.
One experiment
Try 90 BPM and beat 3.5: 2333.333… ms after beat zero, rounded once to 2333333 µs. Try 7/4 or eighth notes: BPM defines your chosen beat unit; the chart decides which subdivisions contain notes.
Match notes and detect misses
Consume each note once, using event time rather than loop time.
The concept & why it matters
On a validated press, search unjudged notes in the same lane within ±100 ms. Choose the closest timestamp; break ties toward the earlier chart entry. This is an explicit policy: early presses never block waiting for the note. A press outside the window is a stray/wrong press, not an automatic hit on a later note.
A missed note has no signed press error. Mark it missed only after its latest acceptable press could no longer arrive from the input pipeline.
Follow the hardware
No new hardware. Main drains the input queue, validates debounce, matches presses, then expires old notes. Keeping ISR work short and bounding main-loop latency now protects gameplay correctness.
Register ownership
TIM2_CNT supplies now. Queue overflow, prolonged masking and main-loop stalls are timing faults; they are not player mistakes. Existing EXTI flags acknowledge hardware events separately from chart state.
A small implementation
// Uses Note, delta_us() and grade() from Stages 5–6.
#define NOTE_COUNT 4U
typedef enum { WAITING, HIT, EXPIRED } NoteState;
static NoteState judged[NOTE_COUNT]; // WAITING=0
static int32_t errors[NOTE_COUNT];
static int match_press(const Note *notes, uint32_t start,
uint8_t lane, uint32_t t) {
int best=-1; uint32_t best_abs=100001U;
for (unsigned i=0;i<NOTE_COUNT;++i) {
if (judged[i]!=WAITING || notes[i].lane!=lane) continue;
int32_t d=delta_us(t,start+notes[i].at_us);
if (d < -100000 || d > 100000) continue;
uint32_t a=(uint32_t)(d<0 ? -d : d);
if (a<best_abs) { best=(int)i; best_abs=a; }
}
if (best>=0) {
errors[best]=delta_us(t,start+notes[best].at_us);
judged[best]=HIT; // grade(errors[best]) supplies Perfect/Good
}
return best; // -1: stray press / wrong lane; consume no note
}
static void expire_notes(const Note *notes, uint32_t start, uint32_t now) {
// 5ms debounce + <=2ms measured main-loop service bound.
// Drain/confirm accepted inputs BEFORE calling this.
uint32_t watermark=now-7000U;
for (unsigned i=0;i<NOTE_COUNT;++i)
if (judged[i]==WAITING &&
delta_us(watermark,start+notes[i].at_us)>100000)
judged[i]=EXPIRED;
}Wire & test
Use a short all-lane-0 chart first. Feed synthetic press timestamps into match_press before trying real taps: exact note time, ±25 ms, ±100 ms, then ±100001 µs. Reset judged[] between trials.
Expected result
Exactly +100 ms remains Good; +100001 µs is stray and the note later expires. Two nearby notes are each consumed at most once. If two same-lane windows overlap, nearest wins; one press never scores both. Holding cannot retrigger because debounce emits transitions only.
One experiment
Put two same-lane notes 80 ms apart and tap midway. Observe the tie policy. Then intentionally exceed the 2 ms service bound: the run should be flagged invalid rather than misreporting timing as a Miss.
Add four independent lanes
Avoid EXTI line conflicts and matrix scanning while learning.
The concept & why it matters
Use S1–S4 from KEY B, after confirming that they are individual active-low switches. Choose unique pin numbers: PA0, PA1, PA4, PB5 → EXTI0, 1, 4, 5. Port B encoding is 1 in EXTICR, even though PB5’s pin number is 5.
EXTI5 belongs to a shared EXTI9_5 IRQ. A shared vector does not mean all lines share one pending bit. Read and clear only owned pending lines.
Follow the hardware
Add three input wires. Each lane has its own Debounce instance and candidate timestamp. All raw-input IRQs use priority 2 so the Stage 4 queue has non-nesting producers. Distinct IRQs may service sequentially even for simultaneous electrical edges.
Register ownership
GPIOA MODER/PUPDR for pins 1 and 4; GPIOB for pin 5. SYSCFG_EXTICR1 bits 7:4 select PA1; EXTICR2 bits 3:0 select PA4 and 7:4 select PB5. NVIC IRQs: EXTI0=6, EXTI1=7, EXTI4=10, EXTI9_5=23. Their enable bits are in ISER[0].
A small implementation
// After Stage 1 GPIO clocks and Stage 4 queue setup, before enabling IRQs.
static void more_lanes_init(void) {
const uint32_t am=(3U<<2)|(3U<<8), bm=3U<<10;
GPIOA->MODER &= ~am; GPIOB->MODER &= ~bm;
GPIOA->PUPDR=(GPIOA->PUPDR & ~am)|(1U<<2)|(1U<<8);
GPIOB->PUPDR=(GPIOB->PUPDR & ~bm)|(1U<<10);
const uint32_t lines=(1U<<1)|(1U<<4)|(1U<<5);
EXTI->IMR &= ~lines;
SYSCFG->EXTICR[0] &= ~(15U<<4); // EXTI1 = A
SYSCFG->EXTICR[1]=(SYSCFG->EXTICR[1]&~255U)|(1U<<4); // 4=A, 5=B
EXTI->RTSR |= lines; EXTI->FTSR |= lines; EXTI->PR=lines;
const IRQn_Type irqs[]={EXTI1_IRQn,EXTI4_IRQn,EXTI9_5_IRQn};
for (unsigned i=0;i<3;++i) {
NVIC_ClearPendingIRQ(irqs[i]); NVIC_SetPriority(irqs[i],2U);
NVIC_EnableIRQ(irqs[i]);
}
EXTI->IMR |= lines;
}
static void capture_line(GPIO_TypeDef *port, unsigned pin, uint8_t lane) {
uint32_t bit=1U<<pin;
if (EXTI->PR & bit) {
uint32_t t=TIM2->CNT; bool down=(port->IDR & bit)==0;
EXTI->PR=bit; push_raw(t,lane,down);
}
}
void EXTI1_IRQHandler(void) { capture_line(GPIOA,1,1); }
void EXTI4_IRQHandler(void) { capture_line(GPIOA,4,2); }
void EXTI9_5_IRQHandler(void) { capture_line(GPIOB,5,3); }
// Keep EXTI0_IRQHandler for lane 0.
// Main: Debounce buttons[4]; initialize each from its physical level.
// Drain Raw events into observe(&buttons[e.lane], e.down, e.t).
// Sample/confirm all four; call match_press(chart,start,lane,stamp).Wire & test
S1→CN7-28 PA0; S2→CN7-30 PA1; S3→CN7-32 PA4; S4→CN10-29 PB5. Ground and 3.3 V remain common. Check header numbering against the Nucleo drawing, not the image orientation.
Expected result
Each switch produces its own lane ID without changing the others. Pressing S2 does not consume a lane-0 note. A lane held down does not prevent other lanes from scoring.
One experiment
Predict what would happen if lane 4 used PB0 while lane 1 used PA0. Both want EXTI0: one EXTICR field cannot select both. Explain why our PB0 LED is fine: it is an output and does not need EXTI0.
Show signed error on eight red LEDs
Make the timing result understandable at a glance.
The concept & why it matters
Start with discrete spatial buckets. Arrange D1→D8 as early→late and label the board with tape; the physical column can be rotated mentally or mounted horizontally. Light the center pair for Perfect, one of three left buckets for early Good, one of three right buckets for late Good, and briefly all eight for Miss.
A bar communicates magnitude but uses more current. Binary milliseconds are an excellent register exercise, but poor rapid game feedback. Equal red LEDs cannot convey quality by color, so patterns and position matter.
Follow the hardware
Eight verified active-low LED inputs connect to PB0, PB1, PB6, PB7, PB12, PB13, PB14, PB15. Each needs its own series resistor. One BSRR write updates all eight while preserving other port pins, including PB5 input.
Register ownership
GPIOB clock already enabled. Configure each LED pin MODER=01, OTYPER=0, PUPDR=00, OSPEEDR=00. BSRR lower half turns selected outputs high/off; upper half turns them low/on. No interrupt is needed to hold a pattern for 120 ms: use a deadline in main.
A small implementation
static const uint8_t led_pin[8]={0,1,6,7,12,13,14,15};
static void leds_init(void) {
for (unsigned i=0;i<8;++i) {
unsigned n=led_pin[i]; uint32_t f=3U<<(2*n);
GPIOB->BSRR=1U<<n; GPIOB->OTYPER &= ~(1U<<n);
GPIOB->OSPEEDR &= ~f; GPIOB->PUPDR &= ~f;
GPIOB->MODER=(GPIOB->MODER&~f)|(1U<<(2*n));
}
}
static void leds(uint8_t pattern) {
uint32_t on=0,all=0;
for (unsigned i=0;i<8;++i) {
uint32_t bit=1U<<led_pin[i]; all|=bit;
if (pattern & (1U<<i)) on|=bit;
}
GPIOB->BSRR=(all & ~on) | (on<<16);
}
static uint8_t feedback(int32_t d, bool missed) {
if (missed || d < -100000 || d > 100000) return 0xFF;
if (d>=-25000 && d<=25000) return 0x18;
unsigned bucket=d<0 ? (d < -75000 ? 0 : d < -50000 ? 1 : 2)
: (d > 75000 ? 7 : d > 50000 ? 6 : 5);
return (uint8_t)(1U<<bucket);
}
// On a scored note: leds(feedback(error_us,false)); until=now_us()+120000U;
// On note expiration: leds(feedback(0,true)); until=now_us()+120000U;
// Main: if ((int32_t)(now_us()-until)>=0) leds(0);
// Keep a feedback_active flag so there is no uninitialized deadline.Wire & test
Use the Hardware mapping table. Walk one LED at a time using 1U<
Expected result
−90 ms → D1; −60 ms → D2; −40 ms → D3; ±25 ms → D4+D5; +40 ms → D6; +60 ms → D7; +90 ms → D8. Miss→all on briefly. No press timestamp is fabricated for an expired note.
One experiment
Show abs(delta_ms) in binary on seven LEDs and use one LED for sign. Compare how quickly you can understand it versus the bucket display. Keep buckets for play and binary for debugging.
Chords, misses and game states
Treat simultaneous notes as independent lane events.
The concept & why it matters
Two chart entries may have the same timestamp in different lanes. Judge each independently; do not require two interrupts to happen in the same CPU cycle. Notes in the same lane at exactly the same time should be rejected by chart validation unless you intentionally support a special mechanic.
Use explicit states: IDLE → COUNT_IN → PLAYING → RESULTS. PAUSED, if added, needs a defined audio and epoch policy; do not merely stop scoring while music continues.
Follow the hardware
The same four switches and queue support chords. Debounce state is per lane. Main owns chart state and feedback scheduling. ISR owns raw event publication. A missed chord member expires normally even when another member is hit.
Register ownership
No new register. TIM2 runs continuously through state changes. Start a fresh song epoch without resetting the timer used by the event queue; discard stale input safely at a state transition.
A small implementation
typedef enum { IDLE, COUNT_IN, PLAYING, RESULTS } GameState;
static GameState game=IDLE;
static uint32_t song_start;
static void arm_song(uint32_t now) {
for (unsigned i=0;i<NOTE_COUNT;++i) judged[i]=WAITING;
uint32_t saved=__get_PRIMASK(); __disable_irq();
rd=wr; // discard queued pre-game edges as one short critical operation
__set_PRIMASK(saved);
song_start=now+2000000U; // two-second count-in
game=COUNT_IN;
// Reset debounce states to actual levels; require held keys to release.
}
// Main: during COUNT_IN, continue sampling/debouncing; don't stop the queue.
// Judge events within the first note's EARLY window, even during count-in.
// Transition the display state when delta_us(now_us(),song_start)>=0.
// During PLAYING: drain -> debounce -> match -> expire -> render feedback.
// RESULTS when all notes are HIT/EXPIRED and audio end policy is satisfied.
// Example chord: chart[0]=(Note){1000000U,0}; chart[1]=(Note){1000000U,2};Wire & test
Wire at least S1 and S3. First test identical synthetic timestamps on lanes 0 and 2, then real two-finger presses. Keep the 7 ms expiration grace or your measured replacement. For the first playable version, pressing a designated start key arms the song only in IDLE.
Expected result
A chord can be Perfect on lane 0 and Good on lane 2. A missing lane gets a Miss without undoing the hit lane. Define an optional chord bonus separately, such as all members hit and a ≤30 ms spread. That spread is a game rule.
One experiment
Create a note at beat zero and press 40 ms before it. Confirm the count-in still permits that valid early hit. For simultaneous results on one LED bank, try “worst grade wins” for the chord; avoid last-processed lane accidentally hiding a Miss.
PWM and output compare
Let hardware schedule edges while the CPU does game logic.
The concept & why it matters
PWM repeats an on/off waveform fast enough that an LED appears dimmer. Duty cycle sets average on-time; it does not reduce instantaneous LED current. Output compare detects when CNT equals CCRx; it can signal an interrupt without toggling a physical pin.
Keep TIM2 as the fixed time ruler. Use TIM3 for PWM so changing brightness frequency does not alter input timestamps. An optional TIM2 compare channel can schedule a cue against the existing epoch.
Follow the hardware
For the one-LED PWM exercise, disconnect D1 from PB0 and connect it to PA6 (CN10-13), AF2 TIM3_CH1. Never connect two output pins together. TIM3EN is APB1ENR bit 1; GPIOA needs its own gate. The peripheral drives the LED electrically without a PWM ISR.
Register ownership
PA6 MODER[13:12]=10, AFRL[27:24]=0010. TIM3_PSC=15, ARR=999 gives 1 kHz at 16 MHz timer input. CCMR1.CC1S=00, OC1M=110 PWM mode 1, OC1PE=1; CCER.CC1E=1, CC1P=1 for active-low sink duty. CR1.ARPE buffers the period.
A small implementation
// Alternative D1 wiring; verified Stage 2 16MHz timer-clock profile.
RCC->APB1ENR |= RCC_APB1ENR_TIM3EN; (void)RCC->APB1ENR;
TIM3->CR1=0; TIM3->DIER=0; TIM3->SMCR=0; TIM3->CCER=0;
TIM3->PSC=15; TIM3->ARR=999; TIM3->CCR1=250; // 25% low/on
TIM3->CCMR1=(6U<<4)|TIM_CCMR1_OC1PE;
TIM3->CCER=TIM_CCER_CC1E|TIM_CCER_CC1P;
TIM3->CR1=TIM_CR1_ARPE; TIM3->EGR=TIM_EGR_UG; TIM3->SR=0;
GPIOA->OTYPER &= ~(1U<<6); GPIOA->PUPDR &= ~(3U<<12);
GPIOA->OSPEEDR &= ~(3U<<12);
GPIOA->AFR[0]=(GPIOA->AFR[0]&~(15U<<24))|(2U<<24);
GPIOA->MODER=(GPIOA->MODER&~(3U<<12))|(2U<<12);
TIM3->CR1 |= TIM_CR1_CEN;
// Main can update CCR1 to 0..1000; preload applies on the next update.
// Separate output-compare experiment: TIM2 channel 2, no pin necessary.
// Exclusive ownership of CH2; schedule well ahead, then poll CC2IF.
TIM2->CCER &= ~TIM_CCER_CC2E;
TIM2->CCMR1 &= ~(255U<<8); // CC2S=00, OC2M=000 frozen
TIM2->CCR2=now_us()+100000U;
TIM2->SR=0x1E5FU & ~TIM_SR_CC2IF; // rc_w0; not EXTI's W1C rule
// 0x1E5F selects implemented TIM2 SR flags; reserved bits stay zero.
// Write 0 to the flag being cleared and 1 to preserve other rc_w0 flags.
// Main checks TIM2->SR & TIM_SR_CC2IF and acknowledges it the same way.
// IRQ alternative: DIER.CC2IE + NVIC TIM2_IRQn; handler clears CC2IF.Wire & test
Disconnect the old D1/PB0 wire first. Test PWM with a scope or slow-motion camera plus brightness observation. Output compare needs no wire. Programming a compare value already passed can wait until wrap: add a guard interval and handle overdue events in software.
Expected result
CCR1=250 gives approximately 25% low time and dimmer light than CCR1=750. TIM2 timestamps keep their units. A polled compare flag appears at the scheduled counter value, although software sees it later.
One experiment
Change PWM ARR to 1999: frequency becomes 500 Hz. Set CCR1=500 to preserve 25% duty. Explain why changing TIM3 does not change TIM2’s timestamps.
Synchronize a recording
Put audio position and expected notes on one clock relationship.
The concept & why it matters
Start with a firmware-generated count-in and chart. Playing a phone track and manually pressing Start is useful for learning, but it is not a synchronized instrument. The command to play audio is not the instant sound reaches the player.
Track startup latency, chart offset, input latency, and ongoing clock drift separately. A fixed calibration offset can compensate a stable delay; it cannot remove jitter or differing clock rates. Bluetooth often adds variable latency and is a poor calibration reference.
Follow the hardware
First use an external player: no new MCU audio peripheral yet. A host can send a scheduled-start message well in advance over USART2, but the host’s playback clock still needs calibration against TIM2. ST-LINK VCP is a communications path, not an audio output. Later use the audio sample counter as the master song clock.
Register ownership
If adding VCP control: PA2 TX and PA3 RX AF7; GPIOAEN plus APB1ENR.USART2EN bit 17; GPIO MODER/AFRL, USART2 BRR/CR1/SR/DR. USART2_IRQn is optional; baud depends on actual PCLK1. Stock board routing and solder bridges matter. This stage needs no UART driver if using a manual start experiment.
A small implementation
// Chart remains in microseconds. Calibrate against measured playback.
// Positive input_bias_us means input timestamps are observed too late.
static int32_t input_bias_us = 0;
static int32_t chart_offset_us = 0;
static int32_t calibrated_error(uint32_t press, uint32_t song_epoch,
uint32_t chart_time) {
uint32_t expected=song_epoch+chart_time+(uint32_t)chart_offset_us;
return delta_us(press,expected)-input_bias_us;
}
// Calibration exercise: collect at least 20 errors to a known cue,
// then inspect median offset and spread before applying a correction.
// Do not keep adapting bias during normal scoring: that rewards drift.
// Later, if firmware owns audio at a verified sample rate Fs:
// song_us = (played_frames * 1000000ULL) / Fs;
// played_frames counts frames actually clocked toward the DAC,
// not merely queued or copied into a DMA buffer.Wire & test
Use wired headphones/speakers for repeatability. Start the same click track several times and log errors at both beginning and end. For objective calibration, record an electrical sync/cue and the button/LED signals on one measurement system; subjective tapping includes the player’s bias.
Expected result
A consistent offset shifts all errors similarly. Drift produces a changing offset across the song. At 1000 ppm frequency error, relative time drifts by roughly 60 ms in a minute. The HSI’s nominal 16 MHz is not an audio-grade frequency guarantee.
One experiment
Fit a line to error versus song position. A nonzero intercept suggests offset; a slope suggests clock mismatch. Compare the slope before and after using a shared audio-derived timebase.
Measure latency, then try input capture
Find out which error source actually matters.
The concept & why it matters
EXTI timestamps include interrupt latency. Input capture latches CNT into CCR1 when hardware recognizes an edge, so a later ISR reads the earlier recorded time. Synchronizer/filter delay remains, and bounce still exists. Capture channels are finite and only connect to specified AF pins.
DWT_CYCCNT counts CPU cycles and is useful for short profiling intervals. It is not automatically an audio clock: conversion changes with core frequency, a 32-bit counter wraps in about 42.95 s at 100 MHz, and sleep/debug behavior must be accounted for.
Follow the hardware
PA0 can route to TIM2_CH1 through AF1. For this alternative experiment, disable EXTI0 and change PA0 from GPIO input mode to AF mode while preserving its pull-up. Keep TIM2 running at 1 MHz; do not reinitialize its epoch. Hardware captures into CCR1; TIM2_IRQn handles capture. Other lanes can remain EXTI.
Register ownership
PA0 MODER[1:0]=10, AFRL[3:0]=0001. TIM2_CCMR1.CC1S=01 maps TI1, IC1PSC=00 captures every edge, IC1F=0000 initially. CCER.CC1P=1, CC1NP=0 selects falling edge. DIER.CC1IE enables capture interrupt. SR.CC1OF reports overcapture. Capture-mode CCR1 read clears CC1IF; CC1OF clears by writing 0.
A small implementation
// Alternative to EXTI0, not an extra duplicate press source.
EXTI->IMR &= ~1U; NVIC_DisableIRQ(EXTI0_IRQn); EXTI->PR=1U;
TIM2->CCER &= ~TIM_CCER_CC1E;
GPIOA->AFR[0]=(GPIOA->AFR[0]&~15U)|1U;
GPIOA->MODER=(GPIOA->MODER&~3U)|2U;
TIM2->CCMR1=(TIM2->CCMR1&~255U)|1U;
TIM2->CCER=(TIM2->CCER&~(TIM_CCER_CC1P|TIM_CCER_CC1NP))|TIM_CCER_CC1P;
TIM2->SR=0x1E5FU & ~(TIM_SR_CC1IF|TIM_SR_CC1OF);
NVIC_SetPriority(TIM2_IRQn,2U); NVIC_ClearPendingIRQ(TIM2_IRQn);
NVIC_EnableIRQ(TIM2_IRQn); TIM2->DIER |= TIM_DIER_CC1IE;
TIM2->CCER |= TIM_CCER_CC1E;
static volatile uint32_t capture_us, service_us, capture_overruns;
void TIM2_IRQHandler(void) {
uint32_t sr=TIM2->SR;
if (sr & TIM_SR_CC1IF) {
capture_us=TIM2->CCR1; // captured time, not current CNT
service_us=TIM2->CNT;
}
if (sr & TIM_SR_CC1OF) {
++capture_overruns; TIM2->SR=0x1E5FU & ~TIM_SR_CC1OF;
}
// If CH2 compare IRQ is enabled, also handle its CC2IF here.
}
// DWT profiling experiment, separate from the song timebase:
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
DWT->CYCCNT=0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
uint32_t before=DWT->CYCCNT;
// ... the short operation being measured ...
uint32_t cycles=DWT->CYCCNT-before;Wire & test
Keep S1 on PA0. First use a clean 3.3 V logic pulse source or a second MCU output with common ground, never two connected outputs. Observe capture_us and service_us without breaking in the ISR. Use mechanical presses afterward to reveal bounce/overcapture.
Expected result
service_us − capture_us measures post-capture service delay, not the pad synchronizer delay before capture. Controlled main workload has little effect unless it masks interrupts; capture time itself survives that delay until another edge overwrites the register.
One experiment
Add bounded interrupt masking and compare captured time with service time. Then add a digital input filter and measure its additional delay. The timer filter rejects short pulses; it is not a substitute for millisecond switch debounce.
Optional audio: tones, then real music
Keep audio delivery separate from game judgment.
The concept & why it matters
The STM32F411RE has no on-chip DAC. A GPIO cannot directly power a speaker. For a first audible metronome, use a suitable passive piezo through its recommended drive circuit, or feed a filtered PWM signal into an amplifier. An active buzzer is useful for beeps but cannot reproduce a music track faithfully.
For real audio, external I²S DAC/amplifier + DMA is a better next project. Store or stream PCM, keep buffers filled, and derive song position from playback frames. An underrun means time and sound no longer match; flag it rather than silently continuing a trusted score.
Follow the hardware
Tone option: reuse TIM3_CH1 on PA6 after disconnecting the LED and connecting a verified high-impedance audio input/appropriate driver. PCM option, candidate routes: SPI2/I2S2 WS=PB12 AF5, CK=PB13 AF5, SD=PB15 AF5, optional MCK=PC6 AF5. These collide with three LED wires, so reassign or use an external LED driver before attempting I²S. Verify the exact DAC module’s pin and power requirements.
Register ownership
Tone: GPIOA mode/AF2, APB1 TIM3EN, TIM3 PSC/ARR/CCR1. PCM: APB1ENR.SPI2EN, GPIOA/B/C gates as used, RCC PLLI2S configuration/readiness and I2S clock selection; SPI2 I2SCFGR/I2SPR, DR/SR, CR2.TXDMAEN; AHB1ENR.DMA1EN. DMA1 stream 4 channel 0 is an SPI2_TX mapping; configure stream CR/PAR/M0AR/NDTR and optional double buffers. DMA1_Stream4_IRQn handles buffer/error events. Exact rate/dividers and format depend on the selected DAC; no values are inferred here.
A small implementation
// First audible exercise: repurpose Stage 11 TIM3 PWM as a 1kHz tone.
// Run only with the LED disconnected and an appropriate audio load/driver.
// fCNT=1MHz, period=1000 counts; duty=50%.
TIM3->ARR=999U; TIM3->CCR1=500U;
// For A4=440Hz, nearest integer period is 2273 counts:
// f=1,000,000/2273 = about 439.947Hz (square wave, not a recording).
TIM3->ARR=2272U; TIM3->CCR1=1136U;
// Nonblocking main-loop gate: set CCR1=0 after a 30ms cue deadline.
// Keep TIM2 untouched. Don't delay inside an ISR to make a note longer.
// Planning contract for the later PCM project (not a complete driver):
// I2S/DAC clock consumes stereo frames at Fs.
// DMA half/full events refill only the buffer region no longer in use.
// Track transmitted frames + known DAC/buffer latency for song position.
// Check DMA transfer errors and I2S underrun; invalidate timing on faults.Wire & test
Begin at low sound level with a known audio module and its datasheet. Never wire an 8 Ω speaker or ordinary headphones directly to PA6. For I²S, choose the DAC/amplifier before committing the pinout; share ground and honor its logic voltage. This is intentionally a separate integration milestone.
Expected result
You can generate a scheduled short tone without stopping input collection. A later PCM pipeline can play music while input ISRs only timestamp; main judges hits against the common playback timeline.
One experiment
Listen to 440 Hz PWM, then vary duty from 50% to 25%. Timbre changes because the harmonic content changes. Explain why a square-wave tone generator is not the same as decoding and playing a song file.
Read the hardware’s contract.
Page numbers refer to the editions used in your supplied atlas; online files may be revised. For complete field behavior and electrical constraints, use the original tables and diagrams.
- RM0383 · p. 118 · Rev 4: GPIO clock gates · RCC clock tree and TIMPRE pp. 95, 136; GPIO pp. 158–163; SYSCFG pp. 141–143; EXTI pp. 203–211; TIM2–TIM5 pp. 313–371.
- DS10314 · p. 36 · Rev 8: LQFP64 pinout · alternate-function matrix pp. 48–50; timer overview pp. 28–29; current ratings p. 62 and I/O characteristics p. 101. Check oscillator accuracy and operating conditions before making time-accuracy claims.
- UM1724 · p. 59 · Rev 17: NUCLEO-F411RE morpho table · stock VCP routing p. 26; board power and solder bridges are separate from MCU AF routing.
- PM0214 · p. 208 · Rev 10: core NVIC · SysTick pp. 246–250; exception model for priorities and entry behavior.
- ST STM32F411xE CMSIS device header · verify register names and IRQ identifiers against the exact version in your project.
Before calling the game accurate
- Record measured switch polarity, resistor values and bounce durations.
- Measure timer rate and maximum input-service delay without debugger stops.
- Prove no queue overflow and bound the main-loop service interval.
- Test thresholds, timer wrap, repeated/held/wrong presses and chords with synthetic timestamps.
- Measure audio offset and drift; identify whether the audio or MCU clock owns the timeline.
- Capture evidence of the electrical edge and feedback—not only serial printouts.
This tutorial supplies a learning progression and testable fragments. It does not claim a complete driver, compiled firmware project, certified module pinout or hardware measurement.