Video Playback Performance
What it costs to play video on MicroPythonOS hardware, derived from the pixel count up, and measured on a Fri3d 2026 badge (ESP32-S3, ST7789 320x240 over SPI, SD card on the same SPI host). Use this to predict a player's framerate before writing one, and to know which term to attack when it is slow.
The short version: a 320x240 frame costs ~72 ms end to end, and the three stages are serial. That is 13.5 fps. No amount of timer tuning changes it; only removing a stage does.
Two things cost framerate without showing up in the frame cost: the LVGL refresh timer (§3) and the framework's fairness gap between task-handler passes (§4). Removing both, plus three details of how you drive the passes, is what took a real 320x240 player from 8.9 to 13.6-14.1 fps. Check those before buying faster hardware.
Sound is a fourth term, and a cheap one if you feed it correctly: one clock, a preallocated ring, and a sink that is never asked to block cost about 1.25 fps (9%) on an already saturated clip, of which only 0.4-2.3 ms per frame is the audio code itself (§6).
1. Model a frame as four serial stages
A player does four things per frame, one after the other:
| Stage | What it moves | Bound by |
|---|---|---|
| Ingest | encoded bytes into RAM | storage or network throughput |
| Decode | JPEG → RGB565 | CPU (jpegdec, C) |
| Invalidate | image.set_src() |
pointer swap, ~0 |
| Flush | RGB565 bytes to the panel | SPI bus + DMA |
Because they are serial, the frame time is the sum:
frame_ms = ingest_bytes / ingest_rate
+ decode_ms
+ flush_bytes / flush_rate
fps = 1000 / frame_ms
Both byte terms are the whole frame: RGB565 is w * h * 2 bytes, MJPEG is
whatever the encoder produced. Decode and flush scale with pixel count; ingest
scales with compression ratio, which is why ingest is the term a player can
actually tune.
Nothing overlaps. The flush is synchronous inside the loop, so while the panel is drawing, the SD card is idle, and vice versa. Sharing one SPI host therefore costs exactly the sum of the two transfers — there is no arbitration penalty to hunt for. Measured: predicted 74.11 ms vs actual 74.11 ms at 320x240 (see §7). If you want overlap, you need a separate task or DMA chain that the current design does not have.
2. Measured constants (Fri3d 2026, 320x240)
| Stage | 320x240 | 240x180 | 160x120 | Per byte / pixel |
|---|---|---|---|---|
| Ingest from SD (MJPEG) | 33.5 ms | 24.2 ms | 15.4 ms | ~0.5 MB/s effective in-loop |
| Decode | 13.8 ms | 10.1 ms | 5.3 ms | ~0.18 µs/pixel |
| Flush (RGB565) | 24.8 ms | 13.1 ms | 8.1 ms | ~6 MB/s effective at 80 MHZ SPI |
| Total | 72-74 ms | 49 ms | 30 ms | |
| Ceiling | 13.5 fps | 20.4 fps | 33.2 fps |
Two sanity checks on those constants:
- Flush: 153,600 bytes in 24.8 ms = 6.2 MB/s. The bus is configured for 80 MHz (10 MB/s theoretical), so the flush runs at ~60% of the wire rate. That is the real number to budget, not 10 MB/s.
- Decode: 76,800 pixels in 13.8 ms = 0.18 µs/pixel. Costs scale with area, so 160x120 is about a quarter of 320x240 — matches (5.3 vs 13.8 ms).
The SD card delivers only ~0.5-0.6 MB/s here, standalone and in-loop, and does not improve with a higher SPI clock request (20 MHz is identical; 40 MHz fails to mount). Treat 0.6 MB/s as the storage ceiling, not a tuning knob.
3. The 33 ms refresh trap
LVGL only refreshes on LV_DEF_REFR_PERIOD (33 ms) ticks. If you drive video
from an LVGL timer, your frame rate is quantized to that grid: a 42 ms frame
becomes 66 ms of wall time, and 24 fps is unreachable no matter how fast the
decode is.
Options, in order of preference:
- Drive the loop yourself and call
lv.refr_now()afterset_src. This bypasses the timer and gives the real cost (24.8 ms flush at 320x240). - Let the timer coalesce, and accept ≤30 fps quantization. Fine for 8-12 fps clips, wrong for anything near the panel limit.
- Raise the refresh rate globally. Not recommended: it taxes every other app and does not remove the flush cost.
A dedicated loop with refr_now measured 13.5 fps at 320x240; the same work
driven by an LVGL timer in a real player (ClipTV 0.12.3) measured 9.9 fps. The
~35 ms/frame difference is scheduling, not hardware. Pinning that player's
timer period to 66 ms or 33 ms changed nothing (9.92 / 9.79 / 9.66 fps), which
is the signature of a cost that lives outside the timer.
4. The scheduler tax: your loop is not the only thing running
A dedicated loop with refr_now reaches 13.5 fps at 320x240 (§3), yet the same
pump inside a normal app measured 8.88 fps on the badge. The missing 4.6 fps
was not the loop: it was the framework putting a gap between passes.
MicroPythonOS runs LVGL from one repeating pass (task_handler.tick). After
every pass it sleeps a share of the time that pass just took:
gap_ms = (last_run_ms >> 2) + tick_slack # fairness: leave the CPU to others
At 320x240 a pass costs ~72 ms, so the gap is ~18 ms, plus up to a 10 ms
FreeRTOS tick (CONFIG_FREERTOS_HZ=100) to notice it expired. The frame is
therefore ~29 ms late every time, and the pacer — correctly — drops source
frames to stay on wall-clock time.
The drop count is predictable. With per-pass overhead O the steady-state
number of dropped frames per presented frame is
d = (O - 1.6) / 39.9 p_fps = 15 / (1 + d)
At O = 2 ms (an idle loop) d = 0.01, i.e. real time. At the measured
O = 29 ms d = 0.69, so 15 / 1.69 = 8.88 fps — the measured value
exactly. The overhead, not the hardware, was halving the framerate.
The fix is to own the passes. On device the player disables the framework
task handler, runs lv.task_handler() and the frame work from its own hardware
timer, and re-enables the framework when the clip ends. No fairness gap, no
tick quantization. Three details did the rest, and each one is worth 1-3 fps:
- Run the pass in the timer callback, not via
micropython.schedule. Scheduling hands the work to the main thread, which only runs it when it next yields. Measured dispatch delay: 25-75 ms — most of a 160x120 frame's 33 ms budget, and it showed up as repeated one-frame drops. - Do not run an LVGL pass when no frame is due. With a fixed 5 ms tick and a pass on every tick, the pass cost 3-5 ms of every frame for nothing; during playback there is nothing else to animate. Skipping the pass on idle ticks dropped 320x240's LVGL term from 4.9 to 1.4-1.8 ms per frame.
- Present every frame that is due per callback. A fixed tick leaves up to a tick of dead time between two frames that are both already late. One callback presenting up to four due frames removed it: 320x240 15 fps went from 13.3 to 13.6-14.1 fps, against a 13.5 fps ceiling for a 72 ms frame.
| Run | 320x240 15 fps | Per-pass overhead |
|---|---|---|
| Framework passes | 8.88 fps | ~29 ms |
| Own passes, scheduled, pass every tick | 13.3 fps | ~2-8 ms |
| Own passes, direct, pass only when due, 4 frames/pass | 13.6-14.1 fps | ~1-2 ms |
At 13.6-14.1 fps the player is at the frame-cost ceiling: 72 ms of read + decode + flush against a 66.7 ms period. It can sit slightly above the naive 13.5 figure because a frame that started on time is allowed to finish, and the 33 ms refresh period lets the tail of one flush overlap the start of the next.
The 160x120 30 fps clip gains the most from the last two fixes, because 25-75 ms of dispatch latency is several of its frames: before them it measured 14-22 fps with a drop every other frame, after them 24.5-27.0 fps.
Two caveats for anyone copying this:
- Hand the passes back in
finally. A pump that raises while the framework is disabled freezes the whole UI — no traceback, no animation, no touch. This is a real failure mode, not a hypothetical: it showed up once during development. - Only do this while the player is on screen, and only on hardware where you can afford a spinner timer. The desktop build keeps the framework passes.
- A fixed tick means the callback sometimes has to wait before its frame is
due. Sleeping out the wait (
time.sleep_ms(wait - tick)) keeps the frame precise but blocks theesp_timertask — and every othermachine.Timercallback — for up to a frame period. That is acceptable here because everything else that needs a timer is disabled for the duration, but do not copy the pattern into a player that has to keep other timers running: there, wake on the frame deadline instead. disable()stops the framework's passes, not LVGL's clock:tick_incis called from the task handler's own 33 ms hardware timer, which keeps running. The player's pass still callslv.task_handler(), so animations andlv.async_callwork during playback, just at frame rate. The LVGL refresh timer does not cause a second flush per frame because the explicitrefr_nowresets it.
5. Dropping frames: read it or seek past it?
Drop-to-keep-time is cheap only if a dropped frame is cheap. MJPEG has no index, so a player has two ways to advance past one:
- Read and discard. Costs the frame's bytes at ~0.55 MB/s. At 320x240 that is 23 KB ≈ 28 ms per dropped frame.
- Seek.
seek()costs ~15 ms on this card, but the read that resynchronises on the next SOI also pulls in about one frame, so a seek costs roughly15 ms + one frame read.
Measured on 320x240, where the sink is already saturated:
| Source | Read and discard | Seek past drops |
|---|---|---|
| 30 fps (433 KB/s of source) | 3.86 fps | 7.6-7.7 fps |
| 60 fps (886 KB/s of source) | 0.46 fps | 7.2-7.3 fps |
The 30 fps source needs 433 KB/s of ingest to stay in real time, which is most of the card's 550 KB/s, so reading every dropped frame leaves almost nothing for the frames that are shown; the 60 fps source cannot be ingested at all. Seeking is what makes those clips play at all — they still cannot run in real time, but they move.
The same trick is poison when the sink is not saturated: an ungated seek dropped a 160x120 30 fps clip from 26.7 to 17.4 fps, because the resync read cost more than the frame it avoided. Hence two gates:
- Only seek when presenting alone cannot keep up — when the mean present cost (read of the shown frame + decode + flush, excluding the time spent dropping) exceeds the clip's frame period.
- Only seek for two frames or more, because one seek costs more than one frame read.
The saturation check must exclude dropped-frame time. If it includes it, the first seek makes the next frame look slow, which justifies another seek: the player locks itself into the slow path and stays there even though reading the drops would have caught up. That feedback loop is why an earlier, simpler version of this gate lost 10 fps on a clip that needed no seeking at all.
Measured Stage 1 matrix (own passes, drop-to-keep-time, 1:1)
| Clip | Presented | Dropped / read | Read | Skip | Decode | Flush | LVGL | Idle |
|---|---|---|---|---|---|---|---|---|
| 320x240 15 fps | 13.6-14.1 fps | 5-6 of 150 | 25.9 | 0.5 | 13.7 | 24.4 | 1.7 | 5.0 |
| 240x180 15 fps | 14.3-14.4 fps | 1-2 of 150 | 17.4 | 0.0 | 10.0 | 12.3 | 3.3 | 25.9 |
| 160x120 15 fps | 14.4 fps | 1 of 150 | 10.8 | 0.0 | 4.7 | 7.9 | 3.6 | 41.7 |
| 160x120 30 fps | 24.5-27.0 fps | 24-47 of 300 | 12.2 | 0.4 | 4.7 | 7.7 | 2.7 | 11.7 |
| 320x240 30 fps | 7.6-7.7 fps | 13 of 91 (200 seek) | 75.5 | 5.7 | 13.8 | 24.3 | 1.8 | 9.3 |
| 320x240 60 fps | 7.2-7.3 fps | 37-40 of 112 (478 seek) | 74.3 | 14.6 | 13.8 | 24.3 | 1.7 | 8.7 |
All values are ms per presented frame, from the player's own counters, except Presented which is derived from frames and elapsed time. Ranges are best and worst across runs of this build: three for 320x240 15 fps and 160x120 30 fps, two for the saturated clips.
Read the table this way:
- Small clips are free. 160x120 has 12 ms of idle per frame even at 30 fps; it is the first size that plays at (nearly) its nominal rate.
- 320x240 is ingest-bound at 15 fps and worse above it. At 15 fps the read is already 26 ms of a 66.7 ms period and the panel flush is another 24.4, so the last 4.6 ms of the budget does not exist. At 30 fps the ingest alone (433 KB/s) is most of the card, which is why the dropped-frame count explodes.
- The flush does not shrink with the source rate. A 30 fps 320x240 clip and a 15 fps one cost the same 24.3 ms per presented frame; only the ingest term differs, and it grows. Note that a seek makes the presented frame's read expensive too: 75 ms instead of 26, because it has to scan for the next SOI.
- The numbers are steady state. A 60 s run of the 320x240 15 fps clip held 14.26 fps over 900 frames (39 dropped), so the 10 s runs above are not a burst that decays once buffers warm up or the heap fragments.
- 160x120 at 30 fps varies between runs (24.3-27.0 fps over six runs on the same build). The per-stage costs barely move, so the swing is in SD tail latency — single frames whose read takes 60-80 ms instead of 12. Real time is maintained either way; the drop count absorbs the variance.
- The camera agrees, coarsely. Filming the 320x240 15 fps clip at 720p30 gave a clip advance of 15.34 fps (102% of nominal) — the player is real-time — and 10.3-11.3 distinct pictures per second on screen. That last number is a lower bound, not a contradiction: a 30 Hz sampler watching a ~14 fps display misses displayed frames to aliasing, and it loses lock on ~5% of its own frames. Use the counters for the framerate; use the camera to prove there is no slow motion and no freeze.
First-frame latency
From intent to the first presented frame: 230-550 ms, dominated by
SDCardManager.mount() (160-400 ms on a cold card, 162 ms when it is already
mounted). With the card mounted, open + first read is ~11 ms. The overlays stay
visible with "loading..." until the first frame is ready, so the delay is not a
blank screen.
Prefer a container: AVI over raw MJPEG
Raw .mjpeg has no index and no metadata: the frame rate can only come from
the file name, and dropping a frame means scanning for the next SOI. MJPEG in
AVI fixes both, and adds the soundtrack to the same file:
- one chunk per frame with its own size, so
skip(n)is n header reads and seeks (~0.4 ms each) instead of n resync scans; dwRate/dwScalegives frames per second anddwLengththe frame count, so the player no longer guesses from the name;- an
audsstream can carry 8/16-bit PCM or IMA ADPCM, which removes the sidecar.wav(and its same-basename convention) entirely; - VLC plays MJPEG-in-AVI with LPCM out of the box, so one file can be checked on a desktop and then played on the badge unchanged.
The demuxer only walks what a player needs (the hdrl stream headers and the
movi chunk list). An unsupported video codec is reported by name rather than
played as garbage, and an unsupported soundtrack still plays the pictures: the
player shows "unsupported audio codec: MP3 (...)" and continues silently.
Measured on the badge (320x240 15 fps, /sdcard/video): with the sound carried
through the same chunk walk as the pictures, a frame costs 1.98-2.9 ms of
audio read and 0.4% of the sound is dropped — against 7.5 ms and 19% when a
second cursor walked the interleave on its own. The frames are read with a
6-frame lookahead: the sound belonging to those frames is what the sink's lead
is primed from. Without a lookahead the interleave ties sound to pictures
one-to-one, so priming the sink would consume the whole queue and every slow
frame would starve the audio.
The same chunk walk also makes seeking cheap: a frame is one header plus its payload, so a seek rewinds and steps over headers (0.3 ms each) instead of scanning for SOI markers.
Preloading to RAM loses on this board. Reading the whole clip into a
bytearray and playing it from memory does what it says — 15.7 → 1.3 ms of read
per frame on a 1.1MB clip — but the start grows from 0.7 s to 2.3 s (the read
happens first) and the pump's own jitter rises from 7.5 to 19.4 ms/frame, so the
framerate drops from 13.9 to 11.9 fps. A 2.6MB 320x240 clip is worse still:
7.3 fps and 20% of the sound dropped. Holding megabytes on the PSRAM heap costs
more in GC and cache pressure than the SD reads it removes. The player keeps the
path (preload intent extra, capped at 3MB) but does not use it by default;
it would pay off on a board where the card, not the heap, is the bottleneck.
Two rules follow for anything that restarts playback (a loop, a seek):
- Restart the sound through the normal path. The sink still holds ~180 ms of the old sound, so priming on top of it mixes old and new positions. Closing and reopening costs the ring's drain (~370 ms) and is the same code path a fresh start uses; a seek then plays with 0% of the sound dropped.
- Do it inside the pump. The pump runs in a timer callback and is the only context allowed to touch the demuxer; seeking from a key handler meanwhile pulled the frame queue out from under it ("pop from empty list").
6. Audio: one clock, two streams
Audio is the second consumer of the same frame budget, and on this board the naive ways to add it are all wrong.
Do not use the shared player. AudioManager.player() decodes in its own
reader thread, and that thread competes with the frame reader for the same SD
card. The video player therefore feeds machine.I2S itself, from the same
pump that presents frames.
A separate audio thread would not help. machine.I2S.write() does not
release the GIL (ports/esp32/machine_i2s.c has no MP_THREAD_GIL_EXIT), so
a thread would serialise against the pump anyway. The write has to be cheap.
How the sink actually behaves
Measured on the badge (mono 16-bit, 22050 Hz, ibuf = ring size):
- A freshly opened channel queues a full ring of silence that drains in real time. Until it has drained, a write can only land as the ring empties: 1 KB took ~22 ms, i.e. the write blocks for its own playing time.
- Once the ring has drained, a write of up to
ibufreturns in ~90 us. ibufis rounded up to a power of two by the driver and must exceedlead + one write, or the top-up blocks again.
So the start sequence is: open the sink, preload the sidecar (the drain runs during the preload), wait out the ring, then prime and present frame 0 from the same instant. Priming before the ring drains costs 137 ms of wall time and puts the sound 137 ms ahead of the picture.
The clock
One byte budget drained at byte_rate from the prime instant:
lead(180 ms) is how far ahead of the clock the sink is kept fed;low(120 ms) is the refill floor, so a top-up is at most 60 ms and always lands in free ring space instead of blocking;- every byte count is rounded down to a whole sample frame. A queue that starts mid-frame plays left and right swapped, and a single odd byte once left the ring's write head one byte short of full, where a block decoder could never use the tail again: playback went silent and every remaining tick "resynced" past a clip that had stopped being read.
A refill never allocates: decoded PCM lands in one preallocated 32 KB ring, compacted in place. Rebuilding the queue per refill cost 7 ms of PSRAM heap churn per frame and triggered GC inside the pump; the in-place refill costs 0.3 ms.
When the clock has passed the audio (a stalled pump), the player drops the queued bytes and skips the source forward by the remainder, noting the skip on the clock. Both streams therefore follow one clock and drift is zero by construction; what a stall costs is the dropped sound, which the player reports in milliseconds and percent.
Format costs
The sidecar is 16-bit PCM or IMA ADPCM at the same rate. PCM is preloaded into
RAM when it fits (AUDIO_PRELOAD_BYTES = 512 KB); IMA is 4x smaller and is
decoded on the fly.
| Sidecar (10 s) | Size | CPU per frame | Note |
|---|---|---|---|
| PCM 16-bit mono 22050 | 441 KB | 0.4 ms (0.26 read + 0.11 write) | memcpy from the preloaded buffer |
| IMA ADPCM mono 22050 | 111 KB | 2.3 ms (2.15 decode) | 4 bits per sample in pure Python |
IMA decoding was 33.6 us per sample with the original per-sample @native
helpers (741 ms of CPU per second of audio, i.e. unusable). The same decoder
written as one @micropython.viper loop over byte-table copies of the step
index costs 1.9 us per sample (42 ms per second of audio, 17x faster); a
320x240 15 fps frame is 2.3 ms of it. Keep decoder inner loops free of
per-sample function calls and tuple returns.
Measured, 320x240 15 fps
| Audio | Presented | Pass | Idle | Audio/frame | Other | Sound dropped |
|---|---|---|---|---|---|---|
| none | 13.85-14.05 fps | 66.4-66.6 | 4.8-5.6 | - | 4.4-4.9 | - |
| PCM | 12.33-12.60 fps | 67.3-67.6 | 12.0-13.5 | 1.1-1.2 | 10.6-12.2 | 206-225 ms (2.1-2.3%), 1-2 resyncs |
| IMA | 11.97-12.44 fps | 68.5-70.1 | 11.9-13.5 | 2.7 | 8.6-10.3 | 151-389 ms (1.5-3.9%), 2 resyncs |
Ranges are across runs in which the clip was the first or second launch after a reset. Launching the player three or four times in one boot makes the IMA runs collapse (9.2 fps, 18% dropped) while PCM holds — the decode allocates a few kilobytes per refill, and by then the heap is fragmented. Measure from a fresh boot, as with the Stage 1 matrix.
Read this way:
- Audio costs about 1.25 fps (9%) on a clip that is already
saturated — but only 1.2-2.7 ms/frame is the audio code. The rest is in
other(the pump not being serviced), which grows from 4.9 to 8.6-10.6 ms; audio changes when the pump is called, not how long a frame takes. - The dropped sound is one or two stalls of 100-200 ms per 10 s clip, not a steady leak: a lead of 180 ms absorbs ordinary frame jitter, and the two resyncs are the outliers. Absorbing those too would need a 400 ms ring, which does not fit in the 32 KB of internal DMA RAM.
- First frame with audio is 466-780 ms against 390 ms without. Opening the sink as soon as the stream headers are read means the read, the buffer allocation and the first decode all happen inside the ring's drain instead of after it; opening it later cost 970 ms.
What was and was not measured
The sound is verified end to end. Recorded from the badge (headset jack into the host's line input, or the webcam microphone that the acoustic tools use by default), the 1 kHz sidecar beeps come back at full scale (0.5 of full scale in the 1 kHz band) and land 1.0002 s apart, drift +0.16 ms/s across five of them: the sink plays the file at its own rate, without gaps and without resampling. An earlier estimate of the sample rate from DMA drain timing ("2.6-3.1% fast") was an artefact of that method, not a property of the hardware — the recorded beeps are the better instrument.
The A/V offset itself is still not measured, and one webcam + line-in capture cannot measure it as it stands:
- the two capture devices run in different clock domains — here v4l2 reports
CLOCK_MONOTONIC(uptime, 323881 s) while ALSA reports the wall clock (1794307262 s), and ffmpeg rebases each stream to zero, which throws their relative alignment away;-copytskeeps the alignment but the absolute values overflow Matroska; - the UVC stream arrives with seconds of buffered frames, so its first frame is not the first frame captured.
A rig that can measure it needs one marker present in both streams: a clap board, or an LED driven by the same event as the beep. The synthetic clips already flash and beep on the same instants for exactly this purpose.
What bounds the offset today is the code path, not a measurement: frame 0 is flushed and sample 0 is primed from the same instant (the prime is what starts the sink, and it is issued right after the flush), the flush itself costs ~24 ms, and the sink's DMA starts within a few milliseconds. So the two should be inside the ~80 ms at which an offset becomes visible — but that is an argument, and it is worth confirming on a bench with audio wired.
7. The isolating matrix
To attribute time, run the same loop six ways: source (SD vs RAM) x display (every frame, 1 in 10, never). Only the source and the flush change.
320x240, ms per frame:
| Run | fps | frame | read | decode | draw |
|---|---|---|---|---|---|
| A full pipeline | 13.49 | 74.11 | 33.46 | 13.84 | 24.78 |
| B SD + decode | 18.95 | 52.77 | 33.58 | 13.98 | 2.25 |
| C decode + flush | 23.73 | 42.14 | 0.16 | 14.94 | 24.76 |
| D decode + 10% flush | 53.50 | 18.69 | 0.26 | 13.67 | 2.43 |
| E SD, never draw | 19.83 | 50.42 | 34.88 | 13.81 | — |
| F decode only | 62.69 | 15.95 | 1.53 | 13.66 | — |
Read the table like this:
- A ≈ E + draw, C ≈ F + draw: flush and ingest are additive.
- E ≈ F + read: ingest is additive.
- Draw 1 frame in 10 adds ~1-2 ms/frame on average: skipping the flush is cheap. Skipping the read is not — see §5, where a dropped frame costs its full ingest time. Drop-to-keep-time should still default ON, but the price is the wasted read.
other(loop overhead, GC, scheduling) is 1-3 ms in a dedicated loop. If you see more, your architecture — not the hardware — is the problem.
The same matrix at 240x180 (A 20.4 fps) and 160x120 (A 33.2 fps) scales as the byte and pixel models predict.
8. Scaling is the most expensive mistake
image.set_scale() resamples on every draw. A 160x120 frame scaled 2x to fill
320x240 costs 86.9 ms/frame versus 8.9 ms at 1:1 — roughly a 10x penalty,
and enough on its own to drop a clip below real time.
Consequences:
- Play clips 1:1. Center them if they are smaller, letterbox if needed.
set_size()+set_scale()are acceptable once at clip load.- If an app must fill the panel from a small clip, decode to the target size offline or pre-generate the larger asset; never scale per frame.
- Rotation is the same trap: a permanent
set_rotation()puts every frame on the per-pixel transform path. Ship pre-rotated content.
9. What this means for a player design
Derived from the model, not from taste:
- One pump loop, not LVGL timers:
read frame → decode → set_src → refr_now → pace. Get the loop cost under 3 ms, and own the LVGL passes: a framework pass costs a quarter of its duration again in fairness gap (§4). - Pace against a wall clock, not against "how long the last frame took".
Drift accumulates otherwise. A one-frame clock (
next_deadline += 1000/fps) with drop-when-behind is enough. - Drop to keep time. If
now > deadline + frame_interval, skip to the next due frame. Cost of dropping is a frame read (~28 ms at 320x240), not 1 ms, because MJPEG has no index; seek past the drops only when presenting alone cannot keep up (§5). Cost of not dropping is a permanently slower-than-real-time clip. - Reuse one decode buffer.
jpegdec.decode(src, rgb)writes into a fixedbytearray(w*h*2); point theimage_dsc_tat it once. Do not reallocate per frame — that is GC pressure and fragmentation. - Read exactly one frame per iteration. Scan for SOI/EOI in a small sliding buffer. A reader that scans a 256 KB buffer per frame measured 204 ms/frame; the same SD card with a 64 KB window measured 32 ms. The buffer size, not the card, was the bug. In AVI (see below) every chunk carries its length, so a frame is one header read plus its payload and a drop is a seek past the header — strictly cheaper than scanning.
- Never call
set_srcfor an identical frame and skip redraws you do not need; invalidation is cheap but the flush is not. - Feed audio from the pump, on one clock. Audio must not block the pump:
refill from a preallocated ring and only when the sink has free space, so a
write costs microseconds instead of its playing time. A separate thread buys
nothing because
I2S.write()holds the GIL (§6). - Do not preload by default. It looks free on paper (a 160x120 clip is ~0.9 MB) but on this board the read is replaced by GC and cache pressure and the framerate drops (§6). Stream, and use the card's sequential bandwidth.
10. Would a network player be faster than SD?
In principle, yes — but bounded, and in this environment it was not.
The network only replaces the ingest term. Decode (13.8 ms) and flush (24.8 ms) are unchanged, so the hard ceiling becomes 23.7 fps at 320x240 (row C: decode + flush with no storage at all). SD costs 33.5 ms on top of that, which is why the full pipeline is 13.5 fps. So the maximum win from streaming is 13.5 → 23.7 fps at 320x240, and only if the network is faster than the card.
Measured here (badge → Fritz!Box AP, RSSI -36 to -46 dBm, 2.4 GHz, HTTP from a wired host):
| Metric | Result |
|---|---|
| Bulk ingest, 16 KB reads | 0.22-0.35 MB/s (1.9-3.0 Mbit/s) |
| Per-read latency | ~11 ms mean, p95 15-20 ms |
| Tail | occasional 1.5 s stalls, even with power save off (pm=0) |
| Frame-sized reads (15.8 KB) | 9.3-12.3 frames/s (0.14-0.18 MB/s) |
| Time to first byte | 5-16 ms |
That is not better than the SD card (0.6 MB/s, no multi-second stalls). The link rate is the limit, not the chip's theoretical WiFi capability; a better AP was not measured, so treat this as "a normal 2.4 GHz router at close range" rather than a ceiling.
Bandwidth needed is small: 15.8 KB x 15 fps = 0.24 MB/s for 320x240 at 15 fps. The measured average is just above that, but the p95 latency and the 1.5 s stalls mean an unbuffered player would stutter. Network playback needs a read-ahead buffer of a second or more, which competes with the frame cache for the same MicroPython heap.
Practical conclusions:
- Streaming cannot beat decode+flush, so it cannot exceed ~23.7 fps at 320x240 on this hardware.
- On a slow or jittery link it is worse than the card, because tail latency is unbounded while SD latency is stable.
- If you build it anyway: buffer ahead, use plain HTTP on a LAN (TLS adds CPU and handshake latency), and keep the buffer in PSRAM-backed allocations.
- The win, when it exists, is at small sizes: 160x120 needs only 0.1 MB/s, so the network stops being the bottleneck and the 33.2 fps ceiling is reachable.
11. How these numbers were obtained
- Camera:
video_samples/tools/capture_measure.pyrecords the badge with a webcam and decodes a binary frame-index strip from the screen. It reportspresented_fps(distinct pictures actually seen) separately fromclip_rate_fps(how fast the clip timeline advanced), so "the player is dropping frames to keep time" is visible rather than misread as a slow clip. - Counters: per-stage
time.ticks_us()around read, decode,set_src, andrefr_now, plus the LVGL pass, aggregated over a run. Stage 1 numbers come from the player ininternal_filesystem/apps/com.micropythonos.video_player, which prints one line per clip of exactly these values. - Matrix:
video_samples/tools/probe_matrix.py, run from a fresh boot (heap fragmentation after a long uptime makes 1 MB allocations fail). - Always measure the display with
lv.refr_now(): the 33 ms refresh timer otherwise flattens everything to ~30 fps and hides the real cost.
Camera caveat: 720p30 capture is exact for display rates up to ~15 fps. Above that, use player counters; a 60 fps capture mode is not deliverable over USB 2.
12. Quick reference
| Question | Answer |
|---|---|
| Why is my 320x240 clip stuck at 10 fps? | Two causes: a pass cost over 70 ms (hardware limit), or the scheduler in §4 adding ~29 ms per pass. Measure both. |
| Will a faster timer help? | No. The cost is outside the timer; pinning it changes nothing. |
| Does the shared SPI bus add overhead? | No. Costs are additive: ingest + decode + flush. |
| Can I play 30 fps at 320x240? | Not with MJPEG from SD: 13.5 fps full pipeline, 23.7 fps with the source in RAM. A 30 fps 320x240 source plays at 7-8 fps. |
| What does a drop cost? | A frame read: ~28 ms at 320x240, ~11 ms at 160x120. Seeking past drops costs ~15 ms + a resync read, so only do it when the sink is saturated. |
| My loop measures 72 ms/frame but the player shows 9 fps. Why? | The framework's fairness gap: a pass sleeps a quarter of its own duration before the next one. Own the passes. |
| Why did 160x120 get slower when I scaled it up? | LVGL scaling: 86.9 ms vs 8.9 ms at 1:1. Never scale in the draw path. |
| Is WiFi streaming the answer? | Only bounded: it removes the 33 ms ingest term, so 23.7 fps max; measured here it was slower than SD. |
| How much does audio cost? | 1.2-2.7 ms/frame for the audio code, ~1.25 fps overall at 320x240 15 fps. Use PCM (0.4 ms) over IMA (2.3 ms) when RAM allows. |
| My audio starts late, or the first write takes 137 ms. | The I2S ring is pre-filled with silence that must drain first. Open the sink, wait out ibuf, then prime; priming early adds the drain to the picture's lead. |
Should I ship .mjpeg or .avi? |
AVI: the container carries the frame rate and the sound, dropping a frame is a chunk-header seek instead of an SOI scan, and VLC opens it unchanged. .mjpeg + WAV sidecar remains the fixture for the low-level tests. |
| Audio goes silent part-way through the clip. | A block decoder cannot use a short tail in the ring: if the read cursor parks at the write head, every refill returns 0. Empty the ring when it is drained to the end, and compact when a refill does not fit. |
| Why does audio drop 200 ms at a time? | The pump stalled longer than the lead, and the resync skipped the sound forward to keep it with the pictures. Look at other (timer latency) in the report, not at the audio code. |