Files
rspektrum/src/spectrogram.c
T
tyler 82294844dd feat: hide control annotations by default, demote auto-crop notice to a toast
Control events are zero-duration log markers about the run rather than
signals on the air. A busy capture carries thousands, and they clutter the
overlay without saying anything about what was transmitted, so the GUI now
starts with that kind hidden; the per-kind checkbox brings it back. The
headless --render path still enables every kind — an export should render
what was asked for, not a GUI preference.

The auto-crop notice was a full modal: it dimmed the window, took focus,
and demanded a click before the user could look at the file they had just
opened. Auto-crop is a helpful default, not a decision worth blocking on.
It is now a bottom-right toast offering Undo / Dismiss, with a progress
strip showing the remaining time so it doesn't just vanish mid-read, and
it is out of UiModalOpen() so it no longer swallows keys or blocks the
spectrogram underneath.

The countdown only advances while the window is focused, so a crop applied
during a background load is still readable when the user returns. The
per-frame step is clamped: auto-crop fires on the frame right after the
blocking STFT compute, and GetFrameTime() there reports the entire
compute, which drained the whole 5 s budget at once and made the toast
flash by in an instant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN
2026-08-12 14:17:05 -07:00

2064 lines
100 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 power management. raylib re-renders the whole scene every frame, so an
// idle window otherwise pins the GPU (and, on software GL, the CPU) at the
// target rate. We run at ACTIVE_FPS while something needs animating, then go
// fully event-driven (block until input) when idle — see the loop below.
#define ACTIVE_FPS 60 // smooth pan/zoom; idle still parks at ~0% CPU
#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.
*/
// The "Processing..." panel shown while the initial STFT runs. Factored out
// so it can also be presented once, on its own, immediately before the
// blocking compute below — otherwise the user stares at an empty window
// with no indication anything is happening.
static void DrawLoadingOverlay(void)
{
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);
// The overview is computed in a single blocking call, so there are no
// intermediate frames in which to animate a percentage. Show an
// indeterminate bar and say the window will stop responding, rather
// than a progress bar frozen at 0% that reads as a hang.
DrawRectangle(barX, barY, barW, (int)(10 * scale), DARKGRAY);
DrawRectangle(barX, barY, barW, (int)(10 * scale), (Color){ 40, 90, 170, 255 });
const char* note = "Computing spectrogram — the window will be";
const char* note2 = "unresponsive until this finishes.";
int nW = MeasureTextScaled(note, 12);
int n2W = MeasureTextScaled(note2, 12);
DrawTextScaled(note, boxX + boxW / 2 - nW / 2, barY + (int)(20 * scale), 12, LIGHTGRAY);
DrawTextScaled(note2, boxX + boxW / 2 - n2W / 2, barY + (int)(36 * scale), 12, LIGHTGRAY);
// Segment count gives a sense of scale for a long capture.
if (app.stft.numSegments > 0) {
char segText[64];
snprintf(segText, sizeof(segText), "%d segments", app.stft.numSegments);
int sW = MeasureTextScaled(segText, 12);
DrawTextScaled(segText, boxX + boxW / 2 - sW / 2, barY + (int)(56 * scale), 12, GRAY);
}
}
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
if (app.autocropNoticeActive) return true; // toast countdown + progress strip
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 = 22 * 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;
// Indices point into the events array we just freed.
app.hoverStackCount = 0;
// Collision analysis indexes the events we just freed.
free(app.collisionFlags);
app.collisionFlags = NULL;
app.collisionCount = 0;
app.collisionRegionCount = 0;
app.currentCollision = -1;
app.jumpCollisionRequest = 0;
// Segment range belongs to the previous file's STFT; 0/0 means "whole file"
// and lets the first rebuild pick the range for the new one.
app.reassignSegFirst = 0;
app.reassignSegLast = 0;
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;
app.autocropNoticeTimer = AUTOCROP_NOTICE_SECONDS;
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;
}
// Centre the view on a collision region, keeping the current zoom unless the
// region is wider than the window (then widen just enough to hold it, plus a
// margin so its edges aren't flush against the viewport).
static void JumpToCollisionRegion(int idx)
{
if (idx < 0 || idx >= app.collisionRegionCount) return;
if (app.signal.duration <= 0.0f) return;
const CollisionRegion* r = &app.collisionRegions[idx];
float t0 = (float)(r->t0 / app.signal.duration);
float t1 = (float)(r->t1 / app.signal.duration);
float centre = (t0 + t1) * 0.5f;
float span = app.view.end - app.view.start;
float need = (t1 - t0) * 1.6f;
if (need > span) span = need;
float minSpan = MinTimeViewWidth();
if (span < minSpan) span = minSpan;
if (span > 1.0f) span = 1.0f;
app.view.start = centre - span * 0.5f;
app.view.end = centre + span * 0.5f;
if (app.view.start < 0.0f) { app.view.start = 0.0f; app.view.end = span; }
if (app.view.end > 1.0f) { app.view.end = 1.0f; app.view.start = 1.0f - span; }
app.currentCollision = idx;
app.visibleTextureValid = false;
// Make the jump self-explanatory: without the overlay on, the view simply
// moves somewhere with no indication of why.
app.showCollisions = true;
snprintf(app.exportMessage, sizeof(app.exportMessage),
"Collision %d/%d - %d frames at %.2fs",
idx + 1, app.collisionRegionCount, r->count, r->t0);
app.exportMessageTimer = 3.0f;
}
// Next/previous collision relative to where the view is now, not to the last
// jump — so it still does the right thing after the user pans away by hand.
static void ActionNextCollision(void)
{
if (app.collisionRegionCount <= 0 || app.signal.duration <= 0.0f) return;
double centre = (app.view.start + app.view.end) * 0.5 * app.signal.duration;
for (int i = 0; i < app.collisionRegionCount; i++) {
if (app.collisionRegions[i].t0 > centre + 1e-6) { JumpToCollisionRegion(i); return; }
}
JumpToCollisionRegion(0); // wrap
}
static void ActionPrevCollision(void)
{
if (app.collisionRegionCount <= 0 || app.signal.duration <= 0.0f) return;
double centre = (app.view.start + app.view.end) * 0.5 * app.signal.duration;
for (int i = app.collisionRegionCount - 1; i >= 0; i--) {
if (app.collisionRegions[i].t0 < centre - 1e-6) { JumpToCollisionRegion(i); return; }
}
JumpToCollisionRegion(app.collisionRegionCount - 1); // wrap
}
static void ActionCollisionNav(void)
{
if (IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT)) ActionPrevCollision();
else ActionNextCollision();
}
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)" },
{ KEY_N, KEYGATE_MODAL | KEYGATE_LOADED,ActionCollisionNav, "N", "next collision (Shift+N = prev)" },
// 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();
}
}
// ============================================================================
// Headless render (no window, no GL, no X server)
// ============================================================================
// Resolve an mLnL kind token (as emitted by MlnlKindName, plus "unknown") to
// its enum value, or -1 if unrecognized.
static int KindFromToken(const char* tok)
{
if (strcmp(tok, "unknown") == 0) return MLNL_KIND_UNKNOWN;
for (int k = 1; k < MLNL_KIND_MAX; k++)
if (strcmp(tok, MlnlKindName((MlnlKind)k)) == 0) return k;
return -1;
}
// Parse a comma-separated kind list ("tx_frame,control,...") into an enable
// mask. Sets *set true if at least one valid token was seen. Unknown tokens
// warn and are skipped.
static void ParseKindList(const char* list, bool* mask, bool* set)
{
char buf[512];
snprintf(buf, sizeof(buf), "%s", list);
for (char* tok = strtok(buf, ","); tok; tok = strtok(NULL, ",")) {
while (*tok == ' ') tok++;
int k = KindFromToken(tok);
if (k >= 0) { mask[k] = true; *set = true; }
else fprintf(stderr, "rspektrum: unknown annotation kind '%s'\n", tok);
}
}
// Compute the spectrogram for `inputArg` and write it to `renderOut` as a PNG
// without ever opening a window or touching GL. Returns a process exit code.
// annoChoice : -1 auto, 0 force overlay off, 1 force on
// annoOpacity: <0 keep default; else resting overlay alpha 0..1
// kindMask : per-kind enable flags (only consulted if kindMaskSet)
// outW : >0 resize output to this width (aspect-preserving); 0 = native
static int RunHeadlessRender(const char* inputArg, const char* renderOut,
const char* originalDir, int annoChoice,
float annoOpacity, const bool* kindMask,
bool kindMaskSet, int outW)
{
// --- GL-free init of the global state the render touches ---
app.colormap = COLORMAP_INFERNO;
app.amplitudeMode = SCALE_RELATIVE;
app.dynRangeDb = 40.0f;
app.absoluteFloorDb = -60.0f;
app.amplitudeFloorDb = -60.0f;
app.amplitudeCeilingDb = 0.0f;
app.fftSize = FFT_SIZE_DEFAULT;
app.skipFactor = 1;
app.displayMaxFreqHz = 0.0f; // full Nyquist axis (native, uncropped)
app.view.start = 0.0f; app.view.end = 1.0f;
app.view.freqStart = 0.0f; app.view.freqEnd = 1.0f;
app.showAnnotations = true;
// No hover in a headless render, so this single knob is the overall overlay
// strength (outlines solid at 1.0, fills kept lighter). Default brighter
// than the GUI's whisper-faint 0.06 so the static PNG reads on its own.
app.annotationOpacityBase = (annoOpacity >= 0.0f) ? annoOpacity : 0.5f;
app.annotationOpacityHover = app.annotationOpacityBase;
for (int i = 0; i < MLNL_KIND_MAX; i++) app.annotationKindEnabled[i] = true;
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;
}
// --- Load the WAV (resolve relative to the launch dir) ---
char resolved[8192] = { 0 };
if (!FileExists(inputArg) && originalDir[0]) {
snprintf(resolved, sizeof(resolved), "%s/%s", originalDir, inputArg);
}
const char* pathToLoad = FileExists(inputArg) ? inputArg : resolved;
if (!FileExists(pathToLoad) || !LoadWavFile(pathToLoad, &app.signal)) {
fprintf(stderr, "rspektrum: failed to load input WAV '%s'\n", inputArg);
return 1;
}
ResetForNewSignal();
LoadMlnlFromWav(pathToLoad, &app.annotations);
ComputeCollisions();
if (annoChoice == 0) app.showAnnotations = false;
else if (annoChoice == 1) app.showAnnotations = true;
if (kindMaskSet)
for (int i = 0; i < MLNL_KIND_MAX; i++) app.annotationKindEnabled[i] = kindMask[i];
// --- Compute the full-resolution STFT in one shot ---
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
app.skipFactor = 1;
ComputeSTFTIncremental(&app.signal, &app.stft, app.fftSize, 0);
AutoScaleAmplitude(&app.stft);
// --- Build the spectrogram bitmap (no GL) + bake the overlay onto it ---
Image img = { 0 };
BuildSpectrogramImageCPU(&app.stft, &img);
if (img.data == NULL) {
fprintf(stderr, "rspektrum: no spectrogram data to render\n");
FreeSTFT(&app.stft);
return 1;
}
Font font = { 0 };
if (app.showAnnotations && app.annotations.loaded) {
// Font size tracks the image; cap so very tall images don't blow it up.
int fs = (int)Clamp((float)img.height / 45.0f, 12.0f, 28.0f);
font = LoadFontCPU("resources/fonts/DejaVuSansMono.ttf", fs);
if (font.glyphCount == 0) // resources/ not yet CWD — try the launch dir
font = LoadFontCPU(TextFormat("%s/resources/fonts/DejaVuSansMono.ttf", originalDir), fs);
DrawAnnotationsToImage(&img, font);
}
// --- Optional resize, then export ---
if (outW > 0 && outW != img.width) {
int outH = (int)((float)img.height * (float)outW / (float)img.width);
if (outH < 1) outH = 1;
ImageResize(&img, outW, outH);
}
char out[8192];
if (renderOut[0] == '/') snprintf(out, sizeof(out), "%s", renderOut);
else snprintf(out, sizeof(out), "%s/%s", originalDir, renderOut);
int outWf = img.width, outHf = img.height;
bool ok = ExportImage(img, out);
if (font.glyphCount > 0) UnloadFontData(font.glyphs, font.glyphCount);
if (font.recs) RL_FREE(font.recs);
UnloadImage(img);
FreeSTFT(&app.stft);
FreeMlnl(&app.annotations);
FreeSignal(&app.signal);
if (ok) { printf("Wrote %s (%dx%d)\n", out, outWf, outHf); return 0; }
fprintf(stderr, "rspektrum: failed to write '%s'\n", out);
return 1;
}
// ============================================================================
// 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 (RunHeadlessRender, dispatched below) computes the
// spectrogram bitmap, bakes the annotation overlay onto it, writes a PNG,
// and exits — entirely on the CPU, with no window, no GL, and no X server.
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
int renderWidth = 0; // >0 resize the PNG to this width (else native STFT size)
bool kindMask[MLNL_KIND_MAX] = { false }; // which kinds to draw (if kindMaskSet)
bool kindMaskSet = false;
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 (strncmp(a, "--annotation-kinds=", 19) == 0) {
ParseKindList(a + 19, kindMask, &kindMaskSet);
} else if (strcmp(a, "--annotation-kinds") == 0 && i + 1 < argc) {
ParseKindList(argv[++i], kindMask, &kindMaskSet);
} else if (strcmp(a, "--width") == 0 && i + 1 < argc) {
renderWidth = 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 render (no window, no GL, no X server) options:\n"
" -r, --render OUT.png render the spectrogram bitmap to OUT.png\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 overlay strength 0..1 (default 0.5)\n"
" --annotation-kinds=LIST comma-separated kinds to draw, e.g.\n"
" tx_frame,control,assertion_failed (default: all)\n"
" --width N resize output to N px wide (default: native STFT size)\n"
" -h, --help show this help\n\n"
"Annotation kinds: tx_frame, tx_burst, control, channel_up, channel_down,\n"
" assertion_passed, assertion_failed, impairment_fire, gain_change, unknown\n");
return 0;
} else if (a[0] != '-') {
if (!inputArg) inputArg = a;
}
}
if (annoOpacity > 1.0f) annoOpacity = 1.0f;
if (headless && !inputArg) {
fprintf(stderr, "rspektrum: --render requires an input WAV file\n");
return 2;
}
// ---- Headless render: compute + write the PNG with no window/GL/X, exit.
if (headless) {
char cwd[4096] = { 0 };
snprintf(cwd, sizeof(cwd), "%s", GetWorkingDirectory());
SetTraceLogLevel(LOG_WARNING);
return RunHeadlessRender(inputArg, renderOut, cwd, annoChoice, annoOpacity,
kindMask, kindMaskSet, renderWidth);
}
#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
SetConfigFlags(FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE | FLAG_WINDOW_HIGHDPI);
#endif
InitWindow(1280, 800, "Spectrogram Viewer");
SetTargetFPS(ACTIVE_FPS);
SetTraceLogLevel(LOG_WARNING); // Suppress INFO texture logs
// Audio device is opened lazily on first playback (see EnsureAudioDevice)
// and released while idle, so an idle/backgrounded window holds no device.
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 the TTF font. The atlas is rasterized at the physical pixel size text
// is actually drawn at (window UI scale * monitor DPI scale) and rebuilt by
// EnsureUIFont() when that density changes, so glyphs stay crisp on both
// HiDPI and standard-DPI displays instead of thinning out when downscaled.
InitUIFont("fonts/DejaVuSansMono.ttf");
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.24f; // quiet but legible by default — signal still wins
app.annotationOpacityHover = 0.65f; // pop on hover / selection
// Optional CLI override of the resting overlay alpha (e.g. for a brighter
// GUI default). The headless render path sets its own default separately.
if (annoOpacity >= 0.0f) app.annotationOpacityBase = annoOpacity;
app.timelineExpanded = false;
app.hoveredTimelineEvent = -1;
app.selectedAnnotation = -1;
app.hoverStackCount = 0;
app.currentCollision = -1;
for (int i = 0; i < MLNL_KIND_MAX; i++) app.annotationKindEnabled[i] = true;
// Control events are zero-duration log markers about the run, not signals on
// the air. On a busy capture there are thousands of them and they clutter
// the overlay without saying anything about what was transmitted, so they
// start hidden; the per-kind checkbox turns them back on. (Headless
// --render keeps every kind enabled — an export should show what was asked
// for, not a GUI default.)
app.annotationKindEnabled[MLNL_KIND_CONTROL] = false;
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);
ComputeCollisions();
TraceLog(LOG_INFO, "File loaded successfully");
}
}
if (!fileLoaded) TraceLog(LOG_INFO, "Press 'O' for file browser or drag & drop WAV file");
while (!WindowShouldClose())
{
// Set when the window is in the background but still has compute to
// finish: the frame runs its logic and skips presenting. See the
// power-management block below.
bool headlessCompute = false;
#ifdef __EMSCRIPTEN__
// Track the browser viewport (fill + reflow on resize, like desktop).
SyncCanvasToWindow();
#else
// Desktop power management. raylib's frame limiter partial-busy-waits
// ~5% of every frame interval, so capping the FPS can't get idle CPU
// below ~5% of a core. Instead we go fully event-driven when idle:
// block in PollInputEvents() (glfwWaitEvents) until an input/window
// event arrives, so an idle/backgrounded window costs ~0% CPU and only
// redraws on demand.
{
static double lastActive = -1000.0;
static int waiting = -1; // -1 unset, 0 = active (poll), 1 = idle (event-wait)
bool focused = IsWindowFocused();
if (focused && IsAppActive()) lastActive = GetTime();
// Work that must finish whether or not anyone is looking: the
// initial STFT and the background high-res fill. Loading a long
// capture takes minutes, and the user should be able to put the
// window behind something else and come back to a finished file
// rather than having to keep it focused to make progress.
bool hasPendingWork = (app.loaded && !app.stftComputed) ||
(app.isBgProcessing && !app.bgFinished);
// Active = something needs animating (or just did, within the grace
// window). Anything else is a static frame we can sleep on. Pending
// work counts as active even unfocused, so the compute keeps running.
bool active = (focused && (GetTime() - lastActive < IDLE_GRACE_SECONDS)) ||
hasPendingWork;
if (active) {
if (waiting != 0) { DisableEventWaiting(); SetTargetFPS(ACTIVE_FPS); waiting = 0; }
// Working with the window in the background: run the compute
// without drawing. Presenting a frame nobody can see costs GPU
// time and, with vsync, pins the loop to the refresh rate —
// and the fill advances a fixed number of segments per frame,
// so that would throttle the very work we're trying to finish.
if (!focused && hasPendingWork) {
headlessCompute = true;
}
} else {
// Idle: no busy-wait limiter; EndDrawing's PollInputEvents blocks.
if (waiting != 1) { SetTargetFPS(0); EnableEventWaiting(); waiting = 1; }
// Release the output device while idle — but never mid-playback
// (isPlaying gates it, so audio always finishes first). Reopened
// on the next play. Safe on the unfocused path too: only closes
// when nothing is playing.
if (!app.isPlaying && IsAudioDeviceReady()) ReleaseAudioDevice();
if (!focused) {
// Unfocused with nothing pending: block on events
// (refocus/close) without drawing at all. hasPendingWork is
// false here — a pending load takes the `active` branch
// above and never reaches this, so PollInputEvents can't
// stall the compute waiting for an input that isn't coming.
PollInputEvents();
continue;
}
// Focused but idle: fall through to draw this one static frame,
// then EndDrawing blocks until the next event.
}
}
#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);
ComputeCollisions();
}
}
}
UnloadDroppedFiles(dropped);
}
// Global key bindings (table-driven; see KEYMAP/GetKeymap). The
// order-sensitive keys (Space, Esc) are handled inline further below.
DispatchKeymap();
// Sidebar collision prev/next (set last frame by DrawSidebar, which
// can't reach the static jump helpers directly).
if (app.jumpCollisionRequest != 0) {
if (app.jumpCollisionRequest < 0) ActionPrevCollision();
else ActionNextCollision();
app.jumpCollisionRequest = 0;
}
// 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, against the length of the buffer
// that's actually playing (see playDuration) rather than a length
// re-derived from the live selection — the user can move the
// selection mid-playback without the marker jumping.
app.playheadElapsed += GetFrameTime();
if (app.playDuration > 0.0f) {
app.playheadT = app.playheadElapsed / app.playDuration;
if (app.playheadT > 1.0f) app.playheadT = 1.0f;
}
}
// 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. Bare wheel zooms both axes together (keeps
// the aspect ratio); Shift+wheel is time-only and Ctrl+wheel is
// frequency-only, for when you need to stretch one axis alone.
if (GetMousePosition().x > sidebarWidth + 5 && CheckCollisionPointRec(GetMousePosition(), viewBounds)) {
int wheel = GetMouseWheelMove();
if (wheel != 0) {
float zoomFactor = (wheel > 0) ? 0.8f : 1.2f;
bool shiftHeld = IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT);
bool ctrlHeld = IsKeyDown(KEY_LEFT_CONTROL) || IsKeyDown(KEY_RIGHT_CONTROL);
bool zoomTime = !ctrlHeld; // Ctrl = frequency only
bool zoomFreq = !shiftHeld; // Shift = time only
// --- Time axis zoom (around cursor X) ---
if (zoomTime) {
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;
// Floor the window in SECONDS, not as a fraction of the file.
// A flat 2% floor meant a 30-minute recording could never show
// less than 36 s, while a 30-second one bottomed out at 0.6 s.
// The real limit is the STFT hop: once fewer than a handful of
// segments span the viewport there is no more detail to expose.
float minWidth = MinTimeViewWidth();
if (newWidth < minWidth) newWidth = minWidth;
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) ---
if (zoomFreq) {
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;
// ---- Progressive full-resolution fill ----
// After the strided overview loads, the missing segments are filled
// in at full resolution: the visible range first (foreground, so a
// zoom-in sharpens immediately), then a monotonic sweep of the whole
// file whenever the user is idle. The sweep RESUMES on its own after
// an interaction — we only gate the per-frame compute, we never latch
// the task off. (Previously an interaction set isBgProcessing=false
// permanently, so the fill died on the first pan/zoom and left
// black/low-res stripes wherever the foreground pass hadn't reached.)
bool fillingDirty = false;
// Foreground: fill missing segments in the visible range right away.
// Must NOT move the background sweep cursor (app.bgHighResSeg), or the
// sweep would skip everything before the current view and strand it.
if (app.stftComputed && !app.bgFinished && app.stft.numSegments > 0 &&
app.view.end - app.view.start <= 0.25f) {
int viewStartSeg = (int)(app.view.start * app.stft.numSegments);
int viewEndSeg = (int)(app.view.end * app.stft.numSegments);
if (viewStartSeg < 0) viewStartSeg = 0;
if (viewEndSeg >= app.stft.numSegments) viewEndSeg = app.stft.numSegments - 1;
for (int seg = viewStartSeg; seg <= viewEndSeg; seg++) {
if (app.stft.segments[seg].spectrum == NULL) {
int endSeg = seg + 50;
if (endSeg > viewEndSeg + 1) endSeg = viewEndSeg + 1;
ComputeNextHighResChunk(&app.signal, &app.stft, app.fftSize, seg, endSeg);
fillingDirty = true;
break;
}
}
}
// Background: when idle, sweep the whole file from the cursor and
// compute any still-missing segment (already-computed ones are skipped
// cheaply). Done once the cursor passes the last segment.
if (app.stftComputed && !app.bgFinished && app.stft.numSegments > 0 &&
!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);
fillingDirty = true;
if (app.bgHighResSeg >= app.stft.numSegments) {
app.bgFinished = true;
TraceLog(LOG_INFO, "Full-res fill complete (%d segments)", app.stft.numSegments);
SaveToCache(); // overwrite the overview-only cache entry
}
}
// Stay awake while work remains so idle frames keep the sweep moving
// (IsAppActive() checks this); let the loop sleep once it's done.
app.isBgProcessing = !app.bgFinished;
// Reveal freshly-filled segments. Reassigning the whole file is too
// costly to do every frame, so throttle during the sweep and force a
// final pass at completion. Skipped while interacting to avoid a hitch
// mid-gesture — the next idle frame repaints.
if (fillingDirty && (app.bgFinished || !IsUserInteracting())) {
static double lastFillColorize = 0.0;
double now = GetTime();
if (app.bgFinished || now - lastFillColorize > 0.5) {
if (app.bgFinished) AutoScaleAmplitude(&app.stft);
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
app.visibleTextureValid = false;
lastFillColorize = now;
}
}
}
// 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 the whole overview in ONE blocking call, having first
// presented the loading panel so the window isn't blank while it
// runs.
//
// This used to advance 200 segments per frame, which made the
// load frame-paced rather than CPU-bound: at ACTIVE_FPS the
// limiter, not the FFT, set the pace, so a 478k-segment capture
// spent over a minute doing nothing but waiting between frames.
// The absurd tell was that backgrounding the window (which skips
// presenting entirely) loaded the same file in seconds — the
// progress bar was slower precisely because you were watching it.
//
// The UI is deliberately unresponsive for the duration: this is
// a batch compute with nothing to interact with, and pretending
// otherwise is what caused the problem. Normal event handling
// resumes the moment it completes.
//
// The panel is drawn and presented here rather than by the main
// draw pass, which sits far below and would only run once the
// compute had already finished.
BeginDrawing();
ClearBackground((Color){ 30, 30, 30, 255 });
DrawLoadingOverlay();
EndDrawing();
ComputeSTFTIncremental(&app.signal, &app.stft, app.fftSize, 0);
app.currentSTFTSegment = app.stft.numSegments;
app.loadingProgress = 1.0f;
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;
// Arm the progressive full-res fill from the start of the file.
// A full-resolution overview (skipFactor 1) has nothing missing,
// so mark it finished and skip the sweep entirely.
app.bgHighResSeg = 0;
app.bgFinished = (app.skipFactor <= 1);
app.isBgProcessing = !app.bgFinished;
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__
}
#ifndef __EMSCRIPTEN__
// Background compute with no visible window: the STFT work above has
// run for this frame, so skip the whole draw pass and loop straight
// back. PollInputEvents (rather than a blocking wait) keeps refocus and
// close responsive while the compute runs at full speed, unthrottled by
// vsync — the fill advances a fixed number of segments per frame, so
// presenting would cap throughput at the refresh rate.
if (headlessCompute) {
PollInputEvents();
continue;
}
#endif
// 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 if (!hoverInsideSelection) {
// Sub-threshold drag outside any existing selection: treat
// as a click on empty space and reset to full range. A
// stray click *inside* the current box leaves it alone —
// silently clearing it there changes what Space plays.
// (hoverInsideSelection, not clickInsideSelection: the
// latter is press-frame-only and is always false here.)
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;
}
}
// 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;
}
// Keep the font atlas baked at the current display density (window scale
// * DPI). Cheap no-op unless the density actually changed (resize, or
// dragging the window between monitors of different DPI).
EnsureUIFont();
// 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) {
// Rebuild the source image whenever the view moves outside the
// segment range it was built for. The image covers the visible span
// (plus margin) rather than the whole file — see
// ComputeSpectrogramReassignment — so this is what keeps on-screen
// resolution tied to the zoom level instead of to total duration.
if (app.stft.numSegments > 0) {
int want0 = (int)(app.view.start * app.stft.numSegments);
int want1 = (int)ceilf(app.view.end * app.stft.numSegments);
// Margin so small pans don't re-render every frame.
int margin = (want1 - want0) / 4;
want0 -= margin; want1 += margin;
if (want0 < 0) want0 = 0;
if (want1 > app.stft.numSegments) want1 = app.stft.numSegments;
if (want1 <= want0) want1 = want0 + 1;
bool needRebuild = app.reassignBuffer == NULL ||
want0 < app.reassignSegFirst ||
want1 > app.reassignSegLast;
// Also re-render once the view has zoomed in far enough that the
// cached image is being magnified — otherwise a deep zoom keeps
// stretching the same columns instead of resolving new detail.
if (!needRebuild && app.reassignSegsPerCol > 1) {
int visSegs = want1 - want0;
int visCols = visSegs / app.reassignSegsPerCol;
if (visCols < MAX_SPECTRO_IMAGE_WIDTH / 4) needRebuild = true;
}
if (needRebuild) {
app.reassignSegFirst = want0;
app.reassignSegLast = want1;
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage,
&app.spectrogramTexture);
app.visibleTextureValid = false;
}
}
int imgWidth = app.spectrogramImage.width;
int imgHeight = app.spectrogramImage.height;
// Calculate visible region (time and frequency). X is relative to
// the segment range the image was built for, not the whole file.
float rangeStart = 0.0f, rangeEnd = 1.0f;
if (app.stft.numSegments > 0 && app.reassignSegLast > app.reassignSegFirst) {
rangeStart = (float)app.reassignSegFirst / app.stft.numSegments;
rangeEnd = (float)app.reassignSegLast / app.stft.numSegments;
}
float rangeSpan = rangeEnd - rangeStart;
if (rangeSpan <= 0.0f) rangeSpan = 1.0f;
float relStart = (app.view.start - rangeStart) / rangeSpan;
float relEnd = (app.view.end - rangeStart) / rangeSpan;
if (relStart < 0.0f) relStart = 0.0f;
if (relEnd > 1.0f) relEnd = 1.0f;
int visibleStartX = (int)(relStart * imgWidth);
int visibleEndX = (int)(relEnd * 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. Thumb geometry uses proper "track travel" math
// (thumb travels over width-thumb, not the whole track) so a min-size
// thumb stays inside the track and dragging maps 1:1 to the cursor at
// any zoom. minThumb keeps the thumb grabbable when zoomed way in.
Vector2 mouse = GetMousePosition();
float minThumb = 28.0f * renderScale;
Color trackColor = (Color){ 40, 40, 46, 255 };
Color trackBorder = (Color){ 72, 72, 82, 255 };
Color thumbColor = (Color){ 110, 114, 126, 255 };
Color thumbHot = (Color){ 158, 164, 180, 255 };
// Horizontal scrollbar (time)
float hViewW = app.view.end - app.view.start;
float hThumbW = fmaxf(hViewW * hScrollbar.width, minThumb);
float hTravel = hScrollbar.width - hThumbW;
float hDenom = 1.0f - hViewW;
float hThumbX = hScrollbar.x + (hDenom > 1e-6f ? (app.view.start / hDenom) * hTravel : 0.0f);
Rectangle hThumb = { hThumbX, hScrollbar.y, hThumbW, hScrollbar.height };
// Vertical scrollbar (frequency; axis flipped so freqEnd=1 is at top)
float vViewH = app.view.freqEnd - app.view.freqStart;
float vThumbH = fmaxf(vViewH * vScrollbar.height, minThumb);
float vTravel = vScrollbar.height - vThumbH;
float vDenom = 1.0f - vViewH;
float vThumbY = vScrollbar.y + (vDenom > 1e-6f ? ((1.0f - app.view.freqEnd) / vDenom) * vTravel : 0.0f);
Rectangle vThumb = { vScrollbar.x, vThumbY, vScrollbar.width, vThumbH };
// Handle scrollbar interaction. A press on the empty track jumps the
// view so the thumb re-centers under the cursor, then drags from there.
static bool draggingH = false, draggingV = false;
static Vector2 dragStartPos;
static float dragStartViewStart, dragStartFreqViewStart;
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && CheckCollisionPointRec(mouse, hScrollbar)) {
if (!CheckCollisionPointRec(mouse, hThumb) && hTravel > 0.0f) {
float frac = Clamp((mouse.x - hScrollbar.x - hThumbW * 0.5f) / hTravel, 0.0f, 1.0f);
app.view.start = frac * hDenom;
app.view.end = app.view.start + hViewW;
app.visibleTextureValid = false;
}
draggingH = true;
dragStartPos = mouse;
dragStartViewStart = app.view.start;
}
if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && CheckCollisionPointRec(mouse, vScrollbar)) {
if (!CheckCollisionPointRec(mouse, vThumb) && vTravel > 0.0f) {
float frac = Clamp((mouse.y - vScrollbar.y - vThumbH * 0.5f) / vTravel, 0.0f, 1.0f);
app.view.freqEnd = 1.0f - frac * vDenom;
app.view.freqStart = app.view.freqEnd - vViewH;
app.visibleTextureValid = false;
}
draggingV = true;
dragStartPos = mouse;
dragStartFreqViewStart = app.view.freqStart;
}
if (draggingH && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float dx = (hTravel > 0.0f) ? ((mouse.x - dragStartPos.x) / hTravel) * hDenom : 0.0f;
app.view.start = dragStartViewStart + dx;
app.view.end = app.view.start + hViewW;
if (app.view.start < 0) { app.view.start = 0; app.view.end = hViewW; }
if (app.view.end > 1) { app.view.end = 1; app.view.start = 1 - hViewW; }
app.visibleTextureValid = false;
}
if (draggingV && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) {
float dy = (vTravel > 0.0f) ? ((mouse.y - dragStartPos.y) / vTravel) * vDenom : 0.0f;
app.view.freqStart = dragStartFreqViewStart - dy;
app.view.freqEnd = app.view.freqStart + vViewH;
if (app.view.freqStart < 0) {
app.view.freqStart = 0;
app.view.freqEnd = fminf(vViewH, 1.0f);
}
if (app.view.freqEnd > 1) {
app.view.freqEnd = 1;
app.view.freqStart = fmaxf(1.0f - vViewH, 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; }
// Draw tracks + thumbs after interaction so hover/active state and the
// jumped position are reflected this frame.
DrawRectangleRec(hScrollbar, trackColor);
DrawRectangleLinesEx(hScrollbar, 1.0f, trackBorder);
DrawRectangleRounded(hThumb, 0.5f, 4,
(draggingH || CheckCollisionPointRec(mouse, hThumb)) ? thumbHot : thumbColor);
DrawRectangleRec(vScrollbar, trackColor);
DrawRectangleLinesEx(vScrollbar, 1.0f, trackBorder);
DrawRectangleRounded(vThumb, 0.5f, 4,
(draggingV || CheckCollisionPointRec(mouse, vThumb)) ? thumbHot : thumbColor);
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.playSelStart + app.playheadT * (app.playSelEnd - app.playSelStart));
} 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();
}
TraceLog(LOG_INFO, "Shutting down...");
if (mainFont.texture.id != 0) UnloadFont(mainFont);
if (IsAudioDeviceReady() && 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);
free(app.collisionFlags);
FreeWaveEnvelope(&app.scopeView.envelope);
FreeMlnl(&app.annotations);
FreeSignal(&app.signal);
if (IsAudioDeviceReady()) CloseAudioDevice();
CloseWindow();
return 0;
}