Files
rspektrum/src/spectrogram.c
T

1288 lines
54 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 8192
#define FFT_SIZE_MIN 512
#define HOP_RATIO 4 // FFT_SIZE / HOP_SIZE = 4 means 75% overlap
#define MAX_SAMPLE_RATE 48000
#define LOUDNESS_FLOOR_DB -80.0f
// Reassignment method for sharper time-frequency localization
#define USE_REASSIGNMENT 1
// 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;
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 region
float viewEnd; // 0-1, end of visible region
bool isPanning;
float panStartViewStart;
float panStartViewEnd;
Vector2 panStartPos;
// Cached visible texture
Texture2D visibleTexture;
int cachedVisibleStart;
int cachedVisibleEnd;
bool visibleTextureValid;
// Display settings
float amplitudeFloorDb;
float amplitudeCeilingDb;
ColormapType colormap;
bool showGrid;
int fftSize; // Current FFT size (512-8192)
// File browser state
bool showFileBrowser;
char browserPath[512];
char** browserFiles;
bool* browserIsDir;
int browserFileCount;
int browserScroll;
int browserSelected;
bool isBrowsing;
} 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 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));
}
memcpy(windowedSamples, signal->samples + offset, samplesToCopy * sizeof(float));
ApplyHannWindow(windowedSamples, fftSize);
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]);
}
}
free(windowedSamples); free(complexInput); free(fftOutput);
}
static void FreeSTFT(StftResult* result)
{
for (int i = 0; i < result->numSegments; i++) free(result->segments[i].spectrum);
free(result->segments);
result->segments = NULL;
result->numSegments = 0;
}
// ============================================================================
// Audio Loading
// ============================================================================
static bool LoadWavFile(const char* filepath, AudioSignal* signal)
{
Wave wave = LoadWave(filepath);
if (wave.data == NULL) { TraceLog(LOG_ERROR, "Failed to open WAV file: %s", filepath); 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);
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 with Reassignment
// ============================================================================
static void GenerateSpectrogramTexture(StftResult* stft, Image* image, Texture2D* texture)
{
if (stft->numSegments == 0) return;
int width = stft->numSegments;
int height = stft->segments[0].numBins;
*image = GenImageColor(width, height, BLACK);
Color* pixels = (Color*)image->data;
// Initialize to black
for (int i = 0; i < width * height; i++) {
pixels[i] = BLACK;
}
// 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;
float hopSize = (float)(stft->segments[0].sampleOffset > 0 ?
stft->segments[1].sampleOffset - stft->segments[0].sampleOffset :
stft->segments[0].sampleCount);
float freqPerBin = (float)stft->sampleRate / (height * 2 - 2);
// Create a floating-point accumulation buffer for reassignment
float* accumBuffer = (float*)calloc(width * height, sizeof(float));
for (int seg = 0; seg < width; seg++) {
float segmentTime = (seg * hopSize) / (float)stft->sampleRate;
for (int bin = 0; bin < height; bin++) {
float amplitude = stft->segments[seg].spectrum[bin].amplitude;
float phase = stft->segments[seg].spectrum[bin].phase;
if (amplitude < 0.0001f) continue;
float reassignedFreq = bin * freqPerBin;
float reassignedTime = segmentTime;
#if USE_REASSIGNMENT
// ===== Reassignment Method =====
// Estimate instantaneous frequency from phase derivative over time
if (seg > 0 && seg < width - 1) {
float prevPhase = stft->segments[seg-1].spectrum[bin].phase;
float nextPhase = stft->segments[seg+1].spectrum[bin].phase;
// Phase difference (unwrapped)
float phaseDiff = nextPhase - prevPhase;
// Unwrap to [-pi, pi]
while (phaseDiff > M_PI) phaseDiff -= 2.0f * M_PI;
while (phaseDiff < -M_PI) phaseDiff += 2.0f * M_PI;
// Instantaneous frequency deviation
float expectedPhaseShift = 2.0f * M_PI * bin * hopSize / (height * 2 - 2);
float phaseDeviation = phaseDiff - expectedPhaseShift;
float instantFreqDev = phaseDeviation * stft->sampleRate / (2.0f * M_PI * hopSize);
reassignedFreq = bin * freqPerBin + instantFreqDev;
}
// Estimate group delay from phase derivative over frequency
if (bin > 0 && bin < height - 1) {
float prevPhaseAdj = stft->segments[seg].spectrum[bin-1].phase;
float nextPhaseAdj = stft->segments[seg].spectrum[bin+1].phase;
float phaseGrad = nextPhaseAdj - prevPhaseAdj;
// Unwrap
while (phaseGrad > M_PI) phaseGrad -= 2.0f * M_PI;
while (phaseGrad < -M_PI) phaseGrad += 2.0f * M_PI;
// Group delay (time correction)
float groupDelay = -phaseGrad / (2.0f * M_PI * freqPerBin);
reassignedTime = segmentTime + groupDelay / (float)stft->sampleRate;
}
// Clamp to valid range
if (reassignedFreq < 0) reassignedFreq = 0;
if (reassignedFreq >= stft->sampleRate / 2.0f) reassignedFreq = stft->sampleRate / 2.0f - 1;
#endif
// Map reassigned coordinates to pixel indices
int reassignedBin = (int)(reassignedFreq / freqPerBin);
int reassignedSeg = (int)((reassignedTime * stft->sampleRate) / hopSize);
// Clamp to texture bounds
if (reassignedBin < 0) reassignedBin = 0;
if (reassignedBin >= height) reassignedBin = height - 1;
if (reassignedSeg < 0) reassignedSeg = 0;
if (reassignedSeg >= width) reassignedSeg = width - 1;
// Accumulate amplitude at reassigned location
int pixelIndex = (height - 1 - reassignedBin) * width + reassignedSeg;
accumBuffer[pixelIndex] += amplitude;
}
}
// 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 };
if (CheckCollisionPointRec(GetMousePosition(), openBtn)) 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) && 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);
}
DrawText("Time", bounds.x + bounds.width / 2 - 20, bounds.y + bounds.height + 25, fontSize, GRAY);
DrawText("Freq", bounds.x - 35, bounds.y + bounds.height / 2, fontSize, GRAY);
}
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 DrawInfo(Rectangle bounds)
{
int fontSize = 12, y = 10;
if (app.loaded) {
DrawText(TextFormat("Sample Rate: %d Hz", app.signal.sampleRate), 10, y, fontSize, LIGHTGRAY); y += 20;
DrawText(TextFormat("Duration: %.2f sec", app.signal.duration), 10, y, fontSize, LIGHTGRAY); y += 20;
DrawText(TextFormat("View: %.1f%%-%.1f%% (%.2f sec)", app.viewStart*100, app.viewEnd*100, (app.viewEnd-app.viewStart)*app.signal.duration), 10, y, fontSize, LIGHTGRAY); y += 20;
DrawText(TextFormat("FFT: %d (%.1f Hz/bin, 75%% overlap)", app.fftSize, (float)app.signal.sampleRate / app.fftSize), 10, y, fontSize, LIGHTGRAY); y += 20;
#if USE_REASSIGNMENT
DrawText("Reassignment: ON (sharp)", 10, y, fontSize, (Color){ 80, 255, 80, 255 }); y += 20;
#endif
DrawText(TextFormat("Max Freq: %.1f kHz", (float)app.signal.sampleRate / 2000.0f), 10, y, fontSize, LIGHTGRAY); y += 20;
y += 10;
float selDuration = (app.timeSelectionEnd - app.timeSelectionStart) * app.signal.duration;
DrawText(TextFormat("Time Sel: %.2f-%.2f sec (%.3f sec)", app.timeSelectionStart*app.signal.duration, app.timeSelectionEnd*app.signal.duration, selDuration), 10, y, fontSize, YELLOW); y += 20;
float maxFreq = (float)app.signal.sampleRate / 2.0f;
DrawText(TextFormat("Freq Sel: %.0f-%.0f Hz", app.freqSelectionStart*maxFreq, app.freqSelectionEnd*maxFreq), 10, y, fontSize, CYAN);
} else {
DrawText("No audio file loaded", 10, y, fontSize, RED);
}
y = GetScreenHeight() - 280;
DrawText("Controls:", 10, y, fontSize, LIGHTGRAY); y += 20;
DrawText(" O - Open file browser", 10, y, fontSize, GRAY); y += 18;
DrawText(" Drag & drop WAV file", 10, y, fontSize, GRAY); y += 18;
DrawText(" Mouse Wheel - Zoom to cursor", 10, y, fontSize, GRAY); y += 18;
DrawText(" Alt/Middle+Drag - Pan view", 10, y, fontSize, GRAY); y += 18;
DrawText(" LMB Drag - Select time region", 10, y, fontSize, GRAY); y += 18;
DrawText(" Shift+LMB - Select freq range", 10, y, fontSize, GRAY); y += 18;
DrawText(" SPACE - Play selection", 10, y, fontSize, GRAY); y += 18;
DrawText(" 1/2 - FFT size (resolution)", 10, y, fontSize, GRAY); y += 18;
DrawText(" M - Cycle colormap", 10, y, fontSize, GRAY); y += 18;
DrawText(" W/S - Adjust dB floor", 10, y, fontSize, GRAY); y += 18;
DrawText(" G - Toggle grid", 10, y, fontSize, GRAY); y += 18;
DrawText(" R - Reset selections", 10, y, fontSize, GRAY); y += 18;
DrawText(" Home - Full view, End - Zoom in", 10, y, fontSize, GRAY); y += 18;
DrawText(" ESC - Clear selections", 10, y, fontSize, GRAY);
y = GetScreenHeight() - 60;
DrawText("Colormap:", GetScreenWidth() - 200, y, fontSize, LIGHTGRAY);
const char* colormapNames[] = { "Grays", "Inferno", "Viridis", "Plasma", "Hot", "Cool" };
DrawText(colormapNames[app.colormap], GetScreenWidth() - 200, y + 18, fontSize, WHITE);
DrawTexturePro(colormapTexture, (Rectangle){ 0, 0, 256, 1 }, (Rectangle){ GetScreenWidth() - 100, y + 15, 80, 15 }, (Vector2){ 0, 0 }, 0.0f, WHITE);
DrawText(TextFormat("dB Floor: %.1f", app.amplitudeFloorDb), GetScreenWidth() - 200, y + 35, fontSize, LIGHTGRAY);
}
// ============================================================================
// Main Application
// ============================================================================
int main(int argc, char* argv[])
{
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_HIGHDPI);
InitWindow(1280, 800, "Spectrogram Viewer");
SetTargetFPS(60);
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.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.visibleTextureValid = false;
app.fftSize = FFT_SIZE_DEFAULT;
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
}
// View controls
if (app.loaded && !app.showFileBrowser) {
Rectangle viewBounds = { 100, 50, GetScreenWidth() - 150, GetScreenHeight() - 150 };
// Zoom with mouse wheel (zooms to cursor position)
if (CheckCollisionPointRec(GetMousePosition(), viewBounds)) {
int wheel = GetMouseWheelMove();
if (wheel != 0) {
// Calculate mouse position as normalized time (0-1)
float mouseT = (GetMousePosition().x - viewBounds.x) / viewBounds.width;
mouseT = Clamp(mouseT + app.viewStart, 0.0f, 1.0f);
float viewWidth = app.viewEnd - app.viewStart;
float zoomFactor = (wheel > 0) ? 0.8f : 1.2f;
float newWidth = viewWidth * zoomFactor;
if (newWidth < 0.02f) newWidth = 0.02f;
if (newWidth > 1.0f) newWidth = 1.0f;
// Zoom around cursor position
float leftOfMouse = mouseT - app.viewStart;
float rightOfMouse = app.viewEnd - mouseT;
float newLeftOfMouse = leftOfMouse * (newWidth / viewWidth);
float newRightOfMouse = rightOfMouse * (newWidth / viewWidth);
app.viewStart = mouseT - newLeftOfMouse;
app.viewEnd = mouseT + newRightOfMouse;
// Clamp to valid range
if (app.viewStart < 0) { app.viewStart = 0; app.viewEnd = newWidth; }
if (app.viewEnd > 1) { app.viewEnd = 1; app.viewStart = 1 - newWidth; }
// Invalidate texture cache
app.visibleTextureValid = false;
}
}
// Pan with Alt+drag or middle mouse button
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;
}
if (app.isPanning && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float dx = (GetMousePosition().x - app.panStartPos.x) / viewBounds.width;
float viewWidth = app.panStartViewEnd - app.panStartViewStart;
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.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.visibleTextureValid = false;
}
if (IsKeyPressed(KEY_END)) {
float newWidth = 0.1f;
app.viewStart = 0.0f;
app.viewEnd = newWidth;
app.visibleTextureValid = false;
}
}
// Other controls
if (IsKeyPressed(KEY_G) && !app.showFileBrowser) {
app.showGrid = !app.showGrid;
}
if (IsKeyPressed(KEY_R) && !app.showFileBrowser) {
app.timeSelectionStart = app.viewStart;
app.timeSelectionEnd = app.viewEnd;
app.freqSelectionStart = 0.0f;
app.freqSelectionEnd = 1.0f;
}
if (IsKeyPressed(KEY_M) && !app.showFileBrowser) {
app.colormap = (ColormapType)((app.colormap + 1) % COLORMAP_COUNT);
GenerateColormapTexture();
if (app.stftComputed) GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
app.visibleTextureValid = false;
}
if (IsKeyPressed(KEY_W) && !app.showFileBrowser) {
app.amplitudeFloorDb += 2.0f;
if (app.amplitudeFloorDb > app.amplitudeCeilingDb - 5) app.amplitudeFloorDb = app.amplitudeCeilingDb - 5;
if (app.stftComputed) GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
app.visibleTextureValid = false;
}
if (IsKeyPressed(KEY_S) && !app.showFileBrowser) {
app.amplitudeFloorDb -= 2.0f;
if (app.amplitudeFloorDb < -100) app.amplitudeFloorDb = -100;
if (app.stftComputed) GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
app.visibleTextureValid = false;
}
if (IsKeyPressed(KEY_SPACE) && !app.showFileBrowser) PlaySelectedRegion();
// FFT size control (1/2 keys)
if (IsKeyPressed(KEY_ONE) && !app.showFileBrowser) {
app.fftSize /= 2;
if (app.fftSize < FFT_SIZE_MIN) app.fftSize = FFT_SIZE_MIN;
else {
app.stftComputed = false;
TraceLog(LOG_INFO, "FFT size: %d", app.fftSize);
}
}
if (IsKeyPressed(KEY_TWO) && !app.showFileBrowser) {
app.fftSize *= 2;
if (app.fftSize > FFT_SIZE_MAX) app.fftSize = FFT_SIZE_MAX;
else {
app.stftComputed = false;
TraceLog(LOG_INFO, "FFT size: %d", app.fftSize);
}
}
if (IsKeyPressed(KEY_ESCAPE)) {
if (app.showFileBrowser) app.showFileBrowser = false;
else {
app.timeSelectionStart = app.viewStart;
app.timeSelectionEnd = app.viewEnd;
app.freqSelectionStart = 0.0f;
app.freqSelectionEnd = 1.0f;
}
}
// Selection
Rectangle selBounds = { 100, 50, GetScreenWidth() - 150, GetScreenHeight() - 150 };
Vector2 mousePos = GetMousePosition();
if (app.loaded && !app.showFileBrowser && 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 });
Rectangle viewBounds = { 100, 50, GetScreenWidth() - 150, GetScreenHeight() - 150 };
// Draw spectrogram first (background)
if (app.loaded && app.stftComputed) {
// Calculate visible region
int visibleStart = (int)(app.viewStart * app.spectrogramImage.width);
int visibleEnd = (int)(app.viewEnd * app.spectrogramImage.width);
int visibleWidth = visibleEnd - visibleStart;
// Invalidate cache if view changed or texture not valid
bool cacheInvalid = !app.visibleTextureValid ||
visibleStart != app.cachedVisibleStart ||
visibleEnd != app.cachedVisibleEnd ||
visibleWidth <= 0;
if (cacheInvalid && visibleWidth > 0 && visibleStart >= 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, app.spectrogramImage.height, BLACK);
Color* srcPixels = (Color*)app.spectrogramImage.data;
Color* dstPixels = (Color*)visibleImage.data;
for (int y = 0; y < app.spectrogramImage.height; y++) {
for (int x = 0; x < visibleWidth; x++) {
dstPixels[y * visibleWidth + x] = srcPixels[y * app.spectrogramImage.width + visibleStart + x];
}
}
app.visibleTexture = LoadTextureFromImage(visibleImage);
UnloadImage(visibleImage);
app.cachedVisibleStart = visibleStart;
app.cachedVisibleEnd = visibleEnd;
app.visibleTextureValid = true;
}
// Draw cached texture
if (app.visibleTextureValid && app.visibleTexture.id != 0) {
DrawTexturePro(app.visibleTexture,
(Rectangle){ 0, 0, visibleWidth, app.spectrogramImage.height },
viewBounds, (Vector2){ 0, 0 }, 0.0f, WHITE);
}
if (app.showGrid) DrawSpectrogramGrid(viewBounds, 10, 8, Fade(GRAY, 0.3f));
DrawSelection(viewBounds);
DrawLabels(viewBounds);
DrawText("Spectrogram", viewBounds.x, viewBounds.y - 30, 20, LIGHTGRAY);
DrawText(TextFormat("Frequency (Hz) - Max: %d", app.signal.sampleRate / 2),
viewBounds.x - 50, viewBounds.y + viewBounds.height / 2 - 10, 14, Fade(LIGHTGRAY, 0.7f));
} else if (!app.showFileBrowser) {
const char* msg1 = "Press 'O' for file browser or drag & drop WAV";
const char* msg2 = "Or use command line: ./rspektrum <file.wav>";
Vector2 t1 = MeasureTextEx(GetFontDefault(), msg1, 24, 0);
Vector2 t2 = MeasureTextEx(GetFontDefault(), msg2, 18, 0);
DrawText(msg1, GetScreenWidth() / 2 - t1.x / 2, GetScreenHeight() / 2 - t1.y / 2 - 15, 24, LIGHTGRAY);
DrawText(msg2, GetScreenWidth() / 2 - t2.x / 2, GetScreenHeight() / 2 - t2.y / 2 + 15, 18, GRAY);
}
// Draw file browser on top (if active)
if (app.showFileBrowser) DrawFileBrowser();
// Draw UI info and FPS (always on top)
DrawInfo(viewBounds);
DrawFPS(GetScreenWidth() - 100, 10);
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;
}