99ef486c5f
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
1086 lines
46 KiB
C
1086 lines
46 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 2048
|
|
#define HOP_SIZE (FFT_SIZE / 2)
|
|
#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;
|
|
int numBins;
|
|
int sampleOffset;
|
|
int sampleCount;
|
|
} StftSegment;
|
|
|
|
typedef struct {
|
|
StftSegment* segments;
|
|
int numSegments;
|
|
int sampleRate;
|
|
int totalSamples;
|
|
bool useHannWindow;
|
|
} StftResult;
|
|
|
|
typedef struct {
|
|
AudioSignal signal;
|
|
StftResult stft;
|
|
Image spectrogramImage;
|
|
Texture2D spectrogramTexture;
|
|
bool loaded;
|
|
bool stftComputed;
|
|
|
|
// Time selection (0-1 normalized)
|
|
float timeSelectionStart;
|
|
float timeSelectionEnd;
|
|
bool isTimeSelecting;
|
|
|
|
// Frequency selection (0-1 normalized)
|
|
float freqSelectionStart;
|
|
float freqSelectionEnd;
|
|
bool isFreqSelecting;
|
|
|
|
// Viewport/zoom controls
|
|
float viewStart; // 0-1, start of visible region
|
|
float viewEnd; // 0-1, end of visible region
|
|
bool isPanning;
|
|
float panStartViewStart;
|
|
float panStartViewEnd;
|
|
Vector2 panStartPos;
|
|
|
|
// Display settings
|
|
float amplitudeFloorDb;
|
|
float amplitudeCeilingDb;
|
|
ColormapType colormap;
|
|
bool showGrid;
|
|
|
|
// File browser state
|
|
bool showFileBrowser;
|
|
char browserPath[512];
|
|
char** browserFiles;
|
|
bool* browserIsDir;
|
|
int browserFileCount;
|
|
int browserScroll;
|
|
int browserSelected;
|
|
bool isBrowsing;
|
|
} SpectrogramApp;
|
|
|
|
// ============================================================================
|
|
// Global State
|
|
// ============================================================================
|
|
|
|
static SpectrogramApp app = {0};
|
|
static Sound AudioPlaybackSound = {0};
|
|
static Texture2D colormapTexture = {0};
|
|
|
|
// ============================================================================
|
|
// Utility Functions
|
|
// ============================================================================
|
|
|
|
static float AmplitudeToDecibels(float amplitude)
|
|
{
|
|
if (amplitude < 0.0001f) amplitude = 0.0001f;
|
|
return 20.0f * log10f(amplitude);
|
|
}
|
|
|
|
static float Clamp(float value, float min, float max)
|
|
{
|
|
if (value < min) return min;
|
|
if (value > max) return max;
|
|
return value;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Colormap Functions
|
|
// ============================================================================
|
|
|
|
static Color GetColormapColor(float t, ColormapType type)
|
|
{
|
|
t = Clamp(t, 0.0f, 1.0f);
|
|
|
|
switch (type) {
|
|
case COLORMAP_GRAYS: {
|
|
unsigned char v = (unsigned char)(t * 255);
|
|
return (Color){ v, v, v, 255 };
|
|
}
|
|
case COLORMAP_INFERNO: {
|
|
float r = 0.0f, g = 0.0f, b = 0.0f;
|
|
if (t < 0.25f) { t = t / 0.25f; r = 0.0f + t * 0.5f; g = 0.0f; b = 0.0f + t * 0.3f; }
|
|
else if (t < 0.5f) { t = (t - 0.25f) / 0.25f; r = 0.5f + t * 0.5f; g = 0.0f + t * 0.3f; b = 0.3f + t * 0.4f; }
|
|
else if (t < 0.75f) { t = (t - 0.5f) / 0.25f; r = 1.0f; g = 0.3f + t * 0.5f; b = 0.7f + t * 0.2f; }
|
|
else { t = (t - 0.75f) / 0.25f; r = 1.0f; g = 0.8f + t * 0.2f; b = 0.9f + t * 0.1f; }
|
|
return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 };
|
|
}
|
|
case COLORMAP_VIRIDIS: {
|
|
float r, g, b;
|
|
if (t < 0.25f) { t = t / 0.25f; r = 0.27f + t * 0.13f; g = 0.00f + t * 0.33f; b = 0.33f + t * 0.27f; }
|
|
else if (t < 0.5f) { t = (t - 0.25f) / 0.25f; r = 0.40f + t * 0.16f; g = 0.33f + t * 0.29f; b = 0.60f - t * 0.20f; }
|
|
else if (t < 0.75f) { t = (t - 0.5f) / 0.25f; r = 0.56f + t * 0.24f; g = 0.62f + t * 0.23f; b = 0.40f - t * 0.20f; }
|
|
else { t = (t - 0.75f) / 0.25f; r = 0.80f + t * 0.17f; g = 0.85f + t * 0.12f; b = 0.20f - t * 0.15f; }
|
|
return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 };
|
|
}
|
|
case COLORMAP_PLASMA: {
|
|
float r = 0.05f + t * 0.9f;
|
|
float g = 0.0f + t * 0.6f + (t > 0.5f ? (t - 0.5f) * 0.4f : 0.0f);
|
|
float b = 0.6f - t * 0.5f;
|
|
return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 };
|
|
}
|
|
case COLORMAP_HOT: {
|
|
float r = Clamp(t * 3.0f, 0.0f, 1.0f);
|
|
float g = Clamp((t - 0.33f) * 3.0f, 0.0f, 1.0f);
|
|
float b = Clamp((t - 0.66f) * 3.0f, 0.0f, 1.0f);
|
|
return (Color){ (unsigned char)(r * 255), (unsigned char)(g * 255), (unsigned char)(b * 255), 255 };
|
|
}
|
|
case COLORMAP_COOL: {
|
|
return (Color){ (unsigned char)(t * 255), (unsigned char)((1.0f - t) * 255), 255, 255 };
|
|
}
|
|
default: return GRAY;
|
|
}
|
|
}
|
|
|
|
static void GenerateColormapTexture(void)
|
|
{
|
|
if (colormapTexture.id != 0) UnloadTexture(colormapTexture);
|
|
Image img = GenImageColor(256, 1, WHITE);
|
|
Color* pixels = (Color*)img.data;
|
|
for (int i = 0; i < 256; i++) pixels[i] = GetColormapColor(i / 255.0f, app.colormap);
|
|
colormapTexture = LoadTextureFromImage(img);
|
|
UnloadImage(img);
|
|
}
|
|
|
|
// ============================================================================
|
|
// FFT Implementation
|
|
// ============================================================================
|
|
|
|
static void BitReverseCopy(float complex* input, float complex* output, int n)
|
|
{
|
|
int bits = 0, temp = n;
|
|
while (temp > 1) { bits++; temp >>= 1; }
|
|
for (int i = 0; i < n; i++) {
|
|
int j = 0, k = i;
|
|
for (int b = 0; b < bits; b++) { j = (j << 1) | (k & 1); k >>= 1; }
|
|
output[j] = input[i];
|
|
}
|
|
}
|
|
|
|
static void FFT(float complex* input, float complex* output, int n, bool inverse)
|
|
{
|
|
if (n <= 1) { output[0] = input[0]; return; }
|
|
BitReverseCopy(input, output, n);
|
|
for (int stage = 1; stage < n; stage *= 2) {
|
|
int step = stage * 2;
|
|
float angleStep = (inverse ? 2.0f : -2.0f) * (float)M_PI / step;
|
|
for (int k = 0; k < stage; k++) {
|
|
float complex twiddle = cexpf(I * angleStep * k);
|
|
for (int i = k; i < n; i += step) {
|
|
int j = i + stage;
|
|
float complex t = output[j] * twiddle;
|
|
output[j] = output[i] - t;
|
|
output[i] = output[i] + t;
|
|
}
|
|
}
|
|
}
|
|
if (inverse) for (int i = 0; i < n; i++) output[i] /= n;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Hann Window
|
|
// ============================================================================
|
|
|
|
static void ApplyHannWindow(float* samples, int n)
|
|
{
|
|
for (int i = 0; i < n; i++) {
|
|
float t = (float)i / (n - 1);
|
|
samples[i] *= 0.5f * (1.0f - cosf(2.0f * M_PI * t));
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// STFT Implementation
|
|
// ============================================================================
|
|
|
|
static void ComputeSTFT(AudioSignal* signal, StftResult* result)
|
|
{
|
|
int windowSize = FFT_SIZE, hopSize = HOP_SIZE;
|
|
int numSegments = (signal->numSamples - windowSize) / 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 = windowSize / 2 + 1;
|
|
float* windowedSamples = (float*)malloc(windowSize * sizeof(float));
|
|
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;
|
|
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++) {
|
|
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);
|
|
}
|
|
|
|
static void FreeSTFT(StftResult* result)
|
|
{
|
|
for (int i = 0; i < result->numSegments; i++) free(result->segments[i].spectrum);
|
|
free(result->segments);
|
|
result->segments = NULL;
|
|
result->numSegments = 0;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Audio Loading
|
|
// ============================================================================
|
|
|
|
static bool LoadWavFile(const char* filepath, AudioSignal* signal)
|
|
{
|
|
Wave wave = LoadWave(filepath);
|
|
if (wave.data == NULL) { TraceLog(LOG_ERROR, "Failed to open WAV file: %s", filepath); return false; }
|
|
|
|
signal->sampleRate = wave.sampleRate;
|
|
signal->channels = wave.channels;
|
|
signal->numSamples = wave.frameCount * wave.channels;
|
|
signal->duration = (float)wave.frameCount / wave.sampleRate;
|
|
signal->samples = (float*)malloc(signal->numSamples * sizeof(float));
|
|
|
|
if (wave.sampleSize == 16) {
|
|
short* samples = (short*)wave.data;
|
|
for (int i = 0; i < signal->numSamples; i++) signal->samples[i] = samples[i] / 32768.0f;
|
|
} else if (wave.sampleSize == 32) {
|
|
float* samples = (float*)wave.data;
|
|
memcpy(signal->samples, samples, signal->numSamples * sizeof(float));
|
|
} else {
|
|
unsigned char* samples = (unsigned char*)wave.data;
|
|
for (int i = 0; i < signal->numSamples; i++) signal->samples[i] = (samples[i] - 128) / 128.0f;
|
|
}
|
|
|
|
if (wave.channels > 1) {
|
|
int monoSamples = wave.frameCount;
|
|
for (int i = 0; i < monoSamples; i++) {
|
|
float sum = 0.0f;
|
|
for (int c = 0; c < wave.channels; c++) sum += signal->samples[i * wave.channels + c];
|
|
signal->samples[i] = sum / wave.channels;
|
|
}
|
|
signal->numSamples = monoSamples;
|
|
}
|
|
UnloadWave(wave);
|
|
TraceLog(LOG_INFO, "Loaded WAV: %d Hz, %.2f sec, %d samples", signal->sampleRate, signal->duration, signal->numSamples);
|
|
return true;
|
|
}
|
|
|
|
static void FreeSignal(AudioSignal* signal)
|
|
{
|
|
if (signal->samples) { free(signal->samples); signal->samples = NULL; }
|
|
signal->numSamples = 0; signal->sampleRate = 0; signal->duration = 0.0f;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Spectrogram Generation
|
|
// ============================================================================
|
|
|
|
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;
|
|
|
|
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;
|
|
|
|
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);
|
|
float normalized = (db - app.amplitudeFloorDb) / (app.amplitudeCeilingDb - app.amplitudeFloorDb);
|
|
normalized = Clamp(normalized, 0.0f, 1.0f);
|
|
int pixelIndex = (height - 1 - bin) * width + seg;
|
|
pixels[pixelIndex] = GetColormapColor(normalized, app.colormap);
|
|
}
|
|
}
|
|
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];
|
|
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;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// UI and Rendering
|
|
// ============================================================================
|
|
|
|
static void DrawSpectrogramGrid(Rectangle bounds, int numCellsX, int numCellsY, Color color)
|
|
{
|
|
float cellWidth = bounds.width / numCellsX, cellHeight = bounds.height / numCellsY;
|
|
for (int i = 0; i <= numCellsX; i++) {
|
|
float x = bounds.x + i * cellWidth;
|
|
DrawLineV((Vector2){ x, bounds.y }, (Vector2){ x, bounds.y + bounds.height }, color);
|
|
}
|
|
for (int i = 0; i <= numCellsY; i++) {
|
|
float y = bounds.y + bounds.height - i * cellHeight;
|
|
DrawLineV((Vector2){ bounds.x, y }, (Vector2){ bounds.x + bounds.width, y }, color);
|
|
}
|
|
}
|
|
|
|
static void DrawLabels(Rectangle bounds)
|
|
{
|
|
int fontSize = 10;
|
|
Color textColor = LIGHTGRAY;
|
|
|
|
// Time labels
|
|
for (int i = 0; i <= 10; i++) {
|
|
float t = (float)i / 10;
|
|
float timeSec = (app.viewStart + t * (app.viewEnd - app.viewStart)) * app.signal.duration;
|
|
float x = bounds.x + t * bounds.width;
|
|
char label[32];
|
|
if (timeSec >= 60) sprintf(label, "%d:%02d", (int)(timeSec / 60), (int)(timeSec) % 60);
|
|
else sprintf(label, "%.1fs", timeSec);
|
|
Vector2 textSize = MeasureTextEx(GetFontDefault(), label, fontSize, 0);
|
|
DrawText(label, x - textSize.x / 2, bounds.y + bounds.height + 5, fontSize, textColor);
|
|
}
|
|
|
|
// Frequency labels at 1kHz intervals with 200Hz ticks
|
|
float maxFreq = (float)app.signal.sampleRate / 2.0f;
|
|
int maxKhz = (int)(maxFreq / 1000.0f);
|
|
|
|
for (int hz = 0; hz <= maxFreq; hz += 200) {
|
|
float t = hz / maxFreq;
|
|
float y = bounds.y + bounds.height - t * bounds.height;
|
|
Color tickColor = (hz % 1000 == 0) ? GRAY : Fade(GRAY, 0.4f);
|
|
DrawLineV((Vector2){ bounds.x - 5, y }, (Vector2){ bounds.x, y }, tickColor);
|
|
}
|
|
|
|
for (int khz = 0; khz <= maxKhz; khz++) {
|
|
float freq = khz * 1000.0f;
|
|
if (freq > maxFreq) break;
|
|
float t = freq / maxFreq;
|
|
float y = bounds.y + bounds.height - t * bounds.height;
|
|
char label[32];
|
|
if (khz == 0) sprintf(label, "0");
|
|
else if (khz < 10) sprintf(label, "%dk", khz);
|
|
else sprintf(label, "%d", khz);
|
|
Vector2 textSize = MeasureTextEx(GetFontDefault(), label, fontSize, 0);
|
|
DrawText(label, bounds.x - textSize.x - 15, y - textSize.y / 2, fontSize, textColor);
|
|
}
|
|
|
|
DrawText("Time", bounds.x + bounds.width / 2 - 20, bounds.y + bounds.height + 25, fontSize, GRAY);
|
|
DrawText("Freq", bounds.x - 35, bounds.y + bounds.height / 2, fontSize, GRAY);
|
|
}
|
|
|
|
static void DrawSelection(Rectangle bounds)
|
|
{
|
|
Color overlayColor = Fade(BLACK, 0.5f);
|
|
float selStartX = bounds.x + app.timeSelectionStart * bounds.width;
|
|
float selEndX = bounds.x + app.timeSelectionEnd * bounds.width;
|
|
|
|
if (selStartX > bounds.x) DrawRectangle(bounds.x, bounds.y, selStartX - bounds.x, bounds.height, overlayColor);
|
|
if (selEndX < bounds.x + bounds.width) DrawRectangle(selEndX, bounds.y, bounds.x + bounds.width - selEndX, bounds.height, overlayColor);
|
|
|
|
float selStartY = bounds.y + bounds.height - app.freqSelectionEnd * bounds.height;
|
|
float selEndY = bounds.y + bounds.height - app.freqSelectionStart * bounds.height;
|
|
|
|
if (selStartY > bounds.y) DrawRectangle(selStartX, bounds.y, selEndX - selStartX, selStartY - bounds.y, overlayColor);
|
|
if (selEndY < bounds.y + bounds.height) DrawRectangle(selStartX, selEndY, selEndX - selStartX, bounds.y + bounds.height - selEndY, overlayColor);
|
|
|
|
DrawLineV((Vector2){ selStartX, bounds.y }, (Vector2){ selStartX, bounds.y + bounds.height }, YELLOW);
|
|
DrawLineV((Vector2){ selEndX, bounds.y }, (Vector2){ selEndX, bounds.y + bounds.height }, YELLOW);
|
|
|
|
if (app.freqSelectionStart > 0.0f || app.freqSelectionEnd < 1.0f) {
|
|
DrawLineV((Vector2){ selStartX, selStartY }, (Vector2){ selEndX, selStartY }, CYAN);
|
|
DrawLineV((Vector2){ selStartX, selEndY }, (Vector2){ selEndX, selEndY }, CYAN);
|
|
}
|
|
}
|
|
|
|
static void DrawInfo(Rectangle bounds)
|
|
{
|
|
int fontSize = 12, y = 10;
|
|
|
|
if (app.loaded) {
|
|
DrawText(TextFormat("Sample Rate: %d Hz", app.signal.sampleRate), 10, y, fontSize, LIGHTGRAY); y += 20;
|
|
DrawText(TextFormat("Duration: %.2f sec", app.signal.duration), 10, y, fontSize, LIGHTGRAY); y += 20;
|
|
DrawText(TextFormat("View: %.1f%%-%.1f%% (%.2f sec)", app.viewStart*100, app.viewEnd*100, (app.viewEnd-app.viewStart)*app.signal.duration), 10, y, fontSize, LIGHTGRAY); y += 20;
|
|
DrawText(TextFormat("Max Freq: %.1f kHz", (float)app.signal.sampleRate / 2000.0f), 10, y, fontSize, LIGHTGRAY); y += 20;
|
|
y += 10;
|
|
|
|
float selDuration = (app.timeSelectionEnd - app.timeSelectionStart) * app.signal.duration;
|
|
DrawText(TextFormat("Time Sel: %.2f-%.2f sec (%.3f sec)", app.timeSelectionStart*app.signal.duration, app.timeSelectionEnd*app.signal.duration, selDuration), 10, y, fontSize, YELLOW); y += 20;
|
|
|
|
float maxFreq = (float)app.signal.sampleRate / 2.0f;
|
|
DrawText(TextFormat("Freq Sel: %.0f-%.0f Hz", app.freqSelectionStart*maxFreq, app.freqSelectionEnd*maxFreq), 10, y, fontSize, CYAN);
|
|
} else {
|
|
DrawText("No audio file loaded", 10, y, fontSize, RED);
|
|
}
|
|
|
|
y = GetScreenHeight() - 240;
|
|
DrawText("Controls:", 10, y, fontSize, LIGHTGRAY); y += 20;
|
|
DrawText(" O - Open file browser", 10, y, fontSize, GRAY); y += 18;
|
|
DrawText(" Drag & drop WAV file", 10, y, fontSize, GRAY); y += 18;
|
|
DrawText(" Mouse Wheel - Zoom 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);
|
|
|
|
y = GetScreenHeight() - 60;
|
|
DrawText("Colormap:", GetScreenWidth() - 200, y, fontSize, LIGHTGRAY);
|
|
const char* colormapNames[] = { "Grays", "Inferno", "Viridis", "Plasma", "Hot", "Cool" };
|
|
DrawText(colormapNames[app.colormap], GetScreenWidth() - 200, y + 18, fontSize, WHITE);
|
|
DrawTexturePro(colormapTexture, (Rectangle){ 0, 0, 256, 1 }, (Rectangle){ GetScreenWidth() - 100, y + 15, 80, 15 }, (Vector2){ 0, 0 }, 0.0f, WHITE);
|
|
DrawText(TextFormat("dB Floor: %.1f", app.amplitudeFloorDb), GetScreenWidth() - 200, y + 35, fontSize, LIGHTGRAY);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Main Application
|
|
// ============================================================================
|
|
|
|
int main(int argc, char* argv[])
|
|
{
|
|
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_HIGHDPI);
|
|
InitWindow(1280, 800, "Spectrogram Viewer");
|
|
SetTargetFPS(60);
|
|
InitAudioDevice();
|
|
SearchAndSetResourceDir("resources");
|
|
|
|
app.timeSelectionStart = 0.0f; app.timeSelectionEnd = 1.0f;
|
|
app.freqSelectionStart = 0.0f; app.freqSelectionEnd = 1.0f;
|
|
app.viewStart = 0.0f; app.viewEnd = 1.0f;
|
|
app.showGrid = true;
|
|
app.colormap = COLORMAP_INFERNO;
|
|
app.amplitudeFloorDb = -60.0f;
|
|
app.amplitudeCeilingDb = 0.0f;
|
|
app.showFileBrowser = false;
|
|
app.isBrowsing = false;
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
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; }
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
// 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.showFileBrowser) {
|
|
app.colormap = (ColormapType)((app.colormap + 1) % COLORMAP_COUNT);
|
|
GenerateColormapTexture();
|
|
}
|
|
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.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.showFileBrowser) PlaySelectedRegion();
|
|
|
|
if (IsKeyPressed(KEY_ESCAPE)) {
|
|
if (app.showFileBrowser) app.showFileBrowser = false;
|
|
else {
|
|
app.timeSelectionStart = app.viewStart;
|
|
app.timeSelectionEnd = app.viewEnd;
|
|
app.freqSelectionStart = 0.0f;
|
|
app.freqSelectionEnd = 1.0f;
|
|
}
|
|
}
|
|
|
|
// Selection
|
|
Rectangle bounds = { 100, 50, GetScreenWidth() - 150, GetScreenHeight() - 150 };
|
|
Vector2 mousePos = GetMousePosition();
|
|
|
|
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.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);
|
|
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 });
|
|
|
|
if (app.showFileBrowser) DrawFileBrowser();
|
|
|
|
if (app.loaded && app.stftComputed) {
|
|
// Calculate visible region
|
|
int visibleStart = (int)(app.viewStart * app.spectrogramImage.width);
|
|
int visibleEnd = (int)(app.viewEnd * app.spectrogramImage.width);
|
|
int visibleWidth = visibleEnd - visibleStart;
|
|
|
|
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);
|
|
}
|
|
|
|
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 <file.wav>";
|
|
Vector2 t1 = MeasureTextEx(GetFontDefault(), msg1, 24, 0);
|
|
Vector2 t2 = MeasureTextEx(GetFontDefault(), msg2, 18, 0);
|
|
DrawText(msg1, GetScreenWidth() / 2 - t1.x / 2, GetScreenHeight() / 2 - t1.y / 2 - 15, 24, LIGHTGRAY);
|
|
DrawText(msg2, GetScreenWidth() / 2 - t2.x / 2, GetScreenHeight() / 2 - t2.y / 2 + 15, 18, GRAY);
|
|
}
|
|
|
|
DrawInfo(bounds);
|
|
DrawFPS(GetScreenWidth() - 100, 10);
|
|
EndDrawing();
|
|
}
|
|
|
|
TraceLog(LOG_INFO, "Shutting down...");
|
|
if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound);
|
|
if (app.stftComputed) { FreeSTFT(&app.stft); UnloadImage(app.spectrogramImage); UnloadTexture(app.spectrogramTexture); }
|
|
UnloadTexture(colormapTexture);
|
|
FreeBrowserFiles();
|
|
FreeSignal(&app.signal);
|
|
CloseAudioDevice();
|
|
CloseWindow();
|
|
return 0;
|
|
}
|