Files
rspektrum/src/ui.c
T
tyler e8ed19d338 feat: spectrum-slice (PSD) panel + two-point marker/ruler tool
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>
2026-05-25 10:53:57 -07:00

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(&region, 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.
}