fb7bc5486e
Rewrite --render to compute the spectrogram and write a PNG entirely on the CPU, with no window, no GL context, and no X server. Previously it opened a hidden GL window and grabbed LoadImageFromScreen(), which still required an X server (Xvfb); the output was a UI screenshot rather than the spectrogram data. The new path (RunHeadlessRender) loads the WAV, computes the STFT, colorizes the bitmap at native STFT resolution, bakes the mLnL annotation overlay onto it, and exports — all CPU-only. render.c gains a GL-free colorize (BuildSpectrogramImageCPU), a CPU font loader (LoadFontCPU), and a CPU overlay drawer (DrawAnnotationsToImage). Annotations draw outline + label only: mLnL captures contain many overlapping full-band boxes whose translucent fills alpha-stack to opaque and bury the signal. The outline marks each region while the spectrogram reads through; a dark backing strip keeps labels legible. Note: MeasureTextEx/ImageText* bail when font.texture.id == 0, so the CPU font sets a sentinel non-zero id (the draw path reads glyph images, never the texture). Render options: --annotation-opacity (overlay strength), --annotation-kinds (comma-separated kind filter), --width (resize; default native). Removed the obsolete --pane/--height window options and the screenshot workaround. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1784 lines
85 KiB
C
1784 lines
85 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 30 // plenty for a non-game UI; halves active-frame cost
|
|
#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();
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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);
|
|
|
|
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 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
|
|
// 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;
|
|
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");
|
|
|
|
while (!WindowShouldClose())
|
|
{
|
|
#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();
|
|
// Active = focused AND something needs animating (or just did, within
|
|
// the grace window). Anything else is a static frame we can sleep on.
|
|
bool active = focused && (GetTime() - lastActive < IDLE_GRACE_SECONDS);
|
|
|
|
if (active) {
|
|
if (waiting != 0) { DisableEventWaiting(); SetTargetFPS(ACTIVE_FPS); waiting = 0; }
|
|
} 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: nothing to show. Block on events (refocus/close)
|
|
// without drawing at all.
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
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();
|
|
}
|
|
|
|
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);
|
|
FreeMlnl(&app.annotations);
|
|
FreeSignal(&app.signal);
|
|
if (IsAudioDeviceReady()) CloseAudioDevice();
|
|
CloseWindow();
|
|
return 0;
|
|
}
|