Files
rspektrum/src/stft.c
T
tyler 3a8f20b783 refactor: split spectrogram.c into per-concern modules
spectrogram.c was ~2950 lines holding everything. Break it into cohesive
translation units; spectrogram.c keeps only globals + the main frame loop.

New modules:
- spectrogram_types.h  shared types, constants, extern globals, inline math
- fft.c/.h             FFT, bit-reverse, twiddle (standalone, no app deps)
- stft.c/.h            STFT compute, adaptive resolution, FFT-size LRU cache
- audio.c/.h           WAV/ffmpeg load, FreeSignal, bandpass, playback
- render.c/.h          UI scaling, colormaps, texture gen, on-screen drawing
- ui.c/.h              file browser, sidebar, sliders, PNG export

Also:
- utils.c now includes utils.h instead of re-typedef'ing AudioSignal/
  SignalStats (they had to be hand-synced before).
- Remove dead code: ApplyHannWindow and ComputeSTFTHighResRange were never
  called (the live high-res path is ComputeNextHighResChunk).
- Delete the unused raylib-template main.c.
- rspektrum.make: build the new units. premake5.lua: glob src/**.c so a
  future regen stays correct.

Pure code movement otherwise; no behavior change. Builds clean (-Wshadow).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 01:15:51 -07:00

388 lines
16 KiB
C

// stft.c - STFT computation, adaptive resolution, and the FFT-size LRU cache
#include "stft.h"
#include "fft.h"
#include "render.h" // GenerateSpectrogramTexture (used by ChangeFFTSize)
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include <complex.h>
// ===== FFT-size cache (LRU) =====
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.
*/
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.
*/
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 =====
int ComputeNextHighResChunk(AudioSignal* signal, StftResult* result,
int fftSize, int startSeg, int endSeg)
{
int hopSize = fftSize / HOP_RATIO;
int numBins = fftSize / 2 + 1;
float* windowedSamples = (float*)malloc(fftSize * sizeof(float));
float* derivWindowedSamples = (float*)malloc(fftSize * sizeof(float));
float complex *complexInput = (float complex*)malloc(fftSize * sizeof(float complex));
float complex* fftOutput = (float complex*)malloc(fftSize * sizeof(float complex));
for (int seg = startSeg; seg < endSeg && seg < result->numSegments; seg++) {
// Skip if already computed (overview or high-res)
if (result->segments[seg].spectrum != NULL) continue;
int offset = seg * hopSize;
int samplesToCopy = fftSize;
if (offset + samplesToCopy > signal->numSamples) {
samplesToCopy = signal->numSamples - offset;
memset(windowedSamples, 0, fftSize * sizeof(float));
memset(derivWindowedSamples, 0, fftSize * sizeof(float));
} else {
memcpy(windowedSamples, signal->samples + offset, fftSize * sizeof(float));
memcpy(derivWindowedSamples, signal->samples + offset, fftSize * sizeof(float));
}
// Apply Hann window and derivative window
for (int i = 0; i < fftSize; i++) {
float t = (float)i / (fftSize - 1);
float hann = 0.5f * (1.0f - cosf(2.0f * M_PI * t));
float derivHann = M_PI * sinf(2.0f * M_PI * t);
windowedSamples[i] *= hann;
derivWindowedSamples[i] *= derivHann;
}
// Normal STFT
for (int i = 0; i < fftSize; i++) complexInput[i] = windowedSamples[i] + 0.0f * I;
FFT(complexInput, fftOutput, fftSize, false);
result->segments[seg].numBins = numBins;
result->segments[seg].sampleOffset = offset;
result->segments[seg].sampleCount = samplesToCopy;
result->segments[seg].spectrum = (FrequencyData*)malloc(numBins * sizeof(FrequencyData));
for (int bin = 0; bin < numBins; bin++) {
result->segments[seg].spectrum[bin].frequency = (float)bin * signal->sampleRate / fftSize;
result->segments[seg].spectrum[bin].amplitude = (bin == 0) ? cabsf(fftOutput[bin]) / fftSize : 2.0f * cabsf(fftOutput[bin]) / fftSize;
result->segments[seg].spectrum[bin].phase = cargf(fftOutput[bin]);
}
// Derivative-window STFT for synchrosqueezing
result->segments[seg].derivativeSpectrum = (FrequencyData*)malloc(numBins * sizeof(FrequencyData));
for (int i = 0; i < fftSize; i++) complexInput[i] = derivWindowedSamples[i] + 0.0f * I;
FFT(complexInput, fftOutput, fftSize, false);
for (int bin = 0; bin < numBins; bin++) {
result->segments[seg].derivativeSpectrum[bin].frequency = (float)bin * signal->sampleRate / fftSize;
result->segments[seg].derivativeSpectrum[bin].amplitude = cabsf(fftOutput[bin]) / fftSize;
result->segments[seg].derivativeSpectrum[bin].phase = cargf(fftOutput[bin]);
}
}
free(windowedSamples);
free(derivWindowedSamples);
free(complexInput);
free(fftOutput);
// Return next segment to process
if (endSeg >= result->numSegments) return result->numSegments;
return endSeg;
}
// ===== STFT computation =====
void ComputeSTFTInit(AudioSignal* signal, StftResult* result, int fftSize)
{
FreeSTFT(result); // release any previous result before reallocating
int hopSize = fftSize / HOP_RATIO; // 75% overlap
int numSegments = (signal->numSamples - fftSize) / hopSize + 1;
if (numSegments <= 0) numSegments = 1;
result->numSegments = numSegments;
result->segments = (StftSegment*)calloc(numSegments, sizeof(StftSegment));
result->sampleRate = signal->sampleRate;
result->totalSamples = signal->numSamples;
result->useHannWindow = true;
}
bool ComputeSTFTIncremental(AudioSignal* signal, StftResult* result, int fftSize, int startSegment)
{
int hopSize = fftSize / HOP_RATIO;
int numBins = fftSize / 2 + 1;
float* windowedSamples = (float*)malloc(fftSize * sizeof(float));
float* derivWindowedSamples = (float*)malloc(fftSize * sizeof(float));
float complex *complexInput = (float complex*)malloc(fftSize * sizeof(float complex));
float complex* fftOutput = (float complex*)malloc(fftSize * sizeof(float complex));
for (int seg = startSegment; seg < result->numSegments; seg++) {
// Skip segments not aligned with the skip factor (overview mode)
if (seg % app.skipFactor != 0) continue;
// Skip if already computed as high-res
if (result->segments[seg].spectrum != NULL) continue;
int offset = seg * hopSize;
int samplesToCopy = fftSize;
if (offset + samplesToCopy > signal->numSamples) {
samplesToCopy = signal->numSamples - offset;
memset(windowedSamples, 0, fftSize * sizeof(float));
memset(derivWindowedSamples, 0, fftSize * sizeof(float));
} else {
memcpy(windowedSamples, signal->samples + offset, fftSize * sizeof(float));
memcpy(derivWindowedSamples, signal->samples + offset, fftSize * sizeof(float));
}
// Apply Hann window: h(t) = 0.5 * (1 - cos(2πt))
// And derivative window: h'(t) = π * sin(2πt)
for (int i = 0; i < fftSize; i++) {
float t = (float)i / (fftSize - 1);
float hann = 0.5f * (1.0f - cosf(2.0f * M_PI * t));
float derivHann = M_PI * sinf(2.0f * M_PI * t);
windowedSamples[i] *= hann;
derivWindowedSamples[i] *= derivHann;
}
// Compute normal STFT (V_f)
for (int i = 0; i < fftSize; i++) complexInput[i] = windowedSamples[i] + 0.0f * I;
FFT(complexInput, fftOutput, fftSize, false);
result->segments[seg].numBins = numBins;
result->segments[seg].sampleOffset = offset;
result->segments[seg].sampleCount = samplesToCopy;
result->segments[seg].spectrum = (FrequencyData*)malloc(numBins * sizeof(FrequencyData));
for (int bin = 0; bin < numBins; bin++) {
result->segments[seg].spectrum[bin].frequency = (float)bin * signal->sampleRate / fftSize;
result->segments[seg].spectrum[bin].amplitude = (bin == 0) ? cabsf(fftOutput[bin]) / fftSize : 2.0f * cabsf(fftOutput[bin]) / fftSize;
result->segments[seg].spectrum[bin].phase = cargf(fftOutput[bin]);
}
// Compute derivative-window STFT (V_fd) for synchrosqueezing
result->segments[seg].derivativeSpectrum = (FrequencyData*)malloc(numBins * sizeof(FrequencyData));
for (int i = 0; i < fftSize; i++) complexInput[i] = derivWindowedSamples[i] + 0.0f * I;
FFT(complexInput, fftOutput, fftSize, false);
for (int bin = 0; bin < numBins; bin++) {
result->segments[seg].derivativeSpectrum[bin].frequency = (float)bin * signal->sampleRate / fftSize;
result->segments[seg].derivativeSpectrum[bin].amplitude = cabsf(fftOutput[bin]) / fftSize;
result->segments[seg].derivativeSpectrum[bin].phase = cargf(fftOutput[bin]);
}
}
free(windowedSamples);
free(derivWindowedSamples);
free(complexInput);
free(fftOutput);
return true;
}
void FreeSTFT(StftResult* result)
{
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;
}
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.
*/
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 =====
int ComputeSkipFactor(float signalDurationSec)
{
if (signalDurationSec <= 60.0f) return 1; // < 1 min: full-res
if (signalDurationSec <= 300.0f) return 2; // 1-5 min: every 2nd
if (signalDurationSec <= 600.0f) return 4; // 5-10 min: every 4th
return 8; // > 10 min: every 8th
}
// Compute full-resolution segments for the range [startSeg, endSeg).
// This replaces existing overview (skipFactor-strided) segments with
// ===== Amplitude auto-scaling =====
void AutoScaleAmplitude(StftResult* stft)
{
float maxDb = -999.0f;
float minDb = 0.0f;
for (int seg = 0; seg < stft->numSegments; seg++) {
for (int bin = 0; bin < stft->segments[seg].numBins; bin++) {
float db = AmplitudeToDecibels(stft->segments[seg].spectrum[bin].amplitude);
if (db > maxDb) maxDb = db;
if (db < minDb) minDb = db;
}
}
// Set ceiling at the max, floor 40dB below — enough range to see structure
// but not so wide that the signal is drowned in black
app.amplitudeCeilingDb = maxDb;
app.amplitudeFloorDb = maxDb - 40.0f;
}