refactor: split spectrogram.c into per-concern modules
spectrogram.c was ~2950 lines holding everything. Break it into cohesive translation units; spectrogram.c keeps only globals + the main frame loop. New modules: - spectrogram_types.h shared types, constants, extern globals, inline math - fft.c/.h FFT, bit-reverse, twiddle (standalone, no app deps) - stft.c/.h STFT compute, adaptive resolution, FFT-size LRU cache - audio.c/.h WAV/ffmpeg load, FreeSignal, bandpass, playback - render.c/.h UI scaling, colormaps, texture gen, on-screen drawing - ui.c/.h file browser, sidebar, sliders, PNG export Also: - utils.c now includes utils.h instead of re-typedef'ing AudioSignal/ SignalStats (they had to be hand-synced before). - Remove dead code: ApplyHannWindow and ComputeSTFTHighResRange were never called (the live high-res path is ComputeNextHighResChunk). - Delete the unused raylib-template main.c. - rspektrum.make: build the new units. premake5.lua: glob src/**.c so a future regen stays correct. Pure code movement otherwise; no behavior change. Builds clean (-Wshadow). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -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"}
|
||||
|
||||
@@ -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 "$<"
|
||||
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
// audio.c - WAV loading (with ffmpeg fallback), bandpass filtering, playback
|
||||
#include "audio.h"
|
||||
#include "fft.h"
|
||||
#include "platform.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
#include <complex.h>
|
||||
#include <stdio.h>
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
+15
@@ -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
|
||||
@@ -0,0 +1,37 @@
|
||||
// fft.c - Radix-2 Cooley-Tukey FFT
|
||||
#include "fft.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <complex.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// fft.h - Radix-2 Cooley-Tukey FFT (standalone DSP, no app dependencies)
|
||||
#ifndef FFT_H
|
||||
#define FFT_H
|
||||
|
||||
#include <complex.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#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
|
||||
-54
@@ -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;
|
||||
}
|
||||
+494
@@ -0,0 +1,494 @@
|
||||
// render.c - colormaps, spectrogram texture generation, and on-screen drawing
|
||||
#include "render.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
// ===== 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 });
|
||||
}
|
||||
@@ -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
|
||||
+13
-1971
File diff suppressed because it is too large
Load Diff
@@ -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 <stdbool.h>
|
||||
#include <math.h>
|
||||
|
||||
#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
|
||||
+387
@@ -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 <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
#include <complex.h>
|
||||
|
||||
// ===== FFT-size cache (LRU) =====
|
||||
static bool IsSTFTComplete(const StftResult* r)
|
||||
{
|
||||
if (r->numSegments <= 0 || r->segments == NULL) return false;
|
||||
for (int i = 0; i < r->numSegments; i++) {
|
||||
if (r->segments[i].spectrum == NULL) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Deep-copy src into dst. dst is assumed to be empty (freed) beforehand.
|
||||
// Handles sparse results safely: a segment with no computed spectrum is copied
|
||||
// as NULL rather than dereferencing a NULL source pointer (the bug that caused
|
||||
// the load crash for files long enough to use a skipFactor > 1 overview).
|
||||
static void CopySTFT(StftResult* dst, const StftResult* src)
|
||||
{
|
||||
dst->numSegments = src->numSegments;
|
||||
dst->sampleRate = src->sampleRate;
|
||||
dst->totalSamples = src->totalSamples;
|
||||
dst->useHannWindow = src->useHannWindow;
|
||||
dst->segments = (StftSegment*)malloc(src->numSegments * sizeof(StftSegment));
|
||||
for (int i = 0; i < src->numSegments; i++) {
|
||||
const StftSegment* s = &src->segments[i];
|
||||
StftSegment* d = &dst->segments[i];
|
||||
d->numBins = s->numBins;
|
||||
d->sampleOffset = s->sampleOffset;
|
||||
d->sampleCount = s->sampleCount;
|
||||
if (s->spectrum != NULL && s->numBins > 0) {
|
||||
d->spectrum = (FrequencyData*)malloc(s->numBins * sizeof(FrequencyData));
|
||||
memcpy(d->spectrum, s->spectrum, s->numBins * sizeof(FrequencyData));
|
||||
} else {
|
||||
d->spectrum = NULL;
|
||||
}
|
||||
if (s->derivativeSpectrum != NULL && s->numBins > 0) {
|
||||
d->derivativeSpectrum = (FrequencyData*)malloc(s->numBins * sizeof(FrequencyData));
|
||||
memcpy(d->derivativeSpectrum, s->derivativeSpectrum, s->numBins * sizeof(FrequencyData));
|
||||
} else {
|
||||
d->derivativeSpectrum = NULL;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Free all STFT results in the cache.
|
||||
*/
|
||||
void FreeAllCacheEntries(FFTSizeCache* cache)
|
||||
{
|
||||
for (int i = 0; i < cache->count; i++) {
|
||||
FreeSTFT(&cache->entries[i].result);
|
||||
cache->entries[i].result.sampleRate = 0;
|
||||
cache->entries[i].accessOrder = 0;
|
||||
}
|
||||
cache->count = 0;
|
||||
cache->nextOrder = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a cache entry by FFT size. Returns NULL if not present.
|
||||
* On a hit, marks the entry as most recently used.
|
||||
*/
|
||||
static FFTCacheEntry* FindCacheEntry(FFTSizeCache* cache, int fftSize)
|
||||
{
|
||||
for (int i = 0; i < cache->count; i++) {
|
||||
if (cache->entries[i].fftSize == fftSize) {
|
||||
cache->entries[i].accessOrder = cache->nextOrder++;
|
||||
return &cache->entries[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a cache entry for the given FFT size, or create one.
|
||||
* If the cache is full, evicts the least-recently-used entry.
|
||||
* Returns a pointer to the entry (valid until next cache access).
|
||||
*/
|
||||
static FFTCacheEntry* FindOrCreateCacheEntry(FFTSizeCache* cache, int fftSize, int sampleRate)
|
||||
{
|
||||
FFTCacheEntry* existing = FindCacheEntry(cache, fftSize);
|
||||
if (existing) return existing;
|
||||
|
||||
// Entry not found — need to create it
|
||||
if (cache->count >= FFT_CACHE_SIZE) {
|
||||
// Evict least recently used (lowest accessOrder)
|
||||
int lruIdx = 0;
|
||||
for (int i = 1; i < cache->count; i++) {
|
||||
if (cache->entries[i].accessOrder < cache->entries[lruIdx].accessOrder) {
|
||||
lruIdx = i;
|
||||
}
|
||||
}
|
||||
FreeSTFT(&cache->entries[lruIdx].result);
|
||||
// Reuse slot
|
||||
cache->entries[lruIdx].fftSize = fftSize;
|
||||
cache->entries[lruIdx].result.numSegments = 0;
|
||||
cache->entries[lruIdx].result.segments = NULL;
|
||||
cache->entries[lruIdx].accessOrder = cache->nextOrder++;
|
||||
return &cache->entries[lruIdx];
|
||||
}
|
||||
|
||||
// Add new entry
|
||||
int idx = cache->count++;
|
||||
cache->entries[idx].fftSize = fftSize;
|
||||
cache->entries[idx].result.numSegments = 0;
|
||||
cache->entries[idx].result.segments = NULL;
|
||||
cache->entries[idx].result.sampleRate = sampleRate;
|
||||
cache->entries[idx].accessOrder = cache->nextOrder++;
|
||||
return &cache->entries[idx];
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current app.stft result to the cache entry matching app.fftSize.
|
||||
* Creates/overwrites the entry and marks it as most recently used.
|
||||
*/
|
||||
void SaveToCache(void)
|
||||
{
|
||||
// Only cache fully-computed (full-resolution) results. A sparse overview
|
||||
// contains NULL segments and isn't worth caching — and restoring one would
|
||||
// leave permanent black gaps since we'd mark it finished.
|
||||
if (!IsSTFTComplete(&app.stft)) return;
|
||||
|
||||
FFTCacheEntry* entry = FindOrCreateCacheEntry(&app.fftCache, app.fftSize, app.signal.sampleRate);
|
||||
FreeSTFT(&entry->result);
|
||||
CopySTFT(&entry->result, &app.stft);
|
||||
TraceLog(LOG_INFO, "Saved STFT result to cache for FFT size %d (%d segments)",
|
||||
app.fftSize, app.stft.numSegments);
|
||||
}
|
||||
|
||||
// ===== Background high-res computation =====
|
||||
int ComputeNextHighResChunk(AudioSignal* signal, StftResult* result,
|
||||
int fftSize, int startSeg, int endSeg)
|
||||
{
|
||||
int hopSize = fftSize / HOP_RATIO;
|
||||
int numBins = fftSize / 2 + 1;
|
||||
float* windowedSamples = (float*)malloc(fftSize * sizeof(float));
|
||||
float* derivWindowedSamples = (float*)malloc(fftSize * sizeof(float));
|
||||
float complex *complexInput = (float complex*)malloc(fftSize * sizeof(float complex));
|
||||
float complex* fftOutput = (float complex*)malloc(fftSize * sizeof(float complex));
|
||||
|
||||
for (int seg = startSeg; seg < endSeg && seg < result->numSegments; seg++) {
|
||||
// Skip if already computed (overview or high-res)
|
||||
if (result->segments[seg].spectrum != NULL) continue;
|
||||
|
||||
int offset = seg * hopSize;
|
||||
int samplesToCopy = fftSize;
|
||||
if (offset + samplesToCopy > signal->numSamples) {
|
||||
samplesToCopy = signal->numSamples - offset;
|
||||
memset(windowedSamples, 0, fftSize * sizeof(float));
|
||||
memset(derivWindowedSamples, 0, fftSize * sizeof(float));
|
||||
} else {
|
||||
memcpy(windowedSamples, signal->samples + offset, fftSize * sizeof(float));
|
||||
memcpy(derivWindowedSamples, signal->samples + offset, fftSize * sizeof(float));
|
||||
}
|
||||
|
||||
// Apply Hann window and derivative window
|
||||
for (int i = 0; i < fftSize; i++) {
|
||||
float t = (float)i / (fftSize - 1);
|
||||
float hann = 0.5f * (1.0f - cosf(2.0f * M_PI * t));
|
||||
float derivHann = M_PI * sinf(2.0f * M_PI * t);
|
||||
windowedSamples[i] *= hann;
|
||||
derivWindowedSamples[i] *= derivHann;
|
||||
}
|
||||
|
||||
// Normal STFT
|
||||
for (int i = 0; i < fftSize; i++) complexInput[i] = windowedSamples[i] + 0.0f * I;
|
||||
FFT(complexInput, fftOutput, fftSize, false);
|
||||
|
||||
result->segments[seg].numBins = numBins;
|
||||
result->segments[seg].sampleOffset = offset;
|
||||
result->segments[seg].sampleCount = samplesToCopy;
|
||||
result->segments[seg].spectrum = (FrequencyData*)malloc(numBins * sizeof(FrequencyData));
|
||||
|
||||
for (int bin = 0; bin < numBins; bin++) {
|
||||
result->segments[seg].spectrum[bin].frequency = (float)bin * signal->sampleRate / fftSize;
|
||||
result->segments[seg].spectrum[bin].amplitude = (bin == 0) ? cabsf(fftOutput[bin]) / fftSize : 2.0f * cabsf(fftOutput[bin]) / fftSize;
|
||||
result->segments[seg].spectrum[bin].phase = cargf(fftOutput[bin]);
|
||||
}
|
||||
|
||||
// Derivative-window STFT for synchrosqueezing
|
||||
result->segments[seg].derivativeSpectrum = (FrequencyData*)malloc(numBins * sizeof(FrequencyData));
|
||||
for (int i = 0; i < fftSize; i++) complexInput[i] = derivWindowedSamples[i] + 0.0f * I;
|
||||
FFT(complexInput, fftOutput, fftSize, false);
|
||||
|
||||
for (int bin = 0; bin < numBins; bin++) {
|
||||
result->segments[seg].derivativeSpectrum[bin].frequency = (float)bin * signal->sampleRate / fftSize;
|
||||
result->segments[seg].derivativeSpectrum[bin].amplitude = cabsf(fftOutput[bin]) / fftSize;
|
||||
result->segments[seg].derivativeSpectrum[bin].phase = cargf(fftOutput[bin]);
|
||||
}
|
||||
}
|
||||
|
||||
free(windowedSamples);
|
||||
free(derivWindowedSamples);
|
||||
free(complexInput);
|
||||
free(fftOutput);
|
||||
|
||||
// Return next segment to process
|
||||
if (endSeg >= result->numSegments) return result->numSegments;
|
||||
return endSeg;
|
||||
}
|
||||
|
||||
// ===== STFT computation =====
|
||||
void ComputeSTFTInit(AudioSignal* signal, StftResult* result, int fftSize)
|
||||
{
|
||||
FreeSTFT(result); // release any previous result before reallocating
|
||||
int hopSize = fftSize / HOP_RATIO; // 75% overlap
|
||||
int numSegments = (signal->numSamples - fftSize) / hopSize + 1;
|
||||
if (numSegments <= 0) numSegments = 1;
|
||||
|
||||
result->numSegments = numSegments;
|
||||
result->segments = (StftSegment*)calloc(numSegments, sizeof(StftSegment));
|
||||
result->sampleRate = signal->sampleRate;
|
||||
result->totalSamples = signal->numSamples;
|
||||
result->useHannWindow = true;
|
||||
}
|
||||
|
||||
bool ComputeSTFTIncremental(AudioSignal* signal, StftResult* result, int fftSize, int startSegment)
|
||||
{
|
||||
int hopSize = fftSize / HOP_RATIO;
|
||||
int numBins = fftSize / 2 + 1;
|
||||
float* windowedSamples = (float*)malloc(fftSize * sizeof(float));
|
||||
float* derivWindowedSamples = (float*)malloc(fftSize * sizeof(float));
|
||||
float complex *complexInput = (float complex*)malloc(fftSize * sizeof(float complex));
|
||||
float complex* fftOutput = (float complex*)malloc(fftSize * sizeof(float complex));
|
||||
|
||||
for (int seg = startSegment; seg < result->numSegments; seg++) {
|
||||
// Skip segments not aligned with the skip factor (overview mode)
|
||||
if (seg % app.skipFactor != 0) continue;
|
||||
|
||||
// Skip if already computed as high-res
|
||||
if (result->segments[seg].spectrum != NULL) continue;
|
||||
int offset = seg * hopSize;
|
||||
int samplesToCopy = fftSize;
|
||||
if (offset + samplesToCopy > signal->numSamples) {
|
||||
samplesToCopy = signal->numSamples - offset;
|
||||
memset(windowedSamples, 0, fftSize * sizeof(float));
|
||||
memset(derivWindowedSamples, 0, fftSize * sizeof(float));
|
||||
} else {
|
||||
memcpy(windowedSamples, signal->samples + offset, fftSize * sizeof(float));
|
||||
memcpy(derivWindowedSamples, signal->samples + offset, fftSize * sizeof(float));
|
||||
}
|
||||
|
||||
// Apply Hann window: h(t) = 0.5 * (1 - cos(2πt))
|
||||
// And derivative window: h'(t) = π * sin(2πt)
|
||||
for (int i = 0; i < fftSize; i++) {
|
||||
float t = (float)i / (fftSize - 1);
|
||||
float hann = 0.5f * (1.0f - cosf(2.0f * M_PI * t));
|
||||
float derivHann = M_PI * sinf(2.0f * M_PI * t);
|
||||
windowedSamples[i] *= hann;
|
||||
derivWindowedSamples[i] *= derivHann;
|
||||
}
|
||||
|
||||
// Compute normal STFT (V_f)
|
||||
for (int i = 0; i < fftSize; i++) complexInput[i] = windowedSamples[i] + 0.0f * I;
|
||||
FFT(complexInput, fftOutput, fftSize, false);
|
||||
|
||||
result->segments[seg].numBins = numBins;
|
||||
result->segments[seg].sampleOffset = offset;
|
||||
result->segments[seg].sampleCount = samplesToCopy;
|
||||
result->segments[seg].spectrum = (FrequencyData*)malloc(numBins * sizeof(FrequencyData));
|
||||
|
||||
for (int bin = 0; bin < numBins; bin++) {
|
||||
result->segments[seg].spectrum[bin].frequency = (float)bin * signal->sampleRate / fftSize;
|
||||
result->segments[seg].spectrum[bin].amplitude = (bin == 0) ? cabsf(fftOutput[bin]) / fftSize : 2.0f * cabsf(fftOutput[bin]) / fftSize;
|
||||
result->segments[seg].spectrum[bin].phase = cargf(fftOutput[bin]);
|
||||
}
|
||||
|
||||
// Compute derivative-window STFT (V_fd) for synchrosqueezing
|
||||
result->segments[seg].derivativeSpectrum = (FrequencyData*)malloc(numBins * sizeof(FrequencyData));
|
||||
for (int i = 0; i < fftSize; i++) complexInput[i] = derivWindowedSamples[i] + 0.0f * I;
|
||||
FFT(complexInput, fftOutput, fftSize, false);
|
||||
|
||||
for (int bin = 0; bin < numBins; bin++) {
|
||||
result->segments[seg].derivativeSpectrum[bin].frequency = (float)bin * signal->sampleRate / fftSize;
|
||||
result->segments[seg].derivativeSpectrum[bin].amplitude = cabsf(fftOutput[bin]) / fftSize;
|
||||
result->segments[seg].derivativeSpectrum[bin].phase = cargf(fftOutput[bin]);
|
||||
}
|
||||
}
|
||||
|
||||
free(windowedSamples);
|
||||
free(derivWindowedSamples);
|
||||
free(complexInput);
|
||||
free(fftOutput);
|
||||
return true;
|
||||
}
|
||||
|
||||
void FreeSTFT(StftResult* result)
|
||||
{
|
||||
if (!result) return;
|
||||
if (result->segments) {
|
||||
for (int i = 0; i < result->numSegments; i++) {
|
||||
free(result->segments[i].spectrum);
|
||||
result->segments[i].spectrum = NULL;
|
||||
if (result->segments[i].derivativeSpectrum) {
|
||||
free(result->segments[i].derivativeSpectrum);
|
||||
result->segments[i].derivativeSpectrum = NULL;
|
||||
}
|
||||
}
|
||||
free(result->segments);
|
||||
result->segments = NULL;
|
||||
}
|
||||
result->numSegments = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the FFT size. If a fully-computed result for the new size is cached,
|
||||
* restore it directly (no recomputation). Otherwise free the current STFT and
|
||||
* let the main loop recompute it from scratch.
|
||||
*/
|
||||
void ChangeFFTSize(int newFFT)
|
||||
{
|
||||
FFTCacheEntry* entry = FindCacheEntry(&app.fftCache, newFFT);
|
||||
|
||||
if (entry != NULL && IsSTFTComplete(&entry->result)) {
|
||||
// Cache hit — restore the cached full-resolution result.
|
||||
TraceLog(LOG_INFO, "FFT size %d: cache hit", newFFT);
|
||||
FreeSTFT(&app.stft);
|
||||
CopySTFT(&app.stft, &entry->result);
|
||||
|
||||
app.fftSize = newFFT;
|
||||
app.skipFactor = 1;
|
||||
app.stftComputed = true; // already complete — skip recompute
|
||||
app.loadingPhase = 0;
|
||||
app.highResFinished = true;
|
||||
app.bgHighResSeg = app.stft.numSegments;
|
||||
app.bgFinished = true;
|
||||
app.isBgProcessing = false;
|
||||
app.visibleTextureValid = false;
|
||||
|
||||
// Rebuild the displayed texture from the restored data. AutoScale here
|
||||
// mirrors the recompute path so the view looks identical either way.
|
||||
AutoScaleAmplitude(&app.stft);
|
||||
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
|
||||
} else {
|
||||
// Cache miss — drop the current STFT and recompute. Freeing here avoids
|
||||
// leaking it, since ComputeSTFTInit re-allocates segments unconditionally.
|
||||
TraceLog(LOG_INFO, "FFT size %d: cache miss, computing", newFFT);
|
||||
FreeSTFT(&app.stft);
|
||||
app.fftSize = newFFT;
|
||||
app.stftComputed = false;
|
||||
app.loadingPhase = 0;
|
||||
app.skipFactor = 1;
|
||||
app.highResFinished = false;
|
||||
app.bgHighResSeg = 0;
|
||||
app.bgFinished = false;
|
||||
app.isBgProcessing = false;
|
||||
app.visibleTextureValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Adaptive resolution =====
|
||||
int ComputeSkipFactor(float signalDurationSec)
|
||||
{
|
||||
if (signalDurationSec <= 60.0f) return 1; // < 1 min: full-res
|
||||
if (signalDurationSec <= 300.0f) return 2; // 1-5 min: every 2nd
|
||||
if (signalDurationSec <= 600.0f) return 4; // 5-10 min: every 4th
|
||||
return 8; // > 10 min: every 8th
|
||||
}
|
||||
|
||||
// Compute full-resolution segments for the range [startSeg, endSeg).
|
||||
// This replaces existing overview (skipFactor-strided) segments with
|
||||
|
||||
// ===== Amplitude auto-scaling =====
|
||||
void AutoScaleAmplitude(StftResult* stft)
|
||||
{
|
||||
float maxDb = -999.0f;
|
||||
float minDb = 0.0f;
|
||||
for (int seg = 0; seg < stft->numSegments; seg++) {
|
||||
for (int bin = 0; bin < stft->segments[seg].numBins; bin++) {
|
||||
float db = AmplitudeToDecibels(stft->segments[seg].spectrum[bin].amplitude);
|
||||
if (db > maxDb) maxDb = db;
|
||||
if (db < minDb) minDb = db;
|
||||
}
|
||||
}
|
||||
// Set ceiling at the max, floor 40dB below — enough range to see structure
|
||||
// but not so wide that the signal is drowned in black
|
||||
app.amplitudeCeilingDb = maxDb;
|
||||
app.amplitudeFloorDb = maxDb - 40.0f;
|
||||
}
|
||||
+22
@@ -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
|
||||
@@ -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 <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <math.h>
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -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
|
||||
+2
-17
@@ -1,26 +1,11 @@
|
||||
// Signal analysis utilities for spectrogram selection stats
|
||||
|
||||
#include "utils.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
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};
|
||||
|
||||
Reference in New Issue
Block a user