Files
rspektrum/src/spectrogram.c
T
tyler 2befb42d71 perf: throttle idle FPS and pause rendering when unfocused
The main loop redrew the whole scene at 60 fps unconditionally, keeping
the GPU (and, on software GL, the CPU) busy even while idle — enough to
heat the machine. Add two cooling measures (desktop only):

- Idle FPS throttle: drop to 10 fps when nothing needs animating (no
  input, mouse movement, playback, loading/background STFT, drag/pan, or
  counting-down notice), with a 0.5s grace window; snap back to 60 fps on
  activity. Measured ~34% -> ~8% idle CPU on real hardware.
- Focus pause: when the window isn't focused, skip the frame entirely —
  pump events via PollInputEvents() + WaitTime() with zero drawing, so
  refocus/close still register. Guarded with !headless so the hidden
  render window still draws its single capture frame.

Also fix two -Wformat-truncation warnings (surfaced at -O2) in the
tx_frame label path: widen pos[16]->[28] for the worst-case "  %d/%d",
and the caller's label buffer lbl[64]->[160] since base can be note[96]
(DrawBoxLabel scissor-clips to the box, so behavior is unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 12:37:38 -07:00

1700 lines
81 KiB
C

// spectrogram.c - Spectrogram viewer: app entry point and main frame loop.
// Subsystems live in fft/stft/audio/render/ui; shared state in spectrogram_types.h.
#include "raylib.h"
#include "resource_dir.h"
#include "spectrogram_types.h"
#include "fft.h"
#include "stft.h"
#include "audio.h"
#include "render.h"
#include "ui.h"
#include "platform.h"
#include "utils.h"
#include "primitives.h"
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include <complex.h>
#include <stdbool.h>
#include <stdio.h>
#ifdef __EMSCRIPTEN__
#include <emscripten/emscripten.h>
// Keep raylib's framebuffer/screen size matched to the browser viewport, so the
// (immediate-mode) UI fills the page and reflows on window resize the same way
// the desktop OS window does. Going through SetWindowSize keeps the screen size,
// GL viewport, and projection consistent; the != guard avoids per-frame churn.
static void SyncCanvasToWindow(void)
{
int w = EM_ASM_INT({ return window.innerWidth; });
int h = EM_ASM_INT({ return window.innerHeight; });
if (w > 0 && h > 0 && (w != GetScreenWidth() || h != GetScreenHeight())) {
SetWindowSize(w, h);
}
}
#endif
// ============================================================================
// Global State (declared extern in spectrogram_types.h)
// ============================================================================
SpectrogramApp app = {0};
Sound AudioPlaybackSound = {0};
Texture2D colormapTexture = {0};
Font mainFont = {0}; // TTF font for crisp text at any scale
// ============================================================================
// Interaction Detection
// ============================================================================
/**
* Returns true if the user has pressed any mouse/keyboard input this frame.
* Used to gate background processing — we only compute when the user is idle.
*/
static bool IsUserInteracting(void)
{
if (IsMouseButtonDown(MOUSE_BUTTON_LEFT) ||
IsMouseButtonDown(MOUSE_BUTTON_RIGHT) ||
IsMouseButtonDown(MOUSE_BUTTON_MIDDLE)) {
return true;
}
// Check for mouse wheel
if (GetMouseWheelMove() != 0) return true;
// Check for key press (key codes are 0..512 in raylib)
for (int key = 0; key < 512; key++) {
if (IsKeyPressed(key)) return true;
}
return false;
}
// Idle FPS throttle. raylib re-renders the whole scene every frame regardless
// of whether anything changed, so an idle window still pins the GPU (and, on a
// software GL stack, the CPU) at 60 fps. We drop to IDLE_FPS whenever nothing
// needs animating, and snap back to ACTIVE_FPS the moment activity resumes.
#define ACTIVE_FPS 60
#define IDLE_FPS 10
#define IDLE_GRACE_SECONDS 0.5 // stay at full rate briefly after the last activity
/**
* Returns true if the frame must keep redrawing at full rate: live input, a
* moving mouse (hover readouts/tooltips), playback, in-progress loading or
* background STFT, an active drag/pan/divider, or a counting-down notice.
* Everything else is a static frame we can throttle.
*/
static bool IsAppActive(void)
{
if (IsUserInteracting()) return true;
Vector2 d = GetMouseDelta();
if (d.x != 0.0f || d.y != 0.0f) return true; // hover / cursor readout
if (IsWindowResized()) return true;
if (app.isPlaying) return true; // playhead is moving
if (app.loaded && !app.stftComputed) return true; // STFT still loading
if (app.isBgProcessing && !app.bgFinished) return true;// background high-res fill
if (app.view.isPanning || app.isDividing) return true;
if (app.sel.isDragging || app.sel.isTimeSelecting || app.sel.isFreqSelecting) return true;
if (app.marker.dragging) return true;
if (app.exportMessageTimer > 0.0f) return true; // notification countdown
return false;
}
// Fraction of the view area used by the spectrogram. When the scope is hidden
// the spectrogram fills the whole area (divider at the bottom); otherwise the
// scope takes the remainder below dividerY.
#define SCOPE_COLLAPSE_DIVIDER 0.88f // drag the handle past this to hide the scope
static float ScopeDivider(void)
{
return app.showScope ? app.dividerY : 1.0f;
}
// Screen layout metrics, derived from window size + UI scale. Single source of
// truth: the input, selection, and render passes all unpack from this so the
// layout formulas live in exactly one place.
typedef struct {
float scale;
float sidebarWidth;
float labelHeight;
float scrollbarHeight;
float freqLabelWidth;
float vScrollbarWidth;
float topMargin;
float bottomMargin;
float spectroHeight; // height of the spectrogram (respects the scope divider AND the timeline lane)
float timelineHeight; // 0 if no annotations / lane hidden
Rectangle viewBounds; // the spectrogram drawing area
Rectangle timelineBounds;// the annotations timeline lane (zero-sized if not shown)
} Layout;
// Number of MlnlKind rows that should be visible in the expanded timeline:
// kinds present in this file AND not filtered out by the per-kind checkboxes.
static int CountVisibleAnnotationKinds(void)
{
int n = 0;
for (int k = 0; k < MLNL_KIND_MAX; k++)
if (app.annotations.kindPresent[k] && app.annotationKindEnabled[k]) n++;
return n;
}
static Layout ComputeLayout(void)
{
Layout L;
L.scale = GetUIScale();
L.sidebarWidth = 320 * L.scale;
L.labelHeight = 15 * L.scale;
L.scrollbarHeight = 18 * L.scale;
L.freqLabelWidth = 65 * L.scale;
L.vScrollbarWidth = 18 * L.scale;
L.topMargin = 50 * L.scale;
L.bottomMargin = 10 * L.scale;
L.spectroHeight = (GetScreenHeight() - L.topMargin - L.bottomMargin - L.labelHeight - L.scrollbarHeight - 10 * L.scale) * ScopeDivider();
// Timeline lane sits above the spectrogram, eating from spectro height
// (not from the scope area). Collapsed is a thin sparkline; expanded grows
// by one row per enabled kind. Only present when the file carries
// annotations and the master toggle is on.
L.timelineHeight = 0;
if (app.annotations.loaded && app.annotations.eventCount > 0 && app.showAnnotations) {
if (app.timelineExpanded) {
int rows = CountVisibleAnnotationKinds();
if (rows < 1) rows = 1;
L.timelineHeight = (rows * 14.0f + 4.0f) * L.scale;
} else {
L.timelineHeight = 10.0f * L.scale;
}
}
float laneX = L.sidebarWidth + L.freqLabelWidth;
float laneW = GetScreenWidth() - L.sidebarWidth - L.freqLabelWidth - L.vScrollbarWidth - 20 * L.scale;
L.timelineBounds = (Rectangle){ laneX, L.topMargin, laneW, L.timelineHeight };
// Spectrogram starts below the lane (with a 2-pixel gap) and shrinks accordingly.
float gap = (L.timelineHeight > 0) ? 2.0f * L.scale : 0.0f;
L.viewBounds = (Rectangle){
laneX,
L.topMargin + L.timelineHeight + gap,
laneW,
L.spectroHeight - L.timelineHeight - gap
};
L.spectroHeight = L.viewBounds.height;
return L;
}
// Reset all per-signal state after a new signal has been loaded into app.signal.
// Drops the cached STFT/FFT-size cache and the on-screen textures so the main
// loop recomputes from scratch (loadingPhase 0 handles the STFT (re)alloc).
void ResetForNewSignal(void)
{
app.loaded = true;
app.stftComputed = false;
app.loadingPhase = 0;
app.loadingProgress = 0.0f;
app.currentSTFTSegment = 0;
app.skipFactor = 1;
app.highResFinished = false;
app.bgHighResSeg = 0;
app.bgFinished = false;
app.isBgProcessing = false;
// Cached STFT results are tied to the old signal data.
FreeAllCacheEntries(&app.fftCache);
// Zoom out both axes and drop the old selection / any in-progress drags.
// Display preferences (colormap, dB scale, FFT size, grid, scope layout)
// are intentionally preserved across loads.
app.view.start = 0.0f; app.view.end = 1.0f;
app.view.freqStart = 0.0f; app.view.freqEnd = 1.0f;
app.view.isPanning = false;
ClearSelection();
app.sel.isDragging = false;
app.sel.isTimeSelecting = false;
app.sel.isFreqSelecting = false;
app.marker.active = false;
app.marker.dragging = false;
app.isDividing = false;
// Stop any playback from the previous signal and rewind the playhead.
if (app.isPlaying && AudioPlaybackSound.frameCount > 0) StopSound(AudioPlaybackSound);
app.isPlaying = false;
app.playbackFinished = false;
app.playheadElapsed = 0.0f;
app.playheadT = 0.0f;
// Invalidate the cached visible texture.
if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture);
app.visibleTexture = (Texture2D){ 0 };
app.visibleTextureValid = false;
// Drop the previous file's annotations; the caller re-parses from the new
// path after this returns (LoadMlnlFromWav needs the source path that
// raylib's LoadWave already consumed).
FreeMlnl(&app.annotations);
app.hoveredEvent = -1;
app.hoveredTimelineEvent = -1;
app.selectedAnnotation = -1;
app.autocropPending = true; // run once when this file's STFT is ready
}
// ============================================================================
// Auto-crop: shrink the displayed freq axis + time view to where the data
// actually lives. Two independent sources, tried in priority order.
// ============================================================================
// 15% headroom above the highest annotated f_hi keeps event boxes from
// touching the top edge; 5% time padding gives breathing room around the
// outermost events without pushing them into the corners.
#define AUTOCROP_FREQ_HEADROOM 1.15f
#define AUTOCROP_TIME_PAD_FRAC 0.05f
// Confidence thresholds for the energy heuristic. If the cropped freq band
// would still cover >80% of Nyquist, or the cropped time would cover >90%
// of the timeline, the signal genuinely uses most of the available range
// and we leave the view alone (the crop would only be churn).
#define AUTOCROP_FREQ_MAX_FRAC 0.80f
#define AUTOCROP_TIME_MAX_FRAC 0.90f
// Cumulative energy fraction that defines "where signal lives". 0.99 means
// the cropped freq range holds 99% of the spectrogram's total power.
#define AUTOCROP_FREQ_ENERGY 0.99
// Per-segment activity threshold (fraction of the peak segment's energy).
// Anything below this is treated as silence at the timeline edges.
#define AUTOCROP_TIME_ACTIVITY 0.01
// Annotation-driven crop: trusts the producer. Always confident when ≥1
// annotation has f_hi or any have a non-zero time span. Returns the computed
// crop in the out-params; leaves them at "no crop" values on failure.
static bool ComputeAnnotationCrop(float* outFreqMaxHz, float* outViewStart, float* outViewEnd)
{
*outFreqMaxHz = 0.0f;
*outViewStart = 0.0f; *outViewEnd = 1.0f;
if (!app.annotations.loaded || app.annotations.eventCount == 0) return false;
double fMax = 0.0;
double tMin = 1e18, tMax = -1e18;
bool anyFreq = false, anyTime = false;
for (int i = 0; i < app.annotations.eventCount; i++) {
const MlnlEvent* e = &app.annotations.events[i];
if (e->has_freq && e->f_hi_hz > fMax) { fMax = e->f_hi_hz; anyFreq = true; }
if (e->t_end >= e->t_start) {
if (e->t_start < tMin) tMin = e->t_start;
if (e->t_end > tMax) tMax = e->t_end;
anyTime = true;
}
}
if (anyFreq) *outFreqMaxHz = (float)(fMax * AUTOCROP_FREQ_HEADROOM);
if (anyTime && app.signal.duration > 0.0f) {
double pad = (tMax - tMin) * AUTOCROP_TIME_PAD_FRAC;
double s = tMin - pad, e = tMax + pad;
if (s < 0.0) s = 0.0;
if (e > app.signal.duration) e = app.signal.duration;
if (e > s) {
*outViewStart = (float)(s / app.signal.duration);
*outViewEnd = (float)(e / app.signal.duration);
}
}
return anyFreq || anyTime;
}
// Energy heuristic: walk the STFT, build per-bin and per-segment energy.
// Crop freq if 99% of energy fits below 80% of Nyquist; crop time if the
// activity envelope occupies <90% of the timeline. Returns true if at least
// one axis was confidently cropped.
static bool ComputeEnergyCrop(float* outFreqMaxHz, float* outViewStart, float* outViewEnd)
{
*outFreqMaxHz = 0.0f;
*outViewStart = 0.0f; *outViewEnd = 1.0f;
if (app.stft.numSegments < 2) return false;
int nbins = 0;
for (int s = 0; s < app.stft.numSegments; s++) {
if (app.stft.segments[s].spectrum && app.stft.segments[s].numBins > nbins)
nbins = app.stft.segments[s].numBins;
}
if (nbins < 4) return false;
int nsegs = app.stft.numSegments;
double* binE = (double*)calloc((size_t)nbins, sizeof(double));
double* segE = (double*)calloc((size_t)nsegs, sizeof(double));
if (!binE || !segE) { free(binE); free(segE); return false; }
double totalE = 0.0, segPeak = 0.0;
for (int s = 0; s < nsegs; s++) {
if (!app.stft.segments[s].spectrum) continue;
int nb = app.stft.segments[s].numBins;
for (int b = 0; b < nb; b++) {
double a = app.stft.segments[s].spectrum[b].amplitude;
double e = a * a;
binE[b] += e;
segE[s] += e;
totalE += e;
}
if (segE[s] > segPeak) segPeak = segE[s];
}
bool didCrop = false;
// --- Freq axis: smallest bin whose cumulative energy reaches 99%. ---
if (totalE > 0.0) {
double thr = totalE * AUTOCROP_FREQ_ENERGY;
double cum = 0.0;
int cropBin = nbins - 1;
for (int b = 0; b < nbins; b++) {
cum += binE[b];
if (cum >= thr) { cropBin = b; break; }
}
float fraction = (float)cropBin / (float)(nbins - 1);
if (fraction <= AUTOCROP_FREQ_MAX_FRAC) {
float nyq = app.signal.sampleRate * 0.5f;
*outFreqMaxHz = fraction * nyq * AUTOCROP_FREQ_HEADROOM;
didCrop = true;
}
}
// --- Time axis: activity envelope at 1% of segment-peak energy. ---
if (segPeak > 0.0) {
double thr = segPeak * AUTOCROP_TIME_ACTIVITY;
int first = -1, last = -1;
for (int s = 0; s < nsegs; s++) {
if (segE[s] >= thr) { if (first < 0) first = s; last = s; }
}
if (first >= 0 && last > first) {
float coverage = (float)(last - first + 1) / (float)nsegs;
if (coverage <= AUTOCROP_TIME_MAX_FRAC) {
float s0 = (float)first / (float)nsegs;
float s1 = (float)(last + 1) / (float)nsegs;
float pad = (s1 - s0) * AUTOCROP_TIME_PAD_FRAC;
s0 -= pad; s1 += pad;
if (s0 < 0.0f) s0 = 0.0f;
if (s1 > 1.0f) s1 = 1.0f;
*outViewStart = s0;
*outViewEnd = s1;
didCrop = true;
}
}
}
free(binE); free(segE);
return didCrop;
}
void ApplyAutoCrop(void)
{
if (app.signal.sampleRate <= 0 || app.signal.duration <= 0.0f) return;
float nyq = app.signal.sampleRate * 0.5f;
// Compute BOTH heuristics for BOTH axes, then pick the more focused
// result per axis. Annotations can be authoritative for freq (the
// producer knows the band) yet wide for time (a single late `control`
// marker can span almost the whole file even if signal activity ended
// long before) — so we don't tie the time choice to the freq choice.
float aFreq = 0.0f, aStart = 0.0f, aEnd = 1.0f;
float eFreq = 0.0f, eStart = 0.0f, eEnd = 1.0f;
ComputeAnnotationCrop(&aFreq, &aStart, &aEnd);
ComputeEnergyCrop(&eFreq, &eStart, &eEnd);
// ---- Freq axis: smaller cropped max wins. ----
// Both candidates are 0 when the source didn't propose a crop; treat
// those as "didn't propose" rather than "crop to 0".
float freqMax = 0.0f;
const char* freqSrc = NULL;
if (aFreq > 0.0f && eFreq > 0.0f) {
if (eFreq < aFreq) { freqMax = eFreq; freqSrc = "energy"; }
else { freqMax = aFreq; freqSrc = "annotations"; }
} else if (aFreq > 0.0f) { freqMax = aFreq; freqSrc = "annotations"; }
else if (eFreq > 0.0f) { freqMax = eFreq; freqSrc = "energy"; }
// ---- Time axis: more focused (shorter) range wins. ----
// Each source's output is a 0..1 fraction of the signal duration; a
// value of [0..1] means "didn't crop". We bias against picking a source
// that's effectively the whole timeline.
bool aShrunk = (aEnd - aStart) < 0.999f;
bool eShrunk = (eEnd - eStart) < 0.999f;
float vStart = 0.0f, vEnd = 1.0f;
const char* timeSrc = NULL;
if (aShrunk && eShrunk) {
if ((eEnd - eStart) < (aEnd - aStart)) {
vStart = eStart; vEnd = eEnd; timeSrc = "energy";
} else {
vStart = aStart; vEnd = aEnd; timeSrc = "annotations";
}
} else if (aShrunk) { vStart = aStart; vEnd = aEnd; timeSrc = "annotations"; }
else if (eShrunk) { vStart = eStart; vEnd = eEnd; timeSrc = "energy"; }
bool freqChanged = (freqMax > 0.0f && freqMax < nyq * 0.99f);
bool timeChanged = (timeSrc != NULL);
if (!freqChanged && !timeChanged) {
TraceLog(LOG_INFO, "Auto-crop: no confident source, leaving view alone");
return;
}
if (freqChanged) {
if (freqMax > nyq) freqMax = nyq;
app.displayMaxFreqHz = freqMax;
}
if (timeChanged) {
app.view.start = vStart;
app.view.end = vEnd;
}
// Fit freq view to the cropped band; otherwise a prior zoom would
// double-zoom on top of the new crop.
app.view.freqStart = 0.0f;
app.view.freqEnd = 1.0f;
app.visibleTextureValid = false;
// Splash message — mention per-axis source separately when they diverge
// (e.g. freq from annotations, time from energy on a file with a stray
// late control event).
char freqPart[64] = "", timePart[64] = "";
if (freqChanged) snprintf(freqPart, sizeof(freqPart), "0-%.0f Hz", freqMax);
if (timeChanged) snprintf(timePart, sizeof(timePart), "%.2f-%.2f s",
vStart * app.signal.duration, vEnd * app.signal.duration);
char srcPart[80];
if (freqChanged && timeChanged && freqSrc && timeSrc && strcmp(freqSrc, timeSrc) != 0) {
snprintf(srcPart, sizeof(srcPart), "freq: %s, time: %s", freqSrc, timeSrc);
} else {
const char* s = freqSrc ? freqSrc : timeSrc;
snprintf(srcPart, sizeof(srcPart), "%s", s ? s : "auto");
}
snprintf(app.autocropNoticeMsg, sizeof(app.autocropNoticeMsg),
"View auto-cropped to %s%s%s (%s).",
freqPart,
(freqChanged && timeChanged) ? ", " : "",
timePart,
srcPart);
app.autocropNoticeActive = true;
TraceLog(LOG_INFO, "Auto-crop: %s", app.autocropNoticeMsg);
}
// ============================================================================
// Keymap — handlers + table + dispatcher. See spectrogram_types.h for the
// KeyBinding contract. Adding a global key = add one row here (and, if it needs
// to run at a specific point in the frame, leave action NULL and wire it inline).
// ============================================================================
static void ActionOpenBrowser(void) { app.showFileBrowser = true; ScanDirectory(GetWorkingDirectory()); }
static void ActionToggleScope(void) { app.showScope = !app.showScope; }
static void ActionToggleAbout(void) { app.showAbout = !app.showAbout; }
static void ActionToggleFullscreen(void){ ToggleFullscreen(); }
static void ActionExport(void) { ExportPNG(&app, app.exportDir); }
static void ActionExportWav(void) { ExportSelectionWAV(app.exportDir); }
static void ActionToggleMarker(void) { app.markerMode = !app.markerMode; }
static void ActionToggleSpectrum(void) { app.showSpectrum = !app.showSpectrum; }
static void ActionResetView(void)
{
app.view.start = 0.0f; app.view.end = 1.0f;
app.view.freqStart = 0.0f; app.view.freqEnd = 1.0f;
app.visibleTextureValid = false;
}
static void ActionZoomToStart(void)
{
app.view.start = 0.0f;
app.view.end = 0.1f;
app.visibleTextureValid = false;
}
static const KeyBinding KEYMAP[] = {
{ KEY_O, KEYGATE_MODAL, ActionOpenBrowser, "O", "open file browser" },
{ KEY_P, KEYGATE_NONE, ActionToggleScope, "P", "show / hide waveform scope" },
{ KEY_F1, KEYGATE_NONE, ActionToggleAbout, "F1", "about / help" },
{ KEY_F11, KEYGATE_NONE, ActionToggleFullscreen,"F11", "toggle fullscreen" },
{ KEY_HOME, KEYGATE_MODAL | KEYGATE_LOADED,ActionResetView, "Home", "reset view (fit all)" },
{ KEY_END, KEYGATE_MODAL | KEYGATE_LOADED,ActionZoomToStart, "End", "zoom to start" },
{ KEY_E, KEYGATE_MODAL | KEYGATE_STFT, ActionExport, "E", "export PNG" },
{ KEY_W, KEYGATE_MODAL | KEYGATE_STFT, ActionExportWav, "W", "export selection WAV" },
{ KEY_M, KEYGATE_MODAL | KEYGATE_LOADED,ActionToggleMarker, "M", "marker / ruler tool" },
{ KEY_S, KEYGATE_MODAL | KEYGATE_STFT, ActionToggleSpectrum, "S", "spectrum slice (PSD)" },
// Order-sensitive: handled inline (see main loop), listed here for the overlay.
{ KEY_SPACE, KEYGATE_NONE, NULL, "Space", "play / stop selection" },
{ KEY_ESCAPE,KEYGATE_NONE, NULL, "Esc", "clear selection / close dialog" },
};
const KeyBinding* GetKeymap(int* count)
{
*count = (int)(sizeof(KEYMAP) / sizeof(KEYMAP[0]));
return KEYMAP;
}
// Run every gated, dispatchable binding whose key was pressed this frame.
static void DispatchKeymap(void)
{
int n;
const KeyBinding* km = GetKeymap(&n);
for (int i = 0; i < n; i++) {
const KeyBinding* b = &km[i];
if (!b->action) continue;
if ((b->gate & KEYGATE_MODAL) && UiModalOpen()) continue;
if ((b->gate & KEYGATE_LOADED) && !app.loaded) continue;
if ((b->gate & KEYGATE_STFT) && !app.stftComputed) continue;
if (IsKeyPressed(b->key)) b->action();
}
}
// ============================================================================
// Main Application
// ============================================================================
int main(int argc, char* argv[])
{
// ---- Command-line arguments ----
// Two modes:
// GUI: rspektrum [input.wav]
// Headless: rspektrum --render OUT.png INPUT.wav [options]
// The headless path computes the spectrogram, draws annotations, writes a
// PNG, and exits without ever showing a window (FLAG_WINDOW_HIDDEN keeps a
// GL context for rendering but puts nothing on screen).
const char* inputArg = NULL; // input WAV (positional)
const char* renderOut = NULL; // --render target; non-NULL => headless mode
bool headless = false;
int annoChoice = -1; // -1 = auto (show if present), 0 = off, 1 = on
float annoOpacity = -1.0f; // <0 = keep default; else override resting overlay alpha
bool paneOnly = false; // crop to the spectrogram pane (no sidebar/scope)
int reqW = 1280, reqH = 800; // headless output size
for (int i = 1; i < argc; i++) {
const char* a = argv[i];
if ((strcmp(a, "--render") == 0 || strcmp(a, "-r") == 0) && i + 1 < argc) {
renderOut = argv[++i];
headless = true;
} else if (strcmp(a, "--annotations") == 0 || strcmp(a, "-a") == 0) {
annoChoice = 1;
} else if (strcmp(a, "--no-annotations") == 0) {
annoChoice = 0;
} else if (strncmp(a, "--annotation-opacity=", 21) == 0) {
annoOpacity = (float)atof(a + 21);
} else if (strcmp(a, "--annotation-opacity") == 0 && i + 1 < argc) {
annoOpacity = (float)atof(argv[++i]);
} else if (strcmp(a, "--pane") == 0) {
paneOnly = true;
} else if (strcmp(a, "--width") == 0 && i + 1 < argc) {
reqW = atoi(argv[++i]);
} else if (strcmp(a, "--height") == 0 && i + 1 < argc) {
reqH = atoi(argv[++i]);
} else if (strcmp(a, "--help") == 0 || strcmp(a, "-h") == 0) {
printf(
"rspektrum - spectrogram viewer\n\n"
"Usage:\n"
" rspektrum [input.wav] open the GUI\n"
" rspektrum --render OUT.png INPUT.wav [opts] write a PNG headlessly\n\n"
"Headless options:\n"
" -r, --render OUT.png render a screenshot to OUT.png (no window)\n"
" -a, --annotations force the annotation overlay on\n"
" --no-annotations force the annotation overlay off\n"
" (default: shown when the WAV carries annotations)\n"
" --annotation-opacity=V resting overlay alpha 0..1 (default 0.06, faint)\n"
" --pane capture only the spectrogram pane (no sidebar/scope)\n"
" --width N output width (default 1280)\n"
" --height N output height (default 800)\n"
" -h, --help show this help\n");
return 0;
} else if (a[0] != '-') {
if (!inputArg) inputArg = a;
}
}
if (reqW < 16) reqW = 1280;
if (reqH < 16) reqH = 800;
if (annoOpacity > 1.0f) annoOpacity = 1.0f;
if (headless && !inputArg) {
fprintf(stderr, "rspektrum: --render requires an input WAV file\n");
return 2;
}
#ifdef __EMSCRIPTEN__
// FLAG_WINDOW_HIGHDPI is buggy on the web backend: the Emscripten resize
// callback sets the screen size to window.innerWidth, but the GLFW window-
// size callback it triggers divides that by devicePixelRatio when HIGHDPI
// is set. On a HiDPI display the framebuffer and the reported screen size
// desync and the UI renders into a corner. UI scaling is handled by
// GetUIScale() regardless, so the flag is unnecessary here. raylib auto-
// resizes the canvas to the window when FLAG_WINDOW_RESIZABLE is set.
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE);
#else
if (headless) {
// Hidden window: keeps a GL context for rendering the frame, but
// nothing is ever shown. No HIGHDPI so the framebuffer matches the
// requested size exactly (LoadImageFromScreen reads it back 1:1).
SetConfigFlags(FLAG_WINDOW_HIDDEN);
} else {
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE | FLAG_WINDOW_HIGHDPI);
}
#endif
InitWindow(headless ? reqW : 1280, headless ? reqH : 800, "Spectrogram Viewer");
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[4096] = { 0 };
snprintf(originalDir, sizeof(originalDir), "%s", GetWorkingDirectory());
TraceLog(LOG_INFO, "Original working directory: %s", originalDir);
// Set export directory to the app's working directory (before CWD changes)
snprintf(app.exportDir, sizeof(app.exportDir), "%s", originalDir);
app.exportScale = 1.0f;
app.exportMessage[0] = '\0';
SearchAndSetResourceDir("resources");
// Load TTF font at a fixed base size. Scaling is handled uniformly by
// GetUIScale() for both layout and DrawTextScaled(), so the font scales
// naturally with the window size on any DPI monitor.
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.sel.timeStart = 0.0f; app.sel.timeEnd = 1.0f;
app.sel.freqStart = 0.0f; app.sel.freqEnd = 1.0f;
app.view.start = 0.0f; app.view.end = 1.0f;
app.view.freqStart = 0.0f; app.view.freqEnd = 1.0f;
app.showGrid = true;
app.colormap = COLORMAP_INFERNO;
app.amplitudeMode = SCALE_RELATIVE;
app.dynRangeDb = 40.0f; // relative: show 40 dB below the peak
app.absoluteFloorDb = -60.0f; // absolute: -60 dBFS floor
app.amplitudeFloorDb = -60.0f;
app.amplitudeCeilingDb = 0.0f;
app.showFileBrowser = false;
app.isBrowsing = false;
app.visibleTexture = (Texture2D){ 0 };
app.cachedVisibleStart = -1;
app.cachedVisibleEnd = -1;
app.cachedVisibleStartY = -1;
app.cachedVisibleEndY = -1;
app.visibleTextureValid = false;
app.fftSize = FFT_SIZE_DEFAULT;
app.skipFactor = 1;
app.highResFinished = false;
app.bgHighResSeg = 0;
app.bgFinished = false;
app.isBgProcessing = false;
// Initialize FFT cache
app.fftCache.count = 0;
app.fftCache.nextOrder = 0;
for (int i = 0; i < FFT_CACHE_SIZE; i++) {
app.fftCache.entries[i].fftSize = 0;
app.fftCache.entries[i].result.numSegments = 0;
app.fftCache.entries[i].result.segments = NULL;
app.fftCache.entries[i].accessOrder = 0;
}
app.isPlaying = false;
app.playbackFinished = false;
app.displayMaxFreqHz = 0.0f; // 0 = no crop; user sets via sidebar slider
app.showAnnotations = true;
app.annotationsExpanded = false;
app.annotationOpacityBase = 0.06f; // whisper-faint by default — signal wins
app.annotationOpacityHover = 0.65f; // pop on hover / selection
// CLI override: there's no hover in a headless render, so the resting alpha
// governs every overlay — bump it to make annotations read in the PNG.
if (annoOpacity >= 0.0f) app.annotationOpacityBase = annoOpacity;
app.timelineExpanded = false;
app.hoveredTimelineEvent = -1;
app.selectedAnnotation = -1;
for (int i = 0; i < MLNL_KIND_MAX; i++) app.annotationKindEnabled[i] = true;
app.showScope = true;
app.dividerY = 0.6f; // Start with 60% spectro, 40% scope
app.isDividing = false;
app.dividerStartPos = (Vector2){ 0, 0 };
app.dividerStartY = 0;
// Initialize scope view (data synced in render loop when signal loads)
InitScopeView(&app.scopeView,
(WaveformData){app.signal.samples, app.signal.numSamples, app.signal.sampleRate},
0, 0, GetScreenWidth(), 200);
GenerateColormapTexture();
ScanDirectory(GetWorkingDirectory());
TraceLog(LOG_INFO, "Spectrogram Viewer initialized");
bool fileLoaded = false;
if (inputArg) {
TraceLog(LOG_INFO, "Loading file from command line: %s", inputArg);
char resolvedPath[8192] = { 0 };
// If the path doesn't exist as-is, try prepending original dir
if (!FileExists(inputArg) && originalDir[0]) {
snprintf(resolvedPath, sizeof(resolvedPath), "%s/%s", originalDir, inputArg);
TraceLog(LOG_INFO, "Trying prepended path: %s", resolvedPath);
}
const char* pathToLoad = FileExists(inputArg) ? inputArg : resolvedPath;
if (FileExists(pathToLoad) && LoadWavFile(pathToLoad, &app.signal)) {
fileLoaded = true;
ResetForNewSignal();
LoadMlnlFromWav(pathToLoad, &app.annotations);
TraceLog(LOG_INFO, "File loaded successfully");
}
}
if (!fileLoaded) TraceLog(LOG_INFO, "Press 'O' for file browser or drag & drop WAV file");
// ---- Headless render setup ----
// Compute the spectrogram synchronously here; the frame is drawn and
// captured at the bottom of the (single-pass) main loop below. headlessRc
// gates the loop: a load failure skips it entirely.
int headlessRc = 0;
char headlessOut[8192] = { 0 };
if (headless) {
if (!fileLoaded) {
fprintf(stderr, "rspektrum: failed to load input WAV '%s'\n", inputArg);
headlessRc = 1;
} else {
// Resolve the output path relative to the launch dir (CWD was
// changed to resources/ by SearchAndSetResourceDir).
if (renderOut[0] == '/') {
snprintf(headlessOut, sizeof(headlessOut), "%s", renderOut);
} else {
snprintf(headlessOut, sizeof(headlessOut), "%s/%s", originalDir, renderOut);
}
if (annoChoice == 0) app.showAnnotations = false;
else if (annoChoice == 1) app.showAnnotations = true;
// Compute the full-resolution STFT in one shot (no incremental /
// background passes — there is no interactive loop to spread them
// over). Mirrors the Emscripten single-shot path above.
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
app.skipFactor = 1;
ComputeSTFTIncremental(&app.signal, &app.stft, app.fftSize, 0);
AutoScaleAmplitude(&app.stft);
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
app.currentSTFTSegment = app.stft.numSegments;
app.bgHighResSeg = app.stft.numSegments;
app.stftComputed = true;
app.highResFinished = true;
app.bgFinished = true;
app.isBgProcessing = false;
app.loadingPhase = 0;
if (app.autocropPending) { ApplyAutoCrop(); app.autocropPending = false; }
app.autocropNoticeActive = false; // don't draw the crop splash into the shot
app.exportMessage[0] = '\0';
}
}
while (!WindowShouldClose() && headlessRc == 0)
{
#ifdef __EMSCRIPTEN__
// Track the browser viewport (fill + reflow on resize, like desktop).
SyncCanvasToWindow();
#else
// When the window isn't focused there's nothing for the user to see, so
// skip the entire frame: pump window events (so focus regain and the
// close button still register) and idle, with zero drawing. The
// headless render uses a hidden window that never reports focus, so it
// is exempt — otherwise it would never draw its single capture frame.
if (!headless && !IsWindowFocused()) {
PollInputEvents();
WaitTime(0.1); // ~10 Hz event poll, no rendering
continue;
}
// Idle FPS throttle (desktop only; the browser drives RAF/vsync itself).
// Keep full rate while active or within a short grace window after the
// last activity, then fall back to a low idle rate.
{
static double lastActive = -1000.0;
static int curFps = ACTIVE_FPS;
if (IsAppActive()) lastActive = GetTime();
int wantFps = (GetTime() - lastActive < IDLE_GRACE_SECONDS) ? ACTIVE_FPS : IDLE_FPS;
if (wantFps != curFps) { SetTargetFPS(wantFps); curFps = wantFps; }
}
#endif
// 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)) {
ResetForNewSignal();
LoadMlnlFromWav(dropped.paths[0], &app.annotations);
}
}
}
UnloadDroppedFiles(dropped);
}
// Global key bindings (table-driven; see KEYMAP/GetKeymap). The
// order-sensitive keys (Space, Esc) are handled inline further below.
DispatchKeymap();
// Check if playback finished naturally
if (app.isPlaying && AudioPlaybackSound.frameCount > 0) {
// Check if sound stopped playing (IsSoundPlaying returns false when done)
if (!IsSoundPlaying(AudioPlaybackSound)) {
app.isPlaying = false;
app.playbackFinished = true;
}
// Track playhead position manually
app.playheadElapsed += GetFrameTime();
float selectionDuration = (app.sel.timeEnd - app.sel.timeStart) * app.signal.duration;
if (selectionDuration > 0) {
app.playheadT = app.playheadElapsed / selectionDuration;
}
}
// Handle window resize
if (IsWindowResized()) {
app.visibleTextureValid = false;
}
// View controls
if (app.loaded && !UiModalOpen()) {
// Spectrogram area fills remaining window space (scaled)
Layout L = ComputeLayout();
float viewScale = L.scale;
float sidebarWidth = L.sidebarWidth;
float labelHeight = L.labelHeight;
float scrollbarHeight = L.scrollbarHeight;
float freqLabelWidth = L.freqLabelWidth;
float vScrollbarWidth = L.vScrollbarWidth;
float topMargin = L.topMargin;
float bottomMargin = L.bottomMargin;
float spectroHeight = L.spectroHeight;
Rectangle viewBounds = L.viewBounds;
// Zoom with mouse wheel (zooms both time and frequency to maintain aspect ratio)
if (GetMousePosition().x > sidebarWidth + 5 && CheckCollisionPointRec(GetMousePosition(), viewBounds)) {
int wheel = GetMouseWheelMove();
if (wheel != 0) {
float zoomFactor = (wheel > 0) ? 0.8f : 1.2f;
// --- Time axis zoom (around cursor X) ---
float mouseT = (GetMousePosition().x - viewBounds.x) / viewBounds.width;
mouseT = app.view.start + mouseT * (app.view.end - app.view.start);
float viewWidth = app.view.end - app.view.start;
float newWidth = viewWidth * zoomFactor;
if (newWidth < 0.02f) newWidth = 0.02f;
if (newWidth > 1.0f) newWidth = 1.0f;
float leftOfMouse = mouseT - app.view.start;
float rightOfMouse = app.view.end - mouseT;
app.view.start = mouseT - leftOfMouse * (newWidth / viewWidth);
app.view.end = mouseT + rightOfMouse * (newWidth / viewWidth);
if (app.view.start < 0) { app.view.start = 0; app.view.end = newWidth; }
if (app.view.end > 1) { app.view.end = 1; app.view.start = 1 - newWidth; }
// --- Frequency axis zoom (around cursor Y) ---
float mouseF = 1.0f - (GetMousePosition().y - viewBounds.y) / viewBounds.height;
mouseF = app.view.freqStart + mouseF * (app.view.freqEnd - app.view.freqStart);
float freqWidth = app.view.freqEnd - app.view.freqStart;
float newFreqWidth = freqWidth * zoomFactor;
if (newFreqWidth < 0.001f) newFreqWidth = 0.001f;
float belowMouse = mouseF - app.view.freqStart;
float aboveMouse = app.view.freqEnd - mouseF;
app.view.freqStart = mouseF - belowMouse * (newFreqWidth / freqWidth);
app.view.freqEnd = mouseF + aboveMouse * (newFreqWidth / freqWidth);
// Clamp to physical frequency limits [0, 1] — can't see beyond Nyquist or below 0 Hz
if (app.view.freqStart < 0) { app.view.freqStart = 0; app.view.freqEnd = fminf(app.view.freqEnd, 1.0f); }
if (app.view.freqEnd > 1) { app.view.freqEnd = 1; app.view.freqStart = fmaxf(app.view.freqStart, 0.0f); }
// Invalidate texture cache
app.visibleTextureValid = false;
}
}
// Pan with Alt+drag or middle mouse button (pans both axes)
bool canPan = IsKeyDown(KEY_LEFT_ALT) || IsKeyDown(KEY_RIGHT_ALT) || IsMouseButtonDown(MOUSE_BUTTON_MIDDLE);
if (canPan && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
app.view.isPanning = true;
app.view.panStartPos = GetMousePosition();
app.view.panStart = app.view.start;
app.view.panEnd = app.view.end;
app.view.panFreqStart = app.view.freqStart;
app.view.panFreqEnd = app.view.freqEnd;
}
if (app.view.isPanning && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float dx = (GetMousePosition().x - app.view.panStartPos.x) / viewBounds.width;
float dy = (GetMousePosition().y - app.view.panStartPos.y) / viewBounds.height;
float viewWidth = app.view.panEnd - app.view.panStart;
float freqWidth = app.view.panFreqEnd - app.view.panFreqStart;
app.view.start = app.view.panStart - dx * viewWidth;
app.view.end = app.view.panEnd - dx * viewWidth;
if (app.view.start < 0) { app.view.start = 0; app.view.end = viewWidth; }
if (app.view.end > 1) { app.view.end = 1; app.view.start = 1 - viewWidth; }
app.view.freqStart = app.view.panFreqStart + dy * freqWidth;
app.view.freqEnd = app.view.panFreqEnd + dy * freqWidth;
// Clamp to physical limits [0, 1]
if (app.view.freqStart < 0) {
float actualWidth = app.view.freqEnd - app.view.freqStart;
app.view.freqStart = 0;
app.view.freqEnd = fminf(actualWidth, 1.0f);
}
if (app.view.freqEnd > 1) {
float actualWidth = app.view.freqEnd - app.view.freqStart;
app.view.freqEnd = 1;
app.view.freqStart = fmaxf(1.0f - actualWidth, 0.0f);
}
if (app.view.freqStart < 0) app.view.freqStart = 0;
if (app.view.freqEnd > 1) app.view.freqEnd = 1;
app.visibleTextureValid = false;
}
if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) app.view.isPanning = false;
// Foreground high-res: when user zooms in, compute missing
// segments in the visible range immediately (responsive).
// Background task handles the rest when idle.
if (app.skipFactor > 1 && app.stft.numSegments > 0 && !app.bgFinished) {
float viewRange = app.view.end - app.view.start;
if (viewRange <= 0.25f) {
// Clamp to valid segment range
int viewStartSeg = (int)(app.view.start * app.stft.numSegments);
int viewEndSeg = (int)(app.view.end * app.stft.numSegments);
if (viewStartSeg < 0) viewStartSeg = 0;
if (viewStartSeg >= app.stft.numSegments) viewStartSeg = app.stft.numSegments - 1;
if (viewEndSeg >= app.stft.numSegments) viewEndSeg = app.stft.numSegments - 1;
// Find first missing segment in the visible range and compute it
for (int seg = viewStartSeg; seg <= viewEndSeg && seg < app.stft.numSegments; seg++) {
if (app.stft.segments[seg].spectrum == NULL) {
int startSeg = seg;
int endSeg = seg + 50;
if (endSeg > viewEndSeg + 1) endSeg = viewEndSeg + 1;
app.bgHighResSeg = ComputeNextHighResChunk(&app.signal, &app.stft, app.fftSize, startSeg, endSeg);
app.visibleTextureValid = false;
TraceLog(LOG_INFO, "Foreground high-res (%d to %d)", startSeg, endSeg - 1);
break;
}
}
}
}
// Background high-res: when user is idle, fill in remaining
// segments at full resolution. Pauses on any interaction.
// Also kicks in when zoomed out (no foreground trigger) to fill
// segments outside the view range.
bool isZoomedIn = (app.skipFactor > 1 && app.view.end - app.view.start <= 0.25f);
if (app.isBgProcessing && !app.bgFinished && !IsUserInteracting()) {
int endSeg = app.bgHighResSeg + 50; // chunks of 50 segments
if (endSeg > app.stft.numSegments) endSeg = app.stft.numSegments;
app.bgHighResSeg = ComputeNextHighResChunk(&app.signal, &app.stft, app.fftSize, app.bgHighResSeg, endSeg);
if (app.bgHighResSeg >= app.stft.numSegments) {
// All done — generate full-res texture and mark complete
AutoScaleAmplitude(&app.stft);
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
app.visibleTextureValid = false;
app.bgFinished = true;
app.isBgProcessing = false;
TraceLog(LOG_INFO, "Background high-res complete (%d segments)", app.stft.numSegments);
// Save the full-res result to cache (overwrites the overview-only entry)
SaveToCache();
}
}
if (app.isBgProcessing && IsUserInteracting()) {
// Pause background processing — user is interacting
app.isBgProcessing = false;
}
// If not zoomed in, scan for missing segments to kick off processing
if (!isZoomedIn && app.isBgProcessing && !app.bgFinished && app.bgHighResSeg < app.stft.numSegments) {
bool hasMissing = false;
for (int i = app.bgHighResSeg; i < app.stft.numSegments; i++) {
if (app.stft.segments[i].spectrum == NULL) { hasMissing = true; break; }
}
if (!hasMissing) {
// No more missing segments — mark complete
app.bgFinished = true;
app.isBgProcessing = false;
SaveToCache();
}
}
}
// Keyboard shortcuts (SPACE for play/stop toggle, ESC for clear)
if (IsKeyPressed(KEY_SPACE) && !UiModalOpen()) {
if (app.isPlaying && AudioPlaybackSound.frameCount > 0) {
// Currently playing - stop it
StopSound(AudioPlaybackSound);
app.isPlaying = false;
app.playbackFinished = false;
app.playheadElapsed = 0;
app.playheadT = 0;
} else if (app.playbackFinished) {
// Playback finished naturally - restart from beginning
PlaySelectedRegion();
app.isPlaying = true;
app.playbackFinished = false;
} else {
// Not playing and didn't just finish - start playback
PlaySelectedRegion();
app.isPlaying = true;
}
}
if (IsKeyPressed(KEY_ESCAPE)) {
if (app.showAbout) {
app.showAbout = false;
} else if (app.showFileBrowser) {
app.showFileBrowser = false;
} else if (app.markerMode && app.marker.active) {
// Clear the marker measurement first when the ruler is active.
app.marker.active = false;
app.marker.dragging = false;
} else {
// Clear selections instead of exiting
ClearSelection();
}
}
// Selection: box select with LMB drag, right-click to clear
Layout selL = ComputeLayout();
float selScale = selL.scale;
float selSidebarWidth = selL.sidebarWidth;
float selLabelHeight = selL.labelHeight;
float selScrollbarHeight = selL.scrollbarHeight;
float selFreqLabelWidth = selL.freqLabelWidth;
float selVScrollbarWidth = selL.vScrollbarWidth;
float selTopMargin = selL.topMargin;
float selBottomMargin = selL.bottomMargin;
float selSpectroHeight = selL.spectroHeight;
Rectangle selBounds = selL.viewBounds;
Vector2 mousePos = GetMousePosition();
// Calculate divider screen position (for hover detection)
// Divider is drawn at the BOTTOM of the spectrogram viewport. With the
// timeline lane occupying space above the viewport, viewBounds.y was
// shifted down — so the divider's actual screen Y is `viewBounds.y +
// viewBounds.height`, NOT `topMargin + spectroHeight` (those two used
// to be equal before the lane existed).
float dividerScreenY = selBounds.y + selBounds.height;
bool mouseNearDivider = mousePos.y >= (dividerScreenY - 5) && mousePos.y <= (dividerScreenY + 5) &&
mousePos.x >= selBounds.x && mousePos.x <= selBounds.x + selBounds.width;
// Right-click clears the marker measurement (in marker mode) or the selection.
if (IsMouseButtonPressed(MOUSE_BUTTON_RIGHT) && CheckCollisionPointRec(mousePos, selBounds)) {
if (app.markerMode) { app.marker.active = false; app.marker.dragging = false; }
else ClearSelection();
}
// Check if click is inside existing selection (for dragging)
bool hasSelection = (app.sel.timeStart > 0.001f || app.sel.timeEnd < 0.999f ||
app.sel.freqStart > 0.001f || app.sel.freqEnd < 0.999f);
bool clickInsideSelection = false;
bool hoverInsideSelection = false;
if (hasSelection && CheckCollisionPointRec(mousePos, selBounds)) {
// Convert mouse position to signal coordinates
float viewWidth = app.view.end - app.view.start;
float freqWidth = app.view.freqEnd - app.view.freqStart;
float mouseTime = app.view.start + ((mousePos.x - selBounds.x) / selBounds.width) * viewWidth;
float mouseFreq = app.view.freqStart + (1.0f - (mousePos.y - selBounds.y) / selBounds.height) * freqWidth;
if (mouseTime >= app.sel.timeStart && mouseTime <= app.sel.timeEnd &&
mouseFreq >= app.sel.freqStart && mouseFreq <= app.sel.freqEnd) {
hoverInsideSelection = true;
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
clickInsideSelection = true;
}
}
}
// Set cursor based on context
if (app.sel.isDragging) {
SetMouseCursor(MOUSE_CURSOR_RESIZE_ALL); // 4-way arrow while dragging
} else if (hoverInsideSelection) {
SetMouseCursor(MOUSE_CURSOR_POINTING_HAND); // Pointing hand on hover
} else {
SetMouseCursor(MOUSE_CURSOR_DEFAULT); // Normal arrow
}
// LMB drag = box select (time + frequency) OR drag existing selection
if (app.loaded && !UiModalOpen() && CheckCollisionPointRec(mousePos, selBounds)) {
// Set cursor to resize all when near divider
if (mouseNearDivider && !app.isDividing) {
SetMouseCursor(MOUSE_CURSOR_RESIZE_ALL);
} else if (app.sel.isDragging) {
SetMouseCursor(MOUSE_CURSOR_RESIZE_ALL); // 4-way arrow while dragging
} else if (hoverInsideSelection) {
SetMouseCursor(MOUSE_CURSOR_POINTING_HAND); // Pointing hand on hover
} else {
SetMouseCursor(MOUSE_CURSOR_DEFAULT); // Normal arrow
}
if (app.markerMode) {
// Marker/ruler mode: LMB press drops point A, dragging moves B,
// release finalizes. Alt / middle-drag still pans (handled
// above), so don't drop a marker while panning.
SetMouseCursor(MOUSE_CURSOR_CROSSHAIR);
bool altPan = IsKeyDown(KEY_LEFT_ALT) || IsKeyDown(KEY_RIGHT_ALT) ||
IsMouseButtonDown(MOUSE_BUTTON_MIDDLE);
if (!altPan) {
float vt = (mousePos.x - selBounds.x) / selBounds.width;
float vf = 1.0f - (mousePos.y - selBounds.y) / selBounds.height;
float tHere = Clamp(app.view.start + vt * (app.view.end - app.view.start), 0.0f, 1.0f);
float fHere = Clamp(app.view.freqStart + vf * (app.view.freqEnd - app.view.freqStart), 0.0f, 1.0f);
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
app.marker.t0 = tHere; app.marker.f0 = fHere;
app.marker.t1 = tHere; app.marker.f1 = fHere;
app.marker.dragging = true;
app.marker.active = true;
}
if (app.marker.dragging && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
app.marker.t1 = tHere; app.marker.f1 = fHere;
}
if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) app.marker.dragging = false;
}
} else {
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
if (clickInsideSelection) {
// Start dragging existing selection
app.sel.isDragging = true;
app.sel.dragStartPos = mousePos;
app.sel.dragTimeStart = app.sel.timeStart;
app.sel.dragFreqStart = app.sel.freqStart;
} else {
// Start new box selection
app.sel.isTimeSelecting = true;
app.sel.isFreqSelecting = true;
app.sel.selectStartPos = mousePos;
// Convert screen position to signal coordinates (accounting for zoom)
float viewportT = (mousePos.x - selBounds.x) / selBounds.width;
float viewportF = 1.0f - (mousePos.y - selBounds.y) / selBounds.height;
app.sel.timeStart = Clamp(app.view.start + viewportT * (app.view.end - app.view.start), 0.0f, 1.0f);
app.sel.timeEnd = app.sel.timeStart;
app.sel.freqStart = Clamp(app.view.freqStart + viewportF * (app.view.freqEnd - app.view.freqStart), 0.0f, 1.0f);
app.sel.freqEnd = app.sel.freqStart;
}
}
// Dragging existing selection
if (app.sel.isDragging && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float viewWidth = app.view.end - app.view.start;
float freqWidth = app.view.freqEnd - app.view.freqStart;
float dx = (mousePos.x - app.sel.dragStartPos.x) / selBounds.width;
float dy = (mousePos.y - app.sel.dragStartPos.y) / selBounds.height;
float timeShift = dx * viewWidth;
float freqShift = -dy * freqWidth; // Y is inverted
float timeWidth = app.sel.timeEnd - app.sel.timeStart;
float freqHeight = app.sel.freqEnd - app.sel.freqStart;
app.sel.timeStart = Clamp(app.sel.dragTimeStart + timeShift, 0.0f, 1.0f - timeWidth);
app.sel.timeEnd = app.sel.timeStart + timeWidth;
app.sel.freqStart = Clamp(app.sel.dragFreqStart + freqShift, 0.0f, 1.0f - freqHeight);
app.sel.freqEnd = app.sel.freqStart + freqHeight;
}
// Creating new box selection
if ((app.sel.isTimeSelecting || app.sel.isFreqSelecting) && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float viewportT = (mousePos.x - selBounds.x) / selBounds.width;
float viewportF = 1.0f - (mousePos.y - selBounds.y) / selBounds.height;
app.sel.timeEnd = Clamp(app.view.start + viewportT * (app.view.end - app.view.start), 0.0f, 1.0f);
app.sel.freqEnd = Clamp(app.view.freqStart + viewportF * (app.view.freqEnd - app.view.freqStart), 0.0f, 1.0f);
}
if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) {
if (app.sel.isDragging) {
app.sel.isDragging = false;
} else if (app.sel.isTimeSelecting || app.sel.isFreqSelecting) {
// Check if drag was large enough (minimum 5 pixels)
float dx = mousePos.x - app.sel.selectStartPos.x;
float dy = mousePos.y - app.sel.selectStartPos.y;
float dragDist = sqrtf(dx * dx + dy * dy);
if (dragDist > 5.0f) {
// Normalize so start < end
if (app.sel.timeEnd < app.sel.timeStart) {
float tmp = app.sel.timeStart;
app.sel.timeStart = app.sel.timeEnd;
app.sel.timeEnd = tmp;
}
if (app.sel.freqEnd < app.sel.freqStart) {
float tmp = app.sel.freqStart;
app.sel.freqStart = app.sel.freqEnd;
app.sel.freqEnd = tmp;
}
} else {
// Drag too small - revert to full range
ClearSelection();
}
app.sel.isTimeSelecting = false;
app.sel.isFreqSelecting = false;
}
}
}
}
// Handle divider drag. Works whether or not the scope is currently shown
// (when hidden, the handle sits at the bottom and can be dragged back up).
if (app.loaded && !UiModalOpen()) {
// Grab the handle. The starting position is the *effective* divider,
// which is the bottom of the view (1.0) while the scope is hidden.
if (mouseNearDivider && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
app.isDividing = true;
app.dividerStartPos = mousePos;
app.dividerStartY = ScopeDivider();
}
if (app.isDividing && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float d = app.dividerStartY + (mousePos.y - app.dividerStartPos.y) / GetScreenHeight();
if (d >= SCOPE_COLLAPSE_DIVIDER) {
// Dragged (almost) to the bottom — hide the scope.
app.showScope = false;
} else {
// Otherwise the scope is shown; clamp the split to 30%..80%.
if (d < 0.3f) d = 0.3f;
if (d > 0.8f) d = 0.8f;
app.showScope = true;
app.dividerY = d;
}
}
if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) {
app.isDividing = false;
}
}
// Processing (incremental across frames)
if (app.loaded && !app.stftComputed) {
#ifdef __EMSCRIPTEN__
// Web build: there are no worker threads, and the desktop path's
// overview-then-deferred-high-res fill depends on many main-loop
// iterations yielding to the browser (which made loading appear to
// stall partway). Compute the full-resolution STFT in one shot so
// the spectrogram is completely ready as soon as the file loads.
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
app.skipFactor = 1; // full resolution, no overview stride
ComputeSTFTIncremental(&app.signal, &app.stft, app.fftSize, 0); // computes every segment
AutoScaleAmplitude(&app.stft);
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
app.currentSTFTSegment = app.stft.numSegments;
app.bgHighResSeg = app.stft.numSegments;
app.loadingProgress = 1.0f;
app.stftComputed = true;
app.highResFinished = true;
app.bgFinished = true;
app.isBgProcessing = false;
app.loadingPhase = 0;
SaveToCache();
if (app.autocropPending) { ApplyAutoCrop(); app.autocropPending = false; }
#else
if (app.loadingPhase == 0) {
// Initialize STFT once
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
app.skipFactor = ComputeSkipFactor(app.signal.duration);
app.bgHighResSeg = 0;
app.bgFinished = false;
app.isBgProcessing = false;
app.currentSTFTSegment = 0;
app.loadingPhase = 1;
}
if (app.loadingPhase == 1) {
// Compute STFT in chunks (overview: skipFactor-strided)
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) {
// Overview loaded — generate texture (NULL segments render as black)
// and transition to ready state so background processing can start.
AutoScaleAmplitude(&app.stft);
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
app.loadingProgress = 1.0f;
app.stftComputed = true;
app.loadingPhase = 0; // Reset — background processing runs outside this block
app.loadingProgress = 0.0f;
app.isBgProcessing = true; // Kick off background high-res next frame
TraceLog(LOG_INFO, "STFT overview computed (%d segments, skipFactor=%d)",
app.stft.numSegments, app.skipFactor);
// Save the overview result to cache (will be overwritten when full-res completes)
SaveToCache();
// Run auto-crop now that we have both annotations (loaded right
// after LoadWavFile) AND an STFT (for the energy fallback).
// Gated on autocropPending so an FFT-size change (which routes
// through the same loadingPhase=2 block) doesn't re-fire it.
if (app.autocropPending) { ApplyAutoCrop(); app.autocropPending = false; }
}
#endif // __EMSCRIPTEN__
}
// 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 (account for skip factor — fewer segments to compute)
int estY = barY + (int)(28 * scale);
float estSec = app.signal.duration / app.signal.sampleRate * app.stft.numSegments / (200.0f * app.skipFactor);
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);
}
}
// Dismiss the About dialog with a click. Handled here, after the
// spectrogram input above (which is gated off while it's open), so the
// dismissing click can't fall through and start a selection/pan.
if (app.showAbout && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
app.showAbout = false;
}
// Rendering
BeginDrawing();
ClearBackground((Color){ 30, 30, 30, 255 });
// Layout: sidebar on left, spectrogram on right (scaled)
// Spectrogram area (excludes labels and scrollbars)
Layout L = ComputeLayout();
float renderScale = L.scale;
float sidebarWidth = L.sidebarWidth;
float labelHeight = L.labelHeight;
float scrollbarHeight = L.scrollbarHeight;
float freqLabelWidth = L.freqLabelWidth;
float vScrollbarWidth = L.vScrollbarWidth;
float topMargin = L.topMargin;
float bottomMargin = L.bottomMargin;
float spectroHeight = L.spectroHeight;
Rectangle viewBounds = L.viewBounds;
// Time labels sit just below the spectrogram
Rectangle timeLabelArea = { viewBounds.x, viewBounds.y + viewBounds.height, viewBounds.width, labelHeight };
// Horizontal scrollbar sits below the time labels
Rectangle hScrollbar = { viewBounds.x, viewBounds.y + viewBounds.height + labelHeight + 5 * renderScale, viewBounds.width, scrollbarHeight };
// Vertical scrollbar sits to the right of the spectrogram
Rectangle vScrollbar = { viewBounds.x + viewBounds.width + 5 * renderScale, viewBounds.y, vScrollbarWidth, viewBounds.height };
// Draw sidebar first (on top left)
DrawSidebar();
// Draw spectrogram (background, in its own area)
if (app.loaded && app.stftComputed) {
int imgWidth = app.spectrogramImage.width;
int imgHeight = app.spectrogramImage.height;
// Calculate visible region (time and frequency)
int visibleStartX = (int)(app.view.start * imgWidth);
int visibleEndX = (int)(app.view.end * imgWidth);
int visibleWidth = visibleEndX - visibleStartX;
// Frequency: 0 = bottom of image (bin 0), 1 = top of image (bin max).
// The display-crop slider maps view.freqStart/End=1.0 to a fraction
// of the texture's height < 1.0, effectively zooming the freq axis
// so the cropped band fills the viewport while leaving the source
// texture untouched.
float cropFrac = DisplayFreqFraction();
int visibleStartY = (int)((1.0f - app.view.freqEnd * cropFrac) * imgHeight);
int visibleEndY = (int)((1.0f - app.view.freqStart * cropFrac) * imgHeight);
int visibleHeight = visibleEndY - visibleStartY;
// Invalidate cache if view changed or texture not valid
bool cacheInvalid = !app.visibleTextureValid ||
visibleStartX != app.cachedVisibleStart ||
visibleEndX != app.cachedVisibleEnd ||
visibleStartY != app.cachedVisibleStartY ||
visibleEndY != app.cachedVisibleEndY ||
visibleWidth <= 0 || visibleHeight <= 0;
if (cacheInvalid && visibleWidth > 0 && visibleStartX >= 0 && visibleHeight > 0 && visibleStartY >= 0) {
// Free old texture if exists
if (app.visibleTexture.id != 0) UnloadTexture(app.visibleTexture);
// Create a sub-image for the visible region
Image visibleImage = GenImageColor(visibleWidth, visibleHeight, BLACK);
Color* srcPixels = (Color*)app.spectrogramImage.data;
Color* dstPixels = (Color*)visibleImage.data;
for (int y = 0; y < visibleHeight; y++) {
for (int x = 0; x < visibleWidth; x++) {
dstPixels[y * visibleWidth + x] = srcPixels[(visibleStartY + y) * imgWidth + visibleStartX + x];
}
}
app.visibleTexture = LoadTextureFromImage(visibleImage);
UnloadImage(visibleImage);
app.cachedVisibleStart = visibleStartX;
app.cachedVisibleEnd = visibleEndX;
app.cachedVisibleStartY = visibleStartY;
app.cachedVisibleEndY = visibleEndY;
app.visibleTextureValid = true;
}
// Draw cached texture
if (app.visibleTextureValid && app.visibleTexture.id != 0) {
DrawTexturePro(app.visibleTexture,
(Rectangle){ 0, 0, visibleWidth, visibleHeight },
viewBounds, (Vector2){ 0, 0 }, 0.0f, WHITE);
}
// Draw scrollbars
// Horizontal scrollbar (time)
DrawRectangleRec(hScrollbar, DARKGRAY);
float hThumbWidth = (app.view.end - app.view.start) * hScrollbar.width;
float hThumbX = hScrollbar.x + app.view.start * hScrollbar.width;
if (hThumbWidth < 10) hThumbWidth = 10;
DrawRectangle(hThumbX, hScrollbar.y, hThumbWidth, hScrollbar.height, GRAY);
// Vertical scrollbar (frequency)
DrawRectangleRec(vScrollbar, DARKGRAY);
float vThumbHeight = (app.view.freqEnd - app.view.freqStart) * vScrollbar.height;
float vThumbY = vScrollbar.y + (1.0f - app.view.freqEnd) * vScrollbar.height;
if (vThumbHeight < 10) vThumbHeight = 10;
DrawRectangle(vScrollbar.x, vThumbY, vScrollbar.width, vThumbHeight, GRAY);
// Handle scrollbar dragging
static bool draggingH = false, draggingV = false;
static Vector2 dragStartPos;
static float dragStartViewStart, dragStartFreqViewStart;
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && CheckCollisionPointRec(GetMousePosition(), hScrollbar)) {
draggingH = true;
dragStartPos = GetMousePosition();
dragStartViewStart = app.view.start;
}
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && CheckCollisionPointRec(GetMousePosition(), vScrollbar)) {
draggingV = true;
dragStartPos = GetMousePosition();
dragStartFreqViewStart = app.view.freqStart;
}
if (draggingH && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float dx = (GetMousePosition().x - dragStartPos.x) / hScrollbar.width;
float viewWidth = app.view.end - app.view.start;
app.view.start = dragStartViewStart + dx;
app.view.end = app.view.start + viewWidth;
if (app.view.start < 0) { app.view.start = 0; app.view.end = viewWidth; }
if (app.view.end > 1) { app.view.end = 1; app.view.start = 1 - viewWidth; }
app.visibleTextureValid = false;
}
if (draggingV && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float dy = (GetMousePosition().y - dragStartPos.y) / vScrollbar.height;
float freqWidth = app.view.freqEnd - app.view.freqStart;
app.view.freqStart = dragStartFreqViewStart - dy;
app.view.freqEnd = app.view.freqStart + freqWidth;
if (app.view.freqStart < 0) {
app.view.freqStart = 0;
app.view.freqEnd = fminf(freqWidth, 1.0f);
}
if (app.view.freqEnd > 1) {
app.view.freqEnd = 1;
app.view.freqStart = fmaxf(1.0f - freqWidth, 0.0f);
}
if (app.view.freqStart < 0) app.view.freqStart = 0;
if (app.view.freqEnd > 1) app.view.freqEnd = 1;
app.visibleTextureValid = false;
}
if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) { draggingH = false; draggingV = false; }
if (app.showGrid) DrawSpectrogramGrid(viewBounds, 10, 8, Fade(GRAY, 0.3f));
// Timeline lane sits above the spectrogram. Drawn before the
// overlays so its hover/selection state is set for the same frame.
if (L.timelineHeight > 0) DrawTimeline(L.timelineBounds);
DrawAnnotations(viewBounds);
DrawSelection(viewBounds);
DrawSelectionDrag(viewBounds);
DrawMarkers(viewBounds);
DrawPlayhead(viewBounds);
DrawLabels(viewBounds);
if (!UiModalOpen() && app.hoveredEvent < 0 && app.hoveredTimelineEvent < 0)
DrawCursorReadout(viewBounds);
DrawSpectrumPanel(viewBounds);
float maxFreq = EffectiveMaxFreqHz();
float freqMin = app.view.freqStart * maxFreq;
float freqMax = app.view.freqEnd * maxFreq;
// Pin to the top margin so the timeline lane (which lives between
// the banner and the spectrogram) doesn't shove the banner down.
DrawTextScaled(TextFormat("Freq: %.0f-%.0f Hz", freqMin, freqMax),
viewBounds.x, topMargin - 30, 20, LIGHTGRAY);
// Draw waveform scope view underneath the spectrogram
if (app.showScope && app.loaded && app.signal.samples != NULL) {
float totalArea = GetScreenHeight() - topMargin - bottomMargin - labelHeight - scrollbarHeight - 10 * renderScale;
float scopeHeight = totalArea * (1.0f - app.dividerY) - 30 * renderScale;
app.scopeView.y = viewBounds.y + viewBounds.height + 30;
app.scopeView.x = viewBounds.x;
app.scopeView.width = viewBounds.width;
app.scopeView.height = (int)scopeHeight;
// Keep time view in sync with spectrogram view
app.scopeView.viewStart = app.view.start;
app.scopeView.viewEnd = app.view.end;
// Update waveform data
app.scopeView.data.samples = app.signal.samples;
app.scopeView.data.numSamples = app.signal.numSamples;
app.scopeView.data.sampleRate = app.signal.sampleRate;
// Show playhead if playing
if (app.isPlaying) {
DrawScopeView(&app.scopeView, app.sel.timeStart + app.playheadT * (app.sel.timeEnd - app.sel.timeStart));
} else {
DrawScopeView(&app.scopeView, -1.0f);
}
// Echo the annotation overlay onto the scope so selecting an
// event in the timeline highlights both surfaces at once.
DrawAnnotationsOnScope((Rectangle){
(float)app.scopeView.x, (float)app.scopeView.y,
(float)app.scopeView.width, (float)app.scopeView.height });
// Scope label, tucked inside the top-left so it clears the time
// axis labels and scrollbar that sit in the band above the scope.
DrawTextScaled("Waveform", viewBounds.x + 4 * renderScale, app.scopeView.y + 3 * renderScale,
11, Fade(LIGHTGRAY, 0.5f));
}
// Draw divider line + handle. Always shown (even when the scope is
// hidden) so the handle can be grabbed at the bottom to bring it back.
if (app.loaded) {
float dividerY = viewBounds.y + viewBounds.height;
Color dividerColor = app.isDividing ? CYAN : Fade((Color){ 180, 180, 200, 255 }, 0.7f);
// Draw divider with handle
int handleW = 40;
int handleH = 10;
int handleX = viewBounds.x + viewBounds.width / 2 - handleW / 2;
int handleY = (int)dividerY - handleH / 2;
if (app.isDividing) {
// Highlighted handle while dragging
DrawRectangle(handleX, handleY, handleW, handleH, CYAN);
DrawRectangleLines(handleX, handleY, handleW, handleH, WHITE);
} else {
// Normal handle
DrawRectangle(handleX, handleY, handleW, handleH, GRAY);
DrawRectangleLines(handleX, handleY, handleW, handleH, Fade(YELLOW, 0.6f));
// Draw handle grips (3 dots)
Color gripColor = Fade(WHITE, 0.6f);
DrawPixel(handleX + handleW / 3, handleY + handleH / 2, gripColor);
DrawPixel(handleX + handleW / 2, handleY + handleH / 2, gripColor);
DrawPixel(handleX + handleW * 2 / 3, handleY + handleH / 2, gripColor);
}
// Draw line extending from handle to edges
DrawLine(viewBounds.x, (int)dividerY, handleX, (int)dividerY, dividerColor);
DrawLine(handleX + handleW, (int)dividerY, viewBounds.x + viewBounds.width, (int)dividerY, dividerColor);
// Hint that the hidden scope can be dragged back up.
if (!app.showScope && !app.isDividing) {
DrawTextScaled("drag up for scope", handleX + handleW + 8, (int)dividerY - 7, 11,
Fade(LIGHTGRAY, 0.6f));
}
}
} 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>";
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)
if (app.showFileBrowser) DrawFileBrowser();
// Auto-crop notice modal — drawn below About so About still wins if
// both happened to be up at once (shouldn't happen in practice).
DrawAutocropNotice();
// About / help dialog (topmost)
DrawAboutDialog();
// Export message notification
if (app.exportMessage[0] != '\0') {
int msgW = MeasureText(app.exportMessage, 20);
int boxW = msgW + 40;
int boxH = 36;
int boxX = GetScreenWidth() / 2 - boxW / 2;
int boxY = 15;
DrawRectangle(boxX, boxY, boxW, boxH, (Color){ 30, 30, 30, 220 });
DrawRectangleLines(boxX, boxY, boxW, boxH, CYAN);
DrawText(app.exportMessage, boxX + (boxW - msgW) / 2, boxY + 10, 20, WHITE);
}
// Hold the export message for a few seconds, then clear it.
if (app.exportMessageTimer > 0.0f) {
app.exportMessageTimer -= GetFrameTime();
if (app.exportMessageTimer <= 0.0f) app.exportMessage[0] = '\0';
}
EndDrawing();
// Headless: the frame is now fully rendered. Read it back, optionally
// crop to the spectrogram pane, write the PNG, and stop the loop.
if (headless) {
Image shot = LoadImageFromScreen();
if (paneOnly) {
// Crop to the spectrogram pane: freq labels + banner + timeline
// lane + spectrogram + time-axis labels. Drops sidebar + scope.
Layout capL = ComputeLayout();
Rectangle vb = capL.viewBounds;
float top = capL.topMargin - 30.0f;
if (top < 0.0f) top = 0.0f;
float left = capL.sidebarWidth;
float right = vb.x + vb.width + capL.vScrollbarWidth + 10.0f * capL.scale;
float bottom = vb.y + vb.height + capL.labelHeight + 4.0f * capL.scale;
ImageCrop(&shot, (Rectangle){ left, top, right - left, bottom - top });
}
int outW = shot.width, outH = shot.height;
bool ok = ExportImage(shot, headlessOut);
UnloadImage(shot);
if (ok) {
printf("Wrote %s (%dx%d)\n", headlessOut, outW, outH);
} else {
fprintf(stderr, "rspektrum: failed to write '%s'\n", headlessOut);
headlessRc = 1;
}
break;
}
}
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);
app.visibleTexture = (Texture2D){ 0 };
app.visibleTextureValid = false;
UnloadTexture(colormapTexture);
FreeBrowserFiles();
FreeAllCacheEntries(&app.fftCache);
free(app.reassignBuffer);
FreeMlnl(&app.annotations);
FreeSignal(&app.signal);
CloseAudioDevice();
CloseWindow();
return headlessRc;
}