Lab 9.1 — Host↔︎Device Streaming Harness (WAV Round-Trip)

Course 2 syllabus · Bonus Module 9 · Prev: « Lab 8.5 · Next: Lab 9.2 »

Goal

Build the reusable pipe that the rest of Module 9 depends on: a framed binary block protocol that streams a real media file from the laptop to the embedded target and back. In this first lab the device does nothing but echo blocks unmodified (a loopback), so the only thing under test is the transport — framing, integrity, ordering, and flow control. The host reads a WAV file, chops it into fixed-size blocks, ships them over the STM32’s USB CDC virtual COM port, receives the echoes, reassembles an output WAV, and asserts it is bit-exact to the input. Getting a lossless, back-pressured, self-checking link working first is exactly how real embedded media-DSP firmware is developed: you prove the plumbing with an identity transform before you trust any DSP result that flows through it. This harness is reused verbatim in Labs 9.2–9.4.

Equipment & parts

  • STM32 Nucleo-L476RG and its USB cable (ST-LINK VCP + optionally the L476RG’s own USB FS device).
  • Host: M-series Mac, Python venv with numpy scipy pyserial (already installed per the syllabus toolchain).
  • A short reference WAV file — e.g. 2–5 s of 16-bit mono PCM at 44.1 kHz or 48 kHz. Any real recording works; keep the first one small.
  • No breadboard, no analog parts. This is a data lab.

Safety & don’t-break-it

This lab has almost no electrical hazard — the only power is USB 5 V through the Nucleo’s regulators, and the STM32 stays in its own 3.3 V world. The failure modes here are software, and they are just as capable of wasting a day:

  • Buffer overrun is the real hazard. If the host sends faster than the device drains its RX buffer, DMA silently overwrites unprocessed bytes and you get corrupted or dropped blocks that look like a DSP bug later. The flow-control handshake below exists to prevent exactly this — do not skip it.
  • Respect the DMA double-buffer boundaries. Never let the CPU read the half of the buffer the DMA is currently writing. Use the half-transfer + transfer-complete interrupts to hand off ownership cleanly; a torn read is a Heisenbug.
  • Bound every allocation. Fixed block size, fixed number of buffers, no malloc in the ISR path. Reject any frame whose declared length exceeds your block size instead of trusting it — a bad length field must never index past your buffer.
  • Match the VCP baud and line settings on both ends. A silent baud mismatch produces framing errors that masquerade as CRC failures.
  • Standard bench note only: keep the 3.3 V and 5 V domains straight; the Nucleo handles this internally over USB, so there is nothing to mis-wire here.

Background

A WAV file’s data chunk is just a sampled sequence \(x[n]\) of 16-bit signed integers. Streaming it losslessly means the reassembled sequence \(y[n]\) must satisfy

\[y[n] = x[n] \quad \text{for all } n,\]

i.e. bit-exact equality, not “close.” Any single dropped, duplicated, reordered, or corrupted byte breaks this, which makes the identity round-trip a strong test of the transport.

Framing. A raw byte stream has no message boundaries, so we impose them. Each block is sent as a self-describing frame:

| SOF (0x55AA) | seq (u16) | len (u16) | payload[len] | CRC-16 (u16) |
  • seq — a monotonic sequence number so the host can detect a dropped or reordered block.
  • len — payload length in bytes (bounded by the agreed block size), so the receiver knows exactly how many bytes to expect.
  • CRC-16 (CCITT) over seq‖len‖payload — catches corruption. With a good CRC the residual undetected-error probability is \(\approx 2^{-16}\) per bad frame, which for a clean USB link means effectively never, but the check is what lets you trust the bit-exact assertion.

Flow control. The device sends a one-byte ACK (or a credit count) after it has finished draining a frame into its double buffer. The host waits for outstanding credits before sending the next block. This is back-pressure: it bounds how far ahead the host can run to exactly the buffer depth, so the device’s RX DMA never overruns regardless of host speed.

Throughput. With payload size \(B\) bytes per frame and framing/ACK overhead \(H\) bytes per frame, the useful (goodput) fraction is

\[\eta = \frac{B}{B + H},\]

and the sustained data rate is bounded by the link. USB CDC on full-speed USB has a raw ceiling far above a UART VCP; a USART2 VCP at baud \(R\) (8N1) carries at most

\[\text{bytes/s} \le \frac{R}{10}\]

(10 bits on the wire per 8 data bits). Measuring achieved bytes/s against this ceiling tells you how much the framing, ACK round-trips, and firmware copy loop cost you.

Procedure

Part A — Define the frame and the firmware loopback.

  1. Fix a block size, e.g. BLOCK = 512 bytes (256 int16 samples). Define the frame struct above with a CRC-16-CCITT.
  2. On the STM32, configure the transport. Two options — pick one and keep it for the whole module:
    • USART2 → ST-LINK VCP with DMA RX (circular, into a double buffer) and DMA TX. Simple, ~921600 baud realistic.
    • USB CDC (the L476RG USB FS device) for much higher throughput. More setup, better for the video lab later.
  3. Illustrative RX side (HAL/LL, structure only — you write the real thing):
/* Double buffer: DMA fills one half while the app drains the other. */
static uint8_t rx_buf[2][BLOCK + FRAME_OVERHEAD];
static volatile uint8_t ready[2];

/* On frame complete (IDLE-line + DMA, or USB CDC RxCplt): */
void on_frame(uint8_t *f, uint16_t nbytes) {
    frame_t *fr = (frame_t *)f;
    if (fr->len > BLOCK)            return nak(fr->seq);   // reject over-length
    if (crc16(f + 2, fr->len + 4) != fr->crc) return nak(fr->seq);
    /* loopback: echo the exact same payload back with the same seq */
    tx_frame(fr->seq, fr->payload, fr->len);
    ack(fr->seq);                                          // credit the host
}
  1. Keep the ISR short: validate, hand the buffer to the TX path, send one ACK. Do the work in a main-loop consumer if the transport needs it.

Part B — Host streamer.

  1. Read the WAV and frame it. Illustrative host code (structure only):
import numpy as np, serial, struct
from scipy.io import wavfile

fs, x = wavfile.read("in.wav")          # x: int16 PCM, mono
assert x.dtype == np.int16
payload = x.tobytes()
BLOCK = 512
ser = serial.Serial("/dev/tty.usbmodemXXXX", 921600, timeout=1)

def frame(seq, data):
    body = struct.pack("<HH", seq, len(data)) + data
    return b"\x55\xAA" + body + struct.pack("<H", crc16(body))

out = bytearray()
credits = DEPTH                          # flow-control window
for seq, off in enumerate(range(0, len(payload), BLOCK)):
    while credits == 0: credits += drain_acks(ser)
    ser.write(frame(seq, payload[off:off+BLOCK])); credits -= 1
    out += read_echo(ser)                # verify seq + CRC on the way back
  1. Reassemble out into int16 and write out.wav with the same fs.

Part C — The bit-exact assertion.

  1. Compare byte-for-byte:
fs2, y = wavfile.read("out.wav")
assert fs2 == fs
assert np.array_equal(y, x)              # bit-exact loopback
  1. If it fails, print the first differing sample index and the seq numbers seen — that localizes a drop vs. a corruption vs. a reorder.

Part D — Throughput.

  1. Time the whole transfer; compute achieved bytes/s = total payload bytes / elapsed. Repeat with DEPTH = 1 (stop-and-wait) vs. a larger credit window to see flow control’s effect on throughput.

Deliverable & expected results

  • out.wav, proven np.array_equal(y, x) — a green bit-exact round-trip.
  • A throughput number vs. the link ceiling, and a note on how the credit window changed it.

For a USART2 VCP at 921600 baud, 8N1, BLOCK = 512, frame overhead \(H = 8\) bytes (SOF 2 + seq 2 + len 2 + CRC 2), plus a 1-byte ACK per frame:

Quantity Predicted Measured
Wire ceiling (921600/10) 92,160 B/s
Goodput fraction \(\eta=512/(512+8+1)\) 0.9827
Payload throughput (ceiling·\(\eta\), ACK-limited) ≈ 90,600 B/s
Bit-exact round-trip np.array_equal(y,x) True
Dropped / reordered / corrupted blocks 0

(For USB CDC the ceiling is set by the CDC bulk endpoints, not baud; predict from measured pyserial write throughput and expect the goodput fraction to dominate.)

Analysis & reconciliation

If bit-exactness holds, the transport is trustworthy and every later lab’s “vs. reference” comparison is meaningful. If throughput falls well short of ceiling·\(\eta\), the usual culprits are: stop-and-wait ACKs (each frame pays a full RTT — widen the credit window), per-byte host writes (batch the frame into one write), or a firmware copy that blocks the RX DMA (drain in the main loop, not the ISR). Reconcile the achieved bytes/s against \(R/10\cdot\eta\); the gap is the RTT and copy cost. Confirm the flow-control window is doing its job by temporarily removing it and watching the bit-exact assertion fail under overrun — then put it back.

Going further

  • Inject a deliberate bit-flip in one echoed frame on the device and confirm the host’s CRC check catches it (assertion should localize the bad seq).
  • Add a tiny stereo WAV path (interleaved L/R int16) and confirm the round-trip still holds — this is what Lab 9.2 filters per channel.
  • Port the transport from USART2 VCP to USB CDC and re-measure throughput; keep both behind the same host API so 9.2–9.5 don’t care which is underneath.
  • Log per-frame RTT and plot the histogram — you’ll reuse this latency tooling for the video pipeline in Lab 9.5.