e8ed19d338
Spectrum slice (S): floating panel plotting the time-averaged power spectrum of the current selection (or the visible view when there's no selection). Frequency on X over the region's band, auto-ranged dB on Y, with a peak marker. Backed by ComputePowerSpectrum() in stft.c (mean linear power per bin over the time span). The selection stat panel now biases to the left when this panel is up so the two don't overlap. Marker/ruler tool (M): press-drag-release drops point A and B; the overlay shows crosshairs, a connecting line, and a readout of the ham-useful deltas — Δt, Δf, tone spacing (1/Δt), and drift (Δf/Δt). Marker mode swaps the LMB-drag gesture from box-select to marker drop (Alt/middle still pan); RMB/Esc clear the measurement. Markers reset on new-file load alongside the selection. Both are gated toggles (off by default), wired into the keymap (so they self-document in the About dialog) and the sidebar. Verified headlessly: idle viewport pixel-identical to baseline (AE=0, deterministic); both panels render correctly with sensible numbers. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
651 lines
28 KiB
C
651 lines
28 KiB
C
// ui.c - file browser, sidebar controls, sliders, and PNG export
|
|
#include "ui.h"
|
|
#include "render.h"
|
|
#include "stft.h"
|
|
#include "audio.h"
|
|
#include "platform.h"
|
|
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <stdio.h>
|
|
#include <math.h>
|
|
|
|
// Internal sidebar widgets (defined after DrawSidebar, which uses them)
|
|
static void DrawSlider(Rectangle bounds, float value);
|
|
static bool UpdateSlider(Rectangle bounds, float* value);
|
|
|
|
// True if the rect was left-clicked this frame (hovered + button pressed).
|
|
static bool Clicked(Rectangle r)
|
|
{
|
|
return CheckCollisionPointRec(GetMousePosition(), r) && IsMouseButtonPressed(MOUSE_LEFT_BUTTON);
|
|
}
|
|
|
|
// The ubiquitous "filled rectangle + 1px border" panel/button chrome.
|
|
static void DrawPanelBox(Rectangle r, Color fill, Color border)
|
|
{
|
|
DrawRectangleRec(r, fill);
|
|
DrawRectangleLinesEx(r, 1, border);
|
|
}
|
|
|
|
// ===== File browser =====
|
|
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;
|
|
}
|
|
|
|
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])) {
|
|
size_t len = strlen(name) + 1;
|
|
app.browserFiles[app.browserFileCount] = (char*)malloc(len);
|
|
if (app.browserFiles[app.browserFileCount]) {
|
|
memcpy(app.browserFiles[app.browserFileCount], name, len);
|
|
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)) {
|
|
size_t len = strlen(name) + 1;
|
|
app.browserFiles[app.browserFileCount] = (char*)malloc(len);
|
|
if (app.browserFiles[app.browserFileCount]) {
|
|
memcpy(app.browserFiles[app.browserFileCount], name, len);
|
|
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)) {
|
|
ResetForNewSignal();
|
|
app.showFileBrowser = false;
|
|
TraceLog(LOG_INFO, "Loaded: %s", filePath);
|
|
}
|
|
}
|
|
|
|
void DrawFileBrowser(void)
|
|
{
|
|
// Draw semi-transparent overlay first
|
|
DrawRectangle(0, 0, GetScreenWidth(), GetScreenHeight(), Fade(BLACK, 0.85f));
|
|
|
|
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 }, (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
|
|
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);
|
|
DrawTextScaled(displayPath, bx + (int)(22 * scale), pathBarY + (int)(5 * scale), (int)(14 * scale), LIGHTGRAY);
|
|
|
|
// Up button
|
|
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 });
|
|
DrawTextScaled("UP (..)", upBtn.x + (int)(10 * scale), upBtn.y + (int)(7 * scale), (int)(14 * scale), WHITE);
|
|
|
|
// File list
|
|
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 }, (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 / lineH);
|
|
if (visibleItems < 1) visibleItems = 1;
|
|
|
|
if (app.browserFileCount <= 0 || !app.browserFiles) {
|
|
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 < (int)(10 * scale)) sh = (int)(10 * scale);
|
|
float sy = ly + (float)app.browserScroll / (app.browserFileCount - visibleItems) * (lh - sh);
|
|
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) * 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 + (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 };
|
|
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 - (int)(10 * scale), 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 - (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;
|
|
if (GetTime() - lastClick < 0.3) LoadSelectedFile();
|
|
lastClick = GetTime();
|
|
}
|
|
}
|
|
|
|
// Buttons
|
|
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, (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 });
|
|
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;
|
|
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;
|
|
}
|
|
}
|
|
|
|
|
|
// ===== PNG export =====
|
|
void ExportPNG(const SpectrogramApp* spa, const char* dirPath)
|
|
{
|
|
if (!spa->stftComputed || !spa->spectrogramImage.data) return;
|
|
|
|
int imgW = spa->spectrogramImage.width;
|
|
int imgH = spa->spectrogramImage.height;
|
|
|
|
// Selection region in image-pixel coordinates
|
|
int selX0 = (int)(spa->sel.timeStart * imgW);
|
|
int selX1 = (int)(spa->sel.timeEnd * imgW);
|
|
int selY0 = (int)((1.0f - spa->sel.freqEnd) * imgH);
|
|
int selY1 = (int)((1.0f - spa->sel.freqStart) * imgH);
|
|
|
|
// Clamp to image bounds
|
|
selX0 = Clamp(selX0, 0, imgW);
|
|
selX1 = Clamp(selX1, 0, imgW);
|
|
selY0 = Clamp(selY0, 0, imgH);
|
|
selY1 = Clamp(selY1, 0, imgH);
|
|
|
|
int regionW = selX1 - selX0;
|
|
int regionH = selY1 - selY0;
|
|
if (regionW <= 0 || regionH <= 0) return;
|
|
|
|
// Extract selected region by copying pixel rows
|
|
Image region = { 0 };
|
|
region.width = regionW;
|
|
region.height = regionH;
|
|
region.mipmaps = 1;
|
|
region.format = spa->spectrogramImage.format;
|
|
region.data = RL_MALLOC(regionW * regionH * 4);
|
|
if (!region.data) return;
|
|
|
|
// Raylib stores images as tightly packed RGBA rows - memcpy each row at a time
|
|
Color* src = (Color*)spa->spectrogramImage.data;
|
|
Color* dst = (Color*)region.data;
|
|
for (int y = 0; y < regionH; y++) {
|
|
memcpy(dst + y * regionW,
|
|
src + (selY0 + y) * imgW + selX0,
|
|
regionW * 4);
|
|
}
|
|
|
|
// Scale if a non-zero exportScale is set (and region isn't too large)
|
|
if (spa->exportScale > 0.0f && regionW < 4096) {
|
|
int outW = regionW * (int)spa->exportScale;
|
|
int outH = regionH * (int)spa->exportScale;
|
|
if (outW > 0 && outH > 0) {
|
|
ImageResize(®ion, outW, outH);
|
|
}
|
|
}
|
|
|
|
// Export as PNG
|
|
char path[4096];
|
|
if (spa->exportScale <= 0.0f && regionW == imgW) {
|
|
// Full width export without scaling
|
|
snprintf(path, sizeof(path), "%s/spectrogram_full.png", dirPath);
|
|
} else {
|
|
snprintf(path, sizeof(path), "%s/spectrogram_export.png", dirPath);
|
|
}
|
|
|
|
if (ExportImage(region, path)) {
|
|
snprintf((char*)spa->exportMessage, sizeof(spa->exportMessage),
|
|
"Exported: %dx%d %.40s", region.width, region.height, path);
|
|
} else {
|
|
snprintf((char*)spa->exportMessage, sizeof(spa->exportMessage), "Export failed");
|
|
}
|
|
((SpectrogramApp*)spa)->exportMessageTimer = 3.0f;
|
|
|
|
RL_FREE(region.data);
|
|
}
|
|
|
|
// ===== Sidebar =====
|
|
void DrawSidebar(void)
|
|
{
|
|
float scale = GetUIScale();
|
|
float sidebarWidth = 300 * scale;
|
|
float x = 10 * scale;
|
|
float y = 10 * scale;
|
|
int fontSize = (int)(12 * scale);
|
|
bool needsRegen = false;
|
|
|
|
// Dark sidebar background
|
|
DrawRectangle(0, 0, (int)(sidebarWidth + 20 * scale), GetScreenHeight(), (Color){ 35, 35, 40, 255 });
|
|
DrawLine((int)(sidebarWidth + 10 * scale), 0, (int)(sidebarWidth + 10 * scale), GetScreenHeight(), GRAY);
|
|
|
|
// Title
|
|
DrawTextScaled("Spectrogram Controls", x, y, 16, WHITE); y += 28 * scale;
|
|
|
|
// FFT Size clicker
|
|
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 (Clicked(fftMinus)) {
|
|
int newFFT = app.fftSize / 2;
|
|
if (newFFT >= FFT_SIZE_MIN) {
|
|
ChangeFFTSize(newFFT);
|
|
}
|
|
}
|
|
if (Clicked(fftPlus)) {
|
|
int newFFT = app.fftSize * 2;
|
|
if (newFFT <= FFT_SIZE_MAX) {
|
|
ChangeFFTSize(newFFT);
|
|
}
|
|
}
|
|
DrawPanelBox(fftMinus, (Color){ 50, 50, 60, 255 }, GRAY);
|
|
DrawTextScaled("-", fftMinus.x + 12 * scale, fftMinus.y + 5 * scale, 18, WHITE);
|
|
DrawPanelBox(fftPlus, (Color){ 50, 50, 60, 255 }, GRAY);
|
|
DrawTextScaled("+", fftPlus.x + 10 * scale, fftPlus.y + 5 * scale, 18, WHITE);
|
|
y += 32 * scale;
|
|
|
|
// Amplitude scale mode toggle (Relative dynamic range vs Absolute dBFS)
|
|
Rectangle scaleBtn = { x, y, sidebarWidth - 10 * scale, 22 * scale };
|
|
if (Clicked(scaleBtn)) {
|
|
app.amplitudeMode = (app.amplitudeMode == SCALE_RELATIVE) ? SCALE_ABSOLUTE : SCALE_RELATIVE;
|
|
AutoScaleAmplitude(&app.stft); // re-derive floor/ceiling for the new mode
|
|
needsRegen = true;
|
|
}
|
|
DrawPanelBox(scaleBtn, (Color){ 50, 50, 60, 255 }, GRAY);
|
|
DrawTextScaled(app.amplitudeMode == SCALE_ABSOLUTE ? "Scale: Absolute (dBFS)" : "Scale: Relative",
|
|
scaleBtn.x + 8 * scale, scaleBtn.y + 4 * scale, 13, WHITE);
|
|
y += 30 * scale;
|
|
|
|
// Floor control — meaning depends on the scale mode.
|
|
Rectangle dbSlider = { x, y + 20 * scale, sidebarWidth - 10 * scale, 20 * scale };
|
|
if (app.amplitudeMode == SCALE_ABSOLUTE) {
|
|
DrawTextScaled(TextFormat("Floor: %.1f dBFS", app.absoluteFloorDb), x, y, 14, LIGHTGRAY);
|
|
float dbValue = (app.absoluteFloorDb + 100.0f) / 100.0f; // -100..0 dBFS -> 0..1
|
|
DrawSlider(dbSlider, dbValue);
|
|
if (UpdateSlider(dbSlider, &dbValue)) {
|
|
app.absoluteFloorDb = -100.0f + dbValue * 100.0f;
|
|
app.amplitudeCeilingDb = 0.0f;
|
|
app.amplitudeFloorDb = app.absoluteFloorDb;
|
|
needsRegen = true;
|
|
}
|
|
} else {
|
|
DrawTextScaled(TextFormat("Dyn Range: %.0f dB", app.dynRangeDb), x, y, 14, LIGHTGRAY);
|
|
float dbValue = (100.0f - app.dynRangeDb) / 90.0f; // 100..10 dB -> 0..1 (right = more contrast)
|
|
DrawSlider(dbSlider, dbValue);
|
|
if (UpdateSlider(dbSlider, &dbValue)) {
|
|
app.dynRangeDb = 100.0f - dbValue * 90.0f;
|
|
app.amplitudeFloorDb = app.amplitudeCeilingDb - app.dynRangeDb;
|
|
needsRegen = true;
|
|
}
|
|
}
|
|
y += 48 * scale;
|
|
|
|
// Colormap dropdown
|
|
DrawTextScaled("Colormap:", x, y, 14, LIGHTGRAY); y += 20 * scale;
|
|
Rectangle cmapButton = { x, y, sidebarWidth - 10 * scale, 25 * scale };
|
|
if (Clicked(cmapButton)) {
|
|
app.colormap = (ColormapType)((app.colormap + 1) % COLORMAP_COUNT);
|
|
GenerateColormapTexture();
|
|
needsRegen = true;
|
|
}
|
|
DrawPanelBox(cmapButton, (Color){ 50, 50, 60, 255 }, GRAY);
|
|
DrawTextScaled(ColormapName(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 * scale, cmapButton.y + 5 * scale, 50 * scale, 15 * scale },
|
|
(Vector2){ 0, 0 }, 0.0f, WHITE);
|
|
y += 32 * scale;
|
|
|
|
// Grid toggle
|
|
Rectangle gridCheck = { x, y, 18 * scale, 18 * scale };
|
|
if (Clicked(gridCheck)) {
|
|
app.showGrid = !app.showGrid;
|
|
}
|
|
DrawPanelBox(gridCheck, app.showGrid ? BLUE : DARKGRAY, WHITE);
|
|
DrawTextScaled("Show Grid", x + 25 * scale, y + 2 * scale, 14, LIGHTGRAY); y += 28 * scale;
|
|
|
|
// Analysis toggles: marker/ruler tool and spectrum-slice (PSD) panel.
|
|
Rectangle markerBtn = { x, y, sidebarWidth - 10 * scale, 24 * scale };
|
|
if (Clicked(markerBtn)) app.markerMode = !app.markerMode;
|
|
DrawPanelBox(markerBtn, app.markerMode ? (Color){ 110, 70, 30, 255 } : (Color){ 50, 50, 60, 255 },
|
|
app.markerMode ? ORANGE : GRAY);
|
|
DrawTextScaled(app.markerMode ? "Marker Tool: ON (M)" : "Marker Tool (M)",
|
|
markerBtn.x + 10 * scale, markerBtn.y + 5 * scale, 13, WHITE);
|
|
y += 28 * scale;
|
|
|
|
Rectangle specBtn = { x, y, sidebarWidth - 10 * scale, 24 * scale };
|
|
if (Clicked(specBtn)) app.showSpectrum = !app.showSpectrum;
|
|
DrawPanelBox(specBtn, app.showSpectrum ? (Color){ 30, 70, 90, 255 } : (Color){ 50, 50, 60, 255 },
|
|
app.showSpectrum ? SKYBLUE : GRAY);
|
|
DrawTextScaled(app.showSpectrum ? "Spectrum: ON (S)" : "Spectrum Slice (S)",
|
|
specBtn.x + 10 * scale, specBtn.y + 5 * scale, 13, WHITE);
|
|
y += 30 * scale;
|
|
|
|
// File loading
|
|
DrawTextScaled("File:", x, y, 14, LIGHTGRAY); y += 20 * scale;
|
|
Rectangle fileButton = { x, y, sidebarWidth - 10 * scale, 25 * scale };
|
|
if (Clicked(fileButton)) {
|
|
app.showFileBrowser = true;
|
|
ScanDirectory(GetWorkingDirectory());
|
|
}
|
|
DrawPanelBox(fileButton, (Color){ 50, 50, 60, 255 }, GRAY);
|
|
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 * scale, 35 * scale };
|
|
if (Clicked(playButton)) {
|
|
if (app.isPlaying && AudioPlaybackSound.frameCount > 0) {
|
|
StopSound(AudioPlaybackSound);
|
|
app.isPlaying = false;
|
|
app.playheadElapsed = 0;
|
|
app.playheadT = 0;
|
|
} else {
|
|
PlaySelectedRegion();
|
|
app.isPlaying = true;
|
|
}
|
|
}
|
|
const char* playText = app.isPlaying ? "STOP (SPACE)" : "PLAY (SPACE)";
|
|
DrawPanelBox(playButton, app.isPlaying ? (Color){ 120, 40, 40, 255 } : (Color){ 40, 100, 40, 255 },
|
|
app.isPlaying ? RED : GREEN);
|
|
DrawTextScaled(playText, playButton.x + 10 * scale, playButton.y + 12 * scale, 14, WHITE);
|
|
y += 48 * scale;
|
|
|
|
// Fullscreen toggle
|
|
Rectangle fsButton = { x, y, sidebarWidth - 10 * scale, 25 * scale };
|
|
bool isFullscreen = IsWindowFullscreen();
|
|
if (Clicked(fsButton)) {
|
|
ToggleFullscreen();
|
|
}
|
|
DrawPanelBox(fsButton, isFullscreen ? (Color){ 40, 80, 120, 255 } : (Color){ 50, 50, 60, 255 }, GRAY);
|
|
DrawTextScaled(isFullscreen ? "Exit Fullscreen (F11)" : "Fullscreen (F11)", fsButton.x + 10 * scale, fsButton.y + 6 * scale, 14, WHITE);
|
|
y += 38 * scale;
|
|
|
|
// Clear selection (identical to the Esc key)
|
|
Rectangle clearButton = { x, y, sidebarWidth - 10 * scale, 25 * scale };
|
|
if (Clicked(clearButton)) {
|
|
ClearSelection();
|
|
}
|
|
DrawPanelBox(clearButton, (Color){ 80, 50, 50, 255 }, RED);
|
|
DrawTextScaled("Clear Selection (ESC)", clearButton.x + 10 * scale, clearButton.y + 6 * scale, 14, WHITE);
|
|
y += 38 * scale;
|
|
|
|
// Export PNG
|
|
{
|
|
Rectangle exportButton = { x, y, sidebarWidth - 10 * scale, 30 * scale };
|
|
if (Clicked(exportButton)) {
|
|
ExportPNG(&app, app.exportDir);
|
|
}
|
|
DrawPanelBox(exportButton, (Color){ 40, 60, 80, 255 }, CYAN);
|
|
DrawTextScaled("Export PNG (E)", exportButton.x + 10 * scale, exportButton.y + 7 * scale, 14, WHITE);
|
|
y += 35 * scale;
|
|
|
|
// Export selection audio (band-limited, time-cropped) as a WAV
|
|
Rectangle wavButton = { x, y, sidebarWidth - 10 * scale, 30 * scale };
|
|
if (Clicked(wavButton)) {
|
|
ExportSelectionWAV(app.exportDir);
|
|
}
|
|
DrawPanelBox(wavButton, (Color){ 40, 60, 80, 255 }, CYAN);
|
|
DrawTextScaled("Export WAV (W)", wavButton.x + 10 * scale, wavButton.y + 7 * scale, 14, WHITE);
|
|
y += 35 * scale;
|
|
|
|
// Export scale slider
|
|
DrawTextScaled(TextFormat("Export Scale: %.1fx", app.exportScale), x, y, 12, LIGHTGRAY); y += 18 * scale;
|
|
Rectangle scaleSlider = { x, y, sidebarWidth - 10 * scale, 14 * scale };
|
|
DrawSlider(scaleSlider, app.exportScale / 10.0f);
|
|
float scaleVal = 0.0f;
|
|
if (UpdateSlider(scaleSlider, &scaleVal)) {
|
|
app.exportScale = scaleVal * 10.0f;
|
|
}
|
|
y += 24 * scale;
|
|
}
|
|
|
|
// Signal info
|
|
DrawTextScaled("Signal Info:", x, y, 14, LIGHTGRAY); y += 20 * scale;
|
|
if (app.loaded) {
|
|
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 {
|
|
DrawTextScaled("No file loaded", x, y, 14, GRAY); y += 18 * scale;
|
|
}
|
|
y += 10 * scale;
|
|
|
|
// About / Help button
|
|
Rectangle aboutBtn = { x, y, sidebarWidth - 10 * scale, 24 * scale };
|
|
if (Clicked(aboutBtn)) {
|
|
app.showAbout = true;
|
|
}
|
|
DrawPanelBox(aboutBtn, (Color){ 50, 50, 60, 255 }, GRAY);
|
|
DrawTextScaled("About / Help (F1)", aboutBtn.x + 8 * scale, aboutBtn.y + 5 * scale, 13, WHITE);
|
|
y += 30 * scale;
|
|
|
|
if (needsRegen && app.stftComputed) {
|
|
// dB floor / colormap only — re-map the cached reassignment, don't recompute it.
|
|
ColorizeSpectrogram(&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;
|
|
}
|
|
|
|
// ===== About / Help dialog =====
|
|
|
|
void DrawAboutDialog(void)
|
|
{
|
|
if (!app.showAbout) return;
|
|
|
|
float scale = GetUIScale();
|
|
int sw = GetScreenWidth();
|
|
int sh = GetScreenHeight();
|
|
|
|
// Dim the background; a click outside the panel (or Esc/F1) closes it.
|
|
DrawRectangle(0, 0, sw, sh, Fade(BLACK, 0.7f));
|
|
|
|
float pw = 560 * scale;
|
|
float ph = 540 * scale;
|
|
Rectangle panel = { (sw - pw) / 2, (sh - ph) / 2, pw, ph };
|
|
DrawRectangleRec(panel, (Color){ 30, 30, 38, 255 });
|
|
DrawRectangleLinesEx(panel, 2, (Color){ 90, 90, 110, 255 });
|
|
|
|
float px = panel.x + 24 * scale;
|
|
float py = panel.y + 20 * scale;
|
|
|
|
DrawTextScaled("rspektrum - synchrosqueezed spectrogram viewer", px, py, 18, WHITE);
|
|
py += 34 * scale;
|
|
|
|
// Body lines. NULL = blank spacer; headings start with no leading spaces.
|
|
const char* lines[] = {
|
|
"Amplitude scale",
|
|
" Relative: ceiling tracks the signal peak; the slider sets how many",
|
|
" dB of range are shown below it (good for seeing structure).",
|
|
" Absolute: fixed dBFS scale, 0 dBFS = digital full scale (sample = 1.0).",
|
|
" Brightness then reflects a real level, comparable across files.",
|
|
"",
|
|
"Why dBFS and not dBm",
|
|
" A WAV carries no power reference (impedance / receiver calibration),",
|
|
" so absolute levels are shown in dBFS, not dBm. 0 dBFS = full scale.",
|
|
"",
|
|
"Accuracy caveats",
|
|
" - The Hann window adds a fixed ~6 dB coherent-gain offset to",
|
|
" spectral magnitudes.",
|
|
" - Pixels show synchrosqueezed (reassigned) energy, not raw FFT bins.",
|
|
" Good to ~a dB for visualization; not lab-grade metrology.",
|
|
};
|
|
int n = sizeof(lines) / sizeof(lines[0]);
|
|
for (int i = 0; i < n; i++) {
|
|
const char* s = lines[i];
|
|
bool heading = (s[0] != '\0' && s[0] != ' ');
|
|
DrawTextScaled(s, px, py, heading ? 14 : 13, heading ? (Color){ 150, 200, 255, 255 } : LIGHTGRAY);
|
|
py += (s[0] == '\0' ? 8 : 18) * scale;
|
|
}
|
|
|
|
// Keys — rendered straight from the keymap table so this list can never
|
|
// drift from the actual bindings.
|
|
py += 10 * scale;
|
|
DrawTextScaled("Keys", px, py, 14, (Color){ 150, 200, 255, 255 });
|
|
py += 20 * scale;
|
|
int kn;
|
|
const KeyBinding* km = GetKeymap(&kn);
|
|
for (int i = 0; i < kn; i++) {
|
|
DrawTextScaled(TextFormat(" %-5s %s", km[i].label, km[i].help), px, py, 13, LIGHTGRAY);
|
|
py += 16 * scale;
|
|
}
|
|
DrawTextScaled(" Mouse wheel = zoom, Alt+drag = pan, drag = select box",
|
|
px, py, 13, LIGHTGRAY);
|
|
|
|
DrawTextScaled("F1 / Esc / click to close", panel.x + pw - 196 * scale,
|
|
panel.y + ph - 26 * scale, 12, GRAY);
|
|
// Note: opening/closing is handled in the main input loop (not here) so the
|
|
// same click/keypress that opens the dialog can't immediately close it.
|
|
}
|