feat: cache STFT results per FFT size, fix load segfault

Add an LRU cache (4 entries) of fully-computed STFT results keyed by FFT
size, so toggling FFT size restores a cached result instead of recomputing.

Fix the segfault that hit right after the loading screen for files long
enough to use a skipFactor > 1 overview: SaveToCache() deep-copied every
segment using segment[0].numBins and memcpy'd from each segment's spectrum,
but a sparse overview leaves most segments with spectrum == NULL, so it
memcpy'd from NULL.

- Add IsSTFTComplete() / CopySTFT() helpers; CopySTFT copies sparse
  segments as NULL instead of dereferencing them.
- Only cache complete (full-resolution) results; never the sparse overview.
- Make a cache hit actually skip recomputation (and stop leaking): restore
  the cached result, mark it finished, and rebuild the texture. On a miss,
  free the current STFT before recomputing.
- Drop the per-frame re-save that ran every frame while zoomed in.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 01:00:46 -07:00
parent 475520b1db
commit ec5f77f6ef
+231 -19
View File
@@ -88,6 +88,20 @@ typedef struct {
bool useHannWindow; bool useHannWindow;
} StftResult; } StftResult;
#define FFT_CACHE_SIZE 4
typedef struct {
int fftSize;
StftResult result;
int accessOrder; // lower = more recently accessed
} FFTCacheEntry;
typedef struct {
FFTCacheEntry entries[FFT_CACHE_SIZE];
int count;
int nextOrder;
} FFTSizeCache;
typedef struct { typedef struct {
AudioSignal signal; AudioSignal signal;
StftResult stft; StftResult stft;
@@ -182,6 +196,11 @@ typedef struct {
int lastInteractedFrame; // frame counter when last user interaction occurred int lastInteractedFrame; // frame counter when last user interaction occurred
bool isBgProcessing; // true while background task is actively computing bool isBgProcessing; // true while background task is actively computing
// FFT size cache — LRU cache of previously computed STFT results.
// When user switches FFT sizes, we check the cache first to avoid
// recomputing. When cache is full, we evict the least-recently-used entry.
FFTSizeCache fftCache;
// Waveform scope view (underneath spectrogram viewport) // Waveform scope view (underneath spectrogram viewport)
ScopeView scopeView; ScopeView scopeView;
bool showScope; // Toggle to show/hide scope view bool showScope; // Toggle to show/hide scope view
@@ -324,6 +343,141 @@ static bool IsUserInteracting(void)
// Forward declarations for functions defined later in this file // Forward declarations for functions defined later in this file
static void FFT(float complex* input, float complex* output, int n, bool inverse); static void FFT(float complex* input, float complex* output, int n, bool inverse);
static void FreeSTFT(StftResult* result);
static void AutoScaleAmplitude(StftResult* stft);
static void GenerateSpectrogramTexture(StftResult* stft, Image* image, Texture2D* texture);
// ============================================================================
// FFT Size Cache (LRU)
// ============================================================================
// Returns true only if every segment has a computed spectrum (full resolution,
// no NULL gaps). Sparse overviews (skipFactor-strided) return false.
static bool IsSTFTComplete(const StftResult* r)
{
if (r->numSegments <= 0 || r->segments == NULL) return false;
for (int i = 0; i < r->numSegments; i++) {
if (r->segments[i].spectrum == NULL) return false;
}
return true;
}
// Deep-copy src into dst. dst is assumed to be empty (freed) beforehand.
// Handles sparse results safely: a segment with no computed spectrum is copied
// as NULL rather than dereferencing a NULL source pointer (the bug that caused
// the load crash for files long enough to use a skipFactor > 1 overview).
static void CopySTFT(StftResult* dst, const StftResult* src)
{
dst->numSegments = src->numSegments;
dst->sampleRate = src->sampleRate;
dst->totalSamples = src->totalSamples;
dst->useHannWindow = src->useHannWindow;
dst->segments = (StftSegment*)malloc(src->numSegments * sizeof(StftSegment));
for (int i = 0; i < src->numSegments; i++) {
const StftSegment* s = &src->segments[i];
StftSegment* d = &dst->segments[i];
d->numBins = s->numBins;
d->sampleOffset = s->sampleOffset;
d->sampleCount = s->sampleCount;
if (s->spectrum != NULL && s->numBins > 0) {
d->spectrum = (FrequencyData*)malloc(s->numBins * sizeof(FrequencyData));
memcpy(d->spectrum, s->spectrum, s->numBins * sizeof(FrequencyData));
} else {
d->spectrum = NULL;
}
if (s->derivativeSpectrum != NULL && s->numBins > 0) {
d->derivativeSpectrum = (FrequencyData*)malloc(s->numBins * sizeof(FrequencyData));
memcpy(d->derivativeSpectrum, s->derivativeSpectrum, s->numBins * sizeof(FrequencyData));
} else {
d->derivativeSpectrum = NULL;
}
}
}
/**
* Free all STFT results in the cache.
*/
static void FreeAllCacheEntries(FFTSizeCache* cache)
{
for (int i = 0; i < cache->count; i++) {
FreeSTFT(&cache->entries[i].result);
cache->entries[i].result.sampleRate = 0;
cache->entries[i].accessOrder = 0;
}
cache->count = 0;
cache->nextOrder = 0;
}
/**
* Look up a cache entry by FFT size. Returns NULL if not present.
* On a hit, marks the entry as most recently used.
*/
static FFTCacheEntry* FindCacheEntry(FFTSizeCache* cache, int fftSize)
{
for (int i = 0; i < cache->count; i++) {
if (cache->entries[i].fftSize == fftSize) {
cache->entries[i].accessOrder = cache->nextOrder++;
return &cache->entries[i];
}
}
return NULL;
}
/**
* Find a cache entry for the given FFT size, or create one.
* If the cache is full, evicts the least-recently-used entry.
* Returns a pointer to the entry (valid until next cache access).
*/
static FFTCacheEntry* FindOrCreateCacheEntry(FFTSizeCache* cache, int fftSize, int sampleRate)
{
FFTCacheEntry* existing = FindCacheEntry(cache, fftSize);
if (existing) return existing;
// Entry not found — need to create it
if (cache->count >= FFT_CACHE_SIZE) {
// Evict least recently used (lowest accessOrder)
int lruIdx = 0;
for (int i = 1; i < cache->count; i++) {
if (cache->entries[i].accessOrder < cache->entries[lruIdx].accessOrder) {
lruIdx = i;
}
}
FreeSTFT(&cache->entries[lruIdx].result);
// Reuse slot
cache->entries[lruIdx].fftSize = fftSize;
cache->entries[lruIdx].result.numSegments = 0;
cache->entries[lruIdx].result.segments = NULL;
cache->entries[lruIdx].accessOrder = cache->nextOrder++;
return &cache->entries[lruIdx];
}
// Add new entry
int idx = cache->count++;
cache->entries[idx].fftSize = fftSize;
cache->entries[idx].result.numSegments = 0;
cache->entries[idx].result.segments = NULL;
cache->entries[idx].result.sampleRate = sampleRate;
cache->entries[idx].accessOrder = cache->nextOrder++;
return &cache->entries[idx];
}
/**
* Save the current app.stft result to the cache entry matching app.fftSize.
* Creates/overwrites the entry and marks it as most recently used.
*/
static void SaveToCache(void)
{
// Only cache fully-computed (full-resolution) results. A sparse overview
// contains NULL segments and isn't worth caching — and restoring one would
// leave permanent black gaps since we'd mark it finished.
if (!IsSTFTComplete(&app.stft)) return;
FFTCacheEntry* entry = FindOrCreateCacheEntry(&app.fftCache, app.fftSize, app.signal.sampleRate);
FreeSTFT(&entry->result);
CopySTFT(&entry->result, &app.stft);
TraceLog(LOG_INFO, "Saved STFT result to cache for FFT size %d (%d segments)",
app.fftSize, app.stft.numSegments);
}
// ============================================================================ // ============================================================================
// Background High-Res Computation // Background High-Res Computation
@@ -543,15 +697,68 @@ static bool ComputeSTFTIncremental(AudioSignal* signal, StftResult* result, int
static void FreeSTFT(StftResult* result) static void FreeSTFT(StftResult* result)
{ {
if (!result) return;
if (result->segments) {
for (int i = 0; i < result->numSegments; i++) { for (int i = 0; i < result->numSegments; i++) {
free(result->segments[i].spectrum); free(result->segments[i].spectrum);
if (result->segments[i].derivativeSpectrum) free(result->segments[i].derivativeSpectrum); result->segments[i].spectrum = NULL;
if (result->segments[i].derivativeSpectrum) {
free(result->segments[i].derivativeSpectrum);
result->segments[i].derivativeSpectrum = NULL;
}
} }
free(result->segments); free(result->segments);
result->segments = NULL; result->segments = NULL;
}
result->numSegments = 0; result->numSegments = 0;
} }
/**
* Change the FFT size. If a fully-computed result for the new size is cached,
* restore it directly (no recomputation). Otherwise free the current STFT and
* let the main loop recompute it from scratch.
*/
static void ChangeFFTSize(int newFFT)
{
FFTCacheEntry* entry = FindCacheEntry(&app.fftCache, newFFT);
if (entry != NULL && IsSTFTComplete(&entry->result)) {
// Cache hit — restore the cached full-resolution result.
TraceLog(LOG_INFO, "FFT size %d: cache hit", newFFT);
FreeSTFT(&app.stft);
CopySTFT(&app.stft, &entry->result);
app.fftSize = newFFT;
app.skipFactor = 1;
app.stftComputed = true; // already complete — skip recompute
app.loadingPhase = 0;
app.highResFinished = true;
app.bgHighResSeg = app.stft.numSegments;
app.bgFinished = true;
app.isBgProcessing = false;
app.visibleTextureValid = false;
// Rebuild the displayed texture from the restored data. AutoScale here
// mirrors the recompute path so the view looks identical either way.
AutoScaleAmplitude(&app.stft);
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
} else {
// Cache miss — drop the current STFT and recompute. Freeing here avoids
// leaking it, since ComputeSTFTInit re-allocates segments unconditionally.
TraceLog(LOG_INFO, "FFT size %d: cache miss, computing", newFFT);
FreeSTFT(&app.stft);
app.fftSize = newFFT;
app.stftComputed = false;
app.loadingPhase = 0;
app.skipFactor = 1;
app.highResFinished = false;
app.bgHighResSeg = 0;
app.bgFinished = false;
app.isBgProcessing = false;
app.visibleTextureValid = false;
}
}
// ============================================================================ // ============================================================================
// Adaptive Resolution: Skip Factor & High-Res Computation // Adaptive Resolution: Skip Factor & High-Res Computation
// ============================================================================ // ============================================================================
@@ -1079,6 +1286,8 @@ static void LoadSelectedFile(void)
app.bgHighResSeg = 0; app.bgHighResSeg = 0;
app.bgFinished = false; app.bgFinished = false;
app.isBgProcessing = false; app.isBgProcessing = false;
// Signal changed — free cache (results are tied to signal data)
FreeAllCacheEntries(&app.fftCache);
app.timeSelectionStart = app.viewStart = 0.0f; app.timeSelectionStart = app.viewStart = 0.0f;
app.timeSelectionEnd = app.viewEnd = 1.0f; app.timeSelectionEnd = app.viewEnd = 1.0f;
app.freqSelectionStart = 0.0f; app.freqSelectionStart = 0.0f;
@@ -1612,29 +1821,13 @@ static void DrawSidebar(void)
if (CheckCollisionPointRec(GetMousePosition(), fftMinus) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { if (CheckCollisionPointRec(GetMousePosition(), fftMinus) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
int newFFT = app.fftSize / 2; int newFFT = app.fftSize / 2;
if (newFFT >= FFT_SIZE_MIN) { if (newFFT >= FFT_SIZE_MIN) {
app.fftSize = newFFT; ChangeFFTSize(newFFT);
app.stftComputed = false;
app.loadingPhase = 0;
app.skipFactor = 1;
app.highResFinished = false;
app.bgHighResSeg = 0;
app.bgFinished = false;
app.isBgProcessing = false;
needsRegen = true;
} }
} }
if (CheckCollisionPointRec(GetMousePosition(), fftPlus) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { if (CheckCollisionPointRec(GetMousePosition(), fftPlus) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
int newFFT = app.fftSize * 2; int newFFT = app.fftSize * 2;
if (newFFT <= FFT_SIZE_MAX) { if (newFFT <= FFT_SIZE_MAX) {
app.fftSize = newFFT; ChangeFFTSize(newFFT);
app.stftComputed = false;
app.loadingPhase = 0;
app.skipFactor = 1;
app.highResFinished = false;
app.bgHighResSeg = 0;
app.bgFinished = false;
app.isBgProcessing = false;
needsRegen = true;
} }
} }
DrawRectangleRec(fftMinus, (Color){ 50, 50, 60, 255 }); DrawRectangleRec(fftMinus, (Color){ 50, 50, 60, 255 });
@@ -1872,6 +2065,15 @@ int main(int argc, char* argv[])
app.bgFinished = false; app.bgFinished = false;
app.lastInteractedFrame = 0; app.lastInteractedFrame = 0;
app.isBgProcessing = false; app.isBgProcessing = false;
// Initialize FFT cache
app.fftCache.count = 0;
app.fftCache.nextOrder = 0;
for (int i = 0; i < FFT_CACHE_SIZE; i++) {
app.fftCache.entries[i].fftSize = 0;
app.fftCache.entries[i].result.numSegments = 0;
app.fftCache.entries[i].result.segments = NULL;
app.fftCache.entries[i].accessOrder = 0;
}
app.isPlaying = false; app.isPlaying = false;
app.playbackFinished = false; app.playbackFinished = false;
app.showScope = true; app.showScope = true;
@@ -1914,6 +2116,8 @@ int main(int argc, char* argv[])
app.bgHighResSeg = 0; app.bgHighResSeg = 0;
app.bgFinished = false; app.bgFinished = false;
app.isBgProcessing = false; app.isBgProcessing = false;
// Signal changed — free cache (results are tied to signal data)
FreeAllCacheEntries(&app.fftCache);
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize); ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
TraceLog(LOG_INFO, "File loaded successfully"); TraceLog(LOG_INFO, "File loaded successfully");
} }
@@ -1941,6 +2145,8 @@ int main(int argc, char* argv[])
app.bgHighResSeg = 0; app.bgHighResSeg = 0;
app.bgFinished = false; app.bgFinished = false;
app.isBgProcessing = false; app.isBgProcessing = false;
// Signal changed — free cache (results are tied to signal data)
FreeAllCacheEntries(&app.fftCache);
app.viewStart = 0.0f; app.viewEnd = 1.0f; app.viewStart = 0.0f; app.viewEnd = 1.0f;
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize); ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
// Invalidate visible texture cache // Invalidate visible texture cache
@@ -2132,6 +2338,8 @@ int main(int argc, char* argv[])
app.bgFinished = true; app.bgFinished = true;
app.isBgProcessing = false; app.isBgProcessing = false;
TraceLog(LOG_INFO, "Background high-res complete (%d segments)", app.stft.numSegments); TraceLog(LOG_INFO, "Background high-res complete (%d segments)", app.stft.numSegments);
// Save the full-res result to cache (overwrites the overview-only entry)
SaveToCache();
} }
} }
if (app.isBgProcessing && IsUserInteracting()) { if (app.isBgProcessing && IsUserInteracting()) {
@@ -2149,6 +2357,7 @@ int main(int argc, char* argv[])
// No more missing segments — mark complete // No more missing segments — mark complete
app.bgFinished = true; app.bgFinished = true;
app.isBgProcessing = false; app.isBgProcessing = false;
SaveToCache();
} }
} }
@@ -2429,6 +2638,8 @@ int main(int argc, char* argv[])
app.isBgProcessing = true; // Kick off background high-res next frame app.isBgProcessing = true; // Kick off background high-res next frame
TraceLog(LOG_INFO, "STFT overview computed (%d segments, skipFactor=%d)", TraceLog(LOG_INFO, "STFT overview computed (%d segments, skipFactor=%d)",
app.stft.numSegments, app.skipFactor); app.stft.numSegments, app.skipFactor);
// Save the overview result to cache (will be overwritten when full-res completes)
SaveToCache();
} }
} }
@@ -2729,6 +2940,7 @@ int main(int argc, char* argv[])
app.visibleTextureValid = false; app.visibleTextureValid = false;
UnloadTexture(colormapTexture); UnloadTexture(colormapTexture);
FreeBrowserFiles(); FreeBrowserFiles();
FreeAllCacheEntries(&app.fftCache);
FreeSignal(&app.signal); FreeSignal(&app.signal);
CloseAudioDevice(); CloseAudioDevice();
CloseWindow(); CloseWindow();