diff --git a/src/spectrogram.c b/src/spectrogram.c index b6e1fec..65c2c6d 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -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();