Skip to content

Covers ​

Let's show the albums's covers!!!

There are a few steps/questions to this:

Steps

  • Generate nice 128x128 covers
  • Index them properlly
  • Display them!

Questions

  • How to get small but still nice images?
  • What image format to pick ? (POI or raw RGB?)
  • Should I buffer them on the PSRAM ?

Generate the covers ​

Turns out the answer on how to get nice small is images is simple: Lanczos Resampling ! It is standard, I just never heard of it before, but it creates much better results on drastic scaledown than naive gaussian approaches.

Index the covers ​

Covers are by album, I did not have track_to_album indexing, so I first need it for arbitrary access ( I could get tracks from a playlist or from the general tracks/ index etc).

Also, some albums might not have a cover, so we need a fallback (basically the cover index 0, with some image)

Then I need to generate a big texture with all the images.

And finally I need a album_to_cover index and.... tadaaa

Display them ​

Now this is the trickiest part: I did not know if I should compress the images to minimize storage on the SD, bandwidth during transferts or if I should just store them as plain RGB.

So I did a benchmark:

main_cover_benchmark.cpp

cpp
// Standalone on-device benchmark: flash the cover_benchmark PlatformIO env
// (see platformio.ini) instead of the real firmware to compare QOI vs
// raw565 album-art loading on actual hardware. Lives in src/ (not
// test/onboard/, where the actual benchmark logic lives) purely because
// PlatformIO's build_src_filter can only select files within src_dir, and
// per-environment src_dir overrides aren't honored by this PlatformIO
// version (6.2.0) - see the comment on env:cover_benchmark in
// platformio.ini. build_src_filter excludes this file from the real
// nucleo_f446re env, and excludes main.cpp from this one, so exactly one
// setup()/loop() pair ever links.
//
// See .agents/covers-plan.md, Phase 0, and test/onboard/CoverBenchmark.h
// for what's measured.
//
// Before flashing: copy both indexer/generate-benchmark-covers.ts output
// folders (bench_qoi/, bench_raw565/) onto the SD card under /opentone/.
#include <Arduino.h>
#include <SdFat.h>

#include "pinout.h"
#include "rendering/ILI9341_GFX.h"
#include "storage/PSRAM_controller.hpp"
#include "../test/onboard/CoverBenchmark.h"

SdFat sd;
ILI9341_GFX display;

// CoverBenchmark holds ~73KB of buffers (COVER_BYTES_565 + MAX_QOI_BYTES,
// see CoverBenchmark.h) - global/static storage (.bss), not a local inside
// setup(). A local of this size would blow setup()'s stack frame the
// instant the function is entered (C++ reserves the full frame upfront,
// not lazily at each variable's declaration point), corrupting memory
// before any of its code even runs - this crashed psram.init() on real
// hardware even though the corruption's root cause was this line, not that
// function.
onboard_bench::CoverBenchmark bench(sd, display);

void setup() {
  Serial.begin(115200);
  while (!Serial)
    delay(10);

  Serial.println("=== Cover format benchmark (QOI vs raw565) ===");

  // SD must be brought up before PSRAM - matches Audio_buffer::begin()'s
  // order (src/sound/Audio_buffer.cpp), which is the only other place in
  // this codebase that initializes both. Doing it the other way around
  // hangs inside psram.init() on real hardware (confirmed on-device).
  if (!sd.begin(SdSpiConfig(CARDCS, SHARED_SPI, SD_SCK_MHZ(25)))) {
    Serial.println("ERROR: SD card init failed");
    while (1) delay(1000);
  }
  Serial.println("SD ready");

  if (!psram.init()) {
    Serial.println("ERROR: PSRAM init failed");
    while (1) delay(1000);
  }
  Serial.println("PSRAM ready");

  display.begin();
  display.fillScreen(0x0000);
  Serial.println("Display ready");
  delay(200);

  auto qoiStats = bench.runSweep("QOI", "/opentone/bench_qoi/thumbs.bin",
                                 "/opentone/bench_qoi/album_to_cover.bin");
  bench.printStats("QOI", qoiStats);

  delay(500);

  auto raw565Stats =
      bench.runSweep("raw565", "/opentone/bench_raw565/thumbs.bin",
                     "/opentone/bench_raw565/album_to_cover.bin");
  bench.printStats("raw565", raw565Stats);

  delay(500);
  bench.benchmarkPsramCacheRoundtrip();

  Serial.println("");
  Serial.println("=== Benchmark complete ===");
}

void loop() {
  delay(1000);
}

Platormio.ini

[env:cover_benchmark]
platform = ststm32
board = nucleo_f446re
framework = arduino
debug_tool = stlink
monitor_speed = 115200
upload_protocol = stlink
build_src_filter = -<*> +<main_cover_benchmark.cpp> +<storage/PSRAM_controller.cpp>
build_flags =
	-DUSE_UTF8_LONG_NAMES=1
lib_deps =
	adafruit/Adafruit GFX Library@^1.11.9
	adafruit/Adafruit BusIO@^1.15.0
	arduino-libraries/SD@^1.3.0
	greiman/SdFat@^2.3.0

And some benchmark files

I got this:

=== Benchmark complete ===
=== Cover format benchmark (QOI vs raw565) ===
SD ready
=== PSRAM INIT START ===
PSRAM ID attempt 0: 0xD 0x5D
PSRAM OK
PSRAM ready
Display ready
  loadCoverIndex /opentone/bench_qoi/album_to_cover.bin: fileSize=229 headerBytesRead=4 entryCount=25
    entry 0: offset=0 length=36513 format=0
    entry 1: offset=36513 length=37199 format=0
    entry 2: offset=73712 length=28092 format=0
=== QOI sweep ===
  album 0: read=27902us decode=5115us
  album 1: read=28049us decode=5334us
  album 2: read=21892us decode=4730us
  album 3: read=2126us decode=781us
  album 4: read=19365us decode=4603us
  album 5: read=2722us decode=781us
  album 6: read=2722us decode=781us
  album 7: read=2721us decode=781us
  album 8: read=2722us decode=781us
  album 9: read=2723us decode=781us
  album 10: read=2722us decode=781us
  album 11: read=2722us decode=781us
  album 12: read=2722us decode=781us
  album 13: read=2725us decode=780us
  album 14: read=2723us decode=781us
  album 15: read=2724us decode=780us
  album 16: read=2724us decode=781us
  album 17: read=2722us decode=781us
  album 18: read=2722us decode=781us
  album 19: read=2724us decode=781us
  album 20: read=2722us decode=781us
  album 21: read=2723us decode=781us
  album 22: read=2722us decode=781us
  album 23: read=2722us decode=781us
  album 24: read=2724us decode=780us
--- QOI summary ---
  covers rendered: 25
  avg read: 4736us  max read: 26634us
  avg decode: 1447us  max decode: 5334us
  avg total (read+decode): 6184us
  loadCoverIndex /opentone/bench_raw565/album_to_cover.bin: fileSize=229 headerBytesRead=4 entryCount=25
    entry 0: offset=0 length=32768 format=1
    entry 1: offset=32768 length=32768 format=1
    entry 2: offset=65536 length=32768 format=1
=== raw565 sweep ===
  album 0: read=24764us decode=0us
  album 1: read=24882us decode=0us
  album 2: read=24888us decode=0us
  album 3: read=24877us decode=0us
  album 4: read=24878us decode=0us
  album 5: read=24880us decode=0us
  album 6: read=24878us decode=0us
  album 7: read=24880us decode=0us
  album 8: read=24879us decode=0us
  album 9: read=24879us decode=0us
  album 10: read=24880us decode=0us
  album 11: read=24878us decode=0us
  album 12: read=24881us decode=0us
  album 13: read=24878us decode=0us
  album 14: read=24881us decode=0us
  album 15: read=24878us decode=0us
  album 16: read=24879us decode=0us
  album 17: read=24880us decode=0us
  album 18: read=24879us decode=0us
  album 19: read=24881us decode=0us
  album 20: read=24879us decode=0us
  album 21: read=24879us decode=0us
  album 22: read=24880us decode=0us
  album 23: read=24879us decode=0us
  album 24: read=24881us decode=0us
--- raw565 summary ---
  covers rendered: 25
  avg read: 23460us  max read: 23473us
  avg decode: 0us  max decode: 0us
  avg total (read+decode): 23460us
=== PSRAM cover-cache roundtrip ===
  PSRAM write (32KB): 77957us
  PSRAM read (32KB): 78683us

=== Benchmark complete ===

Conclusion ​

raw565 it is. much faser, less code, not much larger space on the disk.