// 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 #include #include #include #include #include #ifndef M_PI #define M_PI 3.14159265358979323846 #endif // Define CYAN if not available #ifndef CYAN #define CYAN (Color){ 0, 255, 255, 255 } #endif // ============================================================================ // Configuration // ============================================================================ #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 "; 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; }