Add incremental loading progress, auto-scaled amplitude, DPI-aware TTF fonts, and better file browser scaling
- Add incremental STFT processing with loading overlay and progress bar - Add auto-scaling amplitude (max dB, max-40dB floor) for low-signal files - Replace fixed bitmap text with TTF font scaled by window size for crisp rendering at any resolution - Fix command-line file path resolution relative to original working directory - Scale file browser dialog dimensions by GetUIScale() to prevent text overlap - Disable ESC key closing the window - Fix FFT size changes to restart STFT computation
This commit is contained in:
+1
-1
@@ -46,7 +46,7 @@ echo "=== Step 2: Compiling spectrogram for web ==="
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Linker flags
|
||||
LDFLAGS="-s USE_GLFW=3 -s ASYNCIFY -s TOTAL_MEMORY=67108864 -s FORCE_FILESYSTEM=1 --preload-file resources@resources --shell-file $SCRIPT_DIR/web_shell.html -s NO_EXIT_RUNTIME=1"
|
||||
LDFLAGS="-s USE_GLFW=3 -s ASYNCIFY -s TOTAL_MEMORY=67108864 -s FORCE_FILESYSTEM=1 --preload-file resources@resources --preload-file fonts/DejaVuSansMono.ttf@fonts/DejaVuSansMono.ttf --shell-file $SCRIPT_DIR/web_shell.html -s NO_EXIT_RUNTIME=1"
|
||||
|
||||
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||
LDFLAGS="$LDFLAGS -g -O0 -s ASSERTIONS=1"
|
||||
|
||||
Binary file not shown.
+255
-91
@@ -154,6 +154,11 @@ typedef struct {
|
||||
// Playback state
|
||||
bool isPlaying;
|
||||
bool playbackFinished; // Track if playback completed naturally
|
||||
|
||||
// Loading/processing state
|
||||
int loadingPhase; // 0 = computing STFT, 1 = generating texture
|
||||
float loadingProgress; // 0.0 to 1.0 overall progress
|
||||
int currentSTFTSegment; // Which segment we're on for incremental processing
|
||||
} SpectrogramApp;
|
||||
|
||||
// ============================================================================
|
||||
@@ -163,11 +168,33 @@ typedef struct {
|
||||
static SpectrogramApp app = {0};
|
||||
static Sound AudioPlaybackSound = {0};
|
||||
static Texture2D colormapTexture = {0};
|
||||
static Font mainFont = {0}; // TTF font for crisp text at any scale
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
// Draw text with the loaded TTF font, scaled properly
|
||||
static void DrawTextScaled(const char* text, float x, float y, float baseSize, Color color)
|
||||
{
|
||||
if (mainFont.texture.id == 0) {
|
||||
// Fallback to default if font not loaded
|
||||
DrawText(text, (int)x, (int)y, (int)baseSize, color);
|
||||
return;
|
||||
}
|
||||
float scaledSize = baseSize * GetUIScale();
|
||||
float spacing = scaledSize * 0.25f; // 25% of font size for spacing
|
||||
DrawTextEx(mainFont, text, (Vector2){ x, y }, scaledSize, spacing, color);
|
||||
}
|
||||
|
||||
static float MeasureTextScaled(const char* text, float baseSize)
|
||||
{
|
||||
if (mainFont.texture.id == 0) return MeasureText(text, (int)baseSize);
|
||||
float scaledSize = baseSize * GetUIScale();
|
||||
float spacing = scaledSize * 0.25f;
|
||||
return MeasureTextEx(mainFont, text, scaledSize, spacing).x;
|
||||
}
|
||||
|
||||
static float AmplitudeToDecibels(float amplitude)
|
||||
{
|
||||
if (amplitude < 0.0001f) amplitude = 0.0001f;
|
||||
@@ -290,7 +317,7 @@ static void ApplyHannWindow(float* samples, int n)
|
||||
// STFT Implementation
|
||||
// ============================================================================
|
||||
|
||||
static void ComputeSTFT(AudioSignal* signal, StftResult* result, int fftSize)
|
||||
static void ComputeSTFTInit(AudioSignal* signal, StftResult* result, int fftSize)
|
||||
{
|
||||
int hopSize = fftSize / HOP_RATIO; // 75% overlap
|
||||
int numSegments = (signal->numSamples - fftSize) / hopSize + 1;
|
||||
@@ -301,14 +328,18 @@ static void ComputeSTFT(AudioSignal* signal, StftResult* result, int fftSize)
|
||||
result->sampleRate = signal->sampleRate;
|
||||
result->totalSamples = signal->numSamples;
|
||||
result->useHannWindow = true;
|
||||
}
|
||||
|
||||
static bool ComputeSTFTIncremental(AudioSignal* signal, StftResult* result, int fftSize, int startSegment)
|
||||
{
|
||||
int hopSize = fftSize / HOP_RATIO;
|
||||
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++) {
|
||||
for (int seg = startSegment; seg < result->numSegments; seg++) {
|
||||
int offset = seg * hopSize;
|
||||
int samplesToCopy = fftSize;
|
||||
if (offset + samplesToCopy > signal->numSamples) {
|
||||
@@ -356,7 +387,12 @@ static void ComputeSTFT(AudioSignal* signal, StftResult* result, int fftSize)
|
||||
result->segments[seg].derivativeSpectrum[bin].phase = cargf(fftOutput[bin]);
|
||||
}
|
||||
}
|
||||
free(windowedSamples); free(derivWindowedSamples); free(complexInput); free(fftOutput);
|
||||
|
||||
free(windowedSamples);
|
||||
free(derivWindowedSamples);
|
||||
free(complexInput);
|
||||
free(fftOutput);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void FreeSTFT(StftResult* result)
|
||||
@@ -568,6 +604,24 @@ static void GenerateSpectrogramTexture(StftResult* stft, Image* image, Texture2D
|
||||
SetTextureFilter(*texture, TEXTURE_FILTER_BILINEAR);
|
||||
}
|
||||
|
||||
// Compute auto-adjusted amplitude floor/ceiling from STFT data
|
||||
static void AutoScaleAmplitude(StftResult* stft)
|
||||
{
|
||||
float maxDb = -999.0f;
|
||||
float minDb = 0.0f;
|
||||
for (int seg = 0; seg < stft->numSegments; seg++) {
|
||||
for (int bin = 0; bin < stft->segments[seg].numBins; bin++) {
|
||||
float db = AmplitudeToDecibels(stft->segments[seg].spectrum[bin].amplitude);
|
||||
if (db > maxDb) maxDb = db;
|
||||
if (db < minDb) minDb = db;
|
||||
}
|
||||
}
|
||||
// Set ceiling at the max, floor 40dB below — enough range to see structure
|
||||
// but not so wide that the signal is drowned in black
|
||||
app.amplitudeCeilingDb = maxDb;
|
||||
app.amplitudeFloorDb = maxDb - 40.0f;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Audio Playback with FFT-based Bandpass Filter
|
||||
// ============================================================================
|
||||
@@ -760,11 +814,15 @@ static void LoadSelectedFile(void)
|
||||
} else if (FileExists(filePath) && LoadWavFile(filePath, &app.signal)) {
|
||||
app.loaded = true;
|
||||
app.stftComputed = false;
|
||||
app.loadingPhase = 0;
|
||||
app.loadingProgress = 0.0f;
|
||||
app.currentSTFTSegment = 0;
|
||||
app.timeSelectionStart = app.viewStart = 0.0f;
|
||||
app.timeSelectionEnd = app.viewEnd = 1.0f;
|
||||
app.freqSelectionStart = 0.0f;
|
||||
app.freqSelectionEnd = 1.0f;
|
||||
app.showFileBrowser = false;
|
||||
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
|
||||
// Invalidate visible texture cache
|
||||
if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture);
|
||||
app.visibleTexture = (Texture2D){ 0 };
|
||||
@@ -778,70 +836,77 @@ static void DrawFileBrowser(void)
|
||||
// Draw semi-transparent overlay first
|
||||
DrawRectangle(0, 0, GetScreenWidth(), GetScreenHeight(), Fade(BLACK, 0.85f));
|
||||
|
||||
float bw = 650, bh = 550;
|
||||
float scale = GetUIScale();
|
||||
float bw = 900.0f * scale, bh = 700.0f * scale;
|
||||
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);
|
||||
DrawRectangleLinesEx((Rectangle){ bx, by, bw, bh }, (int)(2 * scale), GRAY);
|
||||
DrawRectangle(bx, by, bw, (int)(40 * scale), (Color){ 60, 60, 75, 255 });
|
||||
DrawTextScaled("File Browser - Select WAV File", bx + (int)(15 * scale), by + (int)(8 * scale), (int)(20 * scale), 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);
|
||||
float pathBarY = by + (int)(46 * scale);
|
||||
DrawRectangle(bx + (int)(15 * scale), pathBarY, bw - (int)(110 * scale), (int)(30 * scale), (Color){ 30, 30, 40, 255 });
|
||||
DrawRectangleLinesEx((Rectangle){ bx + (int)(15 * scale), pathBarY, bw - (int)(110 * scale), (int)(30 * scale) }, (int)(1 * scale), 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);
|
||||
DrawTextScaled(displayPath, bx + (int)(22 * scale), pathBarY + (int)(5 * scale), (int)(14 * scale), LIGHTGRAY);
|
||||
|
||||
// Up button
|
||||
Rectangle upBtn = { bx + bw - 85, by + 45, 70, 28 };
|
||||
Rectangle upBtn = { bx + bw - (int)(90 * scale), pathBarY, (int)(75 * scale), (int)(30 * scale) };
|
||||
if (CheckCollisionPointRec(GetMousePosition(), upBtn)) DrawRectangleRec(upBtn, (Color){ 80, 80, 90, 255 });
|
||||
DrawText("UP (..)", upBtn.x + 10, upBtn.y + 7, 14, WHITE);
|
||||
DrawTextScaled("UP (..)", upBtn.x + (int)(10 * scale), upBtn.y + (int)(7 * scale), (int)(14 * scale), WHITE);
|
||||
|
||||
// File list
|
||||
float lx = bx + 15, ly = by + 82, lw = bw - 30, lh = bh - 150;
|
||||
float lx = bx + (int)(15 * scale), ly = pathBarY + (int)(40 * scale);
|
||||
float lw = bw - (int)(30 * scale), lh = bh - (int)(195 * scale);
|
||||
DrawRectangle(lx, ly, lw, lh, (Color){ 25, 25, 35, 255 });
|
||||
DrawRectangleLinesEx((Rectangle){ lx, ly, lw, lh }, 1, GRAY);
|
||||
DrawRectangleLinesEx((Rectangle){ lx, ly, lw, lh }, (int)(1 * scale), GRAY);
|
||||
|
||||
// Line height: base 36px scaled (enough for icon + filename without overlap)
|
||||
float lineH = 36 * scale;
|
||||
|
||||
// Handle empty directory
|
||||
int visibleItems = (int)(lh / 26);
|
||||
int visibleItems = (int)(lh / lineH);
|
||||
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);
|
||||
DrawTextScaled("(No WAV files in directory)", lx + (int)(20 * scale), ly + (int)(lh / 2 - 12 * scale), (int)(14 * scale), GRAY);
|
||||
} else {
|
||||
if (app.browserFileCount > visibleItems) {
|
||||
float sh = (float)visibleItems / app.browserFileCount * lh;
|
||||
if (sh < 10) sh = 10;
|
||||
if (sh < (int)(10 * scale)) sh = (int)(10 * scale);
|
||||
float sy = ly + (float)app.browserScroll / (app.browserFileCount - visibleItems) * (lh - sh);
|
||||
DrawRectangle(lx + lw - 10, sy, 8, sh, GRAY);
|
||||
DrawRectangle(lx + lw - (int)(10 * scale), sy, (int)(8 * scale), sh, GRAY);
|
||||
}
|
||||
|
||||
int startItem = app.browserScroll;
|
||||
int endItem = startItem + visibleItems + 1;
|
||||
if (endItem > app.browserFileCount) endItem = app.browserFileCount;
|
||||
|
||||
float iconW = (int)(45 * scale); // space for icon column
|
||||
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 });
|
||||
float iy = ly + (i - startItem) * lineH + (int)(2 * scale);
|
||||
bool hovered = CheckCollisionPointRec((Vector2){ GetMouseX(), GetMouseY() }, (Rectangle){ lx + (int)(2 * scale), iy, lw - (int)(14 * scale), lineH - (int)(4 * scale) });
|
||||
|
||||
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 });
|
||||
if (i == app.browserSelected) DrawRectangle(lx + (int)(2 * scale), iy, lw - (int)(14 * scale), (int)((lineH - 4) * scale), (Color){ 50, 70, 120, 180 });
|
||||
else if (hovered) DrawRectangle(lx + (int)(2 * scale), iy, lw - (int)(14 * scale), (int)((lineH - 4) * scale), (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);
|
||||
DrawTextScaled(icon, lx + (int)(8 * scale), iy + (int)(4 * scale), (int)(13 * scale), iconCol);
|
||||
DrawTextScaled(app.browserFiles[i], lx + iconW + (int)(10 * scale), iy + (int)(4 * scale), (int)(14 * scale), WHITE);
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll with mouse wheel
|
||||
if (CheckCollisionPointRec(GetMousePosition(), (Rectangle){ lx, ly, lw - 10, lh }) && app.browserFileCount > 0) {
|
||||
if (CheckCollisionPointRec(GetMousePosition(), (Rectangle){ lx, ly, lw - (int)(10 * scale), lh }) && app.browserFileCount > 0) {
|
||||
int wheel = GetMouseWheelMove();
|
||||
if (wheel > 0) app.browserScroll--;
|
||||
if (wheel < 0) app.browserScroll++;
|
||||
@@ -854,8 +919,8 @@ static void DrawFileBrowser(void)
|
||||
// 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 (CheckCollisionPointRec(GetMousePosition(), (Rectangle){ lx, ly, lw - (int)(10 * scale), lh }) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && app.browserFileCount > 0) {
|
||||
int clicked = app.browserScroll + (int)((GetMouseY() - ly) / lineH);
|
||||
if (clicked >= 0 && clicked < app.browserFileCount) {
|
||||
app.browserSelected = clicked;
|
||||
static double lastClick = 0;
|
||||
@@ -865,19 +930,19 @@ static void DrawFileBrowser(void)
|
||||
}
|
||||
|
||||
// Buttons
|
||||
float btnY = by + bh - 50;
|
||||
Rectangle openBtn = { bx + bw - 160, btnY, 140, 38 };
|
||||
Rectangle cancelBtn = { bx + 15, btnY, 110, 38 };
|
||||
float btnY = by + bh - (int)(55 * scale);
|
||||
Rectangle openBtn = { bx + bw - (int)(170 * scale), btnY, (int)(150 * scale), (int)(40 * scale) };
|
||||
Rectangle cancelBtn = { bx + (int)(15 * scale), btnY, (int)(120 * scale), (int)(40 * scale) };
|
||||
|
||||
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);
|
||||
DrawRectangleLinesEx(openBtn, (int)(1 * scale), WHITE);
|
||||
DrawTextScaled("OPEN (Enter)", openBtn.x + (int)(25 * scale), openBtn.y + (int)(12 * scale), (int)(16 * scale), WHITE);
|
||||
|
||||
DrawRectangleRec(cancelBtn, (Color){ 100, 40, 40, 255 });
|
||||
DrawText("ESC Cancel", cancelBtn.x + 18, cancelBtn.y + 12, 16, WHITE);
|
||||
DrawTextScaled("ESC Cancel", cancelBtn.x + (int)(18 * scale), cancelBtn.y + (int)(12 * scale), (int)(16 * scale), WHITE);
|
||||
|
||||
if ((IsKeyPressed(KEY_ENTER) || openClicked) && app.browserSelected >= 0 && app.browserFileCount > 0) LoadSelectedFile();
|
||||
if (IsKeyPressed(KEY_ESCAPE)) app.showFileBrowser = false;
|
||||
@@ -910,7 +975,7 @@ static void DrawSpectrogramGrid(Rectangle bounds, int numCellsX, int numCellsY,
|
||||
|
||||
static void DrawLabels(Rectangle bounds)
|
||||
{
|
||||
int fontSize = 10;
|
||||
int baseFontSize = 12;
|
||||
Color textColor = LIGHTGRAY;
|
||||
|
||||
// Time labels
|
||||
@@ -921,8 +986,7 @@ static void DrawLabels(Rectangle bounds)
|
||||
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);
|
||||
DrawTextScaled(label, x, bounds.y + bounds.height + 5, baseFontSize, textColor);
|
||||
}
|
||||
|
||||
// Frequency labels at 1kHz intervals with 200Hz ticks
|
||||
@@ -945,11 +1009,8 @@ static void DrawLabels(Rectangle bounds)
|
||||
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);
|
||||
DrawTextScaled(label, bounds.x - 15, y - 5, baseFontSize, textColor);
|
||||
}
|
||||
|
||||
// Axis titles removed - just the tick labels are enough
|
||||
}
|
||||
|
||||
static void DrawSlider(Rectangle bounds, float value);
|
||||
@@ -1034,43 +1095,43 @@ static void DrawSidebar(void)
|
||||
DrawLine((int)(sidebarWidth + 10 * scale), 0, (int)(sidebarWidth + 10 * scale), GetScreenHeight(), GRAY);
|
||||
|
||||
// Title
|
||||
DrawText("Spectrogram Controls", x, y, (int)(14 * scale), WHITE); y += 25 * scale;
|
||||
DrawTextScaled("Spectrogram Controls", x, y, 16, WHITE); y += 28 * scale;
|
||||
|
||||
// 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 };
|
||||
DrawTextScaled(TextFormat("FFT: %d (%.1f Hz/bin)", app.fftSize, (float)app.signal.sampleRate / app.fftSize), x, y, 14, LIGHTGRAY); y += 20 * scale;
|
||||
Rectangle fftMinus = { x, y, 30 * scale, 25 * scale };
|
||||
Rectangle fftPlus = { x + sidebarWidth - 40 * scale, y, 30 * scale, 25 * scale };
|
||||
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 (newFFT >= FFT_SIZE_MIN) { app.fftSize = newFFT; app.stftComputed = false; app.loadingPhase = 0; 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; }
|
||||
if (newFFT <= FFT_SIZE_MAX) { app.fftSize = newFFT; app.stftComputed = false; app.loadingPhase = 0; needsRegen = true; }
|
||||
}
|
||||
DrawRectangleRec(fftMinus, (Color){ 50, 50, 60, 255 });
|
||||
DrawRectangleLinesEx(fftMinus, 1, GRAY);
|
||||
DrawText("-", fftMinus.x + 12, fftMinus.y + 5, 16, WHITE);
|
||||
DrawTextScaled("-", fftMinus.x + 12 * scale, fftMinus.y + 5 * scale, 18, WHITE);
|
||||
DrawRectangleRec(fftPlus, (Color){ 50, 50, 60, 255 });
|
||||
DrawRectangleLinesEx(fftPlus, 1, GRAY);
|
||||
DrawText("+", fftPlus.x + 10, fftPlus.y + 5, 16, WHITE);
|
||||
y += 30;
|
||||
DrawTextScaled("+", fftPlus.x + 10 * scale, fftPlus.y + 5 * scale, 18, WHITE);
|
||||
y += 32 * scale;
|
||||
|
||||
// dB Floor slider
|
||||
DrawText(TextFormat("dB Floor: %.1f", app.amplitudeFloorDb), x, y, fontSize, LIGHTGRAY); y += 18;
|
||||
Rectangle dbSlider = { x, y, sidebarWidth - 10, 20 };
|
||||
DrawTextScaled(TextFormat("dB Floor: %.1f", app.amplitudeFloorDb), x, y, 14, LIGHTGRAY); y += 20 * scale;
|
||||
Rectangle dbSlider = { x, y, sidebarWidth - 10 * scale, 20 * scale };
|
||||
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
|
||||
needsRegen = true;
|
||||
}
|
||||
y += 25;
|
||||
y += 28 * scale;
|
||||
|
||||
// Colormap dropdown
|
||||
DrawText("Colormap:", x, y, fontSize, LIGHTGRAY); y += 18;
|
||||
DrawTextScaled("Colormap:", x, y, 14, LIGHTGRAY); y += 20 * scale;
|
||||
const char* colormapNames[] = { "Grays", "Inferno", "Viridis", "Plasma", "Hot", "Cool" };
|
||||
Rectangle cmapButton = { x, y, sidebarWidth - 10, 25 };
|
||||
Rectangle cmapButton = { x, y, sidebarWidth - 10 * scale, 25 * scale };
|
||||
if (CheckCollisionPointRec(GetMousePosition(), cmapButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
|
||||
app.colormap = (ColormapType)((app.colormap + 1) % COLORMAP_COUNT);
|
||||
GenerateColormapTexture();
|
||||
@@ -1078,35 +1139,35 @@ static void DrawSidebar(void)
|
||||
}
|
||||
DrawRectangleRec(cmapButton, (Color){ 50, 50, 60, 255 });
|
||||
DrawRectangleLinesEx(cmapButton, 1, GRAY);
|
||||
DrawText(colormapNames[app.colormap], cmapButton.x + 10, cmapButton.y + 6, fontSize, WHITE);
|
||||
DrawTextScaled(colormapNames[app.colormap], cmapButton.x + 10 * scale, cmapButton.y + 6 * scale, 14, WHITE);
|
||||
DrawTexturePro(colormapTexture, (Rectangle){ 0, 0, 256, 1 },
|
||||
(Rectangle){ cmapButton.x + cmapButton.width - 60, cmapButton.y + 5, 50, 15 },
|
||||
(Rectangle){ cmapButton.x + cmapButton.width - 60 * scale, cmapButton.y + 5 * scale, 50 * scale, 15 * scale },
|
||||
(Vector2){ 0, 0 }, 0.0f, WHITE);
|
||||
y += 30;
|
||||
y += 32 * scale;
|
||||
|
||||
// Grid toggle
|
||||
Rectangle gridCheck = { x, y, 18, 18 };
|
||||
Rectangle gridCheck = { x, y, 18 * scale, 18 * scale };
|
||||
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;
|
||||
DrawTextScaled("Show Grid", x + 25 * scale, y + 2 * scale, 14, LIGHTGRAY); y += 28 * scale;
|
||||
|
||||
// File loading
|
||||
DrawText("File:", x, y, fontSize, LIGHTGRAY); y += 18;
|
||||
Rectangle fileButton = { x, y, sidebarWidth - 10, 25 };
|
||||
DrawTextScaled("File:", x, y, 14, LIGHTGRAY); y += 20 * scale;
|
||||
Rectangle fileButton = { x, y, sidebarWidth - 10 * scale, 25 * scale };
|
||||
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;
|
||||
DrawTextScaled("Open File Browser (O)", fileButton.x + 10 * scale, fileButton.y + 6 * scale, 14, WHITE);
|
||||
y += 38 * scale;
|
||||
|
||||
// Playback
|
||||
Rectangle playButton = { x, y, sidebarWidth - 10, 35 };
|
||||
Rectangle playButton = { x, y, sidebarWidth - 10 * scale, 35 * scale };
|
||||
if (CheckCollisionPointRec(GetMousePosition(), playButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
|
||||
if (app.isPlaying && AudioPlaybackSound.frameCount > 0) {
|
||||
StopSound(AudioPlaybackSound);
|
||||
@@ -1119,22 +1180,22 @@ static void DrawSidebar(void)
|
||||
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;
|
||||
DrawTextScaled(playText, playButton.x + 10 * scale, playButton.y + 12 * scale, 14, WHITE);
|
||||
y += 48 * scale;
|
||||
|
||||
// Fullscreen toggle
|
||||
Rectangle fsButton = { x, y, sidebarWidth - 10, 25 };
|
||||
Rectangle fsButton = { x, y, sidebarWidth - 10 * scale, 25 * scale };
|
||||
bool isFullscreen = IsWindowFullscreen();
|
||||
if (CheckCollisionPointRec(GetMousePosition(), fsButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
|
||||
ToggleFullscreen();
|
||||
}
|
||||
DrawRectangleRec(fsButton, isFullscreen ? (Color){ 40, 80, 120, 255 } : (Color){ 50, 50, 60, 255 });
|
||||
DrawRectangleLinesEx(fsButton, 1, GRAY);
|
||||
DrawText(isFullscreen ? "Exit Fullscreen (F11)" : "Fullscreen (F11)", (int)(fsButton.x + 10 * scale), (int)(fsButton.y + 6 * scale), fontSize, WHITE);
|
||||
y += 35;
|
||||
DrawTextScaled(isFullscreen ? "Exit Fullscreen (F11)" : "Fullscreen (F11)", fsButton.x + 10 * scale, fsButton.y + 6 * scale, 14, WHITE);
|
||||
y += 38 * scale;
|
||||
|
||||
// Reset/Clear buttons
|
||||
Rectangle resetButton = { x, y, (sidebarWidth - 15) / 2, 25 };
|
||||
Rectangle resetButton = { x, y, (sidebarWidth - 15 * scale) / 2, 25 * scale };
|
||||
if (CheckCollisionPointRec(GetMousePosition(), resetButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
|
||||
app.timeSelectionStart = app.viewStart;
|
||||
app.timeSelectionEnd = app.viewEnd;
|
||||
@@ -1143,9 +1204,9 @@ static void DrawSidebar(void)
|
||||
}
|
||||
DrawRectangleRec(resetButton, (Color){ 80, 50, 50, 255 });
|
||||
DrawRectangleLinesEx(resetButton, 1, RED);
|
||||
DrawText("Reset Sel (R)", resetButton.x + 10, resetButton.y + 6, fontSize, WHITE);
|
||||
DrawTextScaled("Reset Sel (R)", resetButton.x + 10 * scale, resetButton.y + 6 * scale, 14, WHITE);
|
||||
|
||||
Rectangle clearButton = { x + resetButton.width + 5, y, (sidebarWidth - 15) / 2, 25 };
|
||||
Rectangle clearButton = { x + resetButton.width + 5 * scale, y, (sidebarWidth - 15 * scale) / 2, 25 * scale };
|
||||
if (CheckCollisionPointRec(GetMousePosition(), clearButton) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
|
||||
app.timeSelectionStart = 0.0f;
|
||||
app.timeSelectionEnd = 1.0f;
|
||||
@@ -1154,17 +1215,17 @@ static void DrawSidebar(void)
|
||||
}
|
||||
DrawRectangleRec(clearButton, (Color){ 80, 50, 50, 255 });
|
||||
DrawRectangleLinesEx(clearButton, 1, RED);
|
||||
DrawText("Clear (ESC)", clearButton.x + 10, clearButton.y + 6, fontSize, WHITE);
|
||||
y += 35;
|
||||
DrawTextScaled("Clear (ESC)", clearButton.x + 10 * scale, clearButton.y + 6 * scale, 14, WHITE);
|
||||
y += 38 * scale;
|
||||
|
||||
// Signal info
|
||||
DrawText("Signal Info:", x, y, fontSize, LIGHTGRAY); y += 18;
|
||||
DrawTextScaled("Signal Info:", x, y, 14, LIGHTGRAY); y += 20 * scale;
|
||||
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;
|
||||
DrawTextScaled(TextFormat("Sample Rate: %d Hz", app.signal.sampleRate), x, y, 14, GRAY); y += 18 * scale;
|
||||
DrawTextScaled(TextFormat("Duration: %.2f sec", app.signal.duration), x, y, 14, GRAY); y += 18 * scale;
|
||||
DrawTextScaled(TextFormat("Max Freq: %.1f kHz", (float)app.signal.sampleRate / 2000.0f), x, y, 14, GRAY); y += 18 * scale;
|
||||
} else {
|
||||
DrawText("No file loaded", x, y, fontSize, GRAY); y += 16;
|
||||
DrawTextScaled("No file loaded", x, y, 14, GRAY); y += 18 * scale;
|
||||
}
|
||||
|
||||
if (needsRegen && app.stftComputed) {
|
||||
@@ -1213,8 +1274,22 @@ int main(int argc, char* argv[])
|
||||
SetTargetFPS(60);
|
||||
SetTraceLogLevel(LOG_WARNING); // Suppress INFO texture logs
|
||||
InitAudioDevice();
|
||||
SetExitKey(KEY_NULL); // ESC should not close the window
|
||||
|
||||
// Save original working directory so command-line args resolve correctly
|
||||
// before we change working dir to resources/
|
||||
static char originalDir[512] = { 0 };
|
||||
snprintf(originalDir, sizeof(originalDir), "%s", GetWorkingDirectory());
|
||||
TraceLog(LOG_INFO, "Original working directory: %s", originalDir);
|
||||
|
||||
SearchAndSetResourceDir("resources");
|
||||
|
||||
// Load TTF font for crisp text at any scale
|
||||
mainFont = LoadFontEx("fonts/DejaVuSansMono.ttf", 16, 0, 0);
|
||||
if (mainFont.texture.id == 0) {
|
||||
TraceLog(LOG_WARNING, "Failed to load TTF font, using default bitmap font");
|
||||
}
|
||||
|
||||
app.timeSelectionStart = 0.0f; app.timeSelectionEnd = 1.0f;
|
||||
app.freqSelectionStart = 0.0f; app.freqSelectionEnd = 1.0f;
|
||||
app.viewStart = 0.0f; app.viewEnd = 1.0f;
|
||||
@@ -1243,10 +1318,23 @@ int main(int argc, char* argv[])
|
||||
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)) {
|
||||
char resolvedPath[4096] = { 0 };
|
||||
|
||||
// If the path doesn't exist as-is, try prepending original dir
|
||||
if (!FileExists(argv[1]) && originalDir[0]) {
|
||||
snprintf(resolvedPath, sizeof(resolvedPath), "%s/%s", originalDir, argv[1]);
|
||||
TraceLog(LOG_INFO, "Trying prepended path: %s", resolvedPath);
|
||||
}
|
||||
const char* pathToLoad = FileExists(argv[1]) ? argv[1] : resolvedPath;
|
||||
|
||||
if (FileExists(pathToLoad) && LoadWavFile(pathToLoad, &app.signal)) {
|
||||
fileLoaded = true;
|
||||
app.loaded = true;
|
||||
app.stftComputed = false;
|
||||
app.loadingPhase = 0;
|
||||
app.loadingProgress = 0.0f;
|
||||
app.currentSTFTSegment = 0;
|
||||
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
|
||||
TraceLog(LOG_INFO, "File loaded successfully");
|
||||
}
|
||||
}
|
||||
@@ -1265,7 +1353,11 @@ int main(int argc, char* argv[])
|
||||
if (LoadWavFile(dropped.paths[0], &app.signal)) {
|
||||
app.loaded = true;
|
||||
app.stftComputed = false;
|
||||
app.loadingPhase = 0;
|
||||
app.loadingProgress = 0.0f;
|
||||
app.currentSTFTSegment = 0;
|
||||
app.viewStart = 0.0f; app.viewEnd = 1.0f;
|
||||
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
|
||||
// Invalidate visible texture cache
|
||||
if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture);
|
||||
app.visibleTexture = (Texture2D){ 0 };
|
||||
@@ -1575,14 +1667,86 @@ int main(int argc, char* argv[])
|
||||
}
|
||||
}
|
||||
|
||||
// Processing
|
||||
// Processing (incremental across frames)
|
||||
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);
|
||||
if (app.loadingPhase == 0) {
|
||||
// Initialize STFT once
|
||||
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
|
||||
app.currentSTFTSegment = 0;
|
||||
app.loadingPhase = 1;
|
||||
}
|
||||
if (app.loadingPhase == 1) {
|
||||
// Compute STFT in chunks (process all segments this frame)
|
||||
int chunksPerFrame = 200;
|
||||
int startSeg = app.currentSTFTSegment;
|
||||
int endSeg = startSeg + chunksPerFrame;
|
||||
if (endSeg > app.stft.numSegments) endSeg = app.stft.numSegments;
|
||||
ComputeSTFTIncremental(&app.signal, &app.stft, app.fftSize, startSeg);
|
||||
app.currentSTFTSegment = endSeg;
|
||||
app.loadingProgress = (float)app.currentSTFTSegment / (float)app.stft.numSegments;
|
||||
if (app.currentSTFTSegment >= app.stft.numSegments) {
|
||||
app.loadingPhase = 2;
|
||||
}
|
||||
}
|
||||
if (app.loadingPhase == 2) {
|
||||
AutoScaleAmplitude(&app.stft);
|
||||
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
|
||||
app.loadingProgress = 1.0f;
|
||||
app.loadingPhase = 3;
|
||||
}
|
||||
if (app.loadingPhase == 3) {
|
||||
app.stftComputed = true;
|
||||
app.loadingPhase = -1;
|
||||
app.loadingProgress = 0.0f;
|
||||
TraceLog(LOG_INFO, "STFT computed (%d segments)", app.stft.numSegments);
|
||||
}
|
||||
}
|
||||
|
||||
// Loading overlay (drawn during STFT computation)
|
||||
if (app.loaded && !app.stftComputed && app.loadingPhase >= 1) {
|
||||
float scale = GetUIScale();
|
||||
int w = GetScreenWidth();
|
||||
int h = GetScreenHeight();
|
||||
int boxW = (int)(380 * scale);
|
||||
int boxH = (int)(160 * scale);
|
||||
int boxX = (w - boxW) / 2;
|
||||
int boxY = (h - boxH) / 2;
|
||||
|
||||
// Dim overlay
|
||||
DrawRectangle(0, 0, w, h, (Color){ 0, 0, 0, 100 });
|
||||
// Info box
|
||||
DrawRectangleRec((Rectangle){ (float)boxX, (float)boxY, (float)boxW, (float)boxH }, (Color){ 40, 40, 40, 230 });
|
||||
DrawRectangleLines(boxX, boxY, boxW, boxH, GRAY);
|
||||
|
||||
int textY = boxY + (int)(30 * scale);
|
||||
int barY = textY + (int)(28 * scale);
|
||||
int barW = boxW - (int)(60 * scale);
|
||||
int barX = boxX + (int)(30 * scale);
|
||||
|
||||
// Title
|
||||
DrawTextScaled("Processing...", boxX + boxW / 2 - MeasureTextScaled("Processing...", 18) / 2, textY, 18, LIGHTGRAY);
|
||||
|
||||
// Progress bar background
|
||||
DrawRectangle(barX, barY, barW, (int)(10 * scale), DARKGRAY);
|
||||
// Progress bar fill
|
||||
int fillW = (int)(app.loadingProgress * barW);
|
||||
if (fillW > 0) DrawRectangle(barX, barY, fillW, (int)(10 * scale), BLUE);
|
||||
|
||||
// Percentage text
|
||||
char pctText[16];
|
||||
snprintf(pctText, sizeof(pctText), "%d%%", (int)(app.loadingProgress * 100));
|
||||
int pctW = MeasureTextScaled(pctText, 14);
|
||||
DrawTextScaled(pctText, barX + barW / 2 - pctW / 2, barY + (int)(14 * scale), 14, WHITE);
|
||||
|
||||
// Duration estimate
|
||||
int estY = barY + (int)(28 * scale);
|
||||
float estSec = app.signal.duration / app.signal.sampleRate * app.stft.numSegments / 200.0f;
|
||||
if (estSec > 0.5f && !isnan(estSec)) {
|
||||
char estText[64];
|
||||
snprintf(estText, sizeof(estText), "Estimated time: %.1f sec", estSec);
|
||||
int estW = MeasureTextScaled(estText, 12);
|
||||
DrawTextScaled(estText, boxX + boxW / 2 - estW / 2, estY, 12, GRAY);
|
||||
}
|
||||
}
|
||||
|
||||
// Rendering
|
||||
@@ -1728,15 +1892,14 @@ int main(int argc, char* argv[])
|
||||
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),
|
||||
DrawTextScaled(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);
|
||||
float centerX = 350 + (GetScreenWidth() - 380 - 350) / 2;
|
||||
DrawTextScaled(msg1, centerX, GetScreenHeight() / 2 - 25, 24, LIGHTGRAY);
|
||||
DrawTextScaled(msg2, centerX, GetScreenHeight() / 2 + 10, 18, GRAY);
|
||||
}
|
||||
|
||||
// Draw file browser on top (if active)
|
||||
@@ -1746,6 +1909,7 @@ int main(int argc, char* argv[])
|
||||
}
|
||||
|
||||
TraceLog(LOG_INFO, "Shutting down...");
|
||||
if (mainFont.texture.id != 0) UnloadFont(mainFont);
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user