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