Building a Custom Algorithm to Color a Spectrogram from Song Thumbnails
- https://www.youtube.com/watch?v=S7FvdaMUTdc
- Original title: I made a Custom Algorithm for this
Across two live streams plus an offline footnote, Tsoding builds a custom algorithm from first principles that recolors the audio spectrogram of his private timer/music-player app ("Sowon2", visualizer forked from Musializer) to match the dominant colors of each song's thumbnail. The core idea: analyze the frequency of hues in a thumbnail, then stretch the most frequent hues so they occupy proportionally more of the rainbow gradient underneath the spectrogram. The work is done in Jai, a non-public language, in a deliberately private single-user application — a recurring theme of the stream is that software is easy when you are the only user and don't owe anyone stability, portability, or pull-request triage.
Day 1 focuses on setup and a naive version of the algorithm. Tsoding writes a throwaway adaptive-palette GUI in raylib to experiment in isolation, driven by "Castle" — his own lazy, cross-referencing configuration DSL (which he plans to open-source once stable). He argues for a specific hot-reloading philosophy: don't hot-reload code via DLLs (state management becomes painful); instead hot-reload only the magic constants (bucket count, saturation/value thresholds, bar height) through a watched config file, so you can lay down structure and rapidly tune parameters. He explains the HSV color space (hue = pure color on a 0–360° wheel, saturation = colorfulness, value = brightness), and that the existing visualizer just steps hue across the window width with fixed saturation/value. The naive palette extractor iterates every thumbnail pixel (needing the CPU-side raylib Image, not just the GPU Texture), converts each pixel to HSV, and bins hues into buckets, ignoring pixels below configurable value and saturation thresholds so near-black and near-gray pixels don't get miscounted as red. Percentages are computed as bucket-count / valid-pixel-count. The first attempt at stretching floors percentage * n to decide how many spectrogram cells each hue occupies, then experiments with floor vs. ceil vs. round (+0.5) — all of which either underflow (lose colors) or overflow buckets. Notable Jai details along the way: xx for explicit auto-cast, the automatic C-binding generator patched via a visitor to turn an int parameter back into a proper enum, a flat_pool arena allocator pushed to make hot-reload allocations trivially resettable (raylib GPU allocations stay outside it), break i to break out of a named outer loop, and the sloppy_math module's values_are_close for asserting the percentages sum to 1.0.
Day 2 delivers the real algorithm, which came to Tsoding minutes after Day 1 ended. Instead of treating buckets as discrete and throwing away sub-bucket colors, he treats a bucket_progress cursor as a continuous float sweeping left-to-right: its floor is the current bucket index, its fractional remainder is how full that bucket is. Each hue, weighted by percentage * n, is "drained" into buckets using min(remaining_hue_buckets, 1 - frac(progress)) as the step, blending multiple tiny colors into a shared bucket rather than discarding them — zero-waste, and it also softens the crisp edges between color regions. He renames the confusing terminology (hues become buckets, then settles on extract_hue_ratios + stretch) and factors the pipeline into clean functions. Edge cases handled: fully black-and-white thumbnails (zero valid pixels → division by zero) return a "no palette" flag that renders the bars pure white; and off-by-one overflow at the FFT's logarithmic bucket count M is caught by Jai's array-bounds checks. Integrating into the main app requires storing both CPU image and GPU texture per song, caching M across frames and only recomputing the palette when M changes (the pixel-iterating extraction is too slow to run every frame), and honoring the app's "invalid songs still play for 10 seconds" anti-soft-lock convention. A late experiment shuffles the computed buckets (Fisher–Yates, hand-rolled since Jai has no shuffle) but the sorted-hue rainbow order looks better because it blends adjacent colors cleanly.
The offline footnote resolves the last annoyance — hard edges between color bands. Following a chat suggestion from "Sushi" that Tsoding only understood after the fact, he treats the 1-D bucket palette as a 1-D RGBA image and runs a Gaussian blur over it, smoothing the bands into a pleasing gradient. That, layered on top of the hue-stretching, is the base algorithm he'll ship in the visualizer, with more tweaking to come.