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;
|
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)
|
||||||
{
|
{
|
||||||
for (int i = 0; i < result->numSegments; i++) {
|
if (!result) return;
|
||||||
free(result->segments[i].spectrum);
|
if (result->segments) {
|
||||||
if (result->segments[i].derivativeSpectrum) free(result->segments[i].derivativeSpectrum);
|
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;
|
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();
|
||||||
|
|||||||
Reference in New Issue
Block a user