Files
rspektrum/src/spectrogram.c
T

1603 lines
68 KiB
C

// spectrogram.c - Spectrogram viewer with region selection and playback
// Based on Unity Audio-Experiments project
#define _POSIX_C_SOURCE 200809L
#define _DEFAULT_SOURCE
#include "raylib.h"
#include "resource_dir.h"
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include <complex.h>
#include <stdbool.h>
#include <stdio.h>
#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
// ============================================================================
#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
// 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* samples;
int numSamples;
int sampleRate;
int channels;
float duration;
} AudioSignal;
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 {
AudioSignal signal;
StftResult stft;
Image spectrogramImage;
Texture2D spectrogramTexture;
bool loaded;
bool stftComputed;
// Time selection (0-1 normalized)
float timeSelectionStart;
float timeSelectionEnd;
bool isTimeSelecting;
// Frequency selection (0-1 normalized)
float freqSelectionStart;
float freqSelectionEnd;
bool isFreqSelecting;
// 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
} SpectrogramApp;
// ============================================================================
// Global State
// ============================================================================
static SpectrogramApp app = {0};
static Sound AudioPlaybackSound = {0};
static Texture2D colormapTexture = {0};
// ============================================================================
// Utility Functions
// ============================================================================
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);
}
// ============================================================================
// 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 ComputeSTFT(AudioSignal* signal, StftResult* result, int fftSize)
{
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*)malloc(numSegments * sizeof(StftSegment));
result->sampleRate = signal->sampleRate;
result->totalSamples = signal->numSamples;
result->useHannWindow = true;
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 = 0; seg < numSegments; seg++) {
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);
}
static void FreeSTFT(StftResult* result)
{
for (int i = 0; i < result->numSegments; i++) {
free(result->segments[i].spectrum);
if (result->segments[i].derivativeSpectrum) free(result->segments[i].derivativeSpectrum);
}
free(result->segments);
result->segments = NULL;
result->numSegments = 0;
}
// ============================================================================
// Audio Loading
// ============================================================================
// Convert audio file to WAV using ffmpeg (if available)
static bool ConvertToFFmpegWAV(const char* inputPath, char* outputPath, size_t outputSize)
{
// Create temp WAV path
snprintf(outputPath, outputSize, "/tmp/rspektrum_temp_converted.wav");
// Build ffmpeg command
char cmd[1024];
snprintf(cmd, sizeof(cmd), "ffmpeg -y -loglevel quiet -i \"%s\" -ar 48000 -ac 1 -f wav \"%s\" 2>&1", inputPath, outputPath);
int result = system(cmd);
if (result == 0 && FileExists(outputPath)) {
TraceLog(LOG_INFO, "FFmpeg conversion successful: %s", outputPath);
return true;
}
TraceLog(LOG_WARNING, "FFmpeg conversion failed or not available");
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) remove(convertedPath);
return false;
}
signal->sampleRate = wave.sampleRate;
signal->channels = wave.channels;
signal->numSamples = wave.frameCount * wave.channels;
signal->duration = (float)wave.frameCount / wave.sampleRate;
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) {
remove(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;
*image = GenImageColor(width, height, BLACK);
Color* pixels = (Color*)image->data;
// Find max amplitude for normalization
float maxAmplitude = 0.0001f;
for (int seg = 0; seg < stft->numSegments; seg++)
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++) {
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);
}
// ============================================================================
// 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* windowedSamples = (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));
for (int i = 0; i < numSamples; i++) {
float window = 0.5f * (1.0f - cosf(2.0f * M_PI * i / (numSamples - 1)));
windowedSamples[i] = samples[i] * window;
}
for (int i = 0; i < fftSize; i++) fftInput[i] = windowedSamples[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(windowedSamples); 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])) {
app.browserFiles[app.browserFileCount] = strdup(name);
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)) {
app.browserFiles[app.browserFileCount] = strdup(name);
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.timeSelectionStart = app.viewStart = 0.0f;
app.timeSelectionEnd = app.viewEnd = 1.0f;
app.freqSelectionStart = 0.0f;
app.freqSelectionEnd = 1.0f;
app.showFileBrowser = false;
// 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 bw = 650, bh = 550;
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 }, 2, GRAY);
DrawRectangle(bx, by, bw, 35, (Color){ 60, 60, 75, 255 });
DrawText("File Browser - Select WAV File", bx + 15, by + 10, 18, WHITE);
// Path bar
DrawRectangle(bx + 15, by + 45, bw - 110, 28, (Color){ 30, 30, 40, 255 });
DrawRectangleLinesEx((Rectangle){ bx + 15, by + 45, bw - 110, 28 }, 1, 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);
DrawText(displayPath, bx + 20, by + 51, 14, LIGHTGRAY);
// Up button
Rectangle upBtn = { bx + bw - 85, by + 45, 70, 28 };
if (CheckCollisionPointRec(GetMousePosition(), upBtn)) DrawRectangleRec(upBtn, (Color){ 80, 80, 90, 255 });
DrawText("UP (..)", upBtn.x + 10, upBtn.y + 7, 14, WHITE);
// File list
float lx = bx + 15, ly = by + 82, lw = bw - 30, lh = bh - 150;
DrawRectangle(lx, ly, lw, lh, (Color){ 25, 25, 35, 255 });
DrawRectangleLinesEx((Rectangle){ lx, ly, lw, lh }, 1, GRAY);
// Handle empty directory
int visibleItems = (int)(lh / 26);
if (visibleItems < 1) visibleItems = 1;
if (app.browserFileCount <= 0 || !app.browserFiles) {
DrawText("(No WAV files in directory)", lx + 20, ly + lh/2 - 10, 14, GRAY);
} else {
if (app.browserFileCount > visibleItems) {
float sh = (float)visibleItems / app.browserFileCount * lh;
if (sh < 10) sh = 10;
float sy = ly + (float)app.browserScroll / (app.browserFileCount - visibleItems) * (lh - sh);
DrawRectangle(lx + lw - 10, sy, 8, sh, GRAY);
}
int startItem = app.browserScroll;
int endItem = startItem + visibleItems + 1;
if (endItem > app.browserFileCount) endItem = app.browserFileCount;
for (int i = startItem; i < endItem; i++) {
if (i < 0 || i >= app.browserFileCount || !app.browserFiles[i] || !app.browserIsDir) continue;
float iy = ly + (i - startItem) * 26 + 2;
bool hovered = CheckCollisionPointRec((Vector2){ GetMouseX(), GetMouseY() }, (Rectangle){ lx + 2, iy, lw - 14, 24 });
if (i == app.browserSelected) DrawRectangle(lx + 2, iy, lw - 14, 24, (Color){ 50, 70, 120, 180 });
else if (hovered) DrawRectangle(lx + 2, iy, lw - 14, 24, (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 };
DrawText(icon, lx + 8, iy + 5, 13, iconCol);
DrawText(app.browserFiles[i], lx + 55, iy + 5, 14, WHITE);
}
}
// Scroll with mouse wheel
if (CheckCollisionPointRec(GetMousePosition(), (Rectangle){ lx, ly, lw - 10, 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 - 10, lh }) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && app.browserFileCount > 0) {
int clicked = app.browserScroll + (int)((GetMouseY() - ly) / 26);
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 - 50;
Rectangle openBtn = { bx + bw - 160, btnY, 140, 38 };
Rectangle cancelBtn = { bx + 15, btnY, 110, 38 };
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, 1, WHITE);
DrawText("OPEN (Enter)", openBtn.x + 25, openBtn.y + 12, 16, WHITE);
DrawRectangleRec(cancelBtn, (Color){ 100, 40, 40, 255 });
DrawText("ESC Cancel", cancelBtn.x + 18, cancelBtn.y + 12, 16, 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 fontSize = 10;
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);
Vector2 textSize = MeasureTextEx(GetFontDefault(), label, fontSize, 0);
DrawText(label, x - textSize.x / 2, bounds.y + bounds.height + 5, fontSize, textColor);
}
// Frequency labels at 1kHz intervals with 200Hz ticks
float maxFreq = (float)app.signal.sampleRate / 2.0f;
int maxKhz = (int)(maxFreq / 1000.0f);
for (int hz = 0; hz <= maxFreq; hz += 200) {
float t = hz / maxFreq;
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);
}
for (int khz = 0; khz <= maxKhz; khz++) {
float freq = khz * 1000.0f;
if (freq > maxFreq) break;
float t = freq / maxFreq;
float y = bounds.y + bounds.height - t * bounds.height;
char label[32];
if (khz == 0) sprintf(label, "0");
else if (khz < 10) sprintf(label, "%dk", khz);
else sprintf(label, "%d", khz);
Vector2 textSize = MeasureTextEx(GetFontDefault(), label, fontSize, 0);
DrawText(label, bounds.x - textSize.x - 15, y - textSize.y / 2, fontSize, textColor);
}
// Axis titles removed - just the tick labels are enough
}
static void DrawSlider(Rectangle bounds, float value);
static bool UpdateSlider(Rectangle bounds, float* value);
static void DrawSidebar(void);
static void DrawSelection(Rectangle bounds)
{
Color overlayColor = Fade(BLACK, 0.5f);
float selStartX = bounds.x + app.timeSelectionStart * bounds.width;
float selEndX = bounds.x + app.timeSelectionEnd * bounds.width;
if (selStartX > bounds.x) DrawRectangle(bounds.x, bounds.y, selStartX - bounds.x, bounds.height, overlayColor);
if (selEndX < bounds.x + bounds.width) DrawRectangle(selEndX, bounds.y, bounds.x + bounds.width - selEndX, bounds.height, overlayColor);
float selStartY = bounds.y + bounds.height - app.freqSelectionEnd * bounds.height;
float selEndY = bounds.y + bounds.height - app.freqSelectionStart * bounds.height;
if (selStartY > bounds.y) DrawRectangle(selStartX, bounds.y, selEndX - selStartX, selStartY - bounds.y, overlayColor);
if (selEndY < bounds.y + bounds.height) DrawRectangle(selStartX, selEndY, selEndX - selStartX, bounds.y + bounds.height - selEndY, overlayColor);
DrawLineV((Vector2){ selStartX, bounds.y }, (Vector2){ selStartX, bounds.y + bounds.height }, YELLOW);
DrawLineV((Vector2){ selEndX, bounds.y }, (Vector2){ selEndX, bounds.y + bounds.height }, YELLOW);
if (app.freqSelectionStart > 0.0f || app.freqSelectionEnd < 1.0f) {
DrawLineV((Vector2){ selStartX, selStartY }, (Vector2){ selEndX, selStartY }, CYAN);
DrawLineV((Vector2){ selStartX, selEndY }, (Vector2){ selEndX, selEndY }, CYAN);
}
}
static void DrawSidebar(void)
{
float sidebarWidth = 300;
float x = 10;
float y = 10;
int fontSize = 12;
bool needsRegen = false;
// Dark sidebar background
DrawRectangle(0, 0, sidebarWidth + 20, GetScreenHeight(), (Color){ 35, 35, 40, 255 });
DrawLine(sidebarWidth + 10, 0, sidebarWidth + 10, GetScreenHeight(), GRAY);
// Title
DrawText("Spectrogram Controls", x, y, 14, WHITE); y += 25;
// FFT Size clicker
DrawText(TextFormat("FFT Size: %d (%.1f Hz/bin)", app.fftSize, (float)app.signal.sampleRate / app.fftSize), x, y, fontSize, LIGHTGRAY); y += 18;
Rectangle fftMinus = { x, y, 30, 25 };
Rectangle fftPlus = { x + sidebarWidth - 40, y, 30, 25 };
if (CheckCollisionPointRec(GetMousePosition(), fftMinus) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
int newFFT = app.fftSize / 2;
if (newFFT >= FFT_SIZE_MIN) { app.fftSize = newFFT; app.stftComputed = false; needsRegen = true; }
}
if (CheckCollisionPointRec(GetMousePosition(), fftPlus) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
int newFFT = app.fftSize * 2;
if (newFFT <= FFT_SIZE_MAX) { app.fftSize = newFFT; app.stftComputed = false; needsRegen = true; }
}
DrawRectangleRec(fftMinus, (Color){ 50, 50, 60, 255 });
DrawRectangleLinesEx(fftMinus, 1, GRAY);
DrawText("-", fftMinus.x + 12, fftMinus.y + 5, 16, WHITE);
DrawRectangleRec(fftPlus, (Color){ 50, 50, 60, 255 });
DrawRectangleLinesEx(fftPlus, 1, GRAY);
DrawText("+", fftPlus.x + 10, fftPlus.y + 5, 16, WHITE);
y += 30;
// dB Floor slider
DrawText(TextFormat("dB Floor: %.1f", app.amplitudeFloorDb), x, y, fontSize, LIGHTGRAY); y += 18;
Rectangle dbSlider = { x, y, sidebarWidth - 10, 20 };
float dbValue = (app.amplitudeFloorDb + 100.0f) / 80.0f;
DrawSlider(dbSlider, dbValue);
if (UpdateSlider(dbSlider, &dbValue)) {
app.amplitudeFloorDb = -100.0f + dbValue * 80.0f;
needsRegen = true; // Trigger immediate redraw
}
y += 25;
// Colormap dropdown
DrawText("Colormap:", x, y, fontSize, LIGHTGRAY); y += 18;
const char* colormapNames[] = { "Grays", "Inferno", "Viridis", "Plasma", "Hot", "Cool" };
Rectangle cmapButton = { x, y, sidebarWidth - 10, 25 };
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);
DrawText(colormapNames[app.colormap], cmapButton.x + 10, cmapButton.y + 6, fontSize, WHITE);
DrawTexturePro(colormapTexture, (Rectangle){ 0, 0, 256, 1 },
(Rectangle){ cmapButton.x + cmapButton.width - 60, cmapButton.y + 5, 50, 15 },
(Vector2){ 0, 0 }, 0.0f, WHITE);
y += 30;
// Grid toggle
Rectangle gridCheck = { x, y, 18, 18 };
if (CheckCollisionPointRec(GetMousePosition(), gridCheck) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
app.showGrid = !app.showGrid;
}
DrawRectangleRec(gridCheck, app.showGrid ? BLUE : DARKGRAY);
DrawRectangleLinesEx(gridCheck, 1, WHITE);
DrawText("Show Grid", x + 25, y + 2, fontSize, LIGHTGRAY); y += 25;
// File loading
DrawText("File:", x, y, fontSize, LIGHTGRAY); y += 18;
Rectangle fileButton = { x, y, sidebarWidth - 10, 25 };
if (CheckCollisionPointRec(GetMousePosition(), fileButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
app.showFileBrowser = true;
ScanDirectory(GetWorkingDirectory());
}
DrawRectangleRec(fileButton, (Color){ 50, 50, 60, 255 });
DrawRectangleLinesEx(fileButton, 1, GRAY);
DrawText("Open File Browser (O)", fileButton.x + 10, fileButton.y + 6, fontSize, WHITE);
y += 35;
// Playback
Rectangle playButton = { x, y, sidebarWidth - 10, 35 };
if (CheckCollisionPointRec(GetMousePosition(), playButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
if (app.isPlaying && AudioPlaybackSound.frameCount > 0) {
StopSound(AudioPlaybackSound);
app.isPlaying = false;
} 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);
DrawText(playText, playButton.x + 10, playButton.y + 12, fontSize, WHITE);
y += 45;
// Reset/Clear buttons
Rectangle resetButton = { x, y, (sidebarWidth - 15) / 2, 25 };
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);
DrawText("Reset Sel (R)", resetButton.x + 10, resetButton.y + 6, fontSize, WHITE);
Rectangle clearButton = { x + resetButton.width + 5, y, (sidebarWidth - 15) / 2, 25 };
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);
DrawText("Clear (ESC)", clearButton.x + 10, clearButton.y + 6, fontSize, WHITE);
y += 35;
// Signal info
DrawText("Signal Info:", x, y, fontSize, LIGHTGRAY); y += 18;
if (app.loaded) {
DrawText(TextFormat("Sample Rate: %d Hz", app.signal.sampleRate), x, y, fontSize, GRAY); y += 16;
DrawText(TextFormat("Duration: %.2f sec", app.signal.duration), x, y, fontSize, GRAY); y += 16;
DrawText(TextFormat("Max Freq: %.1f kHz", (float)app.signal.sampleRate / 2000.0f), x, y, fontSize, GRAY); y += 16;
} else {
DrawText("No file loaded", x, y, fontSize, GRAY); y += 16;
}
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
// ============================================================================
int main(int argc, char* argv[])
{
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_HIGHDPI);
InitWindow(1280, 800, "Spectrogram Viewer");
SetTargetFPS(60);
SetTraceLogLevel(LOG_WARNING); // Suppress INFO texture logs
InitAudioDevice();
SearchAndSetResourceDir("resources");
app.timeSelectionStart = 0.0f; app.timeSelectionEnd = 1.0f;
app.freqSelectionStart = 0.0f; app.freqSelectionEnd = 1.0f;
app.viewStart = 0.0f; app.viewEnd = 1.0f;
app.freqViewStart = 0.0f; app.freqViewEnd = 1.0f;
app.showGrid = true;
app.colormap = COLORMAP_INFERNO;
app.amplitudeFloorDb = -60.0f;
app.amplitudeCeilingDb = 0.0f;
app.showFileBrowser = false;
app.isBrowsing = false;
app.visibleTexture = (Texture2D){ 0 };
app.cachedVisibleStart = -1;
app.cachedVisibleEnd = -1;
app.cachedVisibleStartY = -1;
app.cachedVisibleEndY = -1;
app.visibleTextureValid = false;
app.fftSize = FFT_SIZE_DEFAULT;
app.isPlaying = false;
app.playbackFinished = false;
GenerateColormapTexture();
ScanDirectory(GetWorkingDirectory());
TraceLog(LOG_INFO, "Spectrogram Viewer initialized");
bool fileLoaded = false;
if (argc > 1) {
TraceLog(LOG_INFO, "Loading file from command line: %s", argv[1]);
if (FileExists(argv[1]) && LoadWavFile(argv[1], &app.signal)) {
fileLoaded = true;
app.loaded = true;
app.stftComputed = false;
TraceLog(LOG_INFO, "File loaded successfully");
}
}
if (!fileLoaded) TraceLog(LOG_INFO, "Press 'O' for file browser or drag & drop WAV file");
while (!WindowShouldClose())
{
// Drag & Drop
if (IsFileDropped()) {
FilePathList dropped = LoadDroppedFiles();
if (dropped.count > 0) {
const char* ext = GetFileExtension(dropped.paths[0]);
bool isWav = ext && (strcmp(ext, ".wav") == 0 || strcmp(ext, ".WAV") == 0 || strcmp(ext, ".Wave") == 0 || strcmp(ext, ".Wav") == 0);
if (isWav && FileExists(dropped.paths[0])) {
if (LoadWavFile(dropped.paths[0], &app.signal)) {
app.loaded = true;
app.stftComputed = false;
app.viewStart = 0.0f; app.viewEnd = 1.0f;
// Invalidate visible texture cache
if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture);
app.visibleTexture = (Texture2D){ 0 };
app.visibleTextureValid = false;
}
}
}
UnloadDroppedFiles(dropped);
}
// File browser toggle
if (IsKeyPressed(KEY_O) && !app.showFileBrowser) {
app.showFileBrowser = true;
ScanDirectory(GetWorkingDirectory());
}
// File browser update
if (app.showFileBrowser) {
// Browser handles its own input
}
// Check if playback finished naturally
if (app.isPlaying && AudioPlaybackSound.frameCount > 0) {
// Check if sound stopped playing (IsSoundPlaying returns false when done)
if (!IsSoundPlaying(AudioPlaybackSound)) {
app.isPlaying = false;
app.playbackFinished = true;
}
}
// View controls
if (app.loaded && !app.showFileBrowser) {
// Spectrogram area fills remaining window space
// Space for: spectrogram + time labels (15px below) + scrollbar (18px below that)
float sidebarWidth = 320;
float labelHeight = 15;
float scrollbarHeight = 18;
float freqLabelWidth = 65;
float vScrollbarWidth = 18;
float topMargin = 50;
float bottomMargin = 10;
Rectangle viewBounds = {
sidebarWidth + freqLabelWidth,
topMargin,
GetScreenWidth() - sidebarWidth - freqLabelWidth - vScrollbarWidth - 20,
GetScreenHeight() - topMargin - bottomMargin - labelHeight - scrollbarHeight - 10
};
// Zoom with mouse wheel (zooms both time and frequency to maintain aspect ratio)
if (GetMousePosition().x > 385 && CheckCollisionPointRec(GetMousePosition(), viewBounds)) {
int wheel = GetMouseWheelMove();
if (wheel != 0) {
float zoomFactor = (wheel > 0) ? 0.8f : 1.2f;
// --- Time axis zoom (around cursor X) ---
float mouseT = (GetMousePosition().x - viewBounds.x) / viewBounds.width;
mouseT = app.viewStart + mouseT * (app.viewEnd - app.viewStart);
float viewWidth = app.viewEnd - app.viewStart;
float newWidth = viewWidth * zoomFactor;
if (newWidth < 0.02f) newWidth = 0.02f;
if (newWidth > 1.0f) newWidth = 1.0f;
float leftOfMouse = mouseT - app.viewStart;
float rightOfMouse = app.viewEnd - mouseT;
app.viewStart = mouseT - leftOfMouse * (newWidth / viewWidth);
app.viewEnd = mouseT + rightOfMouse * (newWidth / viewWidth);
if (app.viewStart < 0) { app.viewStart = 0; app.viewEnd = newWidth; }
if (app.viewEnd > 1) { app.viewEnd = 1; app.viewStart = 1 - newWidth; }
// --- Frequency axis zoom (around cursor Y) ---
float mouseF = 1.0f - (GetMousePosition().y - viewBounds.y) / viewBounds.height;
mouseF = app.freqViewStart + mouseF * (app.freqViewEnd - app.freqViewStart);
float freqWidth = app.freqViewEnd - app.freqViewStart;
float newFreqWidth = freqWidth * zoomFactor;
if (newFreqWidth < 0.02f) newFreqWidth = 0.02f;
if (newFreqWidth > 1.0f) newFreqWidth = 1.0f;
float belowMouse = mouseF - app.freqViewStart;
float aboveMouse = app.freqViewEnd - mouseF;
app.freqViewStart = mouseF - belowMouse * (newFreqWidth / freqWidth);
app.freqViewEnd = mouseF + aboveMouse * (newFreqWidth / freqWidth);
if (app.freqViewStart < 0) { app.freqViewStart = 0; app.freqViewEnd = newFreqWidth; }
if (app.freqViewEnd > 1) { app.freqViewEnd = 1; app.freqViewStart = 1 - newFreqWidth; }
// Invalidate texture cache
app.visibleTextureValid = false;
}
}
// Pan with Alt+drag or middle mouse button (pans both axes)
bool canPan = IsKeyDown(KEY_LEFT_ALT) || IsKeyDown(KEY_RIGHT_ALT) || IsMouseButtonDown(MOUSE_BUTTON_MIDDLE);
if (canPan && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
app.isPanning = true;
app.panStartPos = GetMousePosition();
app.panStartViewStart = app.viewStart;
app.panStartViewEnd = app.viewEnd;
app.panStartFreqViewStart = app.freqViewStart;
app.panStartFreqViewEnd = app.freqViewEnd;
}
if (app.isPanning && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float dx = (GetMousePosition().x - app.panStartPos.x) / viewBounds.width;
float dy = (GetMousePosition().y - app.panStartPos.y) / viewBounds.height;
float viewWidth = app.panStartViewEnd - app.panStartViewStart;
float freqWidth = app.panStartFreqViewEnd - app.panStartFreqViewStart;
app.viewStart = app.panStartViewStart - dx * viewWidth;
app.viewEnd = app.panStartViewEnd - dx * viewWidth;
if (app.viewStart < 0) { app.viewStart = 0; app.viewEnd = viewWidth; }
if (app.viewEnd > 1) { app.viewEnd = 1; app.viewStart = 1 - viewWidth; }
app.freqViewStart = app.panStartFreqViewStart + dy * freqWidth;
app.freqViewEnd = app.panStartFreqViewEnd + dy * freqWidth;
if (app.freqViewStart < 0) { app.freqViewStart = 0; app.freqViewEnd = freqWidth; }
if (app.freqViewEnd > 1) { app.freqViewEnd = 1; app.freqViewStart = 1 - freqWidth; }
app.visibleTextureValid = false;
}
if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) app.isPanning = false;
// Home/End keys
if (IsKeyPressed(KEY_HOME)) {
app.viewStart = 0.0f; app.viewEnd = 1.0f;
app.freqViewStart = 0.0f; app.freqViewEnd = 1.0f;
app.visibleTextureValid = false;
}
if (IsKeyPressed(KEY_END)) {
float newWidth = 0.1f;
app.viewStart = 0.0f;
app.viewEnd = newWidth;
app.visibleTextureValid = false;
}
}
// Keyboard shortcuts (SPACE for play/stop toggle, ESC for clear)
if (IsKeyPressed(KEY_SPACE) && !app.showFileBrowser) {
if (app.isPlaying && AudioPlaybackSound.frameCount > 0) {
// Currently playing - stop it
StopSound(AudioPlaybackSound);
app.isPlaying = false;
app.playbackFinished = false;
} else if (app.playbackFinished) {
// Playback finished naturally - restart from beginning
PlaySelectedRegion();
app.isPlaying = true;
app.playbackFinished = false;
} else {
// Not playing and didn't just finish - start playback
PlaySelectedRegion();
app.isPlaying = true;
}
}
if (IsKeyPressed(KEY_ESCAPE)) {
if (app.showFileBrowser) {
app.showFileBrowser = false;
} else {
// Clear selections instead of exiting
app.timeSelectionStart = 0.0f;
app.timeSelectionEnd = 1.0f;
app.freqSelectionStart = 0.0f;
app.freqSelectionEnd = 1.0f;
}
}
// Selection (only when mouse is in spectrogram area, not sidebar)
float selSidebarWidth = 320;
float selLabelHeight = 15;
float selScrollbarHeight = 18;
float selFreqLabelWidth = 65;
float selVScrollbarWidth = 18;
float selTopMargin = 50;
float selBottomMargin = 10;
Rectangle selBounds = {
selSidebarWidth + selFreqLabelWidth,
selTopMargin,
GetScreenWidth() - selSidebarWidth - selFreqLabelWidth - selVScrollbarWidth - 20,
GetScreenHeight() - selTopMargin - selBottomMargin - selLabelHeight - selScrollbarHeight - 10
};
Vector2 mousePos = GetMousePosition();
if (app.loaded && !app.showFileBrowser && mousePos.x > 385 && CheckCollisionPointRec(mousePos, selBounds)) {
bool shiftHeld = IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT);
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && !app.isPanning) {
if (shiftHeld) {
app.isFreqSelecting = true;
float t = 1.0f - (mousePos.y - selBounds.y) / selBounds.height;
app.freqSelectionStart = Clamp(t, 0.0f, 1.0f);
app.freqSelectionEnd = app.freqSelectionStart;
} else if (!IsKeyDown(KEY_LEFT_ALT) && !IsKeyDown(KEY_RIGHT_ALT)) {
app.isTimeSelecting = true;
app.timeSelectionStart = Clamp((mousePos.x - selBounds.x) / selBounds.width, 0.0f, 1.0f);
app.timeSelectionEnd = app.timeSelectionStart;
}
}
if (app.isTimeSelecting && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
app.timeSelectionEnd = Clamp((mousePos.x - selBounds.x) / selBounds.width, 0.0f, 1.0f);
}
if (app.isFreqSelecting && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float t = 1.0f - (mousePos.y - selBounds.y) / selBounds.height;
app.freqSelectionEnd = Clamp(t, 0.0f, 1.0f);
}
if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) {
if (app.isTimeSelecting) {
app.isTimeSelecting = false;
if (app.timeSelectionEnd < app.timeSelectionStart) {
float tmp = app.timeSelectionStart;
app.timeSelectionStart = app.timeSelectionEnd;
app.timeSelectionEnd = tmp;
}
}
if (app.isFreqSelecting) {
app.isFreqSelecting = false;
if (app.freqSelectionEnd < app.freqSelectionStart) {
float tmp = app.freqSelectionStart;
app.freqSelectionStart = app.freqSelectionEnd;
app.freqSelectionEnd = tmp;
}
}
}
}
// Processing
if (app.loaded && !app.stftComputed) {
TraceLog(LOG_INFO, "Computing STFT...");
double startTime = GetTime();
ComputeSTFT(&app.signal, &app.stft, app.fftSize);
TraceLog(LOG_INFO, "STFT computed in %.2f sec (%d segments)", GetTime() - startTime, app.stft.numSegments);
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
app.stftComputed = true;
}
// Rendering
BeginDrawing();
ClearBackground((Color){ 30, 30, 30, 255 });
// Layout: sidebar on left, spectrogram on right
// Space for: spectrogram + time labels (15px below) + scrollbar (18px below that)
float sidebarWidth = 320;
float labelHeight = 15;
float scrollbarHeight = 18;
float freqLabelWidth = 65;
float vScrollbarWidth = 18;
float topMargin = 50;
float bottomMargin = 10;
// Spectrogram area (excludes labels and scrollbars)
Rectangle viewBounds = {
sidebarWidth + freqLabelWidth,
topMargin,
GetScreenWidth() - sidebarWidth - freqLabelWidth - vScrollbarWidth - 20,
GetScreenHeight() - topMargin - bottomMargin - labelHeight - scrollbarHeight - 10
};
// Time labels sit just below the spectrogram
Rectangle timeLabelArea = { viewBounds.x, viewBounds.y + viewBounds.height, viewBounds.width, labelHeight };
// Horizontal scrollbar sits below the time labels
Rectangle hScrollbar = { viewBounds.x, viewBounds.y + viewBounds.height + labelHeight + 5, viewBounds.width, scrollbarHeight };
// Vertical scrollbar sits to the right of the spectrogram
Rectangle vScrollbar = { viewBounds.x + viewBounds.width + 5, viewBounds.y, vScrollbarWidth, viewBounds.height };
// Draw sidebar first (on top left)
DrawSidebar();
// Draw spectrogram (background, in its own area)
if (app.loaded && app.stftComputed) {
int imgWidth = app.spectrogramImage.width;
int imgHeight = app.spectrogramImage.height;
// Calculate visible region (time and frequency)
int visibleStartX = (int)(app.viewStart * imgWidth);
int visibleEndX = (int)(app.viewEnd * imgWidth);
int visibleWidth = visibleEndX - visibleStartX;
// Frequency: 0 = bottom of image (bin 0), 1 = top of image (bin max)
int visibleStartY = (int)((1.0f - app.freqViewEnd) * imgHeight);
int visibleEndY = (int)((1.0f - app.freqViewStart) * imgHeight);
int visibleHeight = visibleEndY - visibleStartY;
// Invalidate cache if view changed or texture not valid
bool cacheInvalid = !app.visibleTextureValid ||
visibleStartX != app.cachedVisibleStart ||
visibleEndX != app.cachedVisibleEnd ||
visibleStartY != app.cachedVisibleStartY ||
visibleEndY != app.cachedVisibleEndY ||
visibleWidth <= 0 || visibleHeight <= 0;
if (cacheInvalid && visibleWidth > 0 && visibleStartX >= 0 && visibleHeight > 0 && visibleStartY >= 0) {
// Free old texture if exists
if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture);
// Create a sub-image for the visible region
Image visibleImage = GenImageColor(visibleWidth, visibleHeight, BLACK);
Color* srcPixels = (Color*)app.spectrogramImage.data;
Color* dstPixels = (Color*)visibleImage.data;
for (int y = 0; y < visibleHeight; y++) {
for (int x = 0; x < visibleWidth; x++) {
dstPixels[y * visibleWidth + x] = srcPixels[(visibleStartY + y) * imgWidth + visibleStartX + x];
}
}
app.visibleTexture = LoadTextureFromImage(visibleImage);
UnloadImage(visibleImage);
app.cachedVisibleStart = visibleStartX;
app.cachedVisibleEnd = visibleEndX;
app.cachedVisibleStartY = visibleStartY;
app.cachedVisibleEndY = visibleEndY;
app.visibleTextureValid = true;
}
// Draw cached texture
if (app.visibleTextureValid && app.visibleTexture.id != 0) {
DrawTexturePro(app.visibleTexture,
(Rectangle){ 0, 0, visibleWidth, visibleHeight },
viewBounds, (Vector2){ 0, 0 }, 0.0f, WHITE);
}
// Draw scrollbars
// Horizontal scrollbar (time)
DrawRectangleRec(hScrollbar, DARKGRAY);
float hThumbWidth = (app.viewEnd - app.viewStart) * hScrollbar.width;
float hThumbX = hScrollbar.x + app.viewStart * hScrollbar.width;
if (hThumbWidth < 10) hThumbWidth = 10;
DrawRectangle(hThumbX, hScrollbar.y, hThumbWidth, hScrollbar.height, GRAY);
// Vertical scrollbar (frequency)
DrawRectangleRec(vScrollbar, DARKGRAY);
float vThumbHeight = (app.freqViewEnd - app.freqViewStart) * vScrollbar.height;
float vThumbY = vScrollbar.y + (1.0f - app.freqViewEnd) * vScrollbar.height;
if (vThumbHeight < 10) vThumbHeight = 10;
DrawRectangle(vScrollbar.x, vThumbY, vScrollbar.width, vThumbHeight, GRAY);
// Handle scrollbar dragging
static bool draggingH = false, draggingV = false;
static Vector2 dragStartPos;
static float dragStartViewStart, dragStartFreqViewStart;
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && CheckCollisionPointRec(GetMousePosition(), hScrollbar)) {
draggingH = true;
dragStartPos = GetMousePosition();
dragStartViewStart = app.viewStart;
}
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && CheckCollisionPointRec(GetMousePosition(), vScrollbar)) {
draggingV = true;
dragStartPos = GetMousePosition();
dragStartFreqViewStart = app.freqViewStart;
}
if (draggingH && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float dx = (GetMousePosition().x - dragStartPos.x) / hScrollbar.width;
float viewWidth = app.viewEnd - app.viewStart;
app.viewStart = dragStartViewStart + dx;
app.viewEnd = app.viewStart + viewWidth;
if (app.viewStart < 0) { app.viewStart = 0; app.viewEnd = viewWidth; }
if (app.viewEnd > 1) { app.viewEnd = 1; app.viewStart = 1 - viewWidth; }
app.visibleTextureValid = false;
}
if (draggingV && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float dy = (GetMousePosition().y - dragStartPos.y) / vScrollbar.height;
float freqWidth = app.freqViewEnd - app.freqViewStart;
app.freqViewStart = dragStartFreqViewStart - dy;
app.freqViewEnd = app.freqViewStart + freqWidth;
if (app.freqViewStart < 0) { app.freqViewStart = 0; app.freqViewEnd = freqWidth; }
if (app.freqViewEnd > 1) { app.freqViewEnd = 1; app.freqViewStart = 1 - freqWidth; }
app.visibleTextureValid = false;
}
if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) { draggingH = false; draggingV = false; }
if (app.showGrid) DrawSpectrogramGrid(viewBounds, 10, 8, Fade(GRAY, 0.3f));
DrawSelection(viewBounds);
DrawLabels(viewBounds);
float maxFreq = (float)app.signal.sampleRate / 2.0f;
float freqMin = app.freqViewStart * maxFreq;
float freqMax = app.freqViewEnd * maxFreq;
DrawText(TextFormat("Freq: %.0f-%.0f Hz", freqMin, freqMax),
viewBounds.x, viewBounds.y - 30, 20, LIGHTGRAY);
} else if (!app.showFileBrowser) {
const char* msg1 = "Press 'O' or click 'Open File Browser' to load a WAV";
const char* msg2 = "Or drag & drop a file, or use: ./rspektrum <file.wav>";
Vector2 t1 = MeasureTextEx(GetFontDefault(), msg1, 24, 0);
Vector2 t2 = MeasureTextEx(GetFontDefault(), msg2, 18, 0);
DrawText(msg1, 350 + (GetScreenWidth() - 380 - 350) / 2 - t1.x / 2, GetScreenHeight() / 2 - t1.y / 2 - 15, 24, LIGHTGRAY);
DrawText(msg2, 350 + (GetScreenWidth() - 380 - 350) / 2 - t2.x / 2, GetScreenHeight() / 2 - t2.y / 2 + 15, 18, GRAY);
}
// Draw file browser on top (if active)
if (app.showFileBrowser) DrawFileBrowser();
EndDrawing();
}
TraceLog(LOG_INFO, "Shutting down...");
if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound);
if (app.stftComputed) { FreeSTFT(&app.stft); UnloadImage(app.spectrogramImage); UnloadTexture(app.spectrogramTexture); }
if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture);
app.visibleTexture = (Texture2D){ 0 };
app.visibleTextureValid = false;
UnloadTexture(colormapTexture);
FreeBrowserFiles();
FreeSignal(&app.signal);
CloseAudioDevice();
CloseWindow();
return 0;
}