diff --git a/src/spectrogram.c b/src/spectrogram.c index 7582811..a4d4207 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -88,40 +88,35 @@ typedef struct { float timeSelectionStart; float timeSelectionEnd; bool isTimeSelecting; - Vector2 timeSelectStartPos; - // Frequency selection (0-1 normalized, 0 = bottom/low freq) + // Frequency selection (0-1 normalized) float freqSelectionStart; float freqSelectionEnd; bool isFreqSelecting; - Vector2 freqSelectStartPos; // 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; - float panStartView; // Display settings - float amplitudeFloorDb; // Minimum dB to display - float amplitudeCeilingDb; // Maximum dB to display + float amplitudeFloorDb; + float amplitudeCeilingDb; ColormapType colormap; bool showGrid; - // File loading UI - char currentDirectory[512]; - char filePath[512]; - int filePathCursor; - bool showFileInput; - // File browser state bool showFileBrowser; char browserPath[512]; char** browserFiles; + bool* browserIsDir; int browserFileCount; int browserScroll; int browserSelected; + bool isBrowsing; } SpectrogramApp; // ============================================================================ @@ -162,150 +157,75 @@ static Color GetColormapColor(float t, ColormapType type) unsigned char v = (unsigned char)(t * 255); return (Color){ v, v, v, 255 }; } - case COLORMAP_INFERNO: { - // Inferno-like colormap (dark purple to yellow) 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 + t * 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; - } - + 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: { - // Viridis-like colormap (purple to yellow) 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; - } + 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: { - // Plasma-like colormap (purple to yellow) 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: { - // Hot colormap (black -> red -> yellow -> white) - float r = t * 3.0f; - float g = (t - 0.33f) * 3.0f; - float b = (t - 0.66f) * 3.0f; - r = Clamp(r, 0.0f, 1.0f); - g = Clamp(g, 0.0f, 1.0f); - b = Clamp(b, 0.0f, 1.0f); + 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: { - // Cool colormap (cyan to magenta) - float r = t; - float g = 1.0f - t; - float b = 1.0f; - return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 }; + return (Color){ (unsigned char)(t * 255), (unsigned char)((1.0f - t) * 255), 255, 255 }; } - - default: - return GRAY; + 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); - } - + for (int i = 0; i < 256; i++) pixels[i] = GetColormapColor(i / 255.0f, app.colormap); colormapTexture = LoadTextureFromImage(img); UnloadImage(img); } // ============================================================================ -// FFT Implementation (Radix-2 Cooley-Tukey) +// FFT Implementation // ============================================================================ static void BitReverseCopy(float complex* input, float complex* output, int n) { - int bits = 0; - int temp = n; - while (temp > 1) { - bits++; - temp >>= 1; - } - + int bits = 0, temp = n; + while (temp > 1) { bits++; temp >>= 1; } for (int i = 0; i < n; i++) { - int j = 0; - int k = i; - for (int b = 0; b < bits; b++) { - j = (j << 1) | (k & 1); - k >>= 1; - } + 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; - } - + 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; @@ -314,12 +234,7 @@ static void FFT(float complex* input, float complex* output, int n, bool inverse } } } - - if (inverse) { - for (int i = 0; i < n; i++) { - output[i] /= n; - } - } + if (inverse) for (int i = 0; i < n; i++) output[i] /= n; } // ============================================================================ @@ -330,8 +245,7 @@ static void ApplyHannWindow(float* samples, int n) { for (int i = 0; i < n; i++) { float t = (float)i / (n - 1); - float window = 0.5f * (1.0f - cosf(2.0f * M_PI * t)); - samples[i] *= window; + samples[i] *= 0.5f * (1.0f - cosf(2.0f * M_PI * t)); } } @@ -341,9 +255,7 @@ static void ApplyHannWindow(float* samples, int n) static void ComputeSTFT(AudioSignal* signal, StftResult* result) { - int windowSize = FFT_SIZE; - int hopSize = HOP_SIZE; - + int windowSize = FFT_SIZE, hopSize = HOP_SIZE; int numSegments = (signal->numSamples - windowSize) / hopSize + 1; if (numSegments <= 0) numSegments = 1; @@ -354,55 +266,38 @@ static void ComputeSTFT(AudioSignal* signal, StftResult* result) result->useHannWindow = true; int numBins = windowSize / 2 + 1; - float* windowedSamples = (float*)malloc(windowSize * sizeof(float)); - float complex* complexInput = (float complex*)malloc(windowSize * sizeof(float complex)); + float complex *complexInput = (float complex*)malloc(windowSize * sizeof(float complex)); float complex* fftOutput = (float complex*)malloc(windowSize * sizeof(float complex)); for (int seg = 0; seg < numSegments; seg++) { int offset = seg * hopSize; int samplesToCopy = windowSize; - if (offset + samplesToCopy > signal->numSamples) { samplesToCopy = signal->numSamples - offset; memset(windowedSamples, 0, windowSize * sizeof(float)); } - memcpy(windowedSamples, signal->samples + offset, samplesToCopy * sizeof(float)); ApplyHannWindow(windowedSamples, windowSize); - - for (int i = 0; i < windowSize; i++) { - complexInput[i] = windowedSamples[i] + 0.0f * I; - } - + for (int i = 0; i < windowSize; i++) complexInput[i] = windowedSamples[i] + 0.0f * I; FFT(complexInput, fftOutput, windowSize, 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++) { - float frequency = (float)bin * signal->sampleRate / windowSize; - float amplitude = 2.0f * cabsf(fftOutput[bin]) / windowSize; - if (bin == 0) amplitude = cabsf(fftOutput[bin]) / windowSize; - - result->segments[seg].spectrum[bin].frequency = frequency; - result->segments[seg].spectrum[bin].amplitude = amplitude; + result->segments[seg].spectrum[bin].frequency = (float)bin * signal->sampleRate / windowSize; + result->segments[seg].spectrum[bin].amplitude = (bin == 0) ? cabsf(fftOutput[bin]) / windowSize : 2.0f * cabsf(fftOutput[bin]) / windowSize; result->segments[seg].spectrum[bin].phase = cargf(fftOutput[bin]); } } - - free(windowedSamples); - free(complexInput); - free(fftOutput); + free(windowedSamples); free(complexInput); free(fftOutput); } static void FreeSTFT(StftResult* result) { - for (int i = 0; i < result->numSegments; i++) { - free(result->segments[i].spectrum); - } + for (int i = 0; i < result->numSegments; i++) free(result->segments[i].spectrum); free(result->segments); result->segments = NULL; result->numSegments = 0; @@ -415,63 +310,43 @@ static void FreeSTFT(StftResult* result) 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; - } + 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; - } + 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; - } + 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]; - } + 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); - + 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; + if (signal->samples) { free(signal->samples); signal->samples = NULL; } + signal->numSamples = 0; signal->sampleRate = 0; signal->duration = 0.0f; } // ============================================================================ @@ -481,43 +356,27 @@ static void FreeSignal(AudioSignal* signal) 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; - - // 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++) { - float amp = stft->segments[seg].spectrum[bin].amplitude; - if (amp > maxAmplitude) maxAmplitude = amp; - } - } - - float maxDb = AmplitudeToDecibels(maxAmplitude); - + 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; + for (int seg = 0; seg < width; seg++) { for (int bin = 0; bin < height; bin++) { float amplitude = stft->segments[seg].spectrum[bin].amplitude; float db = AmplitudeToDecibels(amplitude); - - // Normalize to 0-1 range using user-adjustable floor/ceiling float normalized = (db - app.amplitudeFloorDb) / (app.amplitudeCeilingDb - app.amplitudeFloorDb); normalized = Clamp(normalized, 0.0f, 1.0f); - - // Apply colormap - // Flip Y so low frequencies are at bottom (bin 0 = bottom of screen) int pixelIndex = (height - 1 - bin) * width + seg; pixels[pixelIndex] = GetColormapColor(normalized, app.colormap); } } - - if (texture->id != 0) { - UnloadTexture(*texture); - } + if (texture->id != 0) UnloadTexture(*texture); *texture = LoadTextureFromImage(*image); SetTextureFilter(*texture, TEXTURE_FILTER_BILINEAR); } @@ -526,158 +385,298 @@ static void GenerateSpectrogramTexture(StftResult* stft, Image* image, Texture2D // Audio Playback with FFT-based Bandpass Filter // ============================================================================ -// Apply bandpass filter using FFT domain filtering (much better frequency isolation) static void ApplyBandpassFilterFFT(float* samples, int numSamples, int sampleRate, float freqLow, float freqHigh) { - if (freqLow <= 0 && freqHigh >= sampleRate / 2.0f) return; // Full range, no filter needed + if (freqLow <= 0 && freqHigh >= sampleRate / 2.0f) return; - // Find next power of 2 for FFT int fftSize = 1; while (fftSize < numSamples) fftSize *= 2; - fftSize *= 2; // Zero-pad for better frequency resolution + fftSize *= 2; - // Allocate buffers 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)); + float complex *fftInput = (float complex*)malloc(fftSize * sizeof(float complex)); + float complex *fftOutput = (float complex*)malloc(fftSize * sizeof(float complex)); - // Apply Hann window to reduce spectral leakage 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; - // Copy to complex input - for (int i = 0; i < fftSize; i++) { - fftInput[i] = windowedSamples[i] + 0.0f * I; - } - - // Forward FFT FFT(fftInput, fftOutput, fftSize, false); - // Calculate frequency bin parameters float freqPerBin = (float)sampleRate / fftSize; - int binLow = (int)(freqLow / freqPerBin); - int binHigh = (int)(freqHigh / freqPerBin); - int binCenter = (binLow + binHigh) / 2; - int binWidth = (binHigh - binLow) / 2; - if (binWidth < 1) binWidth = 1; - - // Apply smooth bandpass in frequency domain for (int bin = 0; bin < fftSize / 2 + 1; bin++) { float frequency = bin * freqPerBin; - float attenuation = 0.0f; - - // Calculate distance from passband edges - float distLow = 0.0f; - float distHigh = 0.0f; - + float attenuation = 1.0f; if (frequency < freqLow) { - // Below passband - attenuate based on distance from low edge - distLow = (freqLow - frequency) / (freqPerBin * 10.0f); // 10-bin rolloff - attenuation = 1.0f / (1.0f + distLow * distLow * distLow); + float dist = (freqLow - frequency) / (freqPerBin * 10.0f); + attenuation = 1.0f / (1.0f + dist * dist * dist); } else if (frequency > freqHigh) { - // Above passband - attenuate based on distance from high edge - distHigh = (frequency - freqHigh) / (freqPerBin * 10.0f); - attenuation = 1.0f / (1.0f + distHigh * distHigh * distHigh); - } else { - // In passband - full amplitude - attenuation = 1.0f; + float dist = (frequency - freqHigh) / (freqPerBin * 10.0f); + attenuation = 1.0f / (1.0f + dist * dist * dist); } - - // Apply to both positive and negative frequency bins fftOutput[bin] *= attenuation; - if (bin > 0 && bin < fftSize / 2) { - fftOutput[fftSize - bin] *= attenuation; // Conjugate symmetry - } + if (bin > 0 && bin < fftSize / 2) fftOutput[fftSize - bin] *= attenuation; } - // Inverse FFT float complex* ifftOutput = (float complex*)malloc(fftSize * sizeof(float complex)); FFT(fftOutput, ifftOutput, fftSize, true); - // Extract real part and apply makeup gain float filteredPeak = 0.0f; for (int i = 0; i < numSamples; i++) { samples[i] = crealf(ifftOutput[i]); - float absVal = fabsf(samples[i]); - if (absVal > filteredPeak) filteredPeak = absVal; + if (fabsf(samples[i]) > filteredPeak) filteredPeak = fabsf(samples[i]); } - // Normalize to 90% of full scale const float TARGET_PEAK = 0.9f; if (filteredPeak > 0.0001f) { float gain = TARGET_PEAK / filteredPeak; - if (gain > 10.0f) gain = 10.0f; // Limit max gain - + if (gain > 10.0f) gain = 10.0f; for (int i = 0; i < numSamples; i++) { samples[i] *= gain; - - // Hard limit to prevent clipping if (samples[i] > 0.95f) samples[i] = 0.95f; if (samples[i] < -0.95f) samples[i] = -0.95f; } } - // Cleanup - free(windowedSamples); - free(fftInput); - free(fftOutput); - free(ifftOutput); + free(windowedSamples); free(fftInput); free(fftOutput); free(ifftOutput); } static void ApplyBandpassFilter(float* samples, int numSamples, int sampleRate, float freqLow, float freqHigh) { - // Use FFT-based filter for better frequency isolation 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) { - TraceLog(LOG_WARNING, "Invalid selection for playback"); - return; - } - + 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)); - // Apply bandpass filter based on frequency selection float maxFreq = (float)app.signal.sampleRate / 2.0f; float freqLow = app.freqSelectionStart * maxFreq; float freqHigh = app.freqSelectionEnd * maxFreq; - - // Only apply filter if not full range 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 - }; - + + 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); +} - TraceLog(LOG_INFO, "Playing selection: %.2f - %.2f sec (%d samples), %.0f-%.0f Hz", - (float)startSample / app.signal.sampleRate, - (float)endSample / app.signal.sampleRate, - numSamples, 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]; + snprintf(newPath, sizeof(newPath), "%s/%s", app.browserPath, dirName); + if (DirectoryExists(newPath)) ScanDirectory(newPath); +} + +static void LoadSelectedFile(void) +{ + if (app.browserSelected < 0 || app.browserSelected >= app.browserFileCount) return; + + char filePath[512]; + snprintf(filePath, sizeof(filePath), "%s/%s", app.browserPath, app.browserFiles[app.browserSelected]); + + 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; + TraceLog(LOG_INFO, "Loaded: %s", filePath); + } +} + +static void DrawFileBrowser(void) +{ + 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); + 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); + + int visibleItems = (int)(lh / 26); + if (app.browserFileCount > visibleItems) { + float sh = (float)visibleItems / app.browserFileCount * lh; + 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++) { + 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); + } + + if (CheckCollisionPointRec(GetMousePosition(), (Rectangle){ lx, ly, lw - 10, lh })) { + int wheel = GetMouseWheelMove(); + if (wheel > 0) app.browserScroll--; + if (wheel < 0) app.browserScroll++; + if (app.browserScroll < 0) app.browserScroll = 0; + if (app.browserScroll > app.browserFileCount - visibleItems) app.browserScroll = app.browserFileCount - visibleItems; + } + + if (CheckCollisionPointRec(GetMousePosition(), upBtn) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) NavigateToParentDirectory(); + + if (CheckCollisionPointRec(GetMousePosition(), (Rectangle){ lx, ly, lw - 10, lh }) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { + 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) LoadSelectedFile(); + if (IsKeyPressed(KEY_ESCAPE)) app.showFileBrowser = false; + if (IsKeyPressed(KEY_UP) && app.browserSelected > 0) { + app.browserSelected--; + if (app.browserSelected < app.browserScroll) app.browserScroll = app.browserSelected; + } + if (IsKeyPressed(KEY_DOWN) && app.browserSelected < app.browserFileCount - 1) { + app.browserSelected++; + if (app.browserSelected >= app.browserScroll + visibleItems) app.browserScroll = app.browserSelected - visibleItems + 1; + } } // ============================================================================ @@ -686,14 +685,11 @@ static void PlaySelectedRegion(void) static void DrawSpectrogramGrid(Rectangle bounds, int numCellsX, int numCellsY, Color color) { - float cellWidth = bounds.width / numCellsX; - float cellHeight = bounds.height / numCellsY; - + 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); @@ -704,97 +700,65 @@ static void DrawLabels(Rectangle bounds) { int fontSize = 10; Color textColor = LIGHTGRAY; - + // Time labels - int numTimeLabels = 10; - for (int i = 0; i <= numTimeLabels; i++) { - float t = (float)i / numTimeLabels; - float timeSec = t * app.signal.duration; + 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); - } - + 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 fixed 1kHz intervals with 200Hz ticks + + // Frequency labels at 1kHz intervals with 200Hz ticks float maxFreq = (float)app.signal.sampleRate / 2.0f; int maxKhz = (int)(maxFreq / 1000.0f); - // Draw 200Hz tick marks for (int hz = 0; hz <= maxFreq; hz += 200) { float t = hz / maxFreq; float y = bounds.y + bounds.height - t * bounds.height; - - // Minor tick Color tickColor = (hz % 1000 == 0) ? GRAY : Fade(GRAY, 0.4f); DrawLineV((Vector2){ bounds.x - 5, y }, (Vector2){ bounds.x, y }, tickColor); } - // Draw 1kHz labels 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); - } - + 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 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) { - // Draw non-selected areas greyed out Color overlayColor = Fade(BLACK, 0.5f); - - // Time selection - grey out left and right 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); - } + 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); - // Frequency selection - grey out top and bottom 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); - } + 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); - // Draw selection borders DrawLineV((Vector2){ selStartX, bounds.y }, (Vector2){ selStartX, bounds.y + bounds.height }, YELLOW); DrawLineV((Vector2){ selEndX, bounds.y }, (Vector2){ selEndX, bounds.y + bounds.height }, YELLOW); - // Frequency selection borders (only if not full range) 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); @@ -803,124 +767,48 @@ static void DrawSelection(Rectangle bounds) static void DrawInfo(Rectangle bounds) { - int fontSize = 12; - int y = 10; + 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("FFT Size: %d, Bins: %d", FFT_SIZE, FFT_SIZE/2+1), 10, y, fontSize, LIGHTGRAY); y += 20; - DrawText(TextFormat("Max Frequency: %.1f kHz", (float)app.signal.sampleRate / 2000.0f), 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("Max Freq: %.1f kHz", (float)app.signal.sampleRate / 2000.0f), 10, y, fontSize, LIGHTGRAY); y += 20; y += 10; - // Selection info float selDuration = (app.timeSelectionEnd - app.timeSelectionStart) * app.signal.duration; - DrawText(TextFormat("Time Selection: %.2f - %.2f sec (%.3f sec)", - app.timeSelectionStart * app.signal.duration, - app.timeSelectionEnd * app.signal.duration, - selDuration), 10, y, fontSize, YELLOW); y += 20; + 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 Selection: %.0f - %.0f Hz", - app.freqSelectionStart * maxFreq, - app.freqSelectionEnd * maxFreq), 10, y, fontSize, CYAN); + 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); } - // Controls - y = GetScreenHeight() - 200; + y = GetScreenHeight() - 240; DrawText("Controls:", 10, y, fontSize, LIGHTGRAY); y += 20; - DrawText(" O - Open file dialog", 10, y, fontSize, GRAY); y += 18; + DrawText(" O - Open file browser", 10, y, fontSize, GRAY); y += 18; DrawText(" Drag & drop WAV file", 10, y, fontSize, GRAY); y += 18; - DrawText(" LMB Drag - Select time region", 10, y, fontSize, GRAY); y += 18; - DrawText(" Shift+LMB Drag - Select freq region", 10, y, fontSize, GRAY); y += 18; + DrawText(" Mouse Wheel - Zoom time", 10, y, fontSize, GRAY); y += 18; + DrawText(" Alt+Drag - Pan view", 10, y, fontSize, GRAY); y += 18; + DrawText(" LMB Drag - Select time", 10, y, fontSize, GRAY); y += 18; + DrawText(" Shift+LMB - Select freq", 10, y, fontSize, GRAY); y += 18; DrawText(" SPACE - Play selection", 10, y, fontSize, GRAY); y += 18; DrawText(" M - Change 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/End - Full view/Zoom in", 10, y, fontSize, GRAY); y += 18; DrawText(" ESC - Clear selections", 10, y, fontSize, GRAY); - // Colormap indicator 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); - - // Draw colormap preview - DrawTexturePro(colormapTexture, (Rectangle){ 0, 0, 256, 1 }, - (Rectangle){ GetScreenWidth() - 100, y + 15, 80, 15 }, - (Vector2){ 0, 0 }, 0.0f, WHITE); - - // dB floor indicator + 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); } -// ============================================================================ -// File Loading UI -// ============================================================================ - -static void LoadFileFromPath(const char* path) -{ - if (FileExists(path)) { - if (LoadWavFile(path, &app.signal)) { - app.loaded = true; - app.stftComputed = false; - // Default selections: full time and frequency range - app.timeSelectionStart = 0.0f; - app.timeSelectionEnd = 1.0f; - app.freqSelectionStart = 0.0f; - app.freqSelectionEnd = 1.0f; - TraceLog(LOG_INFO, "Successfully loaded: %s", path); - } - } else { - TraceLog(LOG_ERROR, "File not found: %s", path); - } -} - -static void DrawFileInputUI(Rectangle bounds) -{ - DrawRectangle(0, 0, GetScreenWidth(), GetScreenHeight(), Fade(BLACK, 0.7f)); - - float dialogWidth = 500; - float dialogHeight = 180; - float dialogX = (GetScreenWidth() - dialogWidth) / 2; - float dialogY = (GetScreenHeight() - dialogHeight) / 2; - - DrawRectangleRounded((Rectangle){ dialogX, dialogY, dialogWidth, dialogHeight }, 0.1f, 8, DARKGRAY); - DrawRectangleLinesEx((Rectangle){ dialogX, dialogY, dialogWidth, dialogHeight }, 2, GRAY); - - DrawText("Load WAV File", dialogX + 20, dialogY + 15, 20, WHITE); - DrawText("Enter file path or drag & drop a WAV file:", dialogX + 20, dialogY + 50, 14, LIGHTGRAY); - - Rectangle inputBounds = { dialogX + 20, dialogY + 75, dialogWidth - 100, 35 }; - DrawRectangleRec(inputBounds, Fade(WHITE, 0.1f)); - DrawRectangleLinesEx(inputBounds, 1, GRAY); - - DrawText(app.filePath, inputBounds.x + 10, inputBounds.y + 10, 14, WHITE); - - if ((GetTime() * 4) - (int)(GetTime() * 4) > 0.5f) { - int textWidth = MeasureText(app.filePath, 14); - DrawRectangle(inputBounds.x + 10 + textWidth, inputBounds.y + 10, 2, 18, WHITE); - } - - Rectangle loadButton = { dialogX + dialogWidth - 70, dialogY + 75, 60, 35 }; - Color btnColor = CheckCollisionPointRec(GetMousePosition(), loadButton) ? GRAY : DARKGRAY; - DrawRectangleRec(loadButton, btnColor); - DrawRectangleLinesEx(loadButton, 1, WHITE); - DrawText("LOAD", loadButton.x + 12, loadButton.y + 10, 16, WHITE); - - Rectangle cancelButton = { dialogX + 20, dialogY + 125, 100, 30 }; - DrawRectangleRec(cancelButton, MAROON); - DrawText("ESC to cancel", cancelButton.x + 10, cancelButton.y + 7, 12, WHITE); - - if (IsKeyPressed(KEY_ENTER) || (CheckCollisionPointRec(GetMousePosition(), loadButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON))) { - LoadFileFromPath(app.filePath); - app.showFileInput = false; - } -} - // ============================================================================ // Main Application // ============================================================================ @@ -930,280 +818,268 @@ int main(int argc, char* argv[]) SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_HIGHDPI); InitWindow(1280, 800, "Spectrogram Viewer"); SetTargetFPS(60); - InitAudioDevice(); SearchAndSetResourceDir("resources"); - // Initialize app state - app.timeSelectionStart = 0.0f; - app.timeSelectionEnd = 1.0f; - app.freqSelectionStart = 0.0f; - app.freqSelectionEnd = 1.0f; + 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.filePath[0] = '\0'; - app.filePathCursor = 0; - app.showFileInput = false; + app.showFileBrowser = false; + app.isBrowsing = false; GenerateColormapTexture(); + ScanDirectory(GetWorkingDirectory()); TraceLog(LOG_INFO, "Spectrogram Viewer initialized"); - // Check for command-line argument (file to load) bool fileLoaded = false; if (argc > 1) { - const char* argFile = argv[1]; - TraceLog(LOG_INFO, "Loading file from command line: %s", argFile); - strncpy(app.filePath, argFile, sizeof(app.filePath) - 1); - if (FileExists(argFile)) { - fileLoaded = LoadWavFile(argFile, &app.signal); - if (fileLoaded) { - app.loaded = true; - app.stftComputed = false; - TraceLog(LOG_INFO, "File loaded successfully, computing STFT..."); - } else { - TraceLog(LOG_ERROR, "Failed to load file: %s", argFile); - } - } else { - TraceLog(LOG_ERROR, "File not found: %s", argFile); + 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' to load a WAV file or drag & drop"); - } + if (!fileLoaded) TraceLog(LOG_INFO, "Press 'O' for file browser or drag & drop WAV file"); while (!WindowShouldClose()) { - // ===== Drag & Drop Handling ===== + // Drag & Drop if (IsFileDropped()) { - FilePathList droppedFiles = LoadDroppedFiles(); - - if (droppedFiles.count > 0) { - const char* ext = GetFileExtension(droppedFiles.paths[0]); - bool isWav = false; - if (ext != NULL) { - isWav = (strcmp(ext, ".wav") == 0 || strcmp(ext, ".WAV") == 0 || - strcmp(ext, ".Wave") == 0 || strcmp(ext, ".Wav") == 0); + 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; + } } - - if (isWav) { - strncpy(app.filePath, droppedFiles.paths[0], sizeof(app.filePath) - 1); - LoadFileFromPath(app.filePath); - } else { - TraceLog(LOG_WARNING, "Dropped file is not a WAV: %s (ext: %s)", droppedFiles.paths[0], ext ? ext : "null"); + } + 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) { + // Zoom with mouse wheel + if (CheckCollisionPointRec(GetMousePosition(), (Rectangle){ 100, 50, GetScreenWidth() - 150, GetScreenHeight() - 150 })) { + int wheel = GetMouseWheelMove(); + if (wheel != 0) { + float viewCenter = (app.viewStart + app.viewEnd) / 2; + float viewWidth = app.viewEnd - app.viewStart; + float zoomFactor = (wheel > 0) ? 0.8f : 1.2f; + float newWidth = viewWidth * zoomFactor; + if (newWidth < 0.05f) newWidth = 0.05f; + if (newWidth > 1.0f) newWidth = 1.0f; + app.viewStart = viewCenter - newWidth / 2; + app.viewEnd = viewCenter + newWidth / 2; + if (app.viewStart < 0) { app.viewStart = 0; app.viewEnd = newWidth; } + if (app.viewEnd > 1) { app.viewEnd = 1; app.viewStart = 1 - newWidth; } } } - UnloadDroppedFiles(droppedFiles); - } - - // ===== Input Handling ===== - - if (IsKeyPressed(KEY_O)) { - app.showFileInput = true; - app.filePath[0] = '\0'; - app.filePathCursor = 0; - } - - if (app.showFileInput) { - int key = GetKeyPressed(); - while (key > 0) { - if (key >= 32 && key < 127 && app.filePathCursor < sizeof(app.filePath) - 1) { - app.filePath[app.filePathCursor++] = (char)key; - app.filePath[app.filePathCursor] = '\0'; - } - if (key == KEY_BACKSPACE && app.filePathCursor > 0) { - app.filePath[--app.filePathCursor] = '\0'; - } - if (key == KEY_HOME) app.filePathCursor = 0; - if (key == KEY_END) app.filePathCursor = strlen(app.filePath); - key = GetKeyPressed(); + // Pan with Alt+drag + bool altHeld = IsKeyDown(KEY_LEFT_ALT) || IsKeyDown(KEY_RIGHT_ALT); + if (altHeld && 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)) { + Rectangle bounds = { 100, 50, GetScreenWidth() - 150, GetScreenHeight() - 150 }; + if (CheckCollisionPointRec(app.panStartPos, bounds)) { + float dx = (GetMousePosition().x - app.panStartPos.x) / bounds.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; } + } + } + if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) app.isPanning = false; - if (IsKeyPressed(KEY_ENTER) || IsKeyPressed(KEY_ENTER)) { - LoadFileFromPath(app.filePath); - app.showFileInput = false; + // Home/End keys + if (IsKeyPressed(KEY_HOME)) { app.viewStart = 0.0f; app.viewEnd = 1.0f; } + if (IsKeyPressed(KEY_END)) { + float newWidth = 0.1f; + app.viewStart = 0.0f; + app.viewEnd = newWidth; } - // Note: ESC handled at end to not exit app } - if (IsKeyPressed(KEY_G) && !app.showFileInput) { - app.showGrid = !app.showGrid; - } - - if (IsKeyPressed(KEY_R) && !app.showFileInput) { - app.timeSelectionStart = 0.0f; - app.timeSelectionEnd = 1.0f; + // Other controls + if (IsKeyPressed(KEY_G) && !app.showFileBrowser) app.showGrid = !app.showFileBrowser; + 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.showFileInput) { + if (IsKeyPressed(KEY_M) && !app.showFileBrowser) { app.colormap = (ColormapType)((app.colormap + 1) % COLORMAP_COUNT); GenerateColormapTexture(); } - - if (IsKeyPressed(KEY_W) && !app.showFileInput) { + 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); } - - if (IsKeyPressed(KEY_S) && !app.showFileInput) { + 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); } - - if (IsKeyPressed(KEY_SPACE) && !app.showFileInput) { - PlaySelectedRegion(); - } + if (IsKeyPressed(KEY_SPACE) && !app.showFileBrowser) PlaySelectedRegion(); if (IsKeyPressed(KEY_ESCAPE)) { - if (app.showFileInput) { - app.showFileInput = false; - } else { - // Clear selections instead of stopping playback - app.timeSelectionStart = 0.0f; - app.timeSelectionEnd = 1.0f; + if (app.showFileBrowser) app.showFileBrowser = false; + else { + app.timeSelectionStart = app.viewStart; + app.timeSelectionEnd = app.viewEnd; app.freqSelectionStart = 0.0f; app.freqSelectionEnd = 1.0f; - TraceLog(LOG_INFO, "Selections cleared"); } } - // Mouse selection - Rectangle spectrogramBounds = { 100, 50, GetScreenWidth() - 150, GetScreenHeight() - 150 }; + // Selection + Rectangle bounds = { 100, 50, GetScreenWidth() - 150, GetScreenHeight() - 150 }; Vector2 mousePos = GetMousePosition(); - if (app.loaded && !app.showFileInput) { - if (CheckCollisionPointRec(mousePos, spectrogramBounds)) { - bool shiftHeld = IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT); - - if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { - if (shiftHeld) { - // Frequency selection mode - app.isFreqSelecting = true; - app.freqSelectStartPos = mousePos; - float t = 1.0f - (mousePos.y - spectrogramBounds.y) / spectrogramBounds.height; - app.freqSelectionStart = Clamp(t, 0.0f, 1.0f); - app.freqSelectionEnd = app.freqSelectionStart; - } else { - // Time selection mode - app.isTimeSelecting = true; - app.timeSelectStartPos = mousePos; - app.timeSelectionStart = Clamp((mousePos.x - spectrogramBounds.x) / spectrogramBounds.width, 0.0f, 1.0f); - app.timeSelectionEnd = app.timeSelectionStart; + if (app.loaded && !app.showFileBrowser && CheckCollisionPointRec(mousePos, bounds)) { + 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 - bounds.y) / bounds.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 - bounds.x) / bounds.width, 0.0f, 1.0f); + app.timeSelectionEnd = app.timeSelectionStart; + } + } + + if (app.isTimeSelecting && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { + app.timeSelectionEnd = Clamp((mousePos.x - bounds.x) / bounds.width, 0.0f, 1.0f); + } + if (app.isFreqSelecting && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { + float t = 1.0f - (mousePos.y - bounds.y) / bounds.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.isTimeSelecting && IsMouseButtonDown(MOUSE_LEFT_BUTTON) && !shiftHeld) { - app.timeSelectionEnd = Clamp((mousePos.x - spectrogramBounds.x) / spectrogramBounds.width, 0.0f, 1.0f); - } - - if (app.isFreqSelecting && IsMouseButtonDown(MOUSE_LEFT_BUTTON) && shiftHeld) { - float t = 1.0f - (mousePos.y - spectrogramBounds.y) / spectrogramBounds.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 temp = app.timeSelectionStart; - app.timeSelectionStart = app.timeSelectionEnd; - app.timeSelectionEnd = temp; - } - } - if (app.isFreqSelecting) { - app.isFreqSelecting = false; - if (app.freqSelectionEnd < app.freqSelectionStart) { - float temp = app.freqSelectionStart; - app.freqSelectionStart = app.freqSelectionEnd; - app.freqSelectionEnd = temp; - } + if (app.isFreqSelecting) { + app.isFreqSelecting = false; + if (app.freqSelectionEnd < app.freqSelectionStart) { + float tmp = app.freqSelectionStart; + app.freqSelectionStart = app.freqSelectionEnd; + app.freqSelectionEnd = tmp; } } } } - // ===== Processing ===== + // Processing if (app.loaded && !app.stftComputed) { TraceLog(LOG_INFO, "Computing STFT..."); double startTime = GetTime(); - ComputeSTFT(&app.signal, &app.stft); - - TraceLog(LOG_INFO, "STFT computed in %.2f sec (%d segments)", - GetTime() - startTime, app.stft.numSegments); - + 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 ===== + // Rendering BeginDrawing(); ClearBackground((Color){ 30, 30, 30, 255 }); - if (app.showFileInput) { - DrawFileInputUI((Rectangle){ 0, 0, GetScreenWidth(), GetScreenHeight() }); - } + if (app.showFileBrowser) DrawFileBrowser(); if (app.loaded && app.stftComputed) { - DrawTexturePro(app.spectrogramTexture, - (Rectangle){ 0, 0, app.spectrogramImage.width, app.spectrogramImage.height }, - spectrogramBounds, - (Vector2){ 0, 0 }, 0.0f, WHITE); + // Calculate visible region + int visibleStart = (int)(app.viewStart * app.spectrogramImage.width); + int visibleEnd = (int)(app.viewEnd * app.spectrogramImage.width); + int visibleWidth = visibleEnd - visibleStart; - if (app.showGrid) { - DrawSpectrogramGrid(spectrogramBounds, 10, 8, Fade(GRAY, 0.3f)); + if (visibleWidth > 0 && visibleStart >= 0) { + // 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]; + } + } + + Texture2D visibleTexture = LoadTextureFromImage(visibleImage); + DrawTexturePro(visibleTexture, + (Rectangle){ 0, 0, visibleWidth, app.spectrogramImage.height }, + bounds, (Vector2){ 0, 0 }, 0.0f, WHITE); + + UnloadTexture(visibleTexture); + UnloadImage(visibleImage); } - DrawSelection(spectrogramBounds); - DrawLabels(spectrogramBounds); - - DrawText("Spectrogram", spectrogramBounds.x, spectrogramBounds.y - 30, 20, LIGHTGRAY); - DrawText(TextFormat("Frequency (Hz) - Max: %d", app.signal.sampleRate / 2), - spectrogramBounds.x - 50, spectrogramBounds.y + spectrogramBounds.height / 2 - 10, - 14, Fade(LIGHTGRAY, 0.7f)); - } else if (!app.showFileInput) { - const char* message1 = "Press 'O' to load a WAV file"; - const char* message2 = "or drag & drop a file onto the window"; - Vector2 textSize1 = MeasureTextEx(GetFontDefault(), message1, 24, 0); - Vector2 textSize2 = MeasureTextEx(GetFontDefault(), message2, 18, 0); - DrawText(message1, GetScreenWidth() / 2 - textSize1.x / 2, - GetScreenHeight() / 2 - textSize1.y / 2 - 15, 24, LIGHTGRAY); - DrawText(message2, GetScreenWidth() / 2 - textSize2.x / 2, - GetScreenHeight() / 2 - textSize2.y / 2 + 15, 18, GRAY); + if (app.showGrid) DrawSpectrogramGrid(bounds, 10, 8, Fade(GRAY, 0.3f)); + DrawSelection(bounds); + DrawLabels(bounds); + DrawText("Spectrogram", bounds.x, bounds.y - 30, 20, LIGHTGRAY); + DrawText(TextFormat("Frequency (Hz) - Max: %d", app.signal.sampleRate / 2), + bounds.x - 50, bounds.y + bounds.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); } - DrawInfo(spectrogramBounds); + DrawInfo(bounds); DrawFPS(GetScreenWidth() - 100, 10); - EndDrawing(); } - // ===== Cleanup ===== TraceLog(LOG_INFO, "Shutting down..."); - - if (AudioPlaybackSound.frameCount != 0) { - UnloadSound(AudioPlaybackSound); - } - - if (app.stftComputed) { - FreeSTFT(&app.stft); - UnloadImage(app.spectrogramImage); - UnloadTexture(app.spectrogramTexture); - } - + if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound); + if (app.stftComputed) { FreeSTFT(&app.stft); UnloadImage(app.spectrogramImage); UnloadTexture(app.spectrogramTexture); } UnloadTexture(colormapTexture); + FreeBrowserFiles(); FreeSignal(&app.signal); - CloseAudioDevice(); CloseWindow(); - return 0; }