// 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 2048 #define FFT_SIZE_MIN 128 #define HOP_RATIO 4 // FFT_SIZE / HOP_SIZE = 4 means 75% overlap #define MAX_SAMPLE_RATE 48000 #define LOUDNESS_FLOOR_DB -80.0f // Colormap types typedef enum { COLORMAP_GRAYS = 0, COLORMAP_INFERNO, COLORMAP_VIRIDIS, COLORMAP_PLASMA, COLORMAP_HOT, COLORMAP_COOL, COLORMAP_COUNT } ColormapType; // ============================================================================ // Data Structures // ============================================================================ typedef struct { float* samples; int numSamples; int sampleRate; int channels; float duration; } AudioSignal; typedef struct { float frequency; float amplitude; float phase; } FrequencyData; typedef struct { FrequencyData* spectrum; FrequencyData* derivativeSpectrum; // STFT with derivative window (for synchrosqueezing) int numBins; int sampleOffset; int sampleCount; } StftSegment; typedef struct { StftSegment* segments; int numSegments; int sampleRate; int totalSamples; bool useHannWindow; } StftResult; typedef struct { AudioSignal signal; StftResult stft; Image spectrogramImage; Texture2D spectrogramTexture; bool loaded; bool stftComputed; // Time selection (0-1 normalized) float timeSelectionStart; float timeSelectionEnd; bool isTimeSelecting; // Frequency selection (0-1 normalized) float freqSelectionStart; float freqSelectionEnd; bool isFreqSelecting; // Viewport/zoom controls float viewStart; // 0-1, start of visible time region float viewEnd; // 0-1, end of visible time region float freqViewStart; // 0-1, start of visible frequency region (0 = 0Hz) float freqViewEnd; // 0-1, end of visible frequency region (1 = Nyquist) bool isPanning; float panStartViewStart; float panStartViewEnd; float panStartFreqViewStart; float panStartFreqViewEnd; Vector2 panStartPos; // Cached visible texture Texture2D visibleTexture; int cachedVisibleStart; int cachedVisibleEnd; int cachedVisibleStartY; int cachedVisibleEndY; bool visibleTextureValid; // Display settings float amplitudeFloorDb; float amplitudeCeilingDb; ColormapType colormap; bool showGrid; int fftSize; // Current FFT size (128-2048) // File browser state bool showFileBrowser; char browserPath[512]; char** browserFiles; bool* browserIsDir; int browserFileCount; int browserScroll; int browserSelected; bool isBrowsing; // Playback state bool isPlaying; bool playbackFinished; // Track if playback completed naturally } SpectrogramApp; // ============================================================================ // Global State // ============================================================================ static SpectrogramApp app = {0}; static Sound AudioPlaybackSound = {0}; static Texture2D colormapTexture = {0}; // ============================================================================ // Utility Functions // ============================================================================ static float AmplitudeToDecibels(float amplitude) { if (amplitude < 0.0001f) amplitude = 0.0001f; return 20.0f * log10f(amplitude); } static float Clamp(float value, float min, float max) { if (value < min) return min; if (value > max) return max; return value; } // ============================================================================ // Colormap Functions // ============================================================================ static Color GetColormapColor(float t, ColormapType type) { t = Clamp(t, 0.0f, 1.0f); switch (type) { case COLORMAP_GRAYS: { unsigned char v = (unsigned char)(t * 255); return (Color){ v, v, v, 255 }; } case COLORMAP_INFERNO: { float r = 0.0f, g = 0.0f, b = 0.0f; if (t < 0.25f) { t = t / 0.25f; r = 0.0f + t * 0.5f; g = 0.0f; b = 0.0f + t * 0.3f; } else if (t < 0.5f) { t = (t - 0.25f) / 0.25f; r = 0.5f + t * 0.5f; g = 0.0f + t * 0.3f; b = 0.3f + t * 0.4f; } else if (t < 0.75f) { t = (t - 0.5f) / 0.25f; r = 1.0f; g = 0.3f + t * 0.5f; b = 0.7f + t * 0.2f; } else { t = (t - 0.75f) / 0.25f; r = 1.0f; g = 0.8f + t * 0.2f; b = 0.9f + t * 0.1f; } return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 }; } case COLORMAP_VIRIDIS: { float r, g, b; if (t < 0.25f) { t = t / 0.25f; r = 0.27f + t * 0.13f; g = 0.00f + t * 0.33f; b = 0.33f + t * 0.27f; } else if (t < 0.5f) { t = (t - 0.25f) / 0.25f; r = 0.40f + t * 0.16f; g = 0.33f + t * 0.29f; b = 0.60f - t * 0.20f; } else if (t < 0.75f) { t = (t - 0.5f) / 0.25f; r = 0.56f + t * 0.24f; g = 0.62f + t * 0.23f; b = 0.40f - t * 0.20f; } else { t = (t - 0.75f) / 0.25f; r = 0.80f + t * 0.17f; g = 0.85f + t * 0.12f; b = 0.20f - t * 0.15f; } return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 }; } case COLORMAP_PLASMA: { float r = 0.05f + t * 0.9f; float g = 0.0f + t * 0.6f + (t > 0.5f ? (t - 0.5f) * 0.4f : 0.0f); float b = 0.6f - t * 0.5f; return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 }; } case COLORMAP_HOT: { float r = Clamp(t * 3.0f, 0.0f, 1.0f); float g = Clamp((t - 0.33f) * 3.0f, 0.0f, 1.0f); float b = Clamp((t - 0.66f) * 3.0f, 0.0f, 1.0f); return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 }; } case COLORMAP_COOL: { return (Color){ (unsigned char)(t * 255), (unsigned char)((1.0f - t) * 255), 255, 255 }; } default: return GRAY; } } static void GenerateColormapTexture(void) { if (colormapTexture.id != 0) UnloadTexture(colormapTexture); Image img = GenImageColor(256, 1, WHITE); Color* pixels = (Color*)img.data; for (int i = 0; i < 256; i++) pixels[i] = GetColormapColor(i / 255.0f, app.colormap); colormapTexture = LoadTextureFromImage(img); UnloadImage(img); } // ============================================================================ // FFT Implementation // ============================================================================ static void BitReverseCopy(float complex* input, float complex* output, int n) { int bits = 0, temp = n; while (temp > 1) { bits++; temp >>= 1; } for (int i = 0; i < n; i++) { int j = 0, k = i; for (int b = 0; b < bits; b++) { j = (j << 1) | (k & 1); k >>= 1; } output[j] = input[i]; } } static void FFT(float complex* input, float complex* output, int n, bool inverse) { if (n <= 1) { output[0] = input[0]; return; } BitReverseCopy(input, output, n); for (int stage = 1; stage < n; stage *= 2) { int step = stage * 2; float angleStep = (inverse ? 2.0f : -2.0f) * (float)M_PI / step; for (int k = 0; k < stage; k++) { float complex twiddle = cexpf(I * angleStep * k); for (int i = k; i < n; i += step) { int j = i + stage; float complex t = output[j] * twiddle; output[j] = output[i] - t; output[i] = output[i] + t; } } } if (inverse) for (int i = 0; i < n; i++) output[i] /= n; } // ============================================================================ // Hann Window // ============================================================================ static void ApplyHannWindow(float* samples, int n) { for (int i = 0; i < n; i++) { float t = (float)i / (n - 1); samples[i] *= 0.5f * (1.0f - cosf(2.0f * M_PI * t)); } } // ============================================================================ // STFT Implementation // ============================================================================ static void ComputeSTFT(AudioSignal* signal, StftResult* result, int fftSize) { int hopSize = fftSize / HOP_RATIO; // 75% overlap int numSegments = (signal->numSamples - fftSize) / hopSize + 1; if (numSegments <= 0) numSegments = 1; result->numSegments = numSegments; result->segments = (StftSegment*)malloc(numSegments * sizeof(StftSegment)); result->sampleRate = signal->sampleRate; result->totalSamples = signal->numSamples; result->useHannWindow = true; int numBins = fftSize / 2 + 1; float* windowedSamples = (float*)malloc(fftSize * sizeof(float)); float* derivWindowedSamples = (float*)malloc(fftSize * sizeof(float)); float complex *complexInput = (float complex*)malloc(fftSize * sizeof(float complex)); float complex* fftOutput = (float complex*)malloc(fftSize * sizeof(float complex)); for (int seg = 0; seg < numSegments; seg++) { int offset = seg * hopSize; int samplesToCopy = fftSize; if (offset + samplesToCopy > signal->numSamples) { samplesToCopy = signal->numSamples - offset; memset(windowedSamples, 0, fftSize * sizeof(float)); memset(derivWindowedSamples, 0, fftSize * sizeof(float)); } else { memcpy(windowedSamples, signal->samples + offset, fftSize * sizeof(float)); memcpy(derivWindowedSamples, signal->samples + offset, fftSize * sizeof(float)); } // Apply Hann window: h(t) = 0.5 * (1 - cos(2πt)) // And derivative window: h'(t) = π * sin(2πt) for (int i = 0; i < fftSize; i++) { float t = (float)i / (fftSize - 1); float hann = 0.5f * (1.0f - cosf(2.0f * M_PI * t)); float derivHann = M_PI * sinf(2.0f * M_PI * t); windowedSamples[i] *= hann; derivWindowedSamples[i] *= derivHann; } // Compute normal STFT (V_f) for (int i = 0; i < fftSize; i++) complexInput[i] = windowedSamples[i] + 0.0f * I; FFT(complexInput, fftOutput, fftSize, false); result->segments[seg].numBins = numBins; result->segments[seg].sampleOffset = offset; result->segments[seg].sampleCount = samplesToCopy; result->segments[seg].spectrum = (FrequencyData*)malloc(numBins * sizeof(FrequencyData)); for (int bin = 0; bin < numBins; bin++) { result->segments[seg].spectrum[bin].frequency = (float)bin * signal->sampleRate / fftSize; result->segments[seg].spectrum[bin].amplitude = (bin == 0) ? cabsf(fftOutput[bin]) / fftSize : 2.0f * cabsf(fftOutput[bin]) / fftSize; result->segments[seg].spectrum[bin].phase = cargf(fftOutput[bin]); } // Compute derivative-window STFT (V_fd) for synchrosqueezing result->segments[seg].derivativeSpectrum = (FrequencyData*)malloc(numBins * sizeof(FrequencyData)); for (int i = 0; i < fftSize; i++) complexInput[i] = derivWindowedSamples[i] + 0.0f * I; FFT(complexInput, fftOutput, fftSize, false); for (int bin = 0; bin < numBins; bin++) { result->segments[seg].derivativeSpectrum[bin].frequency = (float)bin * signal->sampleRate / fftSize; result->segments[seg].derivativeSpectrum[bin].amplitude = cabsf(fftOutput[bin]) / fftSize; result->segments[seg].derivativeSpectrum[bin].phase = cargf(fftOutput[bin]); } } free(windowedSamples); free(derivWindowedSamples); free(complexInput); free(fftOutput); } static void FreeSTFT(StftResult* result) { for (int i = 0; i < result->numSegments; i++) { free(result->segments[i].spectrum); if (result->segments[i].derivativeSpectrum) free(result->segments[i].derivativeSpectrum); } free(result->segments); result->segments = NULL; result->numSegments = 0; } // ============================================================================ // Audio Loading // ============================================================================ // Convert audio file to WAV using ffmpeg (if available) static bool ConvertToFFmpegWAV(const char* inputPath, char* outputPath, size_t outputSize) { // Create temp WAV path snprintf(outputPath, outputSize, "/tmp/rspektrum_temp_converted.wav"); // Build ffmpeg command char cmd[1024]; snprintf(cmd, sizeof(cmd), "ffmpeg -y -loglevel quiet -i \"%s\" -ar 48000 -ac 1 -f wav \"%s\" 2>&1", inputPath, outputPath); int result = system(cmd); if (result == 0 && FileExists(outputPath)) { TraceLog(LOG_INFO, "FFmpeg conversion successful: %s", outputPath); return true; } TraceLog(LOG_WARNING, "FFmpeg conversion failed or not available"); return false; } static bool LoadWavFile(const char* filepath, AudioSignal* signal) { const char* ext = GetFileExtension(filepath); char convertedPath[512] = { 0 }; bool isConverted = false; // Check if we need to convert via ffmpeg bool isWav = ext && (strcmp(ext, ".wav") == 0 || strcmp(ext, ".WAV") == 0); if (!isWav) { // Try ffmpeg conversion if (ConvertToFFmpegWAV(filepath, convertedPath, sizeof(convertedPath))) { filepath = convertedPath; isConverted = true; } else { TraceLog(LOG_ERROR, "Unsupported format and ffmpeg not available: %s", filepath); return false; } } Wave wave = LoadWave(filepath); if (wave.data == NULL) { TraceLog(LOG_ERROR, "Failed to open WAV file: %s", filepath); if (isConverted) remove(convertedPath); return false; } signal->sampleRate = wave.sampleRate; signal->channels = wave.channels; signal->numSamples = wave.frameCount * wave.channels; signal->duration = (float)wave.frameCount / wave.sampleRate; signal->samples = (float*)malloc(signal->numSamples * sizeof(float)); if (wave.sampleSize == 16) { short* samples = (short*)wave.data; for (int i = 0; i < signal->numSamples; i++) signal->samples[i] = samples[i] / 32768.0f; } else if (wave.sampleSize == 32) { float* samples = (float*)wave.data; memcpy(signal->samples, samples, signal->numSamples * sizeof(float)); } else { unsigned char* samples = (unsigned char*)wave.data; for (int i = 0; i < signal->numSamples; i++) signal->samples[i] = (samples[i] - 128) / 128.0f; } if (wave.channels > 1) { int monoSamples = wave.frameCount; for (int i = 0; i < monoSamples; i++) { float sum = 0.0f; for (int c = 0; c < wave.channels; c++) sum += signal->samples[i * wave.channels + c]; signal->samples[i] = sum / wave.channels; } signal->numSamples = monoSamples; } UnloadWave(wave); // Clean up temp file if converted if (isConverted) { remove(convertedPath); TraceLog(LOG_INFO, "Cleaned up temp file: %s", convertedPath); } TraceLog(LOG_INFO, "Loaded WAV: %d Hz, %.2f sec, %d samples", signal->sampleRate, signal->duration, signal->numSamples); return true; } static void FreeSignal(AudioSignal* signal) { if (signal->samples) { free(signal->samples); signal->samples = NULL; } signal->numSamples = 0; signal->sampleRate = 0; signal->duration = 0.0f; } // ============================================================================ // Spectrogram Generation // ============================================================================ static void GenerateSpectrogramTexture(StftResult* stft, Image* image, Texture2D* texture) { if (stft->numSegments == 0) return; int width = stft->numSegments; int height = stft->segments[0].numBins; int fftSize = (height - 1) * 2; float freqPerBin = (float)stft->sampleRate / fftSize; *image = GenImageColor(width, height, BLACK); Color* pixels = (Color*)image->data; // Find max amplitude for normalization float maxAmplitude = 0.0001f; for (int seg = 0; seg < stft->numSegments; seg++) for (int bin = 0; bin < stft->segments[seg].numBins; bin++) if (stft->segments[seg].spectrum[bin].amplitude > maxAmplitude) maxAmplitude = stft->segments[seg].spectrum[bin].amplitude; // ===== SYNCHROSQUEEZING ===== // Reassign energy to true frequencies using derivative STFT // Accumulation buffer for reassigned energy float* accumBuffer = (float*)calloc(width * height, sizeof(float)); // Noise threshold: only reassign bins with significant energy float noiseThreshold = maxAmplitude * 0.01f; // 1% of max amplitude for (int seg = 0; seg < width; seg++) { for (int bin = 0; bin < height; bin++) { FrequencyData* V_f = &stft->segments[seg].spectrum[bin]; FrequencyData* V_fd = &stft->segments[seg].derivativeSpectrum[bin]; float amplitude = V_f->amplitude; // Skip noise bins if (amplitude < noiseThreshold) continue; // Compute instantaneous frequency using synchrosqueezing formula: // ω̂ = bin_freq + Re[V_fd / (i * V_f)] // Complex division: (a+bi)/(c+di) = ((ac+bd) + (bc-ad)i) / (c²+d²) // We need Re[(a+bi) / (i*(c+di))] = Re[(a+bi) / (-d+ci)] = (ad+bc)/(c²+d²) float V_f_real = amplitude * cosf(V_f->phase); float V_f_imag = amplitude * sinf(V_f->phase); float V_fd_real = V_fd->amplitude * cosf(V_fd->phase); float V_fd_imag = V_fd->amplitude * sinf(V_fd->phase); float denom = V_f_real * V_f_real + V_f_imag * V_f_imag; float trueFreq = V_f->frequency; // Default to bin frequency if (denom > 1e-10f) { // Re[V_fd / (i * V_f)] = (-V_fd_real * V_f_imag + V_fd_imag * V_f_real) / denom // Note the MINUS sign on the first term float correction = (-V_fd_real * V_f_imag + V_fd_imag * V_f_real) / denom; trueFreq = V_f->frequency + correction; } // Clamp to valid range if (trueFreq < 0) trueFreq = 0; if (trueFreq >= stft->sampleRate / 2.0f) trueFreq = stft->sampleRate / 2.0f - 1; // Map to bin coordinate float targetBinF = trueFreq / freqPerBin; if (targetBinF < 0) targetBinF = 0; if (targetBinF >= height) targetBinF = height - 0.001f; // Bilinear splatting to neighboring bins int bin0 = (int)targetBinF; int bin1 = bin0 + 1; if (bin1 >= height) bin1 = height - 1; float frac = targetBinF - bin0; int idx0 = (height - 1 - bin0) * width + seg; int idx1 = (height - 1 - bin1) * width + seg; accumBuffer[idx0] += amplitude * (1 - frac); accumBuffer[idx1] += amplitude * frac; } } // Convert accumulation buffer to colors for (int i = 0; i < width * height; i++) { if (accumBuffer[i] > 0.0001f) { float db = AmplitudeToDecibels(accumBuffer[i]); float normalized = (db - app.amplitudeFloorDb) / (app.amplitudeCeilingDb - app.amplitudeFloorDb); normalized = Clamp(normalized, 0.0f, 1.0f); pixels[i] = GetColormapColor(normalized, app.colormap); } } free(accumBuffer); if (texture->id != 0) UnloadTexture(*texture); *texture = LoadTextureFromImage(*image); SetTextureFilter(*texture, TEXTURE_FILTER_BILINEAR); } // ============================================================================ // Audio Playback with FFT-based Bandpass Filter // ============================================================================ static void ApplyBandpassFilterFFT(float* samples, int numSamples, int sampleRate, float freqLow, float freqHigh) { if (freqLow <= 0 && freqHigh >= sampleRate / 2.0f) return; int fftSize = 1; while (fftSize < numSamples) fftSize *= 2; fftSize *= 2; float* windowedSamples = (float*)calloc(fftSize, sizeof(float)); float complex *fftInput = (float complex*)malloc(fftSize * sizeof(float complex)); float complex *fftOutput = (float complex*)malloc(fftSize * sizeof(float complex)); for (int i = 0; i < numSamples; i++) { float window = 0.5f * (1.0f - cosf(2.0f * M_PI * i / (numSamples - 1))); windowedSamples[i] = samples[i] * window; } for (int i = 0; i < fftSize; i++) fftInput[i] = windowedSamples[i] + 0.0f * I; FFT(fftInput, fftOutput, fftSize, false); float freqPerBin = (float)sampleRate / fftSize; for (int bin = 0; bin < fftSize / 2 + 1; bin++) { float frequency = bin * freqPerBin; float attenuation = 1.0f; if (frequency < freqLow) { float dist = (freqLow - frequency) / (freqPerBin * 10.0f); attenuation = 1.0f / (1.0f + dist * dist * dist); } else if (frequency > freqHigh) { float dist = (frequency - freqHigh) / (freqPerBin * 10.0f); attenuation = 1.0f / (1.0f + dist * dist * dist); } fftOutput[bin] *= attenuation; if (bin > 0 && bin < fftSize / 2) fftOutput[fftSize - bin] *= attenuation; } float complex* ifftOutput = (float complex*)malloc(fftSize * sizeof(float complex)); FFT(fftOutput, ifftOutput, fftSize, true); float filteredPeak = 0.0f; for (int i = 0; i < numSamples; i++) { samples[i] = crealf(ifftOutput[i]); if (fabsf(samples[i]) > filteredPeak) filteredPeak = fabsf(samples[i]); } const float TARGET_PEAK = 0.9f; if (filteredPeak > 0.0001f) { float gain = TARGET_PEAK / filteredPeak; if (gain > 10.0f) gain = 10.0f; for (int i = 0; i < numSamples; i++) { samples[i] *= gain; if (samples[i] > 0.95f) samples[i] = 0.95f; if (samples[i] < -0.95f) samples[i] = -0.95f; } } free(windowedSamples); free(fftInput); free(fftOutput); free(ifftOutput); } static void ApplyBandpassFilter(float* samples, int numSamples, int sampleRate, float freqLow, float freqHigh) { ApplyBandpassFilterFFT(samples, numSamples, sampleRate, freqLow, freqHigh); } static void PlaySelectedRegion(void) { if (!app.loaded || !app.stftComputed) return; int startSample = (int)(app.timeSelectionStart * app.signal.numSamples); int endSample = (int)(app.timeSelectionEnd * app.signal.numSamples); int numSamples = endSample - startSample; if (numSamples <= 0 || startSample < 0 || endSample > app.signal.numSamples) return; float* regionSamples = (float*)malloc(numSamples * sizeof(float)); memcpy(regionSamples, app.signal.samples + startSample, numSamples * sizeof(float)); float maxFreq = (float)app.signal.sampleRate / 2.0f; float freqLow = app.freqSelectionStart * maxFreq; float freqHigh = app.freqSelectionEnd * maxFreq; if (freqLow > 10.0f || freqHigh < maxFreq - 10.0f) { TraceLog(LOG_INFO, "Applying bandpass filter: %.0f - %.0f Hz", freqLow, freqHigh); ApplyBandpassFilter(regionSamples, numSamples, app.signal.sampleRate, freqLow, freqHigh); } if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound); Wave wave = { .data = regionSamples, .frameCount = (unsigned int)numSamples, .sampleRate = (unsigned int)app.signal.sampleRate, .sampleSize = 32, .channels = 1 }; AudioPlaybackSound = LoadSoundFromWave(wave); PlaySound(AudioPlaybackSound); TraceLog(LOG_INFO, "Playing: %.2f-%.2f sec, %.0f-%.0f Hz", (float)startSample / app.signal.sampleRate, (float)endSample / app.signal.sampleRate, freqLow, freqHigh); } // ============================================================================ // File Browser // ============================================================================ static void FreeBrowserFiles(void) { if (app.browserFiles) { for (int i = 0; i < app.browserFileCount; i++) free(app.browserFiles[i]); free(app.browserFiles); free(app.browserIsDir); app.browserFiles = NULL; app.browserIsDir = NULL; } app.browserFileCount = 0; } static void ScanDirectory(const char* path) { FreeBrowserFiles(); strncpy(app.browserPath, path, sizeof(app.browserPath) - 1); FilePathList files = LoadDirectoryFiles(path); int dirCount = 0, wavCount = 0; for (int i = 0; i < files.count; i++) { const char* name = GetFileName(files.paths[i]); if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; if (DirectoryExists(files.paths[i])) dirCount++; else { const char* ext = GetFileExtension(files.paths[i]); if (ext && (strcmp(ext, ".wav") == 0 || strcmp(ext, ".WAV") == 0 || strcmp(ext, ".Wave") == 0 || strcmp(ext, ".Wav") == 0)) wavCount++; } } int totalCount = dirCount + wavCount; if (totalCount > 0) { app.browserFiles = (char**)malloc(totalCount * sizeof(char*)); app.browserIsDir = (bool*)malloc(totalCount * sizeof(bool)); app.browserFileCount = 0; for (int i = 0; i < files.count; i++) { const char* name = GetFileName(files.paths[i]); if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; if (DirectoryExists(files.paths[i])) { app.browserFiles[app.browserFileCount] = strdup(name); app.browserIsDir[app.browserFileCount] = true; app.browserFileCount++; } } for (int i = 0; i < files.count; i++) { const char* name = GetFileName(files.paths[i]); if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; if (!DirectoryExists(files.paths[i])) { const char* ext = GetFileExtension(files.paths[i]); if (ext && (strcmp(ext, ".wav") == 0 || strcmp(ext, ".WAV") == 0 || strcmp(ext, ".Wave") == 0 || strcmp(ext, ".Wav") == 0)) { app.browserFiles[app.browserFileCount] = strdup(name); app.browserIsDir[app.browserFileCount] = false; app.browserFileCount++; } } } } UnloadDirectoryFiles(files); app.browserScroll = 0; app.browserSelected = -1; } static void NavigateToParentDirectory(void) { const char* parent = GetPrevDirectoryPath(app.browserPath); if (parent && strlen(parent) > 0) ScanDirectory(parent); } static void NavigateToDirectory(const char* dirName) { char newPath[512]; int written = snprintf(newPath, sizeof(newPath), "%s/%s", app.browserPath, dirName); if (written < 0 || written >= (int)sizeof(newPath)) return; // Path too long if (DirectoryExists(newPath)) ScanDirectory(newPath); } static void LoadSelectedFile(void) { if (app.browserSelected < 0 || app.browserSelected >= app.browserFileCount) return; char filePath[512]; int written = snprintf(filePath, sizeof(filePath), "%s/%s", app.browserPath, app.browserFiles[app.browserSelected]); if (written < 0 || written >= (int)sizeof(filePath)) return; // Path too long if (app.browserIsDir[app.browserSelected]) { NavigateToDirectory(app.browserFiles[app.browserSelected]); } else if (FileExists(filePath) && LoadWavFile(filePath, &app.signal)) { app.loaded = true; app.stftComputed = false; app.timeSelectionStart = app.viewStart = 0.0f; app.timeSelectionEnd = app.viewEnd = 1.0f; app.freqSelectionStart = 0.0f; app.freqSelectionEnd = 1.0f; app.showFileBrowser = false; // Invalidate visible texture cache if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture); app.visibleTexture = (Texture2D){ 0 }; app.visibleTextureValid = false; TraceLog(LOG_INFO, "Loaded: %s", filePath); } } static void DrawFileBrowser(void) { // Draw semi-transparent overlay first DrawRectangle(0, 0, GetScreenWidth(), GetScreenHeight(), Fade(BLACK, 0.85f)); float bw = 650, bh = 550; float bx = (GetScreenWidth() - bw) / 2, by = (GetScreenHeight() - bh) / 2; DrawRectangle(bx, by, bw, bh, (Color){ 45, 45, 55, 255 }); DrawRectangleLinesEx((Rectangle){ bx, by, bw, bh }, 2, GRAY); DrawRectangle(bx, by, bw, 35, (Color){ 60, 60, 75, 255 }); DrawText("File Browser - Select WAV File", bx + 15, by + 10, 18, WHITE); // Path bar DrawRectangle(bx + 15, by + 45, bw - 110, 28, (Color){ 30, 30, 40, 255 }); DrawRectangleLinesEx((Rectangle){ bx + 15, by + 45, bw - 110, 28 }, 1, GRAY); char displayPath[300]; strncpy(displayPath, app.browserPath, sizeof(displayPath) - 1); displayPath[sizeof(displayPath) - 1] = '\0'; if (strlen(displayPath) > 60) sprintf(displayPath, "...%s", app.browserPath + strlen(app.browserPath) - 57); DrawText(displayPath, bx + 20, by + 51, 14, LIGHTGRAY); // Up button Rectangle upBtn = { bx + bw - 85, by + 45, 70, 28 }; if (CheckCollisionPointRec(GetMousePosition(), upBtn)) DrawRectangleRec(upBtn, (Color){ 80, 80, 90, 255 }); DrawText("UP (..)", upBtn.x + 10, upBtn.y + 7, 14, WHITE); // File list float lx = bx + 15, ly = by + 82, lw = bw - 30, lh = bh - 150; DrawRectangle(lx, ly, lw, lh, (Color){ 25, 25, 35, 255 }); DrawRectangleLinesEx((Rectangle){ lx, ly, lw, lh }, 1, GRAY); // Handle empty directory int visibleItems = (int)(lh / 26); if (visibleItems < 1) visibleItems = 1; if (app.browserFileCount <= 0 || !app.browserFiles) { DrawText("(No WAV files in directory)", lx + 20, ly + lh/2 - 10, 14, GRAY); } else { if (app.browserFileCount > visibleItems) { float sh = (float)visibleItems / app.browserFileCount * lh; if (sh < 10) sh = 10; float sy = ly + (float)app.browserScroll / (app.browserFileCount - visibleItems) * (lh - sh); DrawRectangle(lx + lw - 10, sy, 8, sh, GRAY); } int startItem = app.browserScroll; int endItem = startItem + visibleItems + 1; if (endItem > app.browserFileCount) endItem = app.browserFileCount; for (int i = startItem; i < endItem; i++) { if (i < 0 || i >= app.browserFileCount || !app.browserFiles[i] || !app.browserIsDir) continue; float iy = ly + (i - startItem) * 26 + 2; bool hovered = CheckCollisionPointRec((Vector2){ GetMouseX(), GetMouseY() }, (Rectangle){ lx + 2, iy, lw - 14, 24 }); if (i == app.browserSelected) DrawRectangle(lx + 2, iy, lw - 14, 24, (Color){ 50, 70, 120, 180 }); else if (hovered) DrawRectangle(lx + 2, iy, lw - 14, 24, (Color){ 60, 60, 80, 100 }); const char* icon = app.browserIsDir[i] ? "[DIR]" : "[WAV]"; Color iconCol = app.browserIsDir[i] ? (Color){ 255, 220, 80, 255 } : (Color){ 80, 200, 120, 255 }; DrawText(icon, lx + 8, iy + 5, 13, iconCol); DrawText(app.browserFiles[i], lx + 55, iy + 5, 14, WHITE); } } // Scroll with mouse wheel if (CheckCollisionPointRec(GetMousePosition(), (Rectangle){ lx, ly, lw - 10, lh }) && app.browserFileCount > 0) { int wheel = GetMouseWheelMove(); if (wheel > 0) app.browserScroll--; if (wheel < 0) app.browserScroll++; if (app.browserScroll < 0) app.browserScroll = 0; int maxScroll = app.browserFileCount - visibleItems; if (maxScroll < 0) maxScroll = 0; if (app.browserScroll > maxScroll) app.browserScroll = maxScroll; } // Handle clicks if (CheckCollisionPointRec(GetMousePosition(), upBtn) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) NavigateToParentDirectory(); if (CheckCollisionPointRec(GetMousePosition(), (Rectangle){ lx, ly, lw - 10, lh }) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && app.browserFileCount > 0) { int clicked = app.browserScroll + (int)((GetMouseY() - ly) / 26); if (clicked >= 0 && clicked < app.browserFileCount) { app.browserSelected = clicked; static double lastClick = 0; if (GetTime() - lastClick < 0.3) LoadSelectedFile(); lastClick = GetTime(); } } // Buttons float btnY = by + bh - 50; Rectangle openBtn = { bx + bw - 160, btnY, 140, 38 }; Rectangle cancelBtn = { bx + 15, btnY, 110, 38 }; bool openHovered = CheckCollisionPointRec(GetMousePosition(), openBtn); bool openClicked = openHovered && IsMouseButtonPressed(MOUSE_LEFT_BUTTON); if (openHovered) DrawRectangleRec(openBtn, (Color){ 100, 100, 120, 255 }); else DrawRectangleRec(openBtn, (Color){ 80, 80, 90, 255 }); DrawRectangleLinesEx(openBtn, 1, WHITE); DrawText("OPEN (Enter)", openBtn.x + 25, openBtn.y + 12, 16, WHITE); DrawRectangleRec(cancelBtn, (Color){ 100, 40, 40, 255 }); DrawText("ESC Cancel", cancelBtn.x + 18, cancelBtn.y + 12, 16, WHITE); if ((IsKeyPressed(KEY_ENTER) || openClicked) && app.browserSelected >= 0 && app.browserFileCount > 0) LoadSelectedFile(); if (IsKeyPressed(KEY_ESCAPE)) app.showFileBrowser = false; if (IsKeyPressed(KEY_UP) && app.browserSelected > 0 && app.browserFileCount > 0) { app.browserSelected--; if (app.browserSelected < app.browserScroll) app.browserScroll = app.browserSelected; } if (IsKeyPressed(KEY_DOWN) && app.browserSelected < app.browserFileCount - 1 && app.browserFileCount > 0) { app.browserSelected++; if (app.browserSelected >= app.browserScroll + visibleItems) app.browserScroll = app.browserSelected - visibleItems + 1; } } // ============================================================================ // UI and Rendering // ============================================================================ static void DrawSpectrogramGrid(Rectangle bounds, int numCellsX, int numCellsY, Color color) { float cellWidth = bounds.width / numCellsX, cellHeight = bounds.height / numCellsY; for (int i = 0; i <= numCellsX; i++) { float x = bounds.x + i * cellWidth; DrawLineV((Vector2){ x, bounds.y }, (Vector2){ x, bounds.y + bounds.height }, color); } for (int i = 0; i <= numCellsY; i++) { float y = bounds.y + bounds.height - i * cellHeight; DrawLineV((Vector2){ bounds.x, y }, (Vector2){ bounds.x + bounds.width, y }, color); } } static void DrawLabels(Rectangle bounds) { int fontSize = 10; Color textColor = LIGHTGRAY; // Time labels for (int i = 0; i <= 10; i++) { float t = (float)i / 10; float timeSec = (app.viewStart + t * (app.viewEnd - app.viewStart)) * app.signal.duration; float x = bounds.x + t * bounds.width; char label[32]; if (timeSec >= 60) sprintf(label, "%d:%02d", (int)(timeSec / 60), (int)(timeSec) % 60); else sprintf(label, "%.1fs", timeSec); Vector2 textSize = MeasureTextEx(GetFontDefault(), label, fontSize, 0); DrawText(label, x - textSize.x / 2, bounds.y + bounds.height + 5, fontSize, textColor); } // Frequency labels at 1kHz intervals with 200Hz ticks float maxFreq = (float)app.signal.sampleRate / 2.0f; int maxKhz = (int)(maxFreq / 1000.0f); for (int hz = 0; hz <= maxFreq; hz += 200) { float t = hz / maxFreq; float y = bounds.y + bounds.height - t * bounds.height; Color tickColor = (hz % 1000 == 0) ? GRAY : Fade(GRAY, 0.4f); DrawLineV((Vector2){ bounds.x - 5, y }, (Vector2){ bounds.x, y }, tickColor); } for (int khz = 0; khz <= maxKhz; khz++) { float freq = khz * 1000.0f; if (freq > maxFreq) break; float t = freq / maxFreq; float y = bounds.y + bounds.height - t * bounds.height; char label[32]; if (khz == 0) sprintf(label, "0"); else if (khz < 10) sprintf(label, "%dk", khz); else sprintf(label, "%d", khz); Vector2 textSize = MeasureTextEx(GetFontDefault(), label, fontSize, 0); DrawText(label, bounds.x - textSize.x - 15, y - textSize.y / 2, fontSize, textColor); } // Axis titles removed - just the tick labels are enough } static void DrawSlider(Rectangle bounds, float value); static bool UpdateSlider(Rectangle bounds, float* value); static void DrawSidebar(void); static void DrawSelection(Rectangle bounds) { Color overlayColor = Fade(BLACK, 0.5f); float selStartX = bounds.x + app.timeSelectionStart * bounds.width; float selEndX = bounds.x + app.timeSelectionEnd * bounds.width; if (selStartX > bounds.x) DrawRectangle(bounds.x, bounds.y, selStartX - bounds.x, bounds.height, overlayColor); if (selEndX < bounds.x + bounds.width) DrawRectangle(selEndX, bounds.y, bounds.x + bounds.width - selEndX, bounds.height, overlayColor); float selStartY = bounds.y + bounds.height - app.freqSelectionEnd * bounds.height; float selEndY = bounds.y + bounds.height - app.freqSelectionStart * bounds.height; if (selStartY > bounds.y) DrawRectangle(selStartX, bounds.y, selEndX - selStartX, selStartY - bounds.y, overlayColor); if (selEndY < bounds.y + bounds.height) DrawRectangle(selStartX, selEndY, selEndX - selStartX, bounds.y + bounds.height - selEndY, overlayColor); DrawLineV((Vector2){ selStartX, bounds.y }, (Vector2){ selStartX, bounds.y + bounds.height }, YELLOW); DrawLineV((Vector2){ selEndX, bounds.y }, (Vector2){ selEndX, bounds.y + bounds.height }, YELLOW); if (app.freqSelectionStart > 0.0f || app.freqSelectionEnd < 1.0f) { DrawLineV((Vector2){ selStartX, selStartY }, (Vector2){ selEndX, selStartY }, CYAN); DrawLineV((Vector2){ selStartX, selEndY }, (Vector2){ selEndX, selEndY }, CYAN); } } static void DrawSidebar(void) { float sidebarWidth = 300; float x = 10; float y = 10; int fontSize = 12; bool needsRegen = false; // Dark sidebar background DrawRectangle(0, 0, sidebarWidth + 20, GetScreenHeight(), (Color){ 35, 35, 40, 255 }); DrawLine(sidebarWidth + 10, 0, sidebarWidth + 10, GetScreenHeight(), GRAY); // Title DrawText("Spectrogram Controls", x, y, 14, WHITE); y += 25; // FFT Size clicker DrawText(TextFormat("FFT Size: %d (%.1f Hz/bin)", app.fftSize, (float)app.signal.sampleRate / app.fftSize), x, y, fontSize, LIGHTGRAY); y += 18; Rectangle fftMinus = { x, y, 30, 25 }; Rectangle fftPlus = { x + sidebarWidth - 40, y, 30, 25 }; if (CheckCollisionPointRec(GetMousePosition(), fftMinus) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { int newFFT = app.fftSize / 2; if (newFFT >= FFT_SIZE_MIN) { app.fftSize = newFFT; app.stftComputed = false; needsRegen = true; } } if (CheckCollisionPointRec(GetMousePosition(), fftPlus) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { int newFFT = app.fftSize * 2; if (newFFT <= FFT_SIZE_MAX) { app.fftSize = newFFT; app.stftComputed = false; needsRegen = true; } } DrawRectangleRec(fftMinus, (Color){ 50, 50, 60, 255 }); DrawRectangleLinesEx(fftMinus, 1, GRAY); DrawText("-", fftMinus.x + 12, fftMinus.y + 5, 16, WHITE); DrawRectangleRec(fftPlus, (Color){ 50, 50, 60, 255 }); DrawRectangleLinesEx(fftPlus, 1, GRAY); DrawText("+", fftPlus.x + 10, fftPlus.y + 5, 16, WHITE); y += 30; // dB Floor slider DrawText(TextFormat("dB Floor: %.1f", app.amplitudeFloorDb), x, y, fontSize, LIGHTGRAY); y += 18; Rectangle dbSlider = { x, y, sidebarWidth - 10, 20 }; float dbValue = (app.amplitudeFloorDb + 100.0f) / 80.0f; DrawSlider(dbSlider, dbValue); if (UpdateSlider(dbSlider, &dbValue)) { app.amplitudeFloorDb = -100.0f + dbValue * 80.0f; needsRegen = true; // Trigger immediate redraw } y += 25; // Colormap dropdown DrawText("Colormap:", x, y, fontSize, LIGHTGRAY); y += 18; const char* colormapNames[] = { "Grays", "Inferno", "Viridis", "Plasma", "Hot", "Cool" }; Rectangle cmapButton = { x, y, sidebarWidth - 10, 25 }; if (CheckCollisionPointRec(GetMousePosition(), cmapButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { app.colormap = (ColormapType)((app.colormap + 1) % COLORMAP_COUNT); GenerateColormapTexture(); needsRegen = true; } DrawRectangleRec(cmapButton, (Color){ 50, 50, 60, 255 }); DrawRectangleLinesEx(cmapButton, 1, GRAY); DrawText(colormapNames[app.colormap], cmapButton.x + 10, cmapButton.y + 6, fontSize, WHITE); DrawTexturePro(colormapTexture, (Rectangle){ 0, 0, 256, 1 }, (Rectangle){ cmapButton.x + cmapButton.width - 60, cmapButton.y + 5, 50, 15 }, (Vector2){ 0, 0 }, 0.0f, WHITE); y += 30; // Grid toggle Rectangle gridCheck = { x, y, 18, 18 }; if (CheckCollisionPointRec(GetMousePosition(), gridCheck) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { app.showGrid = !app.showGrid; } DrawRectangleRec(gridCheck, app.showGrid ? BLUE : DARKGRAY); DrawRectangleLinesEx(gridCheck, 1, WHITE); DrawText("Show Grid", x + 25, y + 2, fontSize, LIGHTGRAY); y += 25; // File loading DrawText("File:", x, y, fontSize, LIGHTGRAY); y += 18; Rectangle fileButton = { x, y, sidebarWidth - 10, 25 }; if (CheckCollisionPointRec(GetMousePosition(), fileButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { app.showFileBrowser = true; ScanDirectory(GetWorkingDirectory()); } DrawRectangleRec(fileButton, (Color){ 50, 50, 60, 255 }); DrawRectangleLinesEx(fileButton, 1, GRAY); DrawText("Open File Browser (O)", fileButton.x + 10, fileButton.y + 6, fontSize, WHITE); y += 35; // Playback Rectangle playButton = { x, y, sidebarWidth - 10, 35 }; if (CheckCollisionPointRec(GetMousePosition(), playButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { if (app.isPlaying && AudioPlaybackSound.frameCount > 0) { StopSound(AudioPlaybackSound); app.isPlaying = false; } else { PlaySelectedRegion(); app.isPlaying = true; } } const char* playText = app.isPlaying ? "STOP (SPACE)" : "PLAY (SPACE)"; DrawRectangleRec(playButton, app.isPlaying ? (Color){ 120, 40, 40, 255 } : (Color){ 40, 100, 40, 255 }); DrawRectangleLinesEx(playButton, 1, app.isPlaying ? RED : GREEN); DrawText(playText, playButton.x + 10, playButton.y + 12, fontSize, WHITE); y += 45; // Reset/Clear buttons Rectangle resetButton = { x, y, (sidebarWidth - 15) / 2, 25 }; if (CheckCollisionPointRec(GetMousePosition(), resetButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { app.timeSelectionStart = app.viewStart; app.timeSelectionEnd = app.viewEnd; app.freqSelectionStart = 0.0f; app.freqSelectionEnd = 1.0f; } DrawRectangleRec(resetButton, (Color){ 80, 50, 50, 255 }); DrawRectangleLinesEx(resetButton, 1, RED); DrawText("Reset Sel (R)", resetButton.x + 10, resetButton.y + 6, fontSize, WHITE); Rectangle clearButton = { x + resetButton.width + 5, y, (sidebarWidth - 15) / 2, 25 }; if (CheckCollisionPointRec(GetMousePosition(), clearButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { app.timeSelectionStart = 0.0f; app.timeSelectionEnd = 1.0f; app.freqSelectionStart = 0.0f; app.freqSelectionEnd = 1.0f; } DrawRectangleRec(clearButton, (Color){ 80, 50, 50, 255 }); DrawRectangleLinesEx(clearButton, 1, RED); DrawText("Clear (ESC)", clearButton.x + 10, clearButton.y + 6, fontSize, WHITE); y += 35; // Signal info DrawText("Signal Info:", x, y, fontSize, LIGHTGRAY); y += 18; if (app.loaded) { DrawText(TextFormat("Sample Rate: %d Hz", app.signal.sampleRate), x, y, fontSize, GRAY); y += 16; DrawText(TextFormat("Duration: %.2f sec", app.signal.duration), x, y, fontSize, GRAY); y += 16; DrawText(TextFormat("Max Freq: %.1f kHz", (float)app.signal.sampleRate / 2000.0f), x, y, fontSize, GRAY); y += 16; } else { DrawText("No file loaded", x, y, fontSize, GRAY); y += 16; } if (needsRegen && app.stftComputed) { GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture); app.visibleTextureValid = false; // Force cache invalidation } } static void DrawSlider(Rectangle bounds, float value) { // Background DrawRectangleRec(bounds, DARKGRAY); DrawRectangleLinesEx(bounds, 1, GRAY); // Fill float fillWidth = (bounds.width - 4) * value; DrawRectangle(bounds.x + 2, bounds.y + 2, fillWidth, bounds.height - 4, BLUE); // Handle float handleX = bounds.x + 2 + fillWidth; DrawRectangle(handleX - 3, bounds.y + 1, 6, bounds.height - 2, WHITE); } static bool UpdateSlider(Rectangle bounds, float* value) { bool changed = false; if (CheckCollisionPointRec(GetMousePosition(), bounds) && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { float newValue = (GetMousePosition().x - bounds.x - 2) / (bounds.width - 4); newValue = Clamp(newValue, 0.0f, 1.0f); if (fabsf(newValue - *value) > 0.001f) { *value = newValue; changed = true; } } return changed; } // ============================================================================ // Main Application // ============================================================================ int main(int argc, char* argv[]) { SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_HIGHDPI); InitWindow(1280, 800, "Spectrogram Viewer"); SetTargetFPS(60); SetTraceLogLevel(LOG_WARNING); // Suppress INFO texture logs InitAudioDevice(); SearchAndSetResourceDir("resources"); app.timeSelectionStart = 0.0f; app.timeSelectionEnd = 1.0f; app.freqSelectionStart = 0.0f; app.freqSelectionEnd = 1.0f; app.viewStart = 0.0f; app.viewEnd = 1.0f; app.freqViewStart = 0.0f; app.freqViewEnd = 1.0f; app.showGrid = true; app.colormap = COLORMAP_INFERNO; app.amplitudeFloorDb = -60.0f; app.amplitudeCeilingDb = 0.0f; app.showFileBrowser = false; app.isBrowsing = false; app.visibleTexture = (Texture2D){ 0 }; app.cachedVisibleStart = -1; app.cachedVisibleEnd = -1; app.cachedVisibleStartY = -1; app.cachedVisibleEndY = -1; app.visibleTextureValid = false; app.fftSize = FFT_SIZE_DEFAULT; app.isPlaying = false; app.playbackFinished = false; GenerateColormapTexture(); ScanDirectory(GetWorkingDirectory()); TraceLog(LOG_INFO, "Spectrogram Viewer initialized"); bool fileLoaded = false; if (argc > 1) { TraceLog(LOG_INFO, "Loading file from command line: %s", argv[1]); if (FileExists(argv[1]) && LoadWavFile(argv[1], &app.signal)) { fileLoaded = true; app.loaded = true; app.stftComputed = false; TraceLog(LOG_INFO, "File loaded successfully"); } } if (!fileLoaded) TraceLog(LOG_INFO, "Press 'O' for file browser or drag & drop WAV file"); while (!WindowShouldClose()) { // Drag & Drop if (IsFileDropped()) { FilePathList dropped = LoadDroppedFiles(); if (dropped.count > 0) { const char* ext = GetFileExtension(dropped.paths[0]); bool isWav = ext && (strcmp(ext, ".wav") == 0 || strcmp(ext, ".WAV") == 0 || strcmp(ext, ".Wave") == 0 || strcmp(ext, ".Wav") == 0); if (isWav && FileExists(dropped.paths[0])) { if (LoadWavFile(dropped.paths[0], &app.signal)) { app.loaded = true; app.stftComputed = false; app.viewStart = 0.0f; app.viewEnd = 1.0f; // Invalidate visible texture cache if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture); app.visibleTexture = (Texture2D){ 0 }; app.visibleTextureValid = false; } } } UnloadDroppedFiles(dropped); } // File browser toggle if (IsKeyPressed(KEY_O) && !app.showFileBrowser) { app.showFileBrowser = true; ScanDirectory(GetWorkingDirectory()); } // File browser update if (app.showFileBrowser) { // Browser handles its own input } // Check if playback finished naturally if (app.isPlaying && AudioPlaybackSound.frameCount > 0) { // Check if sound stopped playing (IsSoundPlaying returns false when done) if (!IsSoundPlaying(AudioPlaybackSound)) { app.isPlaying = false; app.playbackFinished = true; } } // View controls if (app.loaded && !app.showFileBrowser) { // Spectrogram area fills remaining window space // Space for: spectrogram + time labels (15px below) + scrollbar (18px below that) float sidebarWidth = 320; float labelHeight = 15; float scrollbarHeight = 18; float freqLabelWidth = 65; float vScrollbarWidth = 18; float topMargin = 50; float bottomMargin = 10; Rectangle viewBounds = { sidebarWidth + freqLabelWidth, topMargin, GetScreenWidth() - sidebarWidth - freqLabelWidth - vScrollbarWidth - 20, GetScreenHeight() - topMargin - bottomMargin - labelHeight - scrollbarHeight - 10 }; // Zoom with mouse wheel (zooms both time and frequency to maintain aspect ratio) if (GetMousePosition().x > 385 && CheckCollisionPointRec(GetMousePosition(), viewBounds)) { int wheel = GetMouseWheelMove(); if (wheel != 0) { float zoomFactor = (wheel > 0) ? 0.8f : 1.2f; // --- Time axis zoom (around cursor X) --- float mouseT = (GetMousePosition().x - viewBounds.x) / viewBounds.width; mouseT = app.viewStart + mouseT * (app.viewEnd - app.viewStart); float viewWidth = app.viewEnd - app.viewStart; float newWidth = viewWidth * zoomFactor; if (newWidth < 0.02f) newWidth = 0.02f; if (newWidth > 1.0f) newWidth = 1.0f; float leftOfMouse = mouseT - app.viewStart; float rightOfMouse = app.viewEnd - mouseT; app.viewStart = mouseT - leftOfMouse * (newWidth / viewWidth); app.viewEnd = mouseT + rightOfMouse * (newWidth / viewWidth); if (app.viewStart < 0) { app.viewStart = 0; app.viewEnd = newWidth; } if (app.viewEnd > 1) { app.viewEnd = 1; app.viewStart = 1 - newWidth; } // --- Frequency axis zoom (around cursor Y) --- float mouseF = 1.0f - (GetMousePosition().y - viewBounds.y) / viewBounds.height; mouseF = app.freqViewStart + mouseF * (app.freqViewEnd - app.freqViewStart); float freqWidth = app.freqViewEnd - app.freqViewStart; float newFreqWidth = freqWidth * zoomFactor; if (newFreqWidth < 0.02f) newFreqWidth = 0.02f; if (newFreqWidth > 1.0f) newFreqWidth = 1.0f; float belowMouse = mouseF - app.freqViewStart; float aboveMouse = app.freqViewEnd - mouseF; app.freqViewStart = mouseF - belowMouse * (newFreqWidth / freqWidth); app.freqViewEnd = mouseF + aboveMouse * (newFreqWidth / freqWidth); if (app.freqViewStart < 0) { app.freqViewStart = 0; app.freqViewEnd = newFreqWidth; } if (app.freqViewEnd > 1) { app.freqViewEnd = 1; app.freqViewStart = 1 - newFreqWidth; } // Invalidate texture cache app.visibleTextureValid = false; } } // Pan with Alt+drag or middle mouse button (pans both axes) bool canPan = IsKeyDown(KEY_LEFT_ALT) || IsKeyDown(KEY_RIGHT_ALT) || IsMouseButtonDown(MOUSE_BUTTON_MIDDLE); if (canPan && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { app.isPanning = true; app.panStartPos = GetMousePosition(); app.panStartViewStart = app.viewStart; app.panStartViewEnd = app.viewEnd; app.panStartFreqViewStart = app.freqViewStart; app.panStartFreqViewEnd = app.freqViewEnd; } if (app.isPanning && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { float dx = (GetMousePosition().x - app.panStartPos.x) / viewBounds.width; float dy = (GetMousePosition().y - app.panStartPos.y) / viewBounds.height; float viewWidth = app.panStartViewEnd - app.panStartViewStart; float freqWidth = app.panStartFreqViewEnd - app.panStartFreqViewStart; app.viewStart = app.panStartViewStart - dx * viewWidth; app.viewEnd = app.panStartViewEnd - dx * viewWidth; if (app.viewStart < 0) { app.viewStart = 0; app.viewEnd = viewWidth; } if (app.viewEnd > 1) { app.viewEnd = 1; app.viewStart = 1 - viewWidth; } app.freqViewStart = app.panStartFreqViewStart + dy * freqWidth; app.freqViewEnd = app.panStartFreqViewEnd + dy * freqWidth; if (app.freqViewStart < 0) { app.freqViewStart = 0; app.freqViewEnd = freqWidth; } if (app.freqViewEnd > 1) { app.freqViewEnd = 1; app.freqViewStart = 1 - freqWidth; } app.visibleTextureValid = false; } if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) app.isPanning = false; // Home/End keys if (IsKeyPressed(KEY_HOME)) { app.viewStart = 0.0f; app.viewEnd = 1.0f; app.freqViewStart = 0.0f; app.freqViewEnd = 1.0f; app.visibleTextureValid = false; } if (IsKeyPressed(KEY_END)) { float newWidth = 0.1f; app.viewStart = 0.0f; app.viewEnd = newWidth; app.visibleTextureValid = false; } } // Keyboard shortcuts (SPACE for play/stop toggle, ESC for clear) if (IsKeyPressed(KEY_SPACE) && !app.showFileBrowser) { if (app.isPlaying && AudioPlaybackSound.frameCount > 0) { // Currently playing - stop it StopSound(AudioPlaybackSound); app.isPlaying = false; app.playbackFinished = false; } else if (app.playbackFinished) { // Playback finished naturally - restart from beginning PlaySelectedRegion(); app.isPlaying = true; app.playbackFinished = false; } else { // Not playing and didn't just finish - start playback PlaySelectedRegion(); app.isPlaying = true; } } if (IsKeyPressed(KEY_ESCAPE)) { if (app.showFileBrowser) { app.showFileBrowser = false; } else { // Clear selections instead of exiting app.timeSelectionStart = 0.0f; app.timeSelectionEnd = 1.0f; app.freqSelectionStart = 0.0f; app.freqSelectionEnd = 1.0f; } } // Selection (only when mouse is in spectrogram area, not sidebar) float selSidebarWidth = 320; float selLabelHeight = 15; float selScrollbarHeight = 18; float selFreqLabelWidth = 65; float selVScrollbarWidth = 18; float selTopMargin = 50; float selBottomMargin = 10; Rectangle selBounds = { selSidebarWidth + selFreqLabelWidth, selTopMargin, GetScreenWidth() - selSidebarWidth - selFreqLabelWidth - selVScrollbarWidth - 20, GetScreenHeight() - selTopMargin - selBottomMargin - selLabelHeight - selScrollbarHeight - 10 }; Vector2 mousePos = GetMousePosition(); if (app.loaded && !app.showFileBrowser && mousePos.x > 385 && CheckCollisionPointRec(mousePos, selBounds)) { bool shiftHeld = IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT); if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && !app.isPanning) { if (shiftHeld) { app.isFreqSelecting = true; float t = 1.0f - (mousePos.y - selBounds.y) / selBounds.height; app.freqSelectionStart = Clamp(t, 0.0f, 1.0f); app.freqSelectionEnd = app.freqSelectionStart; } else if (!IsKeyDown(KEY_LEFT_ALT) && !IsKeyDown(KEY_RIGHT_ALT)) { app.isTimeSelecting = true; app.timeSelectionStart = Clamp((mousePos.x - selBounds.x) / selBounds.width, 0.0f, 1.0f); app.timeSelectionEnd = app.timeSelectionStart; } } if (app.isTimeSelecting && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { app.timeSelectionEnd = Clamp((mousePos.x - selBounds.x) / selBounds.width, 0.0f, 1.0f); } if (app.isFreqSelecting && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { float t = 1.0f - (mousePos.y - selBounds.y) / selBounds.height; app.freqSelectionEnd = Clamp(t, 0.0f, 1.0f); } if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) { if (app.isTimeSelecting) { app.isTimeSelecting = false; if (app.timeSelectionEnd < app.timeSelectionStart) { float tmp = app.timeSelectionStart; app.timeSelectionStart = app.timeSelectionEnd; app.timeSelectionEnd = tmp; } } if (app.isFreqSelecting) { app.isFreqSelecting = false; if (app.freqSelectionEnd < app.freqSelectionStart) { float tmp = app.freqSelectionStart; app.freqSelectionStart = app.freqSelectionEnd; app.freqSelectionEnd = tmp; } } } } // Processing if (app.loaded && !app.stftComputed) { TraceLog(LOG_INFO, "Computing STFT..."); double startTime = GetTime(); ComputeSTFT(&app.signal, &app.stft, app.fftSize); TraceLog(LOG_INFO, "STFT computed in %.2f sec (%d segments)", GetTime() - startTime, app.stft.numSegments); GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture); app.stftComputed = true; } // Rendering BeginDrawing(); ClearBackground((Color){ 30, 30, 30, 255 }); // Layout: sidebar on left, spectrogram on right // Space for: spectrogram + time labels (15px below) + scrollbar (18px below that) float sidebarWidth = 320; float labelHeight = 15; float scrollbarHeight = 18; float freqLabelWidth = 65; float vScrollbarWidth = 18; float topMargin = 50; float bottomMargin = 10; // Spectrogram area (excludes labels and scrollbars) Rectangle viewBounds = { sidebarWidth + freqLabelWidth, topMargin, GetScreenWidth() - sidebarWidth - freqLabelWidth - vScrollbarWidth - 20, GetScreenHeight() - topMargin - bottomMargin - labelHeight - scrollbarHeight - 10 }; // Time labels sit just below the spectrogram Rectangle timeLabelArea = { viewBounds.x, viewBounds.y + viewBounds.height, viewBounds.width, labelHeight }; // Horizontal scrollbar sits below the time labels Rectangle hScrollbar = { viewBounds.x, viewBounds.y + viewBounds.height + labelHeight + 5, viewBounds.width, scrollbarHeight }; // Vertical scrollbar sits to the right of the spectrogram Rectangle vScrollbar = { viewBounds.x + viewBounds.width + 5, viewBounds.y, vScrollbarWidth, viewBounds.height }; // Draw sidebar first (on top left) DrawSidebar(); // Draw spectrogram (background, in its own area) if (app.loaded && app.stftComputed) { int imgWidth = app.spectrogramImage.width; int imgHeight = app.spectrogramImage.height; // Calculate visible region (time and frequency) int visibleStartX = (int)(app.viewStart * imgWidth); int visibleEndX = (int)(app.viewEnd * imgWidth); int visibleWidth = visibleEndX - visibleStartX; // Frequency: 0 = bottom of image (bin 0), 1 = top of image (bin max) int visibleStartY = (int)((1.0f - app.freqViewEnd) * imgHeight); int visibleEndY = (int)((1.0f - app.freqViewStart) * imgHeight); int visibleHeight = visibleEndY - visibleStartY; // Invalidate cache if view changed or texture not valid bool cacheInvalid = !app.visibleTextureValid || visibleStartX != app.cachedVisibleStart || visibleEndX != app.cachedVisibleEnd || visibleStartY != app.cachedVisibleStartY || visibleEndY != app.cachedVisibleEndY || visibleWidth <= 0 || visibleHeight <= 0; if (cacheInvalid && visibleWidth > 0 && visibleStartX >= 0 && visibleHeight > 0 && visibleStartY >= 0) { // Free old texture if exists if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture); // Create a sub-image for the visible region Image visibleImage = GenImageColor(visibleWidth, visibleHeight, BLACK); Color* srcPixels = (Color*)app.spectrogramImage.data; Color* dstPixels = (Color*)visibleImage.data; for (int y = 0; y < visibleHeight; y++) { for (int x = 0; x < visibleWidth; x++) { dstPixels[y * visibleWidth + x] = srcPixels[(visibleStartY + y) * imgWidth + visibleStartX + x]; } } app.visibleTexture = LoadTextureFromImage(visibleImage); UnloadImage(visibleImage); app.cachedVisibleStart = visibleStartX; app.cachedVisibleEnd = visibleEndX; app.cachedVisibleStartY = visibleStartY; app.cachedVisibleEndY = visibleEndY; app.visibleTextureValid = true; } // Draw cached texture if (app.visibleTextureValid && app.visibleTexture.id != 0) { DrawTexturePro(app.visibleTexture, (Rectangle){ 0, 0, visibleWidth, visibleHeight }, viewBounds, (Vector2){ 0, 0 }, 0.0f, WHITE); } // Draw scrollbars // Horizontal scrollbar (time) DrawRectangleRec(hScrollbar, DARKGRAY); float hThumbWidth = (app.viewEnd - app.viewStart) * hScrollbar.width; float hThumbX = hScrollbar.x + app.viewStart * hScrollbar.width; if (hThumbWidth < 10) hThumbWidth = 10; DrawRectangle(hThumbX, hScrollbar.y, hThumbWidth, hScrollbar.height, GRAY); // Vertical scrollbar (frequency) DrawRectangleRec(vScrollbar, DARKGRAY); float vThumbHeight = (app.freqViewEnd - app.freqViewStart) * vScrollbar.height; float vThumbY = vScrollbar.y + (1.0f - app.freqViewEnd) * vScrollbar.height; if (vThumbHeight < 10) vThumbHeight = 10; DrawRectangle(vScrollbar.x, vThumbY, vScrollbar.width, vThumbHeight, GRAY); // Handle scrollbar dragging static bool draggingH = false, draggingV = false; static Vector2 dragStartPos; static float dragStartViewStart, dragStartFreqViewStart; if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && CheckCollisionPointRec(GetMousePosition(), hScrollbar)) { draggingH = true; dragStartPos = GetMousePosition(); dragStartViewStart = app.viewStart; } if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && CheckCollisionPointRec(GetMousePosition(), vScrollbar)) { draggingV = true; dragStartPos = GetMousePosition(); dragStartFreqViewStart = app.freqViewStart; } if (draggingH && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { float dx = (GetMousePosition().x - dragStartPos.x) / hScrollbar.width; float viewWidth = app.viewEnd - app.viewStart; app.viewStart = dragStartViewStart + dx; app.viewEnd = app.viewStart + viewWidth; if (app.viewStart < 0) { app.viewStart = 0; app.viewEnd = viewWidth; } if (app.viewEnd > 1) { app.viewEnd = 1; app.viewStart = 1 - viewWidth; } app.visibleTextureValid = false; } if (draggingV && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { float dy = (GetMousePosition().y - dragStartPos.y) / vScrollbar.height; float freqWidth = app.freqViewEnd - app.freqViewStart; app.freqViewStart = dragStartFreqViewStart - dy; app.freqViewEnd = app.freqViewStart + freqWidth; if (app.freqViewStart < 0) { app.freqViewStart = 0; app.freqViewEnd = freqWidth; } if (app.freqViewEnd > 1) { app.freqViewEnd = 1; app.freqViewStart = 1 - freqWidth; } app.visibleTextureValid = false; } if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) { draggingH = false; draggingV = false; } if (app.showGrid) DrawSpectrogramGrid(viewBounds, 10, 8, Fade(GRAY, 0.3f)); DrawSelection(viewBounds); DrawLabels(viewBounds); float maxFreq = (float)app.signal.sampleRate / 2.0f; float freqMin = app.freqViewStart * maxFreq; float freqMax = app.freqViewEnd * maxFreq; DrawText(TextFormat("Freq: %.0f-%.0f Hz", freqMin, freqMax), viewBounds.x, viewBounds.y - 30, 20, LIGHTGRAY); } else if (!app.showFileBrowser) { const char* msg1 = "Press 'O' or click 'Open File Browser' to load a WAV"; const char* msg2 = "Or drag & drop a file, or use: ./rspektrum "; Vector2 t1 = MeasureTextEx(GetFontDefault(), msg1, 24, 0); Vector2 t2 = MeasureTextEx(GetFontDefault(), msg2, 18, 0); DrawText(msg1, 350 + (GetScreenWidth() - 380 - 350) / 2 - t1.x / 2, GetScreenHeight() / 2 - t1.y / 2 - 15, 24, LIGHTGRAY); DrawText(msg2, 350 + (GetScreenWidth() - 380 - 350) / 2 - t2.x / 2, GetScreenHeight() / 2 - t2.y / 2 + 15, 18, GRAY); } // Draw file browser on top (if active) if (app.showFileBrowser) DrawFileBrowser(); EndDrawing(); } TraceLog(LOG_INFO, "Shutting down..."); if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound); if (app.stftComputed) { FreeSTFT(&app.stft); UnloadImage(app.spectrogramImage); UnloadTexture(app.spectrogramTexture); } if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture); app.visibleTexture = (Texture2D){ 0 }; app.visibleTextureValid = false; UnloadTexture(colormapTexture); FreeBrowserFiles(); FreeSignal(&app.signal); CloseAudioDevice(); CloseWindow(); return 0; }