2befb42d71
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>
1700 lines
81 KiB
C
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;
|
|
}
|