From 3a8f20b7830d7011855f1122e9e8649bc0008ffd Mon Sep 17 00:00:00 2001 From: Tyler Date: Mon, 25 May 2026 01:15:51 -0700 Subject: [PATCH] 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 --- build/premake5.lua | 2 +- rspektrum.make | 30 + src/audio.c | 210 +++++ src/audio.h | 15 + src/fft.c | 37 + src/fft.h | 16 + src/main.c | 54 -- src/render.c | 494 ++++++++++ src/render.h | 25 + src/spectrogram.c | 1984 +-------------------------------------- src/spectrogram_types.h | 222 +++++ src/stft.c | 387 ++++++++ src/stft.h | 22 + src/ui.c | 546 +++++++++++ src/ui.h | 18 + src/utils.c | 19 +- 16 files changed, 2038 insertions(+), 2043 deletions(-) create mode 100644 src/audio.c create mode 100644 src/audio.h create mode 100644 src/fft.c create mode 100644 src/fft.h delete mode 100644 src/main.c create mode 100644 src/render.c create mode 100644 src/render.h create mode 100644 src/spectrogram_types.h create mode 100644 src/stft.c create mode 100644 src/stft.h create mode 100644 src/ui.c create mode 100644 src/ui.h diff --git a/build/premake5.lua b/build/premake5.lua index b38b670..3500d8e 100644 --- a/build/premake5.lua +++ b/build/premake5.lua @@ -196,7 +196,7 @@ if (downloadRaylib) then ["Game Resource Files/*"] = {"../resources/**"}, } - files {"../src/spectrogram.c", "../src/**.h", "../src/**.hpp", "../include/**.h", "../include/**.hpp"} + files {"../src/**.c", "../src/**.h", "../src/**.hpp", "../include/**.h", "../include/**.hpp"} filter {"system:windows", "action:vs*"} files {"../src/*.rc", "../src/*.ico"} diff --git a/rspektrum.make b/rspektrum.make index 85e5dc8..a3491e2 100644 --- a/rspektrum.make +++ b/rspektrum.make @@ -119,10 +119,20 @@ GENERATED := OBJECTS := GENERATED += $(OBJDIR)/spectrogram.o +GENERATED += $(OBJDIR)/fft.o +GENERATED += $(OBJDIR)/stft.o +GENERATED += $(OBJDIR)/audio.o +GENERATED += $(OBJDIR)/render.o +GENERATED += $(OBJDIR)/ui.o GENERATED += $(OBJDIR)/platform_linux.o GENERATED += $(OBJDIR)/utils.o GENERATED += $(OBJDIR)/primitives.o OBJECTS += $(OBJDIR)/spectrogram.o +OBJECTS += $(OBJDIR)/fft.o +OBJECTS += $(OBJDIR)/stft.o +OBJECTS += $(OBJDIR)/audio.o +OBJECTS += $(OBJDIR)/render.o +OBJECTS += $(OBJDIR)/ui.o OBJECTS += $(OBJDIR)/platform_linux.o OBJECTS += $(OBJDIR)/utils.o OBJECTS += $(OBJDIR)/primitives.o @@ -193,6 +203,26 @@ $(OBJDIR)/spectrogram.o: src/spectrogram.c @echo "$(notdir $<)" $(SILENT) $(CC) $(ALL_CFLAGS) $(FORCE_INCLUDE) -o "$@" -MF "$(@:%.o=%.d)" -c "$<" +$(OBJDIR)/fft.o: src/fft.c + @echo "$(notdir $<)" + $(SILENT) $(CC) $(ALL_CFLAGS) $(FORCE_INCLUDE) -o "$@" -MF "$(@:%.o=%.d)" -c "$<" + +$(OBJDIR)/stft.o: src/stft.c + @echo "$(notdir $<)" + $(SILENT) $(CC) $(ALL_CFLAGS) $(FORCE_INCLUDE) -o "$@" -MF "$(@:%.o=%.d)" -c "$<" + +$(OBJDIR)/audio.o: src/audio.c + @echo "$(notdir $<)" + $(SILENT) $(CC) $(ALL_CFLAGS) $(FORCE_INCLUDE) -o "$@" -MF "$(@:%.o=%.d)" -c "$<" + +$(OBJDIR)/render.o: src/render.c + @echo "$(notdir $<)" + $(SILENT) $(CC) $(ALL_CFLAGS) $(FORCE_INCLUDE) -o "$@" -MF "$(@:%.o=%.d)" -c "$<" + +$(OBJDIR)/ui.o: src/ui.c + @echo "$(notdir $<)" + $(SILENT) $(CC) $(ALL_CFLAGS) $(FORCE_INCLUDE) -o "$@" -MF "$(@:%.o=%.d)" -c "$<" + $(OBJDIR)/platform_linux.o: src/platform_linux.c @echo "$(notdir $<)" $(SILENT) $(CC) $(ALL_CFLAGS) $(FORCE_INCLUDE) -o "$@" -MF "$(@:%.o=%.d)" -c "$<" diff --git a/src/audio.c b/src/audio.c new file mode 100644 index 0000000..d3b2931 --- /dev/null +++ b/src/audio.c @@ -0,0 +1,210 @@ +// audio.c - WAV loading (with ffmpeg fallback), bandpass filtering, playback +#include "audio.h" +#include "fft.h" +#include "platform.h" + +#include +#include +#include +#include +#include + +static bool ConvertToFFmpegWAV(const char* inputPath, char* outputPath, size_t outputSize) +{ + snprintf(outputPath, outputSize, "%s/rspektrum_temp_converted.wav", + Platform_GetTempDir()); + + /* Build argv array — no shell = no injection risk */ + const char* argv[] = { + "ffmpeg", "-y", "-loglevel", "quiet", + "-i", inputPath, + "-ar", "48000", "-ac", "1", "-f", "wav", + outputPath, NULL + }; + + PlatformSpawnHandle handle = { 0 }; + PlatformError rc = Platform_SpawnChild("ffmpeg", argv, &handle); + if (rc != PLATFORM_OK) { + TraceLog(LOG_WARNING, "Failed to spawn ffmpeg: %s", Platform_GetLastErrorMessage()); + return false; + } + + Platform_WaitForChild(&handle); + + int exit_code = 0; + SpawnStatus status = Platform_GetExitStatus(&handle, &exit_code); + if (status == SPAWN_EXITED && exit_code == 0 && FileExists(outputPath)) { + TraceLog(LOG_INFO, "FFmpeg conversion successful: %s", outputPath); + return true; + } + + TraceLog(LOG_WARNING, "FFmpeg conversion failed (exit code %d)", exit_code); + return false; +} + +bool LoadWavFile(const char* filepath, AudioSignal* signal) +{ + const char* ext = GetFileExtension(filepath); + char convertedPath[512] = { 0 }; + bool isConverted = false; + + // Check if we need to convert via ffmpeg + bool isWav = ext && (strcmp(ext, ".wav") == 0 || strcmp(ext, ".WAV") == 0); + + if (!isWav) { + // Try ffmpeg conversion + if (ConvertToFFmpegWAV(filepath, convertedPath, sizeof(convertedPath))) { + filepath = convertedPath; + isConverted = true; + } else { + TraceLog(LOG_ERROR, "Unsupported format and ffmpeg not available: %s", filepath); + return false; + } + } + + Wave wave = LoadWave(filepath); + if (wave.data == NULL) { + TraceLog(LOG_ERROR, "Failed to open WAV file: %s", filepath); + if (isConverted) FileRemove(convertedPath); + return false; + } + + signal->sampleRate = wave.sampleRate; + signal->channels = wave.channels; + signal->numSamples = wave.frameCount * wave.channels; + signal->duration = (float)wave.frameCount / wave.sampleRate; + if (signal->samples) free(signal->samples); // free previous file's samples + signal->samples = (float*)malloc(signal->numSamples * sizeof(float)); + + if (wave.sampleSize == 16) { + short* samples = (short*)wave.data; + for (int i = 0; i < signal->numSamples; i++) signal->samples[i] = samples[i] / 32768.0f; + } else if (wave.sampleSize == 32) { + float* samples = (float*)wave.data; + memcpy(signal->samples, samples, signal->numSamples * sizeof(float)); + } else { + unsigned char* samples = (unsigned char*)wave.data; + for (int i = 0; i < signal->numSamples; i++) signal->samples[i] = (samples[i] - 128) / 128.0f; + } + + if (wave.channels > 1) { + int monoSamples = wave.frameCount; + for (int i = 0; i < monoSamples; i++) { + float sum = 0.0f; + for (int c = 0; c < wave.channels; c++) sum += signal->samples[i * wave.channels + c]; + signal->samples[i] = sum / wave.channels; + } + signal->numSamples = monoSamples; + } + UnloadWave(wave); + + // Clean up temp file if converted + if (isConverted) { + FileRemove(convertedPath); + TraceLog(LOG_INFO, "Cleaned up temp file: %s", convertedPath); + } + + TraceLog(LOG_INFO, "Loaded WAV: %d Hz, %.2f sec, %d samples", signal->sampleRate, signal->duration, signal->numSamples); + return true; +} + +void FreeSignal(AudioSignal* signal) +{ + if (signal->samples) { free(signal->samples); signal->samples = NULL; } + signal->numSamples = 0; signal->sampleRate = 0; signal->duration = 0.0f; +} + +// ===== Playback with FFT bandpass filter ===== +static void ApplyBandpassFilterFFT(float* samples, int numSamples, int sampleRate, float freqLow, float freqHigh) +{ + if (freqLow <= 0 && freqHigh >= sampleRate / 2.0f) return; + + int fftSize = 1; + while (fftSize < numSamples) fftSize *= 2; + fftSize *= 2; + + float* paddedSamples = (float*)calloc(fftSize, sizeof(float)); + float complex *fftInput = (float complex*)malloc(fftSize * sizeof(float complex)); + float complex *fftOutput = (float complex*)malloc(fftSize * sizeof(float complex)); + + // Copy samples directly without windowing (windowing causes fade in/out) + for (int i = 0; i < numSamples; i++) { + paddedSamples[i] = samples[i]; + } + for (int i = 0; i < fftSize; i++) fftInput[i] = paddedSamples[i] + 0.0f * I; + + FFT(fftInput, fftOutput, fftSize, false); + + float freqPerBin = (float)sampleRate / fftSize; + for (int bin = 0; bin < fftSize / 2 + 1; bin++) { + float frequency = bin * freqPerBin; + float attenuation = 1.0f; + if (frequency < freqLow) { + float dist = (freqLow - frequency) / (freqPerBin * 10.0f); + attenuation = 1.0f / (1.0f + dist * dist * dist); + } else if (frequency > freqHigh) { + float dist = (frequency - freqHigh) / (freqPerBin * 10.0f); + attenuation = 1.0f / (1.0f + dist * dist * dist); + } + fftOutput[bin] *= attenuation; + if (bin > 0 && bin < fftSize / 2) fftOutput[fftSize - bin] *= attenuation; + } + + float complex* ifftOutput = (float complex*)malloc(fftSize * sizeof(float complex)); + FFT(fftOutput, ifftOutput, fftSize, true); + + float filteredPeak = 0.0f; + for (int i = 0; i < numSamples; i++) { + samples[i] = crealf(ifftOutput[i]); + if (fabsf(samples[i]) > filteredPeak) filteredPeak = fabsf(samples[i]); + } + + const float TARGET_PEAK = 0.9f; + if (filteredPeak > 0.0001f) { + float gain = TARGET_PEAK / filteredPeak; + if (gain > 10.0f) gain = 10.0f; + for (int i = 0; i < numSamples; i++) { + samples[i] *= gain; + if (samples[i] > 0.95f) samples[i] = 0.95f; + if (samples[i] < -0.95f) samples[i] = -0.95f; + } + } + + free(paddedSamples); free(fftInput); free(fftOutput); free(ifftOutput); +} + +static void ApplyBandpassFilter(float* samples, int numSamples, int sampleRate, float freqLow, float freqHigh) +{ + ApplyBandpassFilterFFT(samples, numSamples, sampleRate, freqLow, freqHigh); +} + +void PlaySelectedRegion(void) +{ + if (!app.loaded || !app.stftComputed) return; + + int startSample = (int)(app.timeSelectionStart * app.signal.numSamples); + int endSample = (int)(app.timeSelectionEnd * app.signal.numSamples); + int numSamples = endSample - startSample; + if (numSamples <= 0 || startSample < 0 || endSample > app.signal.numSamples) return; + + float* regionSamples = (float*)malloc(numSamples * sizeof(float)); + memcpy(regionSamples, app.signal.samples + startSample, numSamples * sizeof(float)); + + float maxFreq = (float)app.signal.sampleRate / 2.0f; + float freqLow = app.freqSelectionStart * maxFreq; + float freqHigh = app.freqSelectionEnd * maxFreq; + if (freqLow > 10.0f || freqHigh < maxFreq - 10.0f) { + TraceLog(LOG_INFO, "Applying bandpass filter: %.0f - %.0f Hz", freqLow, freqHigh); + ApplyBandpassFilter(regionSamples, numSamples, app.signal.sampleRate, freqLow, freqHigh); + } + + if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound); + + Wave wave = { .data = regionSamples, .frameCount = (unsigned int)numSamples, + .sampleRate = (unsigned int)app.signal.sampleRate, .sampleSize = 32, .channels = 1 }; + AudioPlaybackSound = LoadSoundFromWave(wave); + PlaySound(AudioPlaybackSound); + TraceLog(LOG_INFO, "Playing: %.2f-%.2f sec, %.0f-%.0f Hz", + (float)startSample / app.signal.sampleRate, (float)endSample / app.signal.sampleRate, freqLow, freqHigh); +} + diff --git a/src/audio.h b/src/audio.h new file mode 100644 index 0000000..20f8d96 --- /dev/null +++ b/src/audio.h @@ -0,0 +1,15 @@ +// audio.h - WAV loading (with ffmpeg fallback) and selection playback +#ifndef AUDIO_H +#define AUDIO_H + +#include "spectrogram_types.h" + +// Load a WAV (or any ffmpeg-decodable file) into `signal`, downmixed to mono. +// Returns false on failure (the existing signal is left untouched). +bool LoadWavFile(const char* filepath, AudioSignal* signal); +void FreeSignal(AudioSignal* signal); + +// Play the current time/frequency selection (applies an FFT bandpass filter). +void PlaySelectedRegion(void); + +#endif // AUDIO_H diff --git a/src/fft.c b/src/fft.c new file mode 100644 index 0000000..711162a --- /dev/null +++ b/src/fft.c @@ -0,0 +1,37 @@ +// fft.c - Radix-2 Cooley-Tukey FFT +#include "fft.h" + +#include +#include +#include + +static void BitReverseCopy(float complex* input, float complex* output, int n) +{ + int bits = 0, temp = n; + while (temp > 1) { bits++; temp >>= 1; } + for (int i = 0; i < n; i++) { + int j = 0, k = i; + for (int b = 0; b < bits; b++) { j = (j << 1) | (k & 1); k >>= 1; } + output[j] = input[i]; + } +} + +void FFT(float complex* input, float complex* output, int n, bool inverse) +{ + if (n <= 1) { output[0] = input[0]; return; } + BitReverseCopy(input, output, n); + for (int stage = 1; stage < n; stage *= 2) { + int step = stage * 2; + float angleStep = (inverse ? 2.0f : -2.0f) * (float)M_PI / step; + for (int k = 0; k < stage; k++) { + float complex twiddle = cexpf(I * angleStep * k); + for (int i = k; i < n; i += step) { + int j = i + stage; + float complex t = output[j] * twiddle; + output[j] = output[i] - t; + output[i] = output[i] + t; + } + } + } + if (inverse) for (int i = 0; i < n; i++) output[i] /= n; +} diff --git a/src/fft.h b/src/fft.h new file mode 100644 index 0000000..bc94d1a --- /dev/null +++ b/src/fft.h @@ -0,0 +1,16 @@ +// fft.h - Radix-2 Cooley-Tukey FFT (standalone DSP, no app dependencies) +#ifndef FFT_H +#define FFT_H + +#include +#include + +#ifndef M_PI + #define M_PI 3.14159265358979323846 +#endif + +// In-place-style FFT: writes the transform of `input` (length n, power of two) +// into `output`. Set inverse=true for the inverse transform (1/n scaled). +void FFT(float complex* input, float complex* output, int n, bool inverse); + +#endif // FFT_H diff --git a/src/main.c b/src/main.c deleted file mode 100644 index 0f6a14b..0000000 --- a/src/main.c +++ /dev/null @@ -1,54 +0,0 @@ -/* -Raylib example file. -This is an example main file for a simple raylib project. -Use this as a starting point or replace it with your code. - -by Jeffery Myers is marked with CC0 1.0. To view a copy of this license, visit https://creativecommons.org/publicdomain/zero/1.0/ - -*/ - -#include "raylib.h" - -#include "resource_dir.h" // utility header for SearchAndSetResourceDir - -int main () -{ - // Tell the window to use vsync and work on high DPI displays - SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_HIGHDPI); - - // Create the window and OpenGL context - InitWindow(1280, 800, "Hello Raylib"); - - // Utility function from resource_dir.h to find the resources folder and set it as the current working directory so we can load from it - SearchAndSetResourceDir("resources"); - - // Load a texture from the resources directory - Texture wabbit = LoadTexture("wabbit_alpha.png"); - - // game loop - while (!WindowShouldClose()) // run the loop until the user presses ESCAPE or presses the Close button on the window - { - // drawing - BeginDrawing(); - - // Setup the back buffer for drawing (clear color and depth buffers) - ClearBackground(BLACK); - - // draw some text using the default font - DrawText("Hello Raylib", 200,200,20,WHITE); - - // draw our texture to the screen - DrawTexture(wabbit, 400, 200, WHITE); - - // end the frame and get ready for the next one (display frame, poll input, etc...) - EndDrawing(); - } - - // cleanup - // unload our texture so it can be cleaned up - UnloadTexture(wabbit); - - // destroy the window and cleanup the OpenGL context - CloseWindow(); - return 0; -} diff --git a/src/render.c b/src/render.c new file mode 100644 index 0000000..1d1baf2 --- /dev/null +++ b/src/render.c @@ -0,0 +1,494 @@ +// render.c - colormaps, spectrogram texture generation, and on-screen drawing +#include "render.h" + +#include +#include +#include +#include + +// ===== UI scaling and scaled text ===== +// Base resolution for proportional UI scaling. +// GetUIScale() uses logical screen (not framebuffer) dimensions so that +// layout stays based on window size alone. FLAG_WINDOW_HIGHDPI makes +// BeginDrawing() render to the framebuffer at the correct resolution, so +// every 1px drawn in layout coordinates automatically maps to the right +// physical size on any monitor. +float GetUIScale(void) +{ + float scaleX = (float)GetScreenWidth() / BASE_WIDTH; + float scaleY = (float)GetScreenHeight() / BASE_HEIGHT; + return (scaleX + scaleY) / 2.0f; +} + +void DrawTextScaled(const char* text, float x, float y, float baseSize, Color color) +{ + if (mainFont.texture.id == 0) { + // Fallback to default if font not loaded + DrawText(text, (int)x, (int)y, (int)baseSize, color); + return; + } + float scaledSize = baseSize * GetUIScale(); + float spacing = scaledSize * 0.25f; // 25% of font size for spacing + DrawTextEx(mainFont, text, (Vector2){ x, y }, scaledSize, spacing, color); +} + +float MeasureTextScaled(const char* text, float baseSize) +{ + if (mainFont.texture.id == 0) return MeasureText(text, (int)baseSize); + float scaledSize = baseSize * GetUIScale(); + float spacing = scaledSize * 0.25f; + return MeasureTextEx(mainFont, text, scaledSize, spacing).x; +} + +// ===== Colormaps ===== +static Color GetColormapColor(float t, ColormapType type) +{ + t = Clamp(t, 0.0f, 1.0f); + + switch (type) { + case COLORMAP_GRAYS: { + unsigned char v = (unsigned char)(t * 255); + return (Color){ v, v, v, 255 }; + } + case COLORMAP_INFERNO: { + float r = 0.0f, g = 0.0f, b = 0.0f; + if (t < 0.25f) { t = t / 0.25f; r = 0.0f + t * 0.5f; g = 0.0f; b = 0.0f + t * 0.3f; } + else if (t < 0.5f) { t = (t - 0.25f) / 0.25f; r = 0.5f + t * 0.5f; g = 0.0f + t * 0.3f; b = 0.3f + t * 0.4f; } + else if (t < 0.75f) { t = (t - 0.5f) / 0.25f; r = 1.0f; g = 0.3f + t * 0.5f; b = 0.7f + t * 0.2f; } + else { t = (t - 0.75f) / 0.25f; r = 1.0f; g = 0.8f + t * 0.2f; b = 0.9f + t * 0.1f; } + return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 }; + } + case COLORMAP_VIRIDIS: { + float r, g, b; + if (t < 0.25f) { t = t / 0.25f; r = 0.27f + t * 0.13f; g = 0.00f + t * 0.33f; b = 0.33f + t * 0.27f; } + else if (t < 0.5f) { t = (t - 0.25f) / 0.25f; r = 0.40f + t * 0.16f; g = 0.33f + t * 0.29f; b = 0.60f - t * 0.20f; } + else if (t < 0.75f) { t = (t - 0.5f) / 0.25f; r = 0.56f + t * 0.24f; g = 0.62f + t * 0.23f; b = 0.40f - t * 0.20f; } + else { t = (t - 0.75f) / 0.25f; r = 0.80f + t * 0.17f; g = 0.85f + t * 0.12f; b = 0.20f - t * 0.15f; } + return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 }; + } + case COLORMAP_PLASMA: { + float r = 0.05f + t * 0.9f; + float g = 0.0f + t * 0.6f + (t > 0.5f ? (t - 0.5f) * 0.4f : 0.0f); + float b = 0.6f - t * 0.5f; + return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 }; + } + case COLORMAP_HOT: { + float r = Clamp(t * 3.0f, 0.0f, 1.0f); + float g = Clamp((t - 0.33f) * 3.0f, 0.0f, 1.0f); + float b = Clamp((t - 0.66f) * 3.0f, 0.0f, 1.0f); + return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 }; + } + case COLORMAP_COOL: { + return (Color){ (unsigned char)(t * 255), (unsigned char)((1.0f - t) * 255), 255, 255 }; + } + default: return GRAY; + } +} + +void GenerateColormapTexture(void) +{ + if (colormapTexture.id != 0) UnloadTexture(colormapTexture); + Image img = GenImageColor(256, 1, WHITE); + Color* pixels = (Color*)img.data; + for (int i = 0; i < 256; i++) pixels[i] = GetColormapColor(i / 255.0f, app.colormap); + colormapTexture = LoadTextureFromImage(img); + UnloadImage(img); +} + +// ===== Spectrogram texture ===== +void GenerateSpectrogramTexture(StftResult* stft, Image* image, Texture2D* texture) +{ + if (stft->numSegments == 0) return; + int width = stft->numSegments; + int height = stft->segments[0].numBins; + int fftSize = (height - 1) * 2; + float freqPerBin = (float)stft->sampleRate / fftSize; + + UnloadImage(*image); // release previous image (NULL-safe on first call) + *image = GenImageColor(width, height, BLACK); + Color* pixels = (Color*)image->data; + + // Find max amplitude for normalization (skip NULL segments) + float maxAmplitude = 0.0001f; + for (int seg = 0; seg < stft->numSegments; seg++) { + if (stft->segments[seg].spectrum == NULL) continue; + for (int bin = 0; bin < stft->segments[seg].numBins; bin++) + if (stft->segments[seg].spectrum[bin].amplitude > maxAmplitude) + maxAmplitude = stft->segments[seg].spectrum[bin].amplitude; + } + + // ===== SYNCHROSQUEEZING ===== + // Reassign energy to true frequencies using derivative STFT + + // Accumulation buffer for reassigned energy + float* accumBuffer = (float*)calloc(width * height, sizeof(float)); + + // Noise threshold: only reassign bins with significant energy + float noiseThreshold = maxAmplitude * 0.01f; // 1% of max amplitude + + for (int seg = 0; seg < width; seg++) { + // Skip segments that haven't been computed yet (overview/high-res transition) + if (stft->segments[seg].spectrum == NULL) continue; + + for (int bin = 0; bin < height; bin++) { + FrequencyData* V_f = &stft->segments[seg].spectrum[bin]; + FrequencyData* V_fd = &stft->segments[seg].derivativeSpectrum[bin]; + + float amplitude = V_f->amplitude; + + // Skip noise bins + if (amplitude < noiseThreshold) continue; + + // Compute instantaneous frequency using synchrosqueezing formula: + // ω̂ = bin_freq + Re[V_fd / (i * V_f)] + // Complex division: (a+bi)/(c+di) = ((ac+bd) + (bc-ad)i) / (c²+d²) + // We need Re[(a+bi) / (i*(c+di))] = Re[(a+bi) / (-d+ci)] = (ad+bc)/(c²+d²) + + float V_f_real = amplitude * cosf(V_f->phase); + float V_f_imag = amplitude * sinf(V_f->phase); + float V_fd_real = V_fd->amplitude * cosf(V_fd->phase); + float V_fd_imag = V_fd->amplitude * sinf(V_fd->phase); + + float denom = V_f_real * V_f_real + V_f_imag * V_f_imag; + + float trueFreq = V_f->frequency; // Default to bin frequency + + if (denom > 1e-10f) { + // Re[V_fd / (i * V_f)] = (-V_fd_real * V_f_imag + V_fd_imag * V_f_real) / denom + // Note the MINUS sign on the first term + float correction = (-V_fd_real * V_f_imag + V_fd_imag * V_f_real) / denom; + trueFreq = V_f->frequency + correction; + } + + // Clamp to valid range + if (trueFreq < 0) trueFreq = 0; + if (trueFreq >= stft->sampleRate / 2.0f) trueFreq = stft->sampleRate / 2.0f - 1; + + // Map to bin coordinate + float targetBinF = trueFreq / freqPerBin; + if (targetBinF < 0) targetBinF = 0; + if (targetBinF >= height) targetBinF = height - 0.001f; + + // Bilinear splatting to neighboring bins + int bin0 = (int)targetBinF; + int bin1 = bin0 + 1; + if (bin1 >= height) bin1 = height - 1; + + float frac = targetBinF - bin0; + int idx0 = (height - 1 - bin0) * width + seg; + int idx1 = (height - 1 - bin1) * width + seg; + + accumBuffer[idx0] += amplitude * (1 - frac); + accumBuffer[idx1] += amplitude * frac; + } + } + + // Convert accumulation buffer to colors + for (int i = 0; i < width * height; i++) { + if (accumBuffer[i] > 0.0001f) { + float db = AmplitudeToDecibels(accumBuffer[i]); + float normalized = (db - app.amplitudeFloorDb) / (app.amplitudeCeilingDb - app.amplitudeFloorDb); + normalized = Clamp(normalized, 0.0f, 1.0f); + pixels[i] = GetColormapColor(normalized, app.colormap); + } + } + + free(accumBuffer); + + if (texture->id != 0) UnloadTexture(*texture); + *texture = LoadTextureFromImage(*image); + SetTextureFilter(*texture, TEXTURE_FILTER_BILINEAR); +} + +// Compute auto-adjusted amplitude floor/ceiling from STFT data + +// ===== Grid, labels, selection, playhead ===== +void DrawSpectrogramGrid(Rectangle bounds, int numCellsX, int numCellsY, Color color) +{ + float cellWidth = bounds.width / numCellsX, cellHeight = bounds.height / numCellsY; + for (int i = 0; i <= numCellsX; i++) { + float x = bounds.x + i * cellWidth; + DrawLineV((Vector2){ x, bounds.y }, (Vector2){ x, bounds.y + bounds.height }, color); + } + for (int i = 0; i <= numCellsY; i++) { + float y = bounds.y + bounds.height - i * cellHeight; + DrawLineV((Vector2){ bounds.x, y }, (Vector2){ bounds.x + bounds.width, y }, color); + } +} + +void DrawLabels(Rectangle bounds) +{ + int baseFontSize = 12; + Color textColor = LIGHTGRAY; + + // Time labels + for (int i = 0; i <= 10; i++) { + float t = (float)i / 10; + float timeSec = (app.viewStart + t * (app.viewEnd - app.viewStart)) * app.signal.duration; + float x = bounds.x + t * bounds.width; + char label[32]; + if (timeSec >= 60) sprintf(label, "%d:%02d", (int)(timeSec / 60), (int)(timeSec) % 60); + else sprintf(label, "%.1fs", timeSec); + DrawTextScaled(label, x, bounds.y + bounds.height + 5, baseFontSize, textColor); + } + + // Frequency labels adapted to current zoom level + float maxFreq = (float)app.signal.sampleRate / 2.0f; + float freqMin = app.freqViewStart * maxFreq; + float freqMax = app.freqViewEnd * maxFreq; + + // Choose tick spacing based on zoom range + float freqRange = freqMax - freqMin; + int tickSpacing; + if (freqRange < 20) tickSpacing = 5; + else if (freqRange < 50) tickSpacing = 10; + else if (freqRange < 200) tickSpacing = 50; + else if (freqRange < 1000) tickSpacing = 100; + else if (freqRange < 5000) tickSpacing = 200; + else if (freqRange < 20000) tickSpacing = 1000; + else if (freqRange < 50000) tickSpacing = 5000; + else tickSpacing = 10000; + + // Labels use next coarser spacing so they stay readable + int labelSpacing = tickSpacing; + if (labelSpacing <= 10) labelSpacing = 10; + else if (labelSpacing <= 50) labelSpacing = 50; + else if (labelSpacing <= 200) labelSpacing = 200; + else if (labelSpacing <= 1000) labelSpacing = 1000; + else if (labelSpacing <= 5000) labelSpacing = 5000; + else labelSpacing = 10000; + + // Round freqMin up to nearest tick spacing (smallest multiple >= freqMin) + int firstTick = ((int)(freqMin / tickSpacing)) * tickSpacing; + if (firstTick < freqMin) firstTick += tickSpacing; + for (int hz = firstTick; hz <= freqMax; hz += tickSpacing) { + float t = (hz - freqMin) / freqRange; + float y = bounds.y + bounds.height - t * bounds.height; + Color tickColor = (hz % 1000 == 0) ? GRAY : Fade(GRAY, 0.4f); + DrawLineV((Vector2){ bounds.x - 5, y }, (Vector2){ bounds.x, y }, tickColor); + } + + // Draw labels at the coarser spacing + for (int hz = firstTick; hz <= freqMax; hz += labelSpacing) { + float t = (hz - freqMin) / freqRange; + float y = bounds.y + bounds.height - t * bounds.height; + char label[32]; + if (hz < 10000) sprintf(label, "%.0fHz", (float)hz); + else sprintf(label, "%.0fkHz", (float)hz / 1000.0f); + DrawTextScaled(label, bounds.x - 70, y - 5, baseFontSize, textColor); + } +} + +void DrawSelection(Rectangle bounds) +{ + // Only draw if selection is not full range AND not currently dragging + bool hasSelection = (app.timeSelectionStart > 0.001f || app.timeSelectionEnd < 0.999f || + app.freqSelectionStart > 0.001f || app.freqSelectionEnd < 0.999f); + if (!hasSelection || app.isTimeSelecting) return; // Don't draw overlay while dragging + + Color overlayColor = Fade(BLACK, 0.25f); // Lighter overlay + + // Convert signal coordinates to viewport coordinates + float viewWidth = app.viewEnd - app.viewStart; + float freqWidth = app.freqViewEnd - app.freqViewStart; + + float selStartX = bounds.x + ((app.timeSelectionStart - app.viewStart) / viewWidth) * bounds.width; + float selEndX = bounds.x + ((app.timeSelectionEnd - app.viewStart) / viewWidth) * bounds.width; + float selStartY = bounds.y + bounds.height - ((app.freqSelectionEnd - app.freqViewStart) / freqWidth) * bounds.height; + float selEndY = bounds.y + bounds.height - ((app.freqSelectionStart - app.freqViewStart) / freqWidth) * bounds.height; + + // Clamp to viewport bounds + selStartX = fmaxf(bounds.x, fminf(bounds.x + bounds.width, selStartX)); + selEndX = fmaxf(bounds.x, fminf(bounds.x + bounds.width, selEndX)); + selStartY = fmaxf(bounds.y, fminf(bounds.y + bounds.height, selStartY)); + selEndY = fmaxf(bounds.y, fminf(bounds.y + bounds.height, selEndY)); + + // Draw overlay outside the selection box + DrawRectangle(bounds.x, bounds.y, selStartX - bounds.x, bounds.height, overlayColor); + DrawRectangle(selEndX, bounds.y, bounds.x + bounds.width - selEndX, bounds.height, overlayColor); + DrawRectangle(selStartX, bounds.y, selEndX - selStartX, selStartY - bounds.y, overlayColor); + DrawRectangle(selStartX, selEndY, selEndX - selStartX, bounds.y + bounds.height - selEndY, overlayColor); + + // Draw selection box border + DrawRectangleLinesEx((Rectangle){ selStartX, selStartY, selEndX - selStartX, selEndY - selStartY }, 2, YELLOW); + + // Display selection stats inside viewport, clamped to fit + { + int startSample = (int)(app.timeSelectionStart * app.signal.numSamples); + int endSample = (int)(app.timeSelectionEnd * app.signal.numSamples); + SignalStats stats = ComputeSignalStats(&app.signal, startSample, endSample); + + if (stats.durationSec > 0.0f && app.signal.samples != NULL) { + char lines[5][128]; + int lineCount = 0; + int fontSize = 10; + int maxTextW = 0; + + sprintf(lines[lineCount++], "Duration: %.3fs", stats.durationSec); + sprintf(lines[lineCount++], "Energy: %.2f", stats.energy); + sprintf(lines[lineCount++], "Peak: %.3f", stats.peakAmplitude); + sprintf(lines[lineCount++], "RMS: %.3f", stats.rmsAmplitude); + sprintf(lines[lineCount++], "PAPR: %.1f dB", stats.paprDb); + + // Measure text width + for (int i = 0; i < lineCount; i++) { + int textW = MeasureText(lines[i], fontSize); + if (textW > maxTextW) maxTextW = textW; + } + + int boxW = maxTextW + 20; + int boxH = lineCount * 14 + 12; + + // Center vertically on the selection box, clamp to viewport + float selCenterY = (selStartY + selEndY) / 2.0f; + float boxY = selCenterY - boxH / 2.0f; + if (boxY < bounds.y) boxY = bounds.y; + if (boxY + boxH > bounds.y + bounds.height) boxY = bounds.y + bounds.height - boxH; + + // Place to the right of the selection, or left if not enough room + float boxX = selEndX + 10; + if (boxX + boxW > bounds.x + bounds.width) { + boxX = selStartX - boxW - 10; + if (boxX < bounds.x) boxX = bounds.x; + } + + // Clamp to viewport bounds + if (boxX + boxW > bounds.x + bounds.width) { + // Not enough room on right — draw to the left of selection + if (selStartX > boxW + 20) { + boxX = selStartX - boxW - 10; + } else { + boxX = bounds.x; + } + } + if (boxY + boxH > bounds.y + bounds.height) { + boxY = bounds.y + bounds.height - boxH; + } + + // Draw background box + DrawRectangle((int)boxX, (int)boxY, boxW, boxH, (Color){ 0, 0, 0, 200 }); + DrawRectangleLines((int)boxX, (int)boxY, boxW, boxH, Fade(YELLOW, 0.6f)); + + // Draw text + for (int i = 0; i < lineCount; i++) { + DrawText(lines[i], (int)boxX + 10, (int)boxY + 8 + i * 14, fontSize, LIGHTGRAY); + } + } + } +} + +void DrawSelectionDrag(Rectangle bounds) +{ + // Draw bounding box while dragging (no overlay) + if ((!app.isTimeSelecting && !app.isFreqSelecting && !app.isDraggingSelection) || + (app.isDraggingSelection && !IsMouseButtonDown(MOUSE_LEFT_BUTTON))) return; + + // Convert signal coordinates to viewport coordinates + float viewWidth = app.viewEnd - app.viewStart; + float freqWidth = app.freqViewEnd - app.freqViewStart; + + float selStartX = bounds.x + ((app.timeSelectionStart - app.viewStart) / viewWidth) * bounds.width; + float selEndX = bounds.x + ((app.timeSelectionEnd - app.viewStart) / viewWidth) * bounds.width; + float selStartY = bounds.y + bounds.height - ((app.freqSelectionEnd - app.freqViewStart) / freqWidth) * bounds.height; + float selEndY = bounds.y + bounds.height - ((app.freqSelectionStart - app.freqViewStart) / freqWidth) * bounds.height; + + // Clamp to viewport bounds + selStartX = fmaxf(bounds.x, fminf(bounds.x + bounds.width, selStartX)); + selEndX = fmaxf(bounds.x, fminf(bounds.x + bounds.width, selEndX)); + selStartY = fmaxf(bounds.y, fminf(bounds.y + bounds.height, selStartY)); + selEndY = fmaxf(bounds.y, fminf(bounds.y + bounds.height, selEndY)); + + // Normalize coordinates for drawing + float x = selStartX < selEndX ? selStartX : selEndX; + float w = fabsf(selEndX - selStartX); + float y = selStartY < selEndY ? selStartY : selEndY; + float h = fabsf(selEndY - selStartY); + + DrawRectangleLinesEx((Rectangle){ x, y, w, h }, 2, YELLOW); + + // Display live stats while dragging (inside viewport, clamped to fit) + { + int startSample = (int)(app.timeSelectionStart * app.signal.numSamples); + int endSample = (int)(app.timeSelectionEnd * app.signal.numSamples); + SignalStats stats = ComputeSignalStats(&app.signal, startSample, endSample); + + if (stats.durationSec > 0.0f && app.signal.samples != NULL) { + char lines[5][128]; + int lineCount = 0; + int fontSize = 10; + int maxTextW = 0; + + sprintf(lines[lineCount++], "Duration: %.3fs", stats.durationSec); + sprintf(lines[lineCount++], "Energy: %.2f", stats.energy); + sprintf(lines[lineCount++], "Peak: %.3f", stats.peakAmplitude); + sprintf(lines[lineCount++], "RMS: %.3f", stats.rmsAmplitude); + sprintf(lines[lineCount++], "PAPR: %.1f dB", stats.paprDb); + + // Measure text width + for (int i = 0; i < lineCount; i++) { + int textW = MeasureText(lines[i], fontSize); + if (textW > maxTextW) maxTextW = textW; + } + + int boxW = maxTextW + 20; + int boxH = lineCount * 14 + 12; + + // Center vertically on the selection box, clamp to viewport + float selCenterY = (selStartY + selEndY) / 2.0f; + float boxY = selCenterY - boxH / 2.0f; + if (boxY < bounds.y) boxY = bounds.y; + if (boxY + boxH > bounds.y + bounds.height) boxY = bounds.y + bounds.height - boxH; + + // Place to the right of the selection, or left if not enough room + float boxX = selEndX + 10; + if (boxX + boxW > bounds.x + bounds.width) { + boxX = selStartX - boxW - 10; + if (boxX < bounds.x) boxX = bounds.x; + } + + // Clamp to viewport bounds + if (boxX + boxW > bounds.x + bounds.width) { + // Not enough room on right — draw to the left of selection + if (x > boxW + 20) { + boxX = x - boxW - 10; + } else { + boxX = bounds.x; + } + } + if (boxY + boxH > bounds.y + bounds.height) { + boxY = bounds.y + bounds.height - boxH; + } + + // Draw background box + DrawRectangle((int)boxX, (int)boxY, boxW, boxH, (Color){ 0, 0, 0, 200 }); + DrawRectangleLines((int)boxX, (int)boxY, boxW, boxH, Fade(YELLOW, 0.6f)); + + // Draw text + for (int i = 0; i < lineCount; i++) { + DrawText(lines[i], (int)boxX + 10, (int)boxY + 8 + i * 14, fontSize, LIGHTGRAY); + } + } + } +} + +// ============================================================================ +// Playhead +// ============================================================================ + +void DrawPlayhead(Rectangle bounds) +{ + if (!app.isPlaying || app.playheadT < 0.0f || app.playheadT > 1.0f) return; + + float timePos = app.timeSelectionStart + app.playheadT * (app.timeSelectionEnd - app.timeSelectionStart); + float viewWidth = app.viewEnd - app.viewStart; + float t = (timePos - app.viewStart) / viewWidth; + float x = bounds.x + t * bounds.width; + + // Clamp to bounds + x = fmaxf(bounds.x, fminf(bounds.x + bounds.width, x)); + + // Draw vertical line + DrawLine(x, bounds.y, x, bounds.y + bounds.height, RED); + // Draw semi-transparent overlay to make it stand out + DrawRectangle(x - 2, bounds.y, 4, bounds.height, (Color){ 255, 0, 0, 60 }); +} diff --git a/src/render.h b/src/render.h new file mode 100644 index 0000000..45a3c37 --- /dev/null +++ b/src/render.h @@ -0,0 +1,25 @@ +// render.h - UI scaling, colormaps, spectrogram texture generation, drawing +#ifndef RENDER_H +#define RENDER_H + +#include "spectrogram_types.h" + +// --- UI scaling & scaled text --- +float GetUIScale(void); +void DrawTextScaled(const char* text, float x, float y, float baseSize, Color color); +float MeasureTextScaled(const char* text, float baseSize); + +// --- Colormaps --- +void GenerateColormapTexture(void); + +// --- Spectrogram texture --- +void GenerateSpectrogramTexture(StftResult* stft, Image* image, Texture2D* texture); + +// --- On-screen drawing (operate on the global app state) --- +void DrawSpectrogramGrid(Rectangle bounds, int numCellsX, int numCellsY, Color color); +void DrawLabels(Rectangle bounds); +void DrawSelection(Rectangle bounds); +void DrawSelectionDrag(Rectangle bounds); +void DrawPlayhead(Rectangle bounds); + +#endif // RENDER_H diff --git a/src/spectrogram.c b/src/spectrogram.c index bfe1dd7..fde9336 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -1,8 +1,14 @@ -// spectrogram.c - Spectrogram viewer with region selection and playback -// Based on Unity Audio-Experiments project +// spectrogram.c - Spectrogram viewer: app entry point and main frame loop. +// Subsystems live in fft/stft/audio/render/ui; shared state in spectrogram_types.h. #include "raylib.h" #include "resource_dir.h" +#include "spectrogram_types.h" +#include "fft.h" +#include "stft.h" +#include "audio.h" +#include "render.h" +#include "ui.h" #include "platform.h" #include "utils.h" #include "primitives.h" @@ -14,308 +20,14 @@ #include #include -#ifndef M_PI - #define M_PI 3.14159265358979323846 -#endif - -// Define CYAN if not available -#ifndef CYAN - #define CYAN (Color){ 0, 255, 255, 255 } -#endif - // ============================================================================ -// Configuration +// Global State (declared extern in spectrogram_types.h) // ============================================================================ -#define FFT_SIZE_DEFAULT 2048 -#define FFT_SIZE_MAX 2048 -#define FFT_SIZE_MIN 128 -#define HOP_RATIO 4 // FFT_SIZE / HOP_SIZE = 4 means 75% overlap -#define MAX_SAMPLE_RATE 48000 -#define LOUDNESS_FLOOR_DB -80.0f - -// Base resolution for proportional UI scaling -#define BASE_WIDTH 1280 -#define BASE_HEIGHT 800 - -// Base resolution for proportional UI scaling. -// GetUIScale() uses logical screen (not framebuffer) dimensions so that -// layout stays based on window size alone. FLAG_WINDOW_HIGHDPI makes -// BeginDrawing() render to the framebuffer at the correct resolution, so -// every 1px drawn in layout coordinates automatically maps to the right -// physical size on any monitor. -static float GetUIScale(void) -{ - float scaleX = (float)GetScreenWidth() / BASE_WIDTH; - float scaleY = (float)GetScreenHeight() / BASE_HEIGHT; - return (scaleX + scaleY) / 2.0f; -} - -// Colormap types -typedef enum { - COLORMAP_GRAYS = 0, - COLORMAP_INFERNO, - COLORMAP_VIRIDIS, - COLORMAP_PLASMA, - COLORMAP_HOT, - COLORMAP_COOL, - COLORMAP_COUNT -} ColormapType; - -// ============================================================================ -// Data Structures -// ============================================================================ - -typedef struct { - float frequency; - float amplitude; - float phase; -} FrequencyData; - -typedef struct { - FrequencyData* spectrum; - FrequencyData* derivativeSpectrum; // STFT with derivative window (for synchrosqueezing) - int numBins; - int sampleOffset; - int sampleCount; -} StftSegment; - -typedef struct { - StftSegment* segments; - int numSegments; - int sampleRate; - int totalSamples; - 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; - Image spectrogramImage; - Texture2D spectrogramTexture; - bool loaded; - bool stftComputed; - - // Playback state - float playheadT; // 0-1 normalized position in selection - float playheadElapsed; // Elapsed seconds since play started - - // Time selection (0-1 normalized) - float timeSelectionStart; - float timeSelectionEnd; - bool isTimeSelecting; - - // Frequency selection (0-1 normalized) - float freqSelectionStart; - float freqSelectionEnd; - bool isFreqSelecting; - - // Export settings - float exportScale; - char exportDir[4096]; - char exportMessage[256]; - - Vector2 selectStartPos; // For minimum drag distance check - bool isDraggingSelection; // Dragging existing selection box - Vector2 dragSelectionStartPos; // Mouse position when started dragging selection - float dragSelectionTimeStart; // Selection start time when dragging - float dragSelectionFreqStart; // Selection freq start when dragging - - // Viewport/zoom controls - float viewStart; // 0-1, start of visible time region - float viewEnd; // 0-1, end of visible time region - float freqViewStart; // 0-1, start of visible frequency region (0 = 0Hz) - float freqViewEnd; // 0-1, end of visible frequency region (1 = Nyquist) - bool isPanning; - float panStartViewStart; - float panStartViewEnd; - float panStartFreqViewStart; - float panStartFreqViewEnd; - Vector2 panStartPos; - - // Cached visible texture - Texture2D visibleTexture; - int cachedVisibleStart; - int cachedVisibleEnd; - int cachedVisibleStartY; - int cachedVisibleEndY; - bool visibleTextureValid; - - // Display settings - float amplitudeFloorDb; - float amplitudeCeilingDb; - ColormapType colormap; - bool showGrid; - int fftSize; // Current FFT size (128-2048) - - // File browser state - bool showFileBrowser; - char browserPath[512]; - char** browserFiles; - bool* browserIsDir; - int browserFileCount; - int browserScroll; - int browserSelected; - bool isBrowsing; - - // Playback state - bool isPlaying; - bool playbackFinished; // Track if playback completed naturally - - // Loading/processing state - int loadingPhase; // 0 = computing STFT, 1 = generating texture - float loadingProgress; // 0.0 to 1.0 overall progress - int currentSTFTSegment; // Which segment we're on for incremental processing - - // Adaptive resolution: skipFactor=1 means compute all segments, skipFactor=N - // means compute every Nth segment (faster initial load, overview-only). - // highResFinished tracks whether full-res segments have been computed for - // the current view range. - int skipFactor; - bool highResFinished; - - // Background high-res computation state. - // After the overview (skipFactor-strided) loads, missing segments are - // filled in at full resolution in the background while the user is idle. - int bgHighResSeg; // next segment index to compute at high-res - bool bgFinished; // true when all segments are computed at high-res - 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 - - // Scope view divider - float dividerY; // Y position of divider between spectrogram and scope (0-1 normalized) - bool isDividing; // True while user is dragging the divider - Vector2 dividerStartPos; // Mouse position when started dividing - float dividerStartY; // Spectro height when started dividing -} SpectrogramApp; - -// ============================================================================ -// Global State -// ============================================================================ - -static SpectrogramApp app = {0}; -static Sound AudioPlaybackSound = {0}; -static Texture2D colormapTexture = {0}; -static Font mainFont = {0}; // TTF font for crisp text at any scale - -// ============================================================================ -// Utility Functions -// ============================================================================ - -// Draw text with the loaded TTF font, scaled properly -static void DrawTextScaled(const char* text, float x, float y, float baseSize, Color color) -{ - if (mainFont.texture.id == 0) { - // Fallback to default if font not loaded - DrawText(text, (int)x, (int)y, (int)baseSize, color); - return; - } - float scaledSize = baseSize * GetUIScale(); - float spacing = scaledSize * 0.25f; // 25% of font size for spacing - DrawTextEx(mainFont, text, (Vector2){ x, y }, scaledSize, spacing, color); -} - -static float MeasureTextScaled(const char* text, float baseSize) -{ - if (mainFont.texture.id == 0) return MeasureText(text, (int)baseSize); - float scaledSize = baseSize * GetUIScale(); - float spacing = scaledSize * 0.25f; - return MeasureTextEx(mainFont, text, scaledSize, spacing).x; -} - -static float AmplitudeToDecibels(float amplitude) -{ - if (amplitude < 0.0001f) amplitude = 0.0001f; - return 20.0f * log10f(amplitude); -} - -static float Clamp(float value, float min, float max) -{ - if (value < min) return min; - if (value > max) return max; - return value; -} - -// ============================================================================ -// Colormap Functions -// ============================================================================ - -static Color GetColormapColor(float t, ColormapType type) -{ - t = Clamp(t, 0.0f, 1.0f); - - switch (type) { - case COLORMAP_GRAYS: { - unsigned char v = (unsigned char)(t * 255); - return (Color){ v, v, v, 255 }; - } - case COLORMAP_INFERNO: { - float r = 0.0f, g = 0.0f, b = 0.0f; - if (t < 0.25f) { t = t / 0.25f; r = 0.0f + t * 0.5f; g = 0.0f; b = 0.0f + t * 0.3f; } - else if (t < 0.5f) { t = (t - 0.25f) / 0.25f; r = 0.5f + t * 0.5f; g = 0.0f + t * 0.3f; b = 0.3f + t * 0.4f; } - else if (t < 0.75f) { t = (t - 0.5f) / 0.25f; r = 1.0f; g = 0.3f + t * 0.5f; b = 0.7f + t * 0.2f; } - else { t = (t - 0.75f) / 0.25f; r = 1.0f; g = 0.8f + t * 0.2f; b = 0.9f + t * 0.1f; } - return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 }; - } - case COLORMAP_VIRIDIS: { - float r, g, b; - if (t < 0.25f) { t = t / 0.25f; r = 0.27f + t * 0.13f; g = 0.00f + t * 0.33f; b = 0.33f + t * 0.27f; } - else if (t < 0.5f) { t = (t - 0.25f) / 0.25f; r = 0.40f + t * 0.16f; g = 0.33f + t * 0.29f; b = 0.60f - t * 0.20f; } - else if (t < 0.75f) { t = (t - 0.5f) / 0.25f; r = 0.56f + t * 0.24f; g = 0.62f + t * 0.23f; b = 0.40f - t * 0.20f; } - else { t = (t - 0.75f) / 0.25f; r = 0.80f + t * 0.17f; g = 0.85f + t * 0.12f; b = 0.20f - t * 0.15f; } - return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 }; - } - case COLORMAP_PLASMA: { - float r = 0.05f + t * 0.9f; - float g = 0.0f + t * 0.6f + (t > 0.5f ? (t - 0.5f) * 0.4f : 0.0f); - float b = 0.6f - t * 0.5f; - return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 }; - } - case COLORMAP_HOT: { - float r = Clamp(t * 3.0f, 0.0f, 1.0f); - float g = Clamp((t - 0.33f) * 3.0f, 0.0f, 1.0f); - float b = Clamp((t - 0.66f) * 3.0f, 0.0f, 1.0f); - return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 }; - } - case COLORMAP_COOL: { - return (Color){ (unsigned char)(t * 255), (unsigned char)((1.0f - t) * 255), 255, 255 }; - } - default: return GRAY; - } -} - -static void GenerateColormapTexture(void) -{ - if (colormapTexture.id != 0) UnloadTexture(colormapTexture); - Image img = GenImageColor(256, 1, WHITE); - Color* pixels = (Color*)img.data; - for (int i = 0; i < 256; i++) pixels[i] = GetColormapColor(i / 255.0f, app.colormap); - colormapTexture = LoadTextureFromImage(img); - UnloadImage(img); -} +SpectrogramApp app = {0}; +Sound AudioPlaybackSound = {0}; +Texture2D colormapTexture = {0}; +Font mainFont = {0}; // TTF font for crisp text at any scale // ============================================================================ // Interaction Detection @@ -341,1676 +53,6 @@ static bool IsUserInteracting(void) return false; } -// 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 -// ============================================================================ - -/** - * Compute high-res segments for one chunk of the signal. - * Processes from startSeg up to (but not including) endSeg, skipping - * any segment that already has spectrum data (either overview or high-res). - * Returns the next segment index to process next time (endSeg, or - * numSegments if we've reached the end). - */ -static 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; -} - -// ============================================================================ -// FFT Implementation -// ============================================================================ - -static void BitReverseCopy(float complex* input, float complex* output, int n) -{ - int bits = 0, temp = n; - while (temp > 1) { bits++; temp >>= 1; } - for (int i = 0; i < n; i++) { - int j = 0, k = i; - for (int b = 0; b < bits; b++) { j = (j << 1) | (k & 1); k >>= 1; } - output[j] = input[i]; - } -} - -static void FFT(float complex* input, float complex* output, int n, bool inverse) -{ - if (n <= 1) { output[0] = input[0]; return; } - BitReverseCopy(input, output, n); - for (int stage = 1; stage < n; stage *= 2) { - int step = stage * 2; - float angleStep = (inverse ? 2.0f : -2.0f) * (float)M_PI / step; - for (int k = 0; k < stage; k++) { - float complex twiddle = cexpf(I * angleStep * k); - for (int i = k; i < n; i += step) { - int j = i + stage; - float complex t = output[j] * twiddle; - output[j] = output[i] - t; - output[i] = output[i] + t; - } - } - } - if (inverse) for (int i = 0; i < n; i++) output[i] /= n; -} - -// ============================================================================ -// Hann Window -// ============================================================================ - -static void ApplyHannWindow(float* samples, int n) -{ - for (int i = 0; i < n; i++) { - float t = (float)i / (n - 1); - samples[i] *= 0.5f * (1.0f - cosf(2.0f * M_PI * t)); - } -} - -// ============================================================================ -// STFT Implementation -// ============================================================================ - -static 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; -} - -static 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; -} - -static 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. - */ -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 -// ============================================================================ - -// Compute an appropriate skip factor based on signal duration. -// Short signals: skipFactor=1 (full resolution, no waste). -// Long signals: higher skipFactor for fast overview. -static 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 -// high-resolution versions. Called when the user zooms in. -static bool ComputeSTFTHighResRange(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 this segment was 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)); - } - - 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 true; -} - -// ============================================================================ -// Audio Loading -// ============================================================================ - -// Convert audio file to WAV using ffmpeg (if available) -static bool ConvertToFFmpegWAV(const char* inputPath, char* outputPath, size_t outputSize) -{ - snprintf(outputPath, outputSize, "%s/rspektrum_temp_converted.wav", - Platform_GetTempDir()); - - /* Build argv array — no shell = no injection risk */ - const char* argv[] = { - "ffmpeg", "-y", "-loglevel", "quiet", - "-i", inputPath, - "-ar", "48000", "-ac", "1", "-f", "wav", - outputPath, NULL - }; - - PlatformSpawnHandle handle = { 0 }; - PlatformError rc = Platform_SpawnChild("ffmpeg", argv, &handle); - if (rc != PLATFORM_OK) { - TraceLog(LOG_WARNING, "Failed to spawn ffmpeg: %s", Platform_GetLastErrorMessage()); - return false; - } - - Platform_WaitForChild(&handle); - - int exit_code = 0; - SpawnStatus status = Platform_GetExitStatus(&handle, &exit_code); - if (status == SPAWN_EXITED && exit_code == 0 && FileExists(outputPath)) { - TraceLog(LOG_INFO, "FFmpeg conversion successful: %s", outputPath); - return true; - } - - TraceLog(LOG_WARNING, "FFmpeg conversion failed (exit code %d)", exit_code); - return false; -} - -static bool LoadWavFile(const char* filepath, AudioSignal* signal) -{ - const char* ext = GetFileExtension(filepath); - char convertedPath[512] = { 0 }; - bool isConverted = false; - - // Check if we need to convert via ffmpeg - bool isWav = ext && (strcmp(ext, ".wav") == 0 || strcmp(ext, ".WAV") == 0); - - if (!isWav) { - // Try ffmpeg conversion - if (ConvertToFFmpegWAV(filepath, convertedPath, sizeof(convertedPath))) { - filepath = convertedPath; - isConverted = true; - } else { - TraceLog(LOG_ERROR, "Unsupported format and ffmpeg not available: %s", filepath); - return false; - } - } - - Wave wave = LoadWave(filepath); - if (wave.data == NULL) { - TraceLog(LOG_ERROR, "Failed to open WAV file: %s", filepath); - if (isConverted) FileRemove(convertedPath); - return false; - } - - signal->sampleRate = wave.sampleRate; - signal->channels = wave.channels; - signal->numSamples = wave.frameCount * wave.channels; - signal->duration = (float)wave.frameCount / wave.sampleRate; - if (signal->samples) free(signal->samples); // free previous file's samples - signal->samples = (float*)malloc(signal->numSamples * sizeof(float)); - - if (wave.sampleSize == 16) { - short* samples = (short*)wave.data; - for (int i = 0; i < signal->numSamples; i++) signal->samples[i] = samples[i] / 32768.0f; - } else if (wave.sampleSize == 32) { - float* samples = (float*)wave.data; - memcpy(signal->samples, samples, signal->numSamples * sizeof(float)); - } else { - unsigned char* samples = (unsigned char*)wave.data; - for (int i = 0; i < signal->numSamples; i++) signal->samples[i] = (samples[i] - 128) / 128.0f; - } - - if (wave.channels > 1) { - int monoSamples = wave.frameCount; - for (int i = 0; i < monoSamples; i++) { - float sum = 0.0f; - for (int c = 0; c < wave.channels; c++) sum += signal->samples[i * wave.channels + c]; - signal->samples[i] = sum / wave.channels; - } - signal->numSamples = monoSamples; - } - UnloadWave(wave); - - // Clean up temp file if converted - if (isConverted) { - FileRemove(convertedPath); - TraceLog(LOG_INFO, "Cleaned up temp file: %s", convertedPath); - } - - TraceLog(LOG_INFO, "Loaded WAV: %d Hz, %.2f sec, %d samples", signal->sampleRate, signal->duration, signal->numSamples); - return true; -} - -static void FreeSignal(AudioSignal* signal) -{ - if (signal->samples) { free(signal->samples); signal->samples = NULL; } - signal->numSamples = 0; signal->sampleRate = 0; signal->duration = 0.0f; -} - -// ============================================================================ -// Spectrogram Generation -// ============================================================================ - -static void GenerateSpectrogramTexture(StftResult* stft, Image* image, Texture2D* texture) -{ - if (stft->numSegments == 0) return; - int width = stft->numSegments; - int height = stft->segments[0].numBins; - int fftSize = (height - 1) * 2; - float freqPerBin = (float)stft->sampleRate / fftSize; - - UnloadImage(*image); // release previous image (NULL-safe on first call) - *image = GenImageColor(width, height, BLACK); - Color* pixels = (Color*)image->data; - - // Find max amplitude for normalization (skip NULL segments) - float maxAmplitude = 0.0001f; - for (int seg = 0; seg < stft->numSegments; seg++) { - if (stft->segments[seg].spectrum == NULL) continue; - for (int bin = 0; bin < stft->segments[seg].numBins; bin++) - if (stft->segments[seg].spectrum[bin].amplitude > maxAmplitude) - maxAmplitude = stft->segments[seg].spectrum[bin].amplitude; - } - - // ===== SYNCHROSQUEEZING ===== - // Reassign energy to true frequencies using derivative STFT - - // Accumulation buffer for reassigned energy - float* accumBuffer = (float*)calloc(width * height, sizeof(float)); - - // Noise threshold: only reassign bins with significant energy - float noiseThreshold = maxAmplitude * 0.01f; // 1% of max amplitude - - for (int seg = 0; seg < width; seg++) { - // Skip segments that haven't been computed yet (overview/high-res transition) - if (stft->segments[seg].spectrum == NULL) continue; - - for (int bin = 0; bin < height; bin++) { - FrequencyData* V_f = &stft->segments[seg].spectrum[bin]; - FrequencyData* V_fd = &stft->segments[seg].derivativeSpectrum[bin]; - - float amplitude = V_f->amplitude; - - // Skip noise bins - if (amplitude < noiseThreshold) continue; - - // Compute instantaneous frequency using synchrosqueezing formula: - // ω̂ = bin_freq + Re[V_fd / (i * V_f)] - // Complex division: (a+bi)/(c+di) = ((ac+bd) + (bc-ad)i) / (c²+d²) - // We need Re[(a+bi) / (i*(c+di))] = Re[(a+bi) / (-d+ci)] = (ad+bc)/(c²+d²) - - float V_f_real = amplitude * cosf(V_f->phase); - float V_f_imag = amplitude * sinf(V_f->phase); - float V_fd_real = V_fd->amplitude * cosf(V_fd->phase); - float V_fd_imag = V_fd->amplitude * sinf(V_fd->phase); - - float denom = V_f_real * V_f_real + V_f_imag * V_f_imag; - - float trueFreq = V_f->frequency; // Default to bin frequency - - if (denom > 1e-10f) { - // Re[V_fd / (i * V_f)] = (-V_fd_real * V_f_imag + V_fd_imag * V_f_real) / denom - // Note the MINUS sign on the first term - float correction = (-V_fd_real * V_f_imag + V_fd_imag * V_f_real) / denom; - trueFreq = V_f->frequency + correction; - } - - // Clamp to valid range - if (trueFreq < 0) trueFreq = 0; - if (trueFreq >= stft->sampleRate / 2.0f) trueFreq = stft->sampleRate / 2.0f - 1; - - // Map to bin coordinate - float targetBinF = trueFreq / freqPerBin; - if (targetBinF < 0) targetBinF = 0; - if (targetBinF >= height) targetBinF = height - 0.001f; - - // Bilinear splatting to neighboring bins - int bin0 = (int)targetBinF; - int bin1 = bin0 + 1; - if (bin1 >= height) bin1 = height - 1; - - float frac = targetBinF - bin0; - int idx0 = (height - 1 - bin0) * width + seg; - int idx1 = (height - 1 - bin1) * width + seg; - - accumBuffer[idx0] += amplitude * (1 - frac); - accumBuffer[idx1] += amplitude * frac; - } - } - - // Convert accumulation buffer to colors - for (int i = 0; i < width * height; i++) { - if (accumBuffer[i] > 0.0001f) { - float db = AmplitudeToDecibels(accumBuffer[i]); - float normalized = (db - app.amplitudeFloorDb) / (app.amplitudeCeilingDb - app.amplitudeFloorDb); - normalized = Clamp(normalized, 0.0f, 1.0f); - pixels[i] = GetColormapColor(normalized, app.colormap); - } - } - - free(accumBuffer); - - if (texture->id != 0) UnloadTexture(*texture); - *texture = LoadTextureFromImage(*image); - SetTextureFilter(*texture, TEXTURE_FILTER_BILINEAR); -} - -// Compute auto-adjusted amplitude floor/ceiling from STFT data -static 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; -} - -// ============================================================================ -// Audio Playback with FFT-based Bandpass Filter -// ============================================================================ - -static void ApplyBandpassFilterFFT(float* samples, int numSamples, int sampleRate, float freqLow, float freqHigh) -{ - if (freqLow <= 0 && freqHigh >= sampleRate / 2.0f) return; - - int fftSize = 1; - while (fftSize < numSamples) fftSize *= 2; - fftSize *= 2; - - float* paddedSamples = (float*)calloc(fftSize, sizeof(float)); - float complex *fftInput = (float complex*)malloc(fftSize * sizeof(float complex)); - float complex *fftOutput = (float complex*)malloc(fftSize * sizeof(float complex)); - - // Copy samples directly without windowing (windowing causes fade in/out) - for (int i = 0; i < numSamples; i++) { - paddedSamples[i] = samples[i]; - } - for (int i = 0; i < fftSize; i++) fftInput[i] = paddedSamples[i] + 0.0f * I; - - FFT(fftInput, fftOutput, fftSize, false); - - float freqPerBin = (float)sampleRate / fftSize; - for (int bin = 0; bin < fftSize / 2 + 1; bin++) { - float frequency = bin * freqPerBin; - float attenuation = 1.0f; - if (frequency < freqLow) { - float dist = (freqLow - frequency) / (freqPerBin * 10.0f); - attenuation = 1.0f / (1.0f + dist * dist * dist); - } else if (frequency > freqHigh) { - float dist = (frequency - freqHigh) / (freqPerBin * 10.0f); - attenuation = 1.0f / (1.0f + dist * dist * dist); - } - fftOutput[bin] *= attenuation; - if (bin > 0 && bin < fftSize / 2) fftOutput[fftSize - bin] *= attenuation; - } - - float complex* ifftOutput = (float complex*)malloc(fftSize * sizeof(float complex)); - FFT(fftOutput, ifftOutput, fftSize, true); - - float filteredPeak = 0.0f; - for (int i = 0; i < numSamples; i++) { - samples[i] = crealf(ifftOutput[i]); - if (fabsf(samples[i]) > filteredPeak) filteredPeak = fabsf(samples[i]); - } - - const float TARGET_PEAK = 0.9f; - if (filteredPeak > 0.0001f) { - float gain = TARGET_PEAK / filteredPeak; - if (gain > 10.0f) gain = 10.0f; - for (int i = 0; i < numSamples; i++) { - samples[i] *= gain; - if (samples[i] > 0.95f) samples[i] = 0.95f; - if (samples[i] < -0.95f) samples[i] = -0.95f; - } - } - - free(paddedSamples); free(fftInput); free(fftOutput); free(ifftOutput); -} - -static void ApplyBandpassFilter(float* samples, int numSamples, int sampleRate, float freqLow, float freqHigh) -{ - ApplyBandpassFilterFFT(samples, numSamples, sampleRate, freqLow, freqHigh); -} - -static void PlaySelectedRegion(void) -{ - if (!app.loaded || !app.stftComputed) return; - - int startSample = (int)(app.timeSelectionStart * app.signal.numSamples); - int endSample = (int)(app.timeSelectionEnd * app.signal.numSamples); - int numSamples = endSample - startSample; - if (numSamples <= 0 || startSample < 0 || endSample > app.signal.numSamples) return; - - float* regionSamples = (float*)malloc(numSamples * sizeof(float)); - memcpy(regionSamples, app.signal.samples + startSample, numSamples * sizeof(float)); - - float maxFreq = (float)app.signal.sampleRate / 2.0f; - float freqLow = app.freqSelectionStart * maxFreq; - float freqHigh = app.freqSelectionEnd * maxFreq; - if (freqLow > 10.0f || freqHigh < maxFreq - 10.0f) { - TraceLog(LOG_INFO, "Applying bandpass filter: %.0f - %.0f Hz", freqLow, freqHigh); - ApplyBandpassFilter(regionSamples, numSamples, app.signal.sampleRate, freqLow, freqHigh); - } - - if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound); - - Wave wave = { .data = regionSamples, .frameCount = (unsigned int)numSamples, - .sampleRate = (unsigned int)app.signal.sampleRate, .sampleSize = 32, .channels = 1 }; - AudioPlaybackSound = LoadSoundFromWave(wave); - PlaySound(AudioPlaybackSound); - TraceLog(LOG_INFO, "Playing: %.2f-%.2f sec, %.0f-%.0f Hz", - (float)startSample / app.signal.sampleRate, (float)endSample / app.signal.sampleRate, freqLow, freqHigh); -} - -// ============================================================================ -// File Browser -// ============================================================================ - -static void FreeBrowserFiles(void) -{ - if (app.browserFiles) { - for (int i = 0; i < app.browserFileCount; i++) free(app.browserFiles[i]); - free(app.browserFiles); - free(app.browserIsDir); - app.browserFiles = NULL; - app.browserIsDir = NULL; - } - app.browserFileCount = 0; -} - -static void ScanDirectory(const char* path) -{ - FreeBrowserFiles(); - strncpy(app.browserPath, path, sizeof(app.browserPath) - 1); - FilePathList files = LoadDirectoryFiles(path); - - int dirCount = 0, wavCount = 0; - for (int i = 0; i < files.count; i++) { - const char* name = GetFileName(files.paths[i]); - if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; - if (DirectoryExists(files.paths[i])) dirCount++; - else { - const char* ext = GetFileExtension(files.paths[i]); - if (ext && (strcmp(ext, ".wav") == 0 || strcmp(ext, ".WAV") == 0 || - strcmp(ext, ".Wave") == 0 || strcmp(ext, ".Wav") == 0)) wavCount++; - } - } - - int totalCount = dirCount + wavCount; - if (totalCount > 0) { - app.browserFiles = (char**)malloc(totalCount * sizeof(char*)); - app.browserIsDir = (bool*)malloc(totalCount * sizeof(bool)); - app.browserFileCount = 0; - - for (int i = 0; i < files.count; i++) { - const char* name = GetFileName(files.paths[i]); - if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; - if (DirectoryExists(files.paths[i])) { - size_t len = strlen(name) + 1; - app.browserFiles[app.browserFileCount] = (char*)malloc(len); - if (app.browserFiles[app.browserFileCount]) { - memcpy(app.browserFiles[app.browserFileCount], name, len); - app.browserIsDir[app.browserFileCount] = true; - app.browserFileCount++; - } - } - } - for (int i = 0; i < files.count; i++) { - const char* name = GetFileName(files.paths[i]); - if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; - if (!DirectoryExists(files.paths[i])) { - const char* ext = GetFileExtension(files.paths[i]); - if (ext && (strcmp(ext, ".wav") == 0 || strcmp(ext, ".WAV") == 0 || - strcmp(ext, ".Wave") == 0 || strcmp(ext, ".Wav") == 0)) { - size_t len = strlen(name) + 1; - app.browserFiles[app.browserFileCount] = (char*)malloc(len); - if (app.browserFiles[app.browserFileCount]) { - memcpy(app.browserFiles[app.browserFileCount], name, len); - app.browserIsDir[app.browserFileCount] = false; - app.browserFileCount++; - } - } - } - } - } - UnloadDirectoryFiles(files); - app.browserScroll = 0; - app.browserSelected = -1; -} - -static void NavigateToParentDirectory(void) -{ - const char* parent = GetPrevDirectoryPath(app.browserPath); - if (parent && strlen(parent) > 0) ScanDirectory(parent); -} - -static void NavigateToDirectory(const char* dirName) -{ - char newPath[512]; - int written = snprintf(newPath, sizeof(newPath), "%s/%s", app.browserPath, dirName); - if (written < 0 || written >= (int)sizeof(newPath)) return; // Path too long - if (DirectoryExists(newPath)) ScanDirectory(newPath); -} - -static void LoadSelectedFile(void) -{ - if (app.browserSelected < 0 || app.browserSelected >= app.browserFileCount) return; - - char filePath[512]; - int written = snprintf(filePath, sizeof(filePath), "%s/%s", app.browserPath, app.browserFiles[app.browserSelected]); - if (written < 0 || written >= (int)sizeof(filePath)) return; // Path too long - - if (app.browserIsDir[app.browserSelected]) { - NavigateToDirectory(app.browserFiles[app.browserSelected]); - } else if (FileExists(filePath) && LoadWavFile(filePath, &app.signal)) { - app.loaded = true; - app.stftComputed = false; - app.loadingPhase = 0; - app.loadingProgress = 0.0f; - app.currentSTFTSegment = 0; - app.skipFactor = 1; - app.highResFinished = false; - 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; - app.freqSelectionEnd = 1.0f; - app.showFileBrowser = false; - ComputeSTFTInit(&app.signal, &app.stft, app.fftSize); - // Invalidate visible texture cache - if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture); - app.visibleTexture = (Texture2D){ 0 }; - app.visibleTextureValid = false; - TraceLog(LOG_INFO, "Loaded: %s", filePath); - } -} - -static void DrawFileBrowser(void) -{ - // Draw semi-transparent overlay first - DrawRectangle(0, 0, GetScreenWidth(), GetScreenHeight(), Fade(BLACK, 0.85f)); - - float scale = GetUIScale(); - float bw = 900.0f * scale, bh = 700.0f * scale; - float bx = (GetScreenWidth() - bw) / 2, by = (GetScreenHeight() - bh) / 2; - - DrawRectangle(bx, by, bw, bh, (Color){ 45, 45, 55, 255 }); - DrawRectangleLinesEx((Rectangle){ bx, by, bw, bh }, (int)(2 * scale), GRAY); - DrawRectangle(bx, by, bw, (int)(40 * scale), (Color){ 60, 60, 75, 255 }); - DrawTextScaled("File Browser - Select WAV File", bx + (int)(15 * scale), by + (int)(8 * scale), (int)(20 * scale), WHITE); - - // Path bar - float pathBarY = by + (int)(46 * scale); - DrawRectangle(bx + (int)(15 * scale), pathBarY, bw - (int)(110 * scale), (int)(30 * scale), (Color){ 30, 30, 40, 255 }); - DrawRectangleLinesEx((Rectangle){ bx + (int)(15 * scale), pathBarY, bw - (int)(110 * scale), (int)(30 * scale) }, (int)(1 * scale), GRAY); - - char displayPath[300]; - strncpy(displayPath, app.browserPath, sizeof(displayPath) - 1); - displayPath[sizeof(displayPath) - 1] = '\0'; - if (strlen(displayPath) > 60) sprintf(displayPath, "...%s", app.browserPath + strlen(app.browserPath) - 57); - DrawTextScaled(displayPath, bx + (int)(22 * scale), pathBarY + (int)(5 * scale), (int)(14 * scale), LIGHTGRAY); - - // Up button - Rectangle upBtn = { bx + bw - (int)(90 * scale), pathBarY, (int)(75 * scale), (int)(30 * scale) }; - if (CheckCollisionPointRec(GetMousePosition(), upBtn)) DrawRectangleRec(upBtn, (Color){ 80, 80, 90, 255 }); - DrawTextScaled("UP (..)", upBtn.x + (int)(10 * scale), upBtn.y + (int)(7 * scale), (int)(14 * scale), WHITE); - - // File list - float lx = bx + (int)(15 * scale), ly = pathBarY + (int)(40 * scale); - float lw = bw - (int)(30 * scale), lh = bh - (int)(195 * scale); - DrawRectangle(lx, ly, lw, lh, (Color){ 25, 25, 35, 255 }); - DrawRectangleLinesEx((Rectangle){ lx, ly, lw, lh }, (int)(1 * scale), GRAY); - - // Line height: base 36px scaled (enough for icon + filename without overlap) - float lineH = 36 * scale; - - // Handle empty directory - int visibleItems = (int)(lh / lineH); - if (visibleItems < 1) visibleItems = 1; - - if (app.browserFileCount <= 0 || !app.browserFiles) { - DrawTextScaled("(No WAV files in directory)", lx + (int)(20 * scale), ly + (int)(lh / 2 - 12 * scale), (int)(14 * scale), GRAY); - } else { - if (app.browserFileCount > visibleItems) { - float sh = (float)visibleItems / app.browserFileCount * lh; - if (sh < (int)(10 * scale)) sh = (int)(10 * scale); - float sy = ly + (float)app.browserScroll / (app.browserFileCount - visibleItems) * (lh - sh); - DrawRectangle(lx + lw - (int)(10 * scale), sy, (int)(8 * scale), sh, GRAY); - } - - int startItem = app.browserScroll; - int endItem = startItem + visibleItems + 1; - if (endItem > app.browserFileCount) endItem = app.browserFileCount; - - float iconW = (int)(45 * scale); // space for icon column - for (int i = startItem; i < endItem; i++) { - if (i < 0 || i >= app.browserFileCount || !app.browserFiles[i] || !app.browserIsDir) continue; - - float iy = ly + (i - startItem) * lineH + (int)(2 * scale); - bool hovered = CheckCollisionPointRec((Vector2){ GetMouseX(), GetMouseY() }, (Rectangle){ lx + (int)(2 * scale), iy, lw - (int)(14 * scale), lineH - (int)(4 * scale) }); - - if (i == app.browserSelected) DrawRectangle(lx + (int)(2 * scale), iy, lw - (int)(14 * scale), (int)((lineH - 4) * scale), (Color){ 50, 70, 120, 180 }); - else if (hovered) DrawRectangle(lx + (int)(2 * scale), iy, lw - (int)(14 * scale), (int)((lineH - 4) * scale), (Color){ 60, 60, 80, 100 }); - - const char* icon = app.browserIsDir[i] ? "[DIR]" : "[WAV]"; - Color iconCol = app.browserIsDir[i] ? (Color){ 255, 220, 80, 255 } : (Color){ 80, 200, 120, 255 }; - DrawTextScaled(icon, lx + (int)(8 * scale), iy + (int)(4 * scale), (int)(13 * scale), iconCol); - DrawTextScaled(app.browserFiles[i], lx + iconW + (int)(10 * scale), iy + (int)(4 * scale), (int)(14 * scale), WHITE); - } - } - - // Scroll with mouse wheel - if (CheckCollisionPointRec(GetMousePosition(), (Rectangle){ lx, ly, lw - (int)(10 * scale), lh }) && app.browserFileCount > 0) { - int wheel = GetMouseWheelMove(); - if (wheel > 0) app.browserScroll--; - if (wheel < 0) app.browserScroll++; - if (app.browserScroll < 0) app.browserScroll = 0; - int maxScroll = app.browserFileCount - visibleItems; - if (maxScroll < 0) maxScroll = 0; - if (app.browserScroll > maxScroll) app.browserScroll = maxScroll; - } - - // Handle clicks - if (CheckCollisionPointRec(GetMousePosition(), upBtn) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) NavigateToParentDirectory(); - - if (CheckCollisionPointRec(GetMousePosition(), (Rectangle){ lx, ly, lw - (int)(10 * scale), lh }) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && app.browserFileCount > 0) { - int clicked = app.browserScroll + (int)((GetMouseY() - ly) / lineH); - if (clicked >= 0 && clicked < app.browserFileCount) { - app.browserSelected = clicked; - static double lastClick = 0; - if (GetTime() - lastClick < 0.3) LoadSelectedFile(); - lastClick = GetTime(); - } - } - - // Buttons - float btnY = by + bh - (int)(55 * scale); - Rectangle openBtn = { bx + bw - (int)(170 * scale), btnY, (int)(150 * scale), (int)(40 * scale) }; - Rectangle cancelBtn = { bx + (int)(15 * scale), btnY, (int)(120 * scale), (int)(40 * scale) }; - - bool openHovered = CheckCollisionPointRec(GetMousePosition(), openBtn); - bool openClicked = openHovered && IsMouseButtonPressed(MOUSE_LEFT_BUTTON); - if (openHovered) DrawRectangleRec(openBtn, (Color){ 100, 100, 120, 255 }); - else DrawRectangleRec(openBtn, (Color){ 80, 80, 90, 255 }); - DrawRectangleLinesEx(openBtn, (int)(1 * scale), WHITE); - DrawTextScaled("OPEN (Enter)", openBtn.x + (int)(25 * scale), openBtn.y + (int)(12 * scale), (int)(16 * scale), WHITE); - - DrawRectangleRec(cancelBtn, (Color){ 100, 40, 40, 255 }); - DrawTextScaled("ESC Cancel", cancelBtn.x + (int)(18 * scale), cancelBtn.y + (int)(12 * scale), (int)(16 * scale), WHITE); - - if ((IsKeyPressed(KEY_ENTER) || openClicked) && app.browserSelected >= 0 && app.browserFileCount > 0) LoadSelectedFile(); - if (IsKeyPressed(KEY_ESCAPE)) app.showFileBrowser = false; - if (IsKeyPressed(KEY_UP) && app.browserSelected > 0 && app.browserFileCount > 0) { - app.browserSelected--; - if (app.browserSelected < app.browserScroll) app.browserScroll = app.browserSelected; - } - if (IsKeyPressed(KEY_DOWN) && app.browserSelected < app.browserFileCount - 1 && app.browserFileCount > 0) { - app.browserSelected++; - if (app.browserSelected >= app.browserScroll + visibleItems) app.browserScroll = app.browserSelected - visibleItems + 1; - } -} - -// ============================================================================ -// UI and Rendering -// ============================================================================ - -static void DrawSpectrogramGrid(Rectangle bounds, int numCellsX, int numCellsY, Color color) -{ - float cellWidth = bounds.width / numCellsX, cellHeight = bounds.height / numCellsY; - for (int i = 0; i <= numCellsX; i++) { - float x = bounds.x + i * cellWidth; - DrawLineV((Vector2){ x, bounds.y }, (Vector2){ x, bounds.y + bounds.height }, color); - } - for (int i = 0; i <= numCellsY; i++) { - float y = bounds.y + bounds.height - i * cellHeight; - DrawLineV((Vector2){ bounds.x, y }, (Vector2){ bounds.x + bounds.width, y }, color); - } -} - -static void DrawLabels(Rectangle bounds) -{ - int baseFontSize = 12; - Color textColor = LIGHTGRAY; - - // Time labels - for (int i = 0; i <= 10; i++) { - float t = (float)i / 10; - float timeSec = (app.viewStart + t * (app.viewEnd - app.viewStart)) * app.signal.duration; - float x = bounds.x + t * bounds.width; - char label[32]; - if (timeSec >= 60) sprintf(label, "%d:%02d", (int)(timeSec / 60), (int)(timeSec) % 60); - else sprintf(label, "%.1fs", timeSec); - DrawTextScaled(label, x, bounds.y + bounds.height + 5, baseFontSize, textColor); - } - - // Frequency labels adapted to current zoom level - float maxFreq = (float)app.signal.sampleRate / 2.0f; - float freqMin = app.freqViewStart * maxFreq; - float freqMax = app.freqViewEnd * maxFreq; - - // Choose tick spacing based on zoom range - float freqRange = freqMax - freqMin; - int tickSpacing; - if (freqRange < 20) tickSpacing = 5; - else if (freqRange < 50) tickSpacing = 10; - else if (freqRange < 200) tickSpacing = 50; - else if (freqRange < 1000) tickSpacing = 100; - else if (freqRange < 5000) tickSpacing = 200; - else if (freqRange < 20000) tickSpacing = 1000; - else if (freqRange < 50000) tickSpacing = 5000; - else tickSpacing = 10000; - - // Labels use next coarser spacing so they stay readable - int labelSpacing = tickSpacing; - if (labelSpacing <= 10) labelSpacing = 10; - else if (labelSpacing <= 50) labelSpacing = 50; - else if (labelSpacing <= 200) labelSpacing = 200; - else if (labelSpacing <= 1000) labelSpacing = 1000; - else if (labelSpacing <= 5000) labelSpacing = 5000; - else labelSpacing = 10000; - - // Round freqMin up to nearest tick spacing (smallest multiple >= freqMin) - int firstTick = ((int)(freqMin / tickSpacing)) * tickSpacing; - if (firstTick < freqMin) firstTick += tickSpacing; - for (int hz = firstTick; hz <= freqMax; hz += tickSpacing) { - float t = (hz - freqMin) / freqRange; - float y = bounds.y + bounds.height - t * bounds.height; - Color tickColor = (hz % 1000 == 0) ? GRAY : Fade(GRAY, 0.4f); - DrawLineV((Vector2){ bounds.x - 5, y }, (Vector2){ bounds.x, y }, tickColor); - } - - // Draw labels at the coarser spacing - for (int hz = firstTick; hz <= freqMax; hz += labelSpacing) { - float t = (hz - freqMin) / freqRange; - float y = bounds.y + bounds.height - t * bounds.height; - char label[32]; - if (hz < 10000) sprintf(label, "%.0fHz", (float)hz); - else sprintf(label, "%.0fkHz", (float)hz / 1000.0f); - DrawTextScaled(label, bounds.x - 70, y - 5, baseFontSize, textColor); - } -} - -static void DrawSlider(Rectangle bounds, float value); -static bool UpdateSlider(Rectangle bounds, float* value); -static void DrawSidebar(void); - -static void DrawSelection(Rectangle bounds) -{ - // Only draw if selection is not full range AND not currently dragging - bool hasSelection = (app.timeSelectionStart > 0.001f || app.timeSelectionEnd < 0.999f || - app.freqSelectionStart > 0.001f || app.freqSelectionEnd < 0.999f); - if (!hasSelection || app.isTimeSelecting) return; // Don't draw overlay while dragging - - Color overlayColor = Fade(BLACK, 0.25f); // Lighter overlay - - // Convert signal coordinates to viewport coordinates - float viewWidth = app.viewEnd - app.viewStart; - float freqWidth = app.freqViewEnd - app.freqViewStart; - - float selStartX = bounds.x + ((app.timeSelectionStart - app.viewStart) / viewWidth) * bounds.width; - float selEndX = bounds.x + ((app.timeSelectionEnd - app.viewStart) / viewWidth) * bounds.width; - float selStartY = bounds.y + bounds.height - ((app.freqSelectionEnd - app.freqViewStart) / freqWidth) * bounds.height; - float selEndY = bounds.y + bounds.height - ((app.freqSelectionStart - app.freqViewStart) / freqWidth) * bounds.height; - - // Clamp to viewport bounds - selStartX = fmaxf(bounds.x, fminf(bounds.x + bounds.width, selStartX)); - selEndX = fmaxf(bounds.x, fminf(bounds.x + bounds.width, selEndX)); - selStartY = fmaxf(bounds.y, fminf(bounds.y + bounds.height, selStartY)); - selEndY = fmaxf(bounds.y, fminf(bounds.y + bounds.height, selEndY)); - - // Draw overlay outside the selection box - DrawRectangle(bounds.x, bounds.y, selStartX - bounds.x, bounds.height, overlayColor); - DrawRectangle(selEndX, bounds.y, bounds.x + bounds.width - selEndX, bounds.height, overlayColor); - DrawRectangle(selStartX, bounds.y, selEndX - selStartX, selStartY - bounds.y, overlayColor); - DrawRectangle(selStartX, selEndY, selEndX - selStartX, bounds.y + bounds.height - selEndY, overlayColor); - - // Draw selection box border - DrawRectangleLinesEx((Rectangle){ selStartX, selStartY, selEndX - selStartX, selEndY - selStartY }, 2, YELLOW); - - // Display selection stats inside viewport, clamped to fit - { - int startSample = (int)(app.timeSelectionStart * app.signal.numSamples); - int endSample = (int)(app.timeSelectionEnd * app.signal.numSamples); - SignalStats stats = ComputeSignalStats(&app.signal, startSample, endSample); - - if (stats.durationSec > 0.0f && app.signal.samples != NULL) { - char lines[5][128]; - int lineCount = 0; - int fontSize = 10; - int maxTextW = 0; - - sprintf(lines[lineCount++], "Duration: %.3fs", stats.durationSec); - sprintf(lines[lineCount++], "Energy: %.2f", stats.energy); - sprintf(lines[lineCount++], "Peak: %.3f", stats.peakAmplitude); - sprintf(lines[lineCount++], "RMS: %.3f", stats.rmsAmplitude); - sprintf(lines[lineCount++], "PAPR: %.1f dB", stats.paprDb); - - // Measure text width - for (int i = 0; i < lineCount; i++) { - int textW = MeasureText(lines[i], fontSize); - if (textW > maxTextW) maxTextW = textW; - } - - int boxW = maxTextW + 20; - int boxH = lineCount * 14 + 12; - - // Center vertically on the selection box, clamp to viewport - float selCenterY = (selStartY + selEndY) / 2.0f; - float boxY = selCenterY - boxH / 2.0f; - if (boxY < bounds.y) boxY = bounds.y; - if (boxY + boxH > bounds.y + bounds.height) boxY = bounds.y + bounds.height - boxH; - - // Place to the right of the selection, or left if not enough room - float boxX = selEndX + 10; - if (boxX + boxW > bounds.x + bounds.width) { - boxX = selStartX - boxW - 10; - if (boxX < bounds.x) boxX = bounds.x; - } - - // Clamp to viewport bounds - if (boxX + boxW > bounds.x + bounds.width) { - // Not enough room on right — draw to the left of selection - if (selStartX > boxW + 20) { - boxX = selStartX - boxW - 10; - } else { - boxX = bounds.x; - } - } - if (boxY + boxH > bounds.y + bounds.height) { - boxY = bounds.y + bounds.height - boxH; - } - - // Draw background box - DrawRectangle((int)boxX, (int)boxY, boxW, boxH, (Color){ 0, 0, 0, 200 }); - DrawRectangleLines((int)boxX, (int)boxY, boxW, boxH, Fade(YELLOW, 0.6f)); - - // Draw text - for (int i = 0; i < lineCount; i++) { - DrawText(lines[i], (int)boxX + 10, (int)boxY + 8 + i * 14, fontSize, LIGHTGRAY); - } - } - } -} - -static void DrawSelectionDrag(Rectangle bounds) -{ - // Draw bounding box while dragging (no overlay) - if ((!app.isTimeSelecting && !app.isFreqSelecting && !app.isDraggingSelection) || - (app.isDraggingSelection && !IsMouseButtonDown(MOUSE_LEFT_BUTTON))) return; - - // Convert signal coordinates to viewport coordinates - float viewWidth = app.viewEnd - app.viewStart; - float freqWidth = app.freqViewEnd - app.freqViewStart; - - float selStartX = bounds.x + ((app.timeSelectionStart - app.viewStart) / viewWidth) * bounds.width; - float selEndX = bounds.x + ((app.timeSelectionEnd - app.viewStart) / viewWidth) * bounds.width; - float selStartY = bounds.y + bounds.height - ((app.freqSelectionEnd - app.freqViewStart) / freqWidth) * bounds.height; - float selEndY = bounds.y + bounds.height - ((app.freqSelectionStart - app.freqViewStart) / freqWidth) * bounds.height; - - // Clamp to viewport bounds - selStartX = fmaxf(bounds.x, fminf(bounds.x + bounds.width, selStartX)); - selEndX = fmaxf(bounds.x, fminf(bounds.x + bounds.width, selEndX)); - selStartY = fmaxf(bounds.y, fminf(bounds.y + bounds.height, selStartY)); - selEndY = fmaxf(bounds.y, fminf(bounds.y + bounds.height, selEndY)); - - // Normalize coordinates for drawing - float x = selStartX < selEndX ? selStartX : selEndX; - float w = fabsf(selEndX - selStartX); - float y = selStartY < selEndY ? selStartY : selEndY; - float h = fabsf(selEndY - selStartY); - - DrawRectangleLinesEx((Rectangle){ x, y, w, h }, 2, YELLOW); - - // Display live stats while dragging (inside viewport, clamped to fit) - { - int startSample = (int)(app.timeSelectionStart * app.signal.numSamples); - int endSample = (int)(app.timeSelectionEnd * app.signal.numSamples); - SignalStats stats = ComputeSignalStats(&app.signal, startSample, endSample); - - if (stats.durationSec > 0.0f && app.signal.samples != NULL) { - char lines[5][128]; - int lineCount = 0; - int fontSize = 10; - int maxTextW = 0; - - sprintf(lines[lineCount++], "Duration: %.3fs", stats.durationSec); - sprintf(lines[lineCount++], "Energy: %.2f", stats.energy); - sprintf(lines[lineCount++], "Peak: %.3f", stats.peakAmplitude); - sprintf(lines[lineCount++], "RMS: %.3f", stats.rmsAmplitude); - sprintf(lines[lineCount++], "PAPR: %.1f dB", stats.paprDb); - - // Measure text width - for (int i = 0; i < lineCount; i++) { - int textW = MeasureText(lines[i], fontSize); - if (textW > maxTextW) maxTextW = textW; - } - - int boxW = maxTextW + 20; - int boxH = lineCount * 14 + 12; - - // Center vertically on the selection box, clamp to viewport - float selCenterY = (selStartY + selEndY) / 2.0f; - float boxY = selCenterY - boxH / 2.0f; - if (boxY < bounds.y) boxY = bounds.y; - if (boxY + boxH > bounds.y + bounds.height) boxY = bounds.y + bounds.height - boxH; - - // Place to the right of the selection, or left if not enough room - float boxX = selEndX + 10; - if (boxX + boxW > bounds.x + bounds.width) { - boxX = selStartX - boxW - 10; - if (boxX < bounds.x) boxX = bounds.x; - } - - // Clamp to viewport bounds - if (boxX + boxW > bounds.x + bounds.width) { - // Not enough room on right — draw to the left of selection - if (x > boxW + 20) { - boxX = x - boxW - 10; - } else { - boxX = bounds.x; - } - } - if (boxY + boxH > bounds.y + bounds.height) { - boxY = bounds.y + bounds.height - boxH; - } - - // Draw background box - DrawRectangle((int)boxX, (int)boxY, boxW, boxH, (Color){ 0, 0, 0, 200 }); - DrawRectangleLines((int)boxX, (int)boxY, boxW, boxH, Fade(YELLOW, 0.6f)); - - // Draw text - for (int i = 0; i < lineCount; i++) { - DrawText(lines[i], (int)boxX + 10, (int)boxY + 8 + i * 14, fontSize, LIGHTGRAY); - } - } - } -} - -// ============================================================================ -// Playhead -// ============================================================================ - -static void DrawPlayhead(Rectangle bounds) -{ - if (!app.isPlaying || app.playheadT < 0.0f || app.playheadT > 1.0f) return; - - float timePos = app.timeSelectionStart + app.playheadT * (app.timeSelectionEnd - app.timeSelectionStart); - float viewWidth = app.viewEnd - app.viewStart; - float t = (timePos - app.viewStart) / viewWidth; - float x = bounds.x + t * bounds.width; - - // Clamp to bounds - x = fmaxf(bounds.x, fminf(bounds.x + bounds.width, x)); - - // Draw vertical line - DrawLine(x, bounds.y, x, bounds.y + bounds.height, RED); - // Draw semi-transparent overlay to make it stand out - DrawRectangle(x - 2, bounds.y, 4, bounds.height, (Color){ 255, 0, 0, 60 }); -} - -// ============================================================================ -// PNG Export -// ============================================================================ - -static void ExportPNG(const SpectrogramApp* spa, const char* dirPath) -{ - if (!spa->stftComputed || !spa->spectrogramImage.data) return; - - int imgW = spa->spectrogramImage.width; - int imgH = spa->spectrogramImage.height; - - // Selection region in image-pixel coordinates - int selX0 = (int)(spa->timeSelectionStart * imgW); - int selX1 = (int)(spa->timeSelectionEnd * imgW); - int selY0 = (int)((1.0f - spa->freqSelectionEnd) * imgH); - int selY1 = (int)((1.0f - spa->freqSelectionStart) * imgH); - - // Clamp to image bounds - selX0 = Clamp(selX0, 0, imgW); - selX1 = Clamp(selX1, 0, imgW); - selY0 = Clamp(selY0, 0, imgH); - selY1 = Clamp(selY1, 0, imgH); - - int regionW = selX1 - selX0; - int regionH = selY1 - selY0; - if (regionW <= 0 || regionH <= 0) return; - - // Extract selected region by copying pixel rows - Image region = { 0 }; - region.width = regionW; - region.height = regionH; - region.mipmaps = 1; - region.format = spa->spectrogramImage.format; - region.data = RL_MALLOC(regionW * regionH * 4); - if (!region.data) return; - - // Raylib stores images as tightly packed RGBA rows - memcpy each row at a time - Color* src = (Color*)spa->spectrogramImage.data; - Color* dst = (Color*)region.data; - for (int y = 0; y < regionH; y++) { - memcpy(dst + y * regionW, - src + (selY0 + y) * imgW + selX0, - regionW * 4); - } - - // Scale if a non-zero exportScale is set (and region isn't too large) - if (spa->exportScale > 0.0f && regionW < 4096) { - int outW = regionW * (int)spa->exportScale; - int outH = regionH * (int)spa->exportScale; - if (outW > 0 && outH > 0) { - ImageResize(®ion, outW, outH); - } - } - - // Export as PNG - char path[4096]; - if (spa->exportScale <= 0.0f && regionW == imgW) { - // Full width export without scaling - snprintf(path, sizeof(path), "%s/spectrogram_full.png", dirPath); - } else { - snprintf(path, sizeof(path), "%s/spectrogram_export.png", dirPath); - } - - if (ExportImage(region, path)) { - snprintf((char*)spa->exportMessage, sizeof(spa->exportMessage), - "Exported: %dx%d %.40s", region.width, region.height, path); - } else { - snprintf((char*)spa->exportMessage, sizeof(spa->exportMessage), "Export failed"); - } - - RL_FREE(region.data); -} - -static void DrawSidebar(void) -{ - float scale = GetUIScale(); - float sidebarWidth = 300 * scale; - float x = 10 * scale; - float y = 10 * scale; - int fontSize = (int)(12 * scale); - bool needsRegen = false; - - // Dark sidebar background - DrawRectangle(0, 0, (int)(sidebarWidth + 20 * scale), GetScreenHeight(), (Color){ 35, 35, 40, 255 }); - DrawLine((int)(sidebarWidth + 10 * scale), 0, (int)(sidebarWidth + 10 * scale), GetScreenHeight(), GRAY); - - // Title - DrawTextScaled("Spectrogram Controls", x, y, 16, WHITE); y += 28 * scale; - - // FFT Size clicker - DrawTextScaled(TextFormat("FFT: %d (%.1f Hz/bin)", app.fftSize, (float)app.signal.sampleRate / app.fftSize), x, y, 14, LIGHTGRAY); y += 20 * scale; - Rectangle fftMinus = { x, y, 30 * scale, 25 * scale }; - Rectangle fftPlus = { x + sidebarWidth - 40 * scale, y, 30 * scale, 25 * scale }; - if (CheckCollisionPointRec(GetMousePosition(), fftMinus) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { - int newFFT = app.fftSize / 2; - if (newFFT >= FFT_SIZE_MIN) { - ChangeFFTSize(newFFT); - } - } - if (CheckCollisionPointRec(GetMousePosition(), fftPlus) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { - int newFFT = app.fftSize * 2; - if (newFFT <= FFT_SIZE_MAX) { - ChangeFFTSize(newFFT); - } - } - DrawRectangleRec(fftMinus, (Color){ 50, 50, 60, 255 }); - DrawRectangleLinesEx(fftMinus, 1, GRAY); - DrawTextScaled("-", fftMinus.x + 12 * scale, fftMinus.y + 5 * scale, 18, WHITE); - DrawRectangleRec(fftPlus, (Color){ 50, 50, 60, 255 }); - DrawRectangleLinesEx(fftPlus, 1, GRAY); - DrawTextScaled("+", fftPlus.x + 10 * scale, fftPlus.y + 5 * scale, 18, WHITE); - y += 32 * scale; - - // dB Floor slider - DrawTextScaled(TextFormat("dB Floor: %.1f", app.amplitudeFloorDb), x, y, 14, LIGHTGRAY); y += 20 * scale; - Rectangle dbSlider = { x, y, sidebarWidth - 10 * scale, 20 * scale }; - float dbValue = (app.amplitudeFloorDb + 100.0f) / 80.0f; - DrawSlider(dbSlider, dbValue); - if (UpdateSlider(dbSlider, &dbValue)) { - app.amplitudeFloorDb = -100.0f + dbValue * 80.0f; - needsRegen = true; - } - y += 28 * scale; - - // Colormap dropdown - DrawTextScaled("Colormap:", x, y, 14, LIGHTGRAY); y += 20 * scale; - const char* colormapNames[] = { "Grays", "Inferno", "Viridis", "Plasma", "Hot", "Cool" }; - Rectangle cmapButton = { x, y, sidebarWidth - 10 * scale, 25 * scale }; - if (CheckCollisionPointRec(GetMousePosition(), cmapButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { - app.colormap = (ColormapType)((app.colormap + 1) % COLORMAP_COUNT); - GenerateColormapTexture(); - needsRegen = true; - } - DrawRectangleRec(cmapButton, (Color){ 50, 50, 60, 255 }); - DrawRectangleLinesEx(cmapButton, 1, GRAY); - DrawTextScaled(colormapNames[app.colormap], cmapButton.x + 10 * scale, cmapButton.y + 6 * scale, 14, WHITE); - DrawTexturePro(colormapTexture, (Rectangle){ 0, 0, 256, 1 }, - (Rectangle){ cmapButton.x + cmapButton.width - 60 * scale, cmapButton.y + 5 * scale, 50 * scale, 15 * scale }, - (Vector2){ 0, 0 }, 0.0f, WHITE); - y += 32 * scale; - - // Grid toggle - Rectangle gridCheck = { x, y, 18 * scale, 18 * scale }; - if (CheckCollisionPointRec(GetMousePosition(), gridCheck) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { - app.showGrid = !app.showGrid; - } - DrawRectangleRec(gridCheck, app.showGrid ? BLUE : DARKGRAY); - DrawRectangleLinesEx(gridCheck, 1, WHITE); - DrawTextScaled("Show Grid", x + 25 * scale, y + 2 * scale, 14, LIGHTGRAY); y += 28 * scale; - - // File loading - DrawTextScaled("File:", x, y, 14, LIGHTGRAY); y += 20 * scale; - Rectangle fileButton = { x, y, sidebarWidth - 10 * scale, 25 * scale }; - if (CheckCollisionPointRec(GetMousePosition(), fileButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { - app.showFileBrowser = true; - ScanDirectory(GetWorkingDirectory()); - } - DrawRectangleRec(fileButton, (Color){ 50, 50, 60, 255 }); - DrawRectangleLinesEx(fileButton, 1, GRAY); - DrawTextScaled("Open File Browser (O)", fileButton.x + 10 * scale, fileButton.y + 6 * scale, 14, WHITE); - y += 38 * scale; - - // Playback - Rectangle playButton = { x, y, sidebarWidth - 10 * scale, 35 * scale }; - if (CheckCollisionPointRec(GetMousePosition(), playButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { - if (app.isPlaying && AudioPlaybackSound.frameCount > 0) { - StopSound(AudioPlaybackSound); - app.isPlaying = false; - app.playheadElapsed = 0; - app.playheadT = 0; - } else { - PlaySelectedRegion(); - app.isPlaying = true; - } - } - const char* playText = app.isPlaying ? "STOP (SPACE)" : "PLAY (SPACE)"; - DrawRectangleRec(playButton, app.isPlaying ? (Color){ 120, 40, 40, 255 } : (Color){ 40, 100, 40, 255 }); - DrawRectangleLinesEx(playButton, 1, app.isPlaying ? RED : GREEN); - DrawTextScaled(playText, playButton.x + 10 * scale, playButton.y + 12 * scale, 14, WHITE); - y += 48 * scale; - - // Fullscreen toggle - Rectangle fsButton = { x, y, sidebarWidth - 10 * scale, 25 * scale }; - bool isFullscreen = IsWindowFullscreen(); - if (CheckCollisionPointRec(GetMousePosition(), fsButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { - ToggleFullscreen(); - } - DrawRectangleRec(fsButton, isFullscreen ? (Color){ 40, 80, 120, 255 } : (Color){ 50, 50, 60, 255 }); - DrawRectangleLinesEx(fsButton, 1, GRAY); - DrawTextScaled(isFullscreen ? "Exit Fullscreen (F11)" : "Fullscreen (F11)", fsButton.x + 10 * scale, fsButton.y + 6 * scale, 14, WHITE); - y += 38 * scale; - - // Reset/Clear buttons - Rectangle resetButton = { x, y, (sidebarWidth - 15 * scale) / 2, 25 * scale }; - if (CheckCollisionPointRec(GetMousePosition(), resetButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { - app.timeSelectionStart = app.viewStart; - app.timeSelectionEnd = app.viewEnd; - app.freqSelectionStart = 0.0f; - app.freqSelectionEnd = 1.0f; - } - DrawRectangleRec(resetButton, (Color){ 80, 50, 50, 255 }); - DrawRectangleLinesEx(resetButton, 1, RED); - DrawTextScaled("Reset Sel (R)", resetButton.x + 10 * scale, resetButton.y + 6 * scale, 14, WHITE); - - Rectangle clearButton = { x + resetButton.width + 5 * scale, y, (sidebarWidth - 15 * scale) / 2, 25 * scale }; - if (CheckCollisionPointRec(GetMousePosition(), clearButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { - app.timeSelectionStart = 0.0f; - app.timeSelectionEnd = 1.0f; - app.freqSelectionStart = 0.0f; - app.freqSelectionEnd = 1.0f; - } - DrawRectangleRec(clearButton, (Color){ 80, 50, 50, 255 }); - DrawRectangleLinesEx(clearButton, 1, RED); - DrawTextScaled("Clear (ESC)", clearButton.x + 10 * scale, clearButton.y + 6 * scale, 14, WHITE); - y += 38 * scale; - - // Export PNG - { - Rectangle exportButton = { x, y, sidebarWidth - 10 * scale, 30 * scale }; - if (CheckCollisionPointRec(GetMousePosition(), exportButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { - ExportPNG(&app, app.exportDir); - } - DrawRectangleRec(exportButton, (Color){ 40, 60, 80, 255 }); - DrawRectangleLinesEx(exportButton, 1, CYAN); - DrawTextScaled("Export PNG (E)", exportButton.x + 10 * scale, exportButton.y + 7 * scale, 14, WHITE); - y += 35 * scale; - - // Export scale slider - DrawTextScaled(TextFormat("Export Scale: %.1fx", app.exportScale), x, y, 12, LIGHTGRAY); y += 18 * scale; - Rectangle scaleSlider = { x, y, sidebarWidth - 10 * scale, 14 * scale }; - DrawSlider(scaleSlider, app.exportScale / 10.0f); - float scaleVal = 0.0f; - if (UpdateSlider(scaleSlider, &scaleVal)) { - app.exportScale = scaleVal * 10.0f; - } - y += 24 * scale; - } - - // Signal info - DrawTextScaled("Signal Info:", x, y, 14, LIGHTGRAY); y += 20 * scale; - if (app.loaded) { - DrawTextScaled(TextFormat("Sample Rate: %d Hz", app.signal.sampleRate), x, y, 14, GRAY); y += 18 * scale; - DrawTextScaled(TextFormat("Duration: %.2f sec", app.signal.duration), x, y, 14, GRAY); y += 18 * scale; - DrawTextScaled(TextFormat("Max Freq: %.1f kHz", (float)app.signal.sampleRate / 2000.0f), x, y, 14, GRAY); y += 18 * scale; - } else { - DrawTextScaled("No file loaded", x, y, 14, GRAY); y += 18 * scale; - } - - if (needsRegen && app.stftComputed) { - GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture); - app.visibleTextureValid = false; // Force cache invalidation - } -} - -static void DrawSlider(Rectangle bounds, float value) -{ - // Background - DrawRectangleRec(bounds, DARKGRAY); - DrawRectangleLinesEx(bounds, 1, GRAY); - - // Fill - float fillWidth = (bounds.width - 4) * value; - DrawRectangle(bounds.x + 2, bounds.y + 2, fillWidth, bounds.height - 4, BLUE); - - // Handle - float handleX = bounds.x + 2 + fillWidth; - DrawRectangle(handleX - 3, bounds.y + 1, 6, bounds.height - 2, WHITE); -} - -static bool UpdateSlider(Rectangle bounds, float* value) -{ - bool changed = false; - if (CheckCollisionPointRec(GetMousePosition(), bounds) && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { - float newValue = (GetMousePosition().x - bounds.x - 2) / (bounds.width - 4); - newValue = Clamp(newValue, 0.0f, 1.0f); - if (fabsf(newValue - *value) > 0.001f) { - *value = newValue; - changed = true; - } - } - return changed; -} - // ============================================================================ // Main Application // ============================================================================ diff --git a/src/spectrogram_types.h b/src/spectrogram_types.h new file mode 100644 index 0000000..7d10f39 --- /dev/null +++ b/src/spectrogram_types.h @@ -0,0 +1,222 @@ +// spectrogram_types.h - Shared types, constants, globals, and small math helpers. +// This is the "spine" header included by every module. +#ifndef SPECTROGRAM_TYPES_H +#define SPECTROGRAM_TYPES_H + +#include "raylib.h" +#include "utils.h" // AudioSignal, SignalStats +#include "primitives.h" // ScopeView, WaveformData + +#include +#include + +#ifndef M_PI + #define M_PI 3.14159265358979323846 +#endif + +#ifndef CYAN + #define CYAN (Color){ 0, 255, 255, 255 } +#endif + +// ============================================================================ +// Configuration +// ============================================================================ + +#define FFT_SIZE_DEFAULT 2048 +#define FFT_SIZE_MAX 2048 +#define FFT_SIZE_MIN 128 +#define HOP_RATIO 4 // FFT_SIZE / HOP_SIZE = 4 means 75% overlap +#define MAX_SAMPLE_RATE 48000 +#define LOUDNESS_FLOOR_DB -80.0f + +// Base resolution for proportional UI scaling (see GetUIScale in render.c) +#define BASE_WIDTH 1280 +#define BASE_HEIGHT 800 + +#define FFT_CACHE_SIZE 4 + +// ============================================================================ +// Data Structures +// ============================================================================ + +typedef enum { + COLORMAP_GRAYS = 0, + COLORMAP_INFERNO, + COLORMAP_VIRIDIS, + COLORMAP_PLASMA, + COLORMAP_HOT, + COLORMAP_COOL, + COLORMAP_COUNT +} ColormapType; + +typedef struct { + float frequency; + float amplitude; + float phase; +} FrequencyData; + +typedef struct { + FrequencyData* spectrum; + FrequencyData* derivativeSpectrum; // STFT with derivative window (for synchrosqueezing) + int numBins; + int sampleOffset; + int sampleCount; +} StftSegment; + +typedef struct { + StftSegment* segments; + int numSegments; + int sampleRate; + int totalSamples; + bool useHannWindow; +} StftResult; + +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; + Image spectrogramImage; + Texture2D spectrogramTexture; + bool loaded; + bool stftComputed; + + // Playback state + float playheadT; // 0-1 normalized position in selection + float playheadElapsed; // Elapsed seconds since play started + + // Time selection (0-1 normalized) + float timeSelectionStart; + float timeSelectionEnd; + bool isTimeSelecting; + + // Frequency selection (0-1 normalized) + float freqSelectionStart; + float freqSelectionEnd; + bool isFreqSelecting; + + // Export settings + float exportScale; + char exportDir[4096]; + char exportMessage[256]; + + Vector2 selectStartPos; // For minimum drag distance check + bool isDraggingSelection; // Dragging existing selection box + Vector2 dragSelectionStartPos; // Mouse position when started dragging selection + float dragSelectionTimeStart; // Selection start time when dragging + float dragSelectionFreqStart; // Selection freq start when dragging + + // Viewport/zoom controls + float viewStart; // 0-1, start of visible time region + float viewEnd; // 0-1, end of visible time region + float freqViewStart; // 0-1, start of visible frequency region (0 = 0Hz) + float freqViewEnd; // 0-1, end of visible frequency region (1 = Nyquist) + bool isPanning; + float panStartViewStart; + float panStartViewEnd; + float panStartFreqViewStart; + float panStartFreqViewEnd; + Vector2 panStartPos; + + // Cached visible texture + Texture2D visibleTexture; + int cachedVisibleStart; + int cachedVisibleEnd; + int cachedVisibleStartY; + int cachedVisibleEndY; + bool visibleTextureValid; + + // Display settings + float amplitudeFloorDb; + float amplitudeCeilingDb; + ColormapType colormap; + bool showGrid; + int fftSize; // Current FFT size (128-2048) + + // File browser state + bool showFileBrowser; + char browserPath[512]; + char** browserFiles; + bool* browserIsDir; + int browserFileCount; + int browserScroll; + int browserSelected; + bool isBrowsing; + + // Playback state + bool isPlaying; + bool playbackFinished; // Track if playback completed naturally + + // Loading/processing state + int loadingPhase; // 0 = computing STFT, 1 = generating texture + float loadingProgress; // 0.0 to 1.0 overall progress + int currentSTFTSegment; // Which segment we're on for incremental processing + + // Adaptive resolution: skipFactor=1 means compute all segments, skipFactor=N + // means compute every Nth segment (faster initial load, overview-only). + // highResFinished tracks whether full-res segments have been computed for + // the current view range. + int skipFactor; + bool highResFinished; + + // Background high-res computation state. + // After the overview (skipFactor-strided) loads, missing segments are + // filled in at full resolution in the background while the user is idle. + int bgHighResSeg; // next segment index to compute at high-res + bool bgFinished; // true when all segments are computed at high-res + 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 + + // Scope view divider + float dividerY; // Y position of divider between spectrogram and scope (0-1 normalized) + bool isDividing; // True while user is dragging the divider + Vector2 dividerStartPos; // Mouse position when started dividing + float dividerStartY; // Spectro height when started dividing +} SpectrogramApp; + +// ============================================================================ +// Global State (defined in spectrogram.c) +// ============================================================================ + +extern SpectrogramApp app; +extern Sound AudioPlaybackSound; +extern Texture2D colormapTexture; +extern Font mainFont; + +// ============================================================================ +// Small math helpers (header-inline so every module can use them) +// ============================================================================ + +static inline float AmplitudeToDecibels(float amplitude) +{ + if (amplitude < 0.0001f) amplitude = 0.0001f; + return 20.0f * log10f(amplitude); +} + +static inline float Clamp(float value, float min, float max) +{ + if (value < min) return min; + if (value > max) return max; + return value; +} + +#endif // SPECTROGRAM_TYPES_H diff --git a/src/stft.c b/src/stft.c new file mode 100644 index 0000000..9542bc5 --- /dev/null +++ b/src/stft.c @@ -0,0 +1,387 @@ +// 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 +#include +#include +#include + +// ===== 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; +} diff --git a/src/stft.h b/src/stft.h new file mode 100644 index 0000000..9284485 --- /dev/null +++ b/src/stft.h @@ -0,0 +1,22 @@ +// stft.h - STFT computation, adaptive resolution, and the FFT-size LRU cache +#ifndef STFT_H +#define STFT_H + +#include "spectrogram_types.h" + +// --- STFT computation --- +void ComputeSTFTInit(AudioSignal* signal, StftResult* result, int fftSize); +bool ComputeSTFTIncremental(AudioSignal* signal, StftResult* result, int fftSize, int startSegment); +int ComputeNextHighResChunk(AudioSignal* signal, StftResult* result, int fftSize, int startSeg, int endSeg); +void FreeSTFT(StftResult* result); + +// --- Adaptive resolution & display scaling --- +int ComputeSkipFactor(float signalDurationSec); +void AutoScaleAmplitude(StftResult* stft); + +// --- FFT-size handling & cache --- +void ChangeFFTSize(int newFFT); +void SaveToCache(void); +void FreeAllCacheEntries(FFTSizeCache* cache); + +#endif // STFT_H diff --git a/src/ui.c b/src/ui.c new file mode 100644 index 0000000..3b21e8d --- /dev/null +++ b/src/ui.c @@ -0,0 +1,546 @@ +// ui.c - file browser, sidebar controls, sliders, and PNG export +#include "ui.h" +#include "render.h" +#include "stft.h" +#include "audio.h" +#include "platform.h" + +#include +#include +#include +#include + +// Internal sidebar widgets (defined after DrawSidebar, which uses them) +static void DrawSlider(Rectangle bounds, float value); +static bool UpdateSlider(Rectangle bounds, float* value); + +// ===== File browser ===== +void FreeBrowserFiles(void) +{ + if (app.browserFiles) { + for (int i = 0; i < app.browserFileCount; i++) free(app.browserFiles[i]); + free(app.browserFiles); + free(app.browserIsDir); + app.browserFiles = NULL; + app.browserIsDir = NULL; + } + app.browserFileCount = 0; +} + +void ScanDirectory(const char* path) +{ + FreeBrowserFiles(); + strncpy(app.browserPath, path, sizeof(app.browserPath) - 1); + FilePathList files = LoadDirectoryFiles(path); + + int dirCount = 0, wavCount = 0; + for (int i = 0; i < files.count; i++) { + const char* name = GetFileName(files.paths[i]); + if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; + if (DirectoryExists(files.paths[i])) dirCount++; + else { + const char* ext = GetFileExtension(files.paths[i]); + if (ext && (strcmp(ext, ".wav") == 0 || strcmp(ext, ".WAV") == 0 || + strcmp(ext, ".Wave") == 0 || strcmp(ext, ".Wav") == 0)) wavCount++; + } + } + + int totalCount = dirCount + wavCount; + if (totalCount > 0) { + app.browserFiles = (char**)malloc(totalCount * sizeof(char*)); + app.browserIsDir = (bool*)malloc(totalCount * sizeof(bool)); + app.browserFileCount = 0; + + for (int i = 0; i < files.count; i++) { + const char* name = GetFileName(files.paths[i]); + if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; + if (DirectoryExists(files.paths[i])) { + size_t len = strlen(name) + 1; + app.browserFiles[app.browserFileCount] = (char*)malloc(len); + if (app.browserFiles[app.browserFileCount]) { + memcpy(app.browserFiles[app.browserFileCount], name, len); + app.browserIsDir[app.browserFileCount] = true; + app.browserFileCount++; + } + } + } + for (int i = 0; i < files.count; i++) { + const char* name = GetFileName(files.paths[i]); + if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; + if (!DirectoryExists(files.paths[i])) { + const char* ext = GetFileExtension(files.paths[i]); + if (ext && (strcmp(ext, ".wav") == 0 || strcmp(ext, ".WAV") == 0 || + strcmp(ext, ".Wave") == 0 || strcmp(ext, ".Wav") == 0)) { + size_t len = strlen(name) + 1; + app.browserFiles[app.browserFileCount] = (char*)malloc(len); + if (app.browserFiles[app.browserFileCount]) { + memcpy(app.browserFiles[app.browserFileCount], name, len); + app.browserIsDir[app.browserFileCount] = false; + app.browserFileCount++; + } + } + } + } + } + UnloadDirectoryFiles(files); + app.browserScroll = 0; + app.browserSelected = -1; +} + +static void NavigateToParentDirectory(void) +{ + const char* parent = GetPrevDirectoryPath(app.browserPath); + if (parent && strlen(parent) > 0) ScanDirectory(parent); +} + +static void NavigateToDirectory(const char* dirName) +{ + char newPath[512]; + int written = snprintf(newPath, sizeof(newPath), "%s/%s", app.browserPath, dirName); + if (written < 0 || written >= (int)sizeof(newPath)) return; // Path too long + if (DirectoryExists(newPath)) ScanDirectory(newPath); +} + +static void LoadSelectedFile(void) +{ + if (app.browserSelected < 0 || app.browserSelected >= app.browserFileCount) return; + + char filePath[512]; + int written = snprintf(filePath, sizeof(filePath), "%s/%s", app.browserPath, app.browserFiles[app.browserSelected]); + if (written < 0 || written >= (int)sizeof(filePath)) return; // Path too long + + if (app.browserIsDir[app.browserSelected]) { + NavigateToDirectory(app.browserFiles[app.browserSelected]); + } else if (FileExists(filePath) && LoadWavFile(filePath, &app.signal)) { + app.loaded = true; + app.stftComputed = false; + app.loadingPhase = 0; + app.loadingProgress = 0.0f; + app.currentSTFTSegment = 0; + app.skipFactor = 1; + app.highResFinished = false; + 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; + app.freqSelectionEnd = 1.0f; + app.showFileBrowser = false; + ComputeSTFTInit(&app.signal, &app.stft, app.fftSize); + // Invalidate visible texture cache + if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture); + app.visibleTexture = (Texture2D){ 0 }; + app.visibleTextureValid = false; + TraceLog(LOG_INFO, "Loaded: %s", filePath); + } +} + +void DrawFileBrowser(void) +{ + // Draw semi-transparent overlay first + DrawRectangle(0, 0, GetScreenWidth(), GetScreenHeight(), Fade(BLACK, 0.85f)); + + float scale = GetUIScale(); + float bw = 900.0f * scale, bh = 700.0f * scale; + float bx = (GetScreenWidth() - bw) / 2, by = (GetScreenHeight() - bh) / 2; + + DrawRectangle(bx, by, bw, bh, (Color){ 45, 45, 55, 255 }); + DrawRectangleLinesEx((Rectangle){ bx, by, bw, bh }, (int)(2 * scale), GRAY); + DrawRectangle(bx, by, bw, (int)(40 * scale), (Color){ 60, 60, 75, 255 }); + DrawTextScaled("File Browser - Select WAV File", bx + (int)(15 * scale), by + (int)(8 * scale), (int)(20 * scale), WHITE); + + // Path bar + float pathBarY = by + (int)(46 * scale); + DrawRectangle(bx + (int)(15 * scale), pathBarY, bw - (int)(110 * scale), (int)(30 * scale), (Color){ 30, 30, 40, 255 }); + DrawRectangleLinesEx((Rectangle){ bx + (int)(15 * scale), pathBarY, bw - (int)(110 * scale), (int)(30 * scale) }, (int)(1 * scale), GRAY); + + char displayPath[300]; + strncpy(displayPath, app.browserPath, sizeof(displayPath) - 1); + displayPath[sizeof(displayPath) - 1] = '\0'; + if (strlen(displayPath) > 60) sprintf(displayPath, "...%s", app.browserPath + strlen(app.browserPath) - 57); + DrawTextScaled(displayPath, bx + (int)(22 * scale), pathBarY + (int)(5 * scale), (int)(14 * scale), LIGHTGRAY); + + // Up button + Rectangle upBtn = { bx + bw - (int)(90 * scale), pathBarY, (int)(75 * scale), (int)(30 * scale) }; + if (CheckCollisionPointRec(GetMousePosition(), upBtn)) DrawRectangleRec(upBtn, (Color){ 80, 80, 90, 255 }); + DrawTextScaled("UP (..)", upBtn.x + (int)(10 * scale), upBtn.y + (int)(7 * scale), (int)(14 * scale), WHITE); + + // File list + float lx = bx + (int)(15 * scale), ly = pathBarY + (int)(40 * scale); + float lw = bw - (int)(30 * scale), lh = bh - (int)(195 * scale); + DrawRectangle(lx, ly, lw, lh, (Color){ 25, 25, 35, 255 }); + DrawRectangleLinesEx((Rectangle){ lx, ly, lw, lh }, (int)(1 * scale), GRAY); + + // Line height: base 36px scaled (enough for icon + filename without overlap) + float lineH = 36 * scale; + + // Handle empty directory + int visibleItems = (int)(lh / lineH); + if (visibleItems < 1) visibleItems = 1; + + if (app.browserFileCount <= 0 || !app.browserFiles) { + DrawTextScaled("(No WAV files in directory)", lx + (int)(20 * scale), ly + (int)(lh / 2 - 12 * scale), (int)(14 * scale), GRAY); + } else { + if (app.browserFileCount > visibleItems) { + float sh = (float)visibleItems / app.browserFileCount * lh; + if (sh < (int)(10 * scale)) sh = (int)(10 * scale); + float sy = ly + (float)app.browserScroll / (app.browserFileCount - visibleItems) * (lh - sh); + DrawRectangle(lx + lw - (int)(10 * scale), sy, (int)(8 * scale), sh, GRAY); + } + + int startItem = app.browserScroll; + int endItem = startItem + visibleItems + 1; + if (endItem > app.browserFileCount) endItem = app.browserFileCount; + + float iconW = (int)(45 * scale); // space for icon column + for (int i = startItem; i < endItem; i++) { + if (i < 0 || i >= app.browserFileCount || !app.browserFiles[i] || !app.browserIsDir) continue; + + float iy = ly + (i - startItem) * lineH + (int)(2 * scale); + bool hovered = CheckCollisionPointRec((Vector2){ GetMouseX(), GetMouseY() }, (Rectangle){ lx + (int)(2 * scale), iy, lw - (int)(14 * scale), lineH - (int)(4 * scale) }); + + if (i == app.browserSelected) DrawRectangle(lx + (int)(2 * scale), iy, lw - (int)(14 * scale), (int)((lineH - 4) * scale), (Color){ 50, 70, 120, 180 }); + else if (hovered) DrawRectangle(lx + (int)(2 * scale), iy, lw - (int)(14 * scale), (int)((lineH - 4) * scale), (Color){ 60, 60, 80, 100 }); + + const char* icon = app.browserIsDir[i] ? "[DIR]" : "[WAV]"; + Color iconCol = app.browserIsDir[i] ? (Color){ 255, 220, 80, 255 } : (Color){ 80, 200, 120, 255 }; + DrawTextScaled(icon, lx + (int)(8 * scale), iy + (int)(4 * scale), (int)(13 * scale), iconCol); + DrawTextScaled(app.browserFiles[i], lx + iconW + (int)(10 * scale), iy + (int)(4 * scale), (int)(14 * scale), WHITE); + } + } + + // Scroll with mouse wheel + if (CheckCollisionPointRec(GetMousePosition(), (Rectangle){ lx, ly, lw - (int)(10 * scale), lh }) && app.browserFileCount > 0) { + int wheel = GetMouseWheelMove(); + if (wheel > 0) app.browserScroll--; + if (wheel < 0) app.browserScroll++; + if (app.browserScroll < 0) app.browserScroll = 0; + int maxScroll = app.browserFileCount - visibleItems; + if (maxScroll < 0) maxScroll = 0; + if (app.browserScroll > maxScroll) app.browserScroll = maxScroll; + } + + // Handle clicks + if (CheckCollisionPointRec(GetMousePosition(), upBtn) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) NavigateToParentDirectory(); + + if (CheckCollisionPointRec(GetMousePosition(), (Rectangle){ lx, ly, lw - (int)(10 * scale), lh }) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && app.browserFileCount > 0) { + int clicked = app.browserScroll + (int)((GetMouseY() - ly) / lineH); + if (clicked >= 0 && clicked < app.browserFileCount) { + app.browserSelected = clicked; + static double lastClick = 0; + if (GetTime() - lastClick < 0.3) LoadSelectedFile(); + lastClick = GetTime(); + } + } + + // Buttons + float btnY = by + bh - (int)(55 * scale); + Rectangle openBtn = { bx + bw - (int)(170 * scale), btnY, (int)(150 * scale), (int)(40 * scale) }; + Rectangle cancelBtn = { bx + (int)(15 * scale), btnY, (int)(120 * scale), (int)(40 * scale) }; + + bool openHovered = CheckCollisionPointRec(GetMousePosition(), openBtn); + bool openClicked = openHovered && IsMouseButtonPressed(MOUSE_LEFT_BUTTON); + if (openHovered) DrawRectangleRec(openBtn, (Color){ 100, 100, 120, 255 }); + else DrawRectangleRec(openBtn, (Color){ 80, 80, 90, 255 }); + DrawRectangleLinesEx(openBtn, (int)(1 * scale), WHITE); + DrawTextScaled("OPEN (Enter)", openBtn.x + (int)(25 * scale), openBtn.y + (int)(12 * scale), (int)(16 * scale), WHITE); + + DrawRectangleRec(cancelBtn, (Color){ 100, 40, 40, 255 }); + DrawTextScaled("ESC Cancel", cancelBtn.x + (int)(18 * scale), cancelBtn.y + (int)(12 * scale), (int)(16 * scale), WHITE); + + if ((IsKeyPressed(KEY_ENTER) || openClicked) && app.browserSelected >= 0 && app.browserFileCount > 0) LoadSelectedFile(); + if (IsKeyPressed(KEY_ESCAPE)) app.showFileBrowser = false; + if (IsKeyPressed(KEY_UP) && app.browserSelected > 0 && app.browserFileCount > 0) { + app.browserSelected--; + if (app.browserSelected < app.browserScroll) app.browserScroll = app.browserSelected; + } + if (IsKeyPressed(KEY_DOWN) && app.browserSelected < app.browserFileCount - 1 && app.browserFileCount > 0) { + app.browserSelected++; + if (app.browserSelected >= app.browserScroll + visibleItems) app.browserScroll = app.browserSelected - visibleItems + 1; + } +} + + +// ===== PNG export ===== +void ExportPNG(const SpectrogramApp* spa, const char* dirPath) +{ + if (!spa->stftComputed || !spa->spectrogramImage.data) return; + + int imgW = spa->spectrogramImage.width; + int imgH = spa->spectrogramImage.height; + + // Selection region in image-pixel coordinates + int selX0 = (int)(spa->timeSelectionStart * imgW); + int selX1 = (int)(spa->timeSelectionEnd * imgW); + int selY0 = (int)((1.0f - spa->freqSelectionEnd) * imgH); + int selY1 = (int)((1.0f - spa->freqSelectionStart) * imgH); + + // Clamp to image bounds + selX0 = Clamp(selX0, 0, imgW); + selX1 = Clamp(selX1, 0, imgW); + selY0 = Clamp(selY0, 0, imgH); + selY1 = Clamp(selY1, 0, imgH); + + int regionW = selX1 - selX0; + int regionH = selY1 - selY0; + if (regionW <= 0 || regionH <= 0) return; + + // Extract selected region by copying pixel rows + Image region = { 0 }; + region.width = regionW; + region.height = regionH; + region.mipmaps = 1; + region.format = spa->spectrogramImage.format; + region.data = RL_MALLOC(regionW * regionH * 4); + if (!region.data) return; + + // Raylib stores images as tightly packed RGBA rows - memcpy each row at a time + Color* src = (Color*)spa->spectrogramImage.data; + Color* dst = (Color*)region.data; + for (int y = 0; y < regionH; y++) { + memcpy(dst + y * regionW, + src + (selY0 + y) * imgW + selX0, + regionW * 4); + } + + // Scale if a non-zero exportScale is set (and region isn't too large) + if (spa->exportScale > 0.0f && regionW < 4096) { + int outW = regionW * (int)spa->exportScale; + int outH = regionH * (int)spa->exportScale; + if (outW > 0 && outH > 0) { + ImageResize(®ion, outW, outH); + } + } + + // Export as PNG + char path[4096]; + if (spa->exportScale <= 0.0f && regionW == imgW) { + // Full width export without scaling + snprintf(path, sizeof(path), "%s/spectrogram_full.png", dirPath); + } else { + snprintf(path, sizeof(path), "%s/spectrogram_export.png", dirPath); + } + + if (ExportImage(region, path)) { + snprintf((char*)spa->exportMessage, sizeof(spa->exportMessage), + "Exported: %dx%d %.40s", region.width, region.height, path); + } else { + snprintf((char*)spa->exportMessage, sizeof(spa->exportMessage), "Export failed"); + } + + RL_FREE(region.data); +} + +// ===== Sidebar ===== +void DrawSidebar(void) +{ + float scale = GetUIScale(); + float sidebarWidth = 300 * scale; + float x = 10 * scale; + float y = 10 * scale; + int fontSize = (int)(12 * scale); + bool needsRegen = false; + + // Dark sidebar background + DrawRectangle(0, 0, (int)(sidebarWidth + 20 * scale), GetScreenHeight(), (Color){ 35, 35, 40, 255 }); + DrawLine((int)(sidebarWidth + 10 * scale), 0, (int)(sidebarWidth + 10 * scale), GetScreenHeight(), GRAY); + + // Title + DrawTextScaled("Spectrogram Controls", x, y, 16, WHITE); y += 28 * scale; + + // FFT Size clicker + DrawTextScaled(TextFormat("FFT: %d (%.1f Hz/bin)", app.fftSize, (float)app.signal.sampleRate / app.fftSize), x, y, 14, LIGHTGRAY); y += 20 * scale; + Rectangle fftMinus = { x, y, 30 * scale, 25 * scale }; + Rectangle fftPlus = { x + sidebarWidth - 40 * scale, y, 30 * scale, 25 * scale }; + if (CheckCollisionPointRec(GetMousePosition(), fftMinus) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + int newFFT = app.fftSize / 2; + if (newFFT >= FFT_SIZE_MIN) { + ChangeFFTSize(newFFT); + } + } + if (CheckCollisionPointRec(GetMousePosition(), fftPlus) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + int newFFT = app.fftSize * 2; + if (newFFT <= FFT_SIZE_MAX) { + ChangeFFTSize(newFFT); + } + } + DrawRectangleRec(fftMinus, (Color){ 50, 50, 60, 255 }); + DrawRectangleLinesEx(fftMinus, 1, GRAY); + DrawTextScaled("-", fftMinus.x + 12 * scale, fftMinus.y + 5 * scale, 18, WHITE); + DrawRectangleRec(fftPlus, (Color){ 50, 50, 60, 255 }); + DrawRectangleLinesEx(fftPlus, 1, GRAY); + DrawTextScaled("+", fftPlus.x + 10 * scale, fftPlus.y + 5 * scale, 18, WHITE); + y += 32 * scale; + + // dB Floor slider + DrawTextScaled(TextFormat("dB Floor: %.1f", app.amplitudeFloorDb), x, y, 14, LIGHTGRAY); y += 20 * scale; + Rectangle dbSlider = { x, y, sidebarWidth - 10 * scale, 20 * scale }; + float dbValue = (app.amplitudeFloorDb + 100.0f) / 80.0f; + DrawSlider(dbSlider, dbValue); + if (UpdateSlider(dbSlider, &dbValue)) { + app.amplitudeFloorDb = -100.0f + dbValue * 80.0f; + needsRegen = true; + } + y += 28 * scale; + + // Colormap dropdown + DrawTextScaled("Colormap:", x, y, 14, LIGHTGRAY); y += 20 * scale; + const char* colormapNames[] = { "Grays", "Inferno", "Viridis", "Plasma", "Hot", "Cool" }; + Rectangle cmapButton = { x, y, sidebarWidth - 10 * scale, 25 * scale }; + if (CheckCollisionPointRec(GetMousePosition(), cmapButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + app.colormap = (ColormapType)((app.colormap + 1) % COLORMAP_COUNT); + GenerateColormapTexture(); + needsRegen = true; + } + DrawRectangleRec(cmapButton, (Color){ 50, 50, 60, 255 }); + DrawRectangleLinesEx(cmapButton, 1, GRAY); + DrawTextScaled(colormapNames[app.colormap], cmapButton.x + 10 * scale, cmapButton.y + 6 * scale, 14, WHITE); + DrawTexturePro(colormapTexture, (Rectangle){ 0, 0, 256, 1 }, + (Rectangle){ cmapButton.x + cmapButton.width - 60 * scale, cmapButton.y + 5 * scale, 50 * scale, 15 * scale }, + (Vector2){ 0, 0 }, 0.0f, WHITE); + y += 32 * scale; + + // Grid toggle + Rectangle gridCheck = { x, y, 18 * scale, 18 * scale }; + if (CheckCollisionPointRec(GetMousePosition(), gridCheck) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + app.showGrid = !app.showGrid; + } + DrawRectangleRec(gridCheck, app.showGrid ? BLUE : DARKGRAY); + DrawRectangleLinesEx(gridCheck, 1, WHITE); + DrawTextScaled("Show Grid", x + 25 * scale, y + 2 * scale, 14, LIGHTGRAY); y += 28 * scale; + + // File loading + DrawTextScaled("File:", x, y, 14, LIGHTGRAY); y += 20 * scale; + Rectangle fileButton = { x, y, sidebarWidth - 10 * scale, 25 * scale }; + if (CheckCollisionPointRec(GetMousePosition(), fileButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + app.showFileBrowser = true; + ScanDirectory(GetWorkingDirectory()); + } + DrawRectangleRec(fileButton, (Color){ 50, 50, 60, 255 }); + DrawRectangleLinesEx(fileButton, 1, GRAY); + DrawTextScaled("Open File Browser (O)", fileButton.x + 10 * scale, fileButton.y + 6 * scale, 14, WHITE); + y += 38 * scale; + + // Playback + Rectangle playButton = { x, y, sidebarWidth - 10 * scale, 35 * scale }; + if (CheckCollisionPointRec(GetMousePosition(), playButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + if (app.isPlaying && AudioPlaybackSound.frameCount > 0) { + StopSound(AudioPlaybackSound); + app.isPlaying = false; + app.playheadElapsed = 0; + app.playheadT = 0; + } else { + PlaySelectedRegion(); + app.isPlaying = true; + } + } + const char* playText = app.isPlaying ? "STOP (SPACE)" : "PLAY (SPACE)"; + DrawRectangleRec(playButton, app.isPlaying ? (Color){ 120, 40, 40, 255 } : (Color){ 40, 100, 40, 255 }); + DrawRectangleLinesEx(playButton, 1, app.isPlaying ? RED : GREEN); + DrawTextScaled(playText, playButton.x + 10 * scale, playButton.y + 12 * scale, 14, WHITE); + y += 48 * scale; + + // Fullscreen toggle + Rectangle fsButton = { x, y, sidebarWidth - 10 * scale, 25 * scale }; + bool isFullscreen = IsWindowFullscreen(); + if (CheckCollisionPointRec(GetMousePosition(), fsButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + ToggleFullscreen(); + } + DrawRectangleRec(fsButton, isFullscreen ? (Color){ 40, 80, 120, 255 } : (Color){ 50, 50, 60, 255 }); + DrawRectangleLinesEx(fsButton, 1, GRAY); + DrawTextScaled(isFullscreen ? "Exit Fullscreen (F11)" : "Fullscreen (F11)", fsButton.x + 10 * scale, fsButton.y + 6 * scale, 14, WHITE); + y += 38 * scale; + + // Reset/Clear buttons + Rectangle resetButton = { x, y, (sidebarWidth - 15 * scale) / 2, 25 * scale }; + if (CheckCollisionPointRec(GetMousePosition(), resetButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + app.timeSelectionStart = app.viewStart; + app.timeSelectionEnd = app.viewEnd; + app.freqSelectionStart = 0.0f; + app.freqSelectionEnd = 1.0f; + } + DrawRectangleRec(resetButton, (Color){ 80, 50, 50, 255 }); + DrawRectangleLinesEx(resetButton, 1, RED); + DrawTextScaled("Reset Sel (R)", resetButton.x + 10 * scale, resetButton.y + 6 * scale, 14, WHITE); + + Rectangle clearButton = { x + resetButton.width + 5 * scale, y, (sidebarWidth - 15 * scale) / 2, 25 * scale }; + if (CheckCollisionPointRec(GetMousePosition(), clearButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + app.timeSelectionStart = 0.0f; + app.timeSelectionEnd = 1.0f; + app.freqSelectionStart = 0.0f; + app.freqSelectionEnd = 1.0f; + } + DrawRectangleRec(clearButton, (Color){ 80, 50, 50, 255 }); + DrawRectangleLinesEx(clearButton, 1, RED); + DrawTextScaled("Clear (ESC)", clearButton.x + 10 * scale, clearButton.y + 6 * scale, 14, WHITE); + y += 38 * scale; + + // Export PNG + { + Rectangle exportButton = { x, y, sidebarWidth - 10 * scale, 30 * scale }; + if (CheckCollisionPointRec(GetMousePosition(), exportButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + ExportPNG(&app, app.exportDir); + } + DrawRectangleRec(exportButton, (Color){ 40, 60, 80, 255 }); + DrawRectangleLinesEx(exportButton, 1, CYAN); + DrawTextScaled("Export PNG (E)", exportButton.x + 10 * scale, exportButton.y + 7 * scale, 14, WHITE); + y += 35 * scale; + + // Export scale slider + DrawTextScaled(TextFormat("Export Scale: %.1fx", app.exportScale), x, y, 12, LIGHTGRAY); y += 18 * scale; + Rectangle scaleSlider = { x, y, sidebarWidth - 10 * scale, 14 * scale }; + DrawSlider(scaleSlider, app.exportScale / 10.0f); + float scaleVal = 0.0f; + if (UpdateSlider(scaleSlider, &scaleVal)) { + app.exportScale = scaleVal * 10.0f; + } + y += 24 * scale; + } + + // Signal info + DrawTextScaled("Signal Info:", x, y, 14, LIGHTGRAY); y += 20 * scale; + if (app.loaded) { + DrawTextScaled(TextFormat("Sample Rate: %d Hz", app.signal.sampleRate), x, y, 14, GRAY); y += 18 * scale; + DrawTextScaled(TextFormat("Duration: %.2f sec", app.signal.duration), x, y, 14, GRAY); y += 18 * scale; + DrawTextScaled(TextFormat("Max Freq: %.1f kHz", (float)app.signal.sampleRate / 2000.0f), x, y, 14, GRAY); y += 18 * scale; + } else { + DrawTextScaled("No file loaded", x, y, 14, GRAY); y += 18 * scale; + } + + if (needsRegen && app.stftComputed) { + GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture); + app.visibleTextureValid = false; // Force cache invalidation + } +} + +static void DrawSlider(Rectangle bounds, float value) +{ + // Background + DrawRectangleRec(bounds, DARKGRAY); + DrawRectangleLinesEx(bounds, 1, GRAY); + + // Fill + float fillWidth = (bounds.width - 4) * value; + DrawRectangle(bounds.x + 2, bounds.y + 2, fillWidth, bounds.height - 4, BLUE); + + // Handle + float handleX = bounds.x + 2 + fillWidth; + DrawRectangle(handleX - 3, bounds.y + 1, 6, bounds.height - 2, WHITE); +} + +static bool UpdateSlider(Rectangle bounds, float* value) +{ + bool changed = false; + if (CheckCollisionPointRec(GetMousePosition(), bounds) && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { + float newValue = (GetMousePosition().x - bounds.x - 2) / (bounds.width - 4); + newValue = Clamp(newValue, 0.0f, 1.0f); + if (fabsf(newValue - *value) > 0.001f) { + *value = newValue; + changed = true; + } + } + return changed; +} diff --git a/src/ui.h b/src/ui.h new file mode 100644 index 0000000..18be09c --- /dev/null +++ b/src/ui.h @@ -0,0 +1,18 @@ +// ui.h - file browser, sidebar controls, and PNG export +#ifndef UI_H +#define UI_H + +#include "spectrogram_types.h" + +// --- File browser --- +void ScanDirectory(const char* path); +void FreeBrowserFiles(void); +void DrawFileBrowser(void); + +// --- Sidebar --- +void DrawSidebar(void); + +// --- PNG export --- +void ExportPNG(const SpectrogramApp* spa, const char* dirPath); + +#endif // UI_H diff --git a/src/utils.c b/src/utils.c index 93c798b..1ce3d2e 100644 --- a/src/utils.c +++ b/src/utils.c @@ -1,26 +1,11 @@ // Signal analysis utilities for spectrogram selection stats +#include "utils.h" + #include #include #include -typedef struct { - float* samples; - int numSamples; - int sampleRate; - int channels; - float duration; -} AudioSignal; - -typedef struct { - float durationSec; - float energy; - float peakAmplitude; - float rmsAmplitude; - float paprDb; // Peak-to-Average Power Ratio in dB - int peakSampleIdx; // sample index of peak amplitude -} SignalStats; - SignalStats ComputeSignalStats(const AudioSignal* signal, int startSample, int endSample) { SignalStats stats = {0};