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:
+235
-23
@@ -88,6 +88,20 @@ typedef struct {
|
||||
bool useHannWindow;
|
||||
} 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 {
|
||||
AudioSignal signal;
|
||||
StftResult stft;
|
||||
@@ -182,6 +196,11 @@ typedef struct {
|
||||
int lastInteractedFrame; // frame counter when last user interaction occurred
|
||||
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)
|
||||
ScopeView scopeView;
|
||||
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
|
||||
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
|
||||
@@ -543,15 +697,68 @@ static bool ComputeSTFTIncremental(AudioSignal* signal, StftResult* result, int
|
||||
|
||||
static void FreeSTFT(StftResult* result)
|
||||
{
|
||||
for (int i = 0; i < result->numSegments; i++) {
|
||||
free(result->segments[i].spectrum);
|
||||
if (result->segments[i].derivativeSpectrum) free(result->segments[i].derivativeSpectrum);
|
||||
if (!result) return;
|
||||
if (result->segments) {
|
||||
for (int i = 0; i < result->numSegments; i++) {
|
||||
free(result->segments[i].spectrum);
|
||||
result->segments[i].spectrum = NULL;
|
||||
if (result->segments[i].derivativeSpectrum) {
|
||||
free(result->segments[i].derivativeSpectrum);
|
||||
result->segments[i].derivativeSpectrum = NULL;
|
||||
}
|
||||
}
|
||||
free(result->segments);
|
||||
result->segments = NULL;
|
||||
}
|
||||
free(result->segments);
|
||||
result->segments = NULL;
|
||||
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
|
||||
// ============================================================================
|
||||
@@ -1079,6 +1286,8 @@ static void LoadSelectedFile(void)
|
||||
app.bgHighResSeg = 0;
|
||||
app.bgFinished = false;
|
||||
app.isBgProcessing = false;
|
||||
// Signal changed — free cache (results are tied to signal data)
|
||||
FreeAllCacheEntries(&app.fftCache);
|
||||
app.timeSelectionStart = app.viewStart = 0.0f;
|
||||
app.timeSelectionEnd = app.viewEnd = 1.0f;
|
||||
app.freqSelectionStart = 0.0f;
|
||||
@@ -1612,29 +1821,13 @@ static void DrawSidebar(void)
|
||||
if (CheckCollisionPointRec(GetMousePosition(), fftMinus) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
|
||||
int newFFT = app.fftSize / 2;
|
||||
if (newFFT >= FFT_SIZE_MIN) {
|
||||
app.fftSize = newFFT;
|
||||
app.stftComputed = false;
|
||||
app.loadingPhase = 0;
|
||||
app.skipFactor = 1;
|
||||
app.highResFinished = false;
|
||||
app.bgHighResSeg = 0;
|
||||
app.bgFinished = false;
|
||||
app.isBgProcessing = false;
|
||||
needsRegen = true;
|
||||
ChangeFFTSize(newFFT);
|
||||
}
|
||||
}
|
||||
if (CheckCollisionPointRec(GetMousePosition(), fftPlus) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
|
||||
int newFFT = app.fftSize * 2;
|
||||
if (newFFT <= FFT_SIZE_MAX) {
|
||||
app.fftSize = newFFT;
|
||||
app.stftComputed = false;
|
||||
app.loadingPhase = 0;
|
||||
app.skipFactor = 1;
|
||||
app.highResFinished = false;
|
||||
app.bgHighResSeg = 0;
|
||||
app.bgFinished = false;
|
||||
app.isBgProcessing = false;
|
||||
needsRegen = true;
|
||||
ChangeFFTSize(newFFT);
|
||||
}
|
||||
}
|
||||
DrawRectangleRec(fftMinus, (Color){ 50, 50, 60, 255 });
|
||||
@@ -1872,6 +2065,15 @@ int main(int argc, char* argv[])
|
||||
app.bgFinished = false;
|
||||
app.lastInteractedFrame = 0;
|
||||
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.playbackFinished = false;
|
||||
app.showScope = true;
|
||||
@@ -1914,6 +2116,8 @@ int main(int argc, char* argv[])
|
||||
app.bgHighResSeg = 0;
|
||||
app.bgFinished = false;
|
||||
app.isBgProcessing = false;
|
||||
// Signal changed — free cache (results are tied to signal data)
|
||||
FreeAllCacheEntries(&app.fftCache);
|
||||
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
|
||||
TraceLog(LOG_INFO, "File loaded successfully");
|
||||
}
|
||||
@@ -1941,6 +2145,8 @@ int main(int argc, char* argv[])
|
||||
app.bgHighResSeg = 0;
|
||||
app.bgFinished = 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;
|
||||
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
|
||||
// Invalidate visible texture cache
|
||||
@@ -2132,6 +2338,8 @@ int main(int argc, char* argv[])
|
||||
app.bgFinished = true;
|
||||
app.isBgProcessing = false;
|
||||
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()) {
|
||||
@@ -2149,6 +2357,7 @@ int main(int argc, char* argv[])
|
||||
// No more missing segments — mark complete
|
||||
app.bgFinished = true;
|
||||
app.isBgProcessing = false;
|
||||
SaveToCache();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2429,6 +2638,8 @@ int main(int argc, char* argv[])
|
||||
app.isBgProcessing = true; // Kick off background high-res next frame
|
||||
TraceLog(LOG_INFO, "STFT overview computed (%d segments, skipFactor=%d)",
|
||||
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;
|
||||
UnloadTexture(colormapTexture);
|
||||
FreeBrowserFiles();
|
||||
FreeAllCacheEntries(&app.fftCache);
|
||||
FreeSignal(&app.signal);
|
||||
CloseAudioDevice();
|
||||
CloseWindow();
|
||||
|
||||
Reference in New Issue
Block a user