perf: unblock long-file loading; add collision detection and jump nav
Three things, all surfaced while working a multi-node protocol issue on a 5.7-hour capture. **Loading was frame-paced, not compute-bound.** The STFT overview advanced a fixed 200 segments per frame, so the frame limiter — not the FFT — set the pace: 478k segments at ACTIVE_FPS meant over a minute spent waiting between frames rather than computing. The tell was absurd: backgrounding the window, which skips presenting entirely, loaded the same file in seconds. The progress bar was slower precisely because you were watching it. The overview is now computed in one blocking call after presenting the loading panel, so focused load matches the unfocused speed. The panel says the window will stop responding and drops the percentage, which could not animate and would have read as a hang. ACTIVE_FPS 30 -> 60 while here; idle still parks at ~0% CPU through the event-wait path. Background work also no longer stops when the window loses focus. Pending work now counts as "active" regardless of focus, so the loop doesn't block in PollInputEvents waiting for input that isn't coming, and an unfocused frame with work outstanding skips the draw pass entirely rather than throttling the high-res fill to the refresh rate. **Collision detection.** Annotations that overlap in both time and frequency are flagged, merged into contiguous regions, and drawn as red bands confined to the band the overlap occupies (padded, so a narrow overlap stays findable) rather than spanning the full axis and hiding the signal being pointed at. N / Shift+N and sidebar buttons jump between regions, centring each without disturbing the current zoom. Point markers are excluded: control events and assertions have no band and zero duration, and treating a missing band as "whole spectrum" — which is how they are *drawn* — made every marker collide with whatever it sat inside. That was 38% of the reported collisions on a real capture. Only things that actually occupy the air can interfere. Counts verified against an independent reference implementation on two captures. **Scope waveform via min/max summary.** The envelope rescanned every visible sample every frame — ~245M reads, near 1 GB of memory traffic, on a multi-hour file, measured at ~60 ms/frame for a few hundred pixel columns. It now draws from 1024-sample buckets built once at load (60 ms, 1.9 MB), measured at ~0.07 ms/frame. Keeping both extremes per bucket means single-sample transients still show at full zoom-out, which plain decimation would drop; verified that no column ever understates a true peak. Zoomed in past a bucket it falls back to raw samples, which is cheap there by definition. Also fixes ComputeCollisions never running for files opened through the file browser, and moves the collision panel above the annotations dropdown where it isn't pushed off the bottom of the sidebar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN
This commit is contained in:
+276
-128
@@ -74,7 +74,7 @@ static bool IsUserInteracting(void)
|
||||
// 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 ACTIVE_FPS 60 // smooth pan/zoom; idle still parks at ~0% CPU
|
||||
#define IDLE_GRACE_SECONDS 0.5 // stay at full rate briefly after the last activity
|
||||
|
||||
/**
|
||||
@@ -83,6 +83,57 @@ static bool IsUserInteracting(void)
|
||||
* background STFT, an active drag/pan/divider, or a counting-down notice.
|
||||
* Everything else is a static frame we can throttle.
|
||||
*/
|
||||
// The "Processing..." panel shown while the initial STFT runs. Factored out
|
||||
// so it can also be presented once, on its own, immediately before the
|
||||
// blocking compute below — otherwise the user stares at an empty window
|
||||
// with no indication anything is happening.
|
||||
static void DrawLoadingOverlay(void)
|
||||
{
|
||||
float scale = GetUIScale();
|
||||
int w = GetScreenWidth();
|
||||
int h = GetScreenHeight();
|
||||
int boxW = (int)(380 * scale);
|
||||
int boxH = (int)(160 * scale);
|
||||
int boxX = (w - boxW) / 2;
|
||||
int boxY = (h - boxH) / 2;
|
||||
|
||||
// Dim overlay
|
||||
DrawRectangle(0, 0, w, h, (Color){ 0, 0, 0, 100 });
|
||||
// Info box
|
||||
DrawRectangleRec((Rectangle){ (float)boxX, (float)boxY, (float)boxW, (float)boxH }, (Color){ 40, 40, 40, 230 });
|
||||
DrawRectangleLines(boxX, boxY, boxW, boxH, GRAY);
|
||||
|
||||
int textY = boxY + (int)(30 * scale);
|
||||
int barY = textY + (int)(28 * scale);
|
||||
int barW = boxW - (int)(60 * scale);
|
||||
int barX = boxX + (int)(30 * scale);
|
||||
|
||||
// Title
|
||||
DrawTextScaled("Processing...", boxX + boxW / 2 - MeasureTextScaled("Processing...", 18) / 2, textY, 18, LIGHTGRAY);
|
||||
|
||||
// The overview is computed in a single blocking call, so there are no
|
||||
// intermediate frames in which to animate a percentage. Show an
|
||||
// indeterminate bar and say the window will stop responding, rather
|
||||
// than a progress bar frozen at 0% that reads as a hang.
|
||||
DrawRectangle(barX, barY, barW, (int)(10 * scale), DARKGRAY);
|
||||
DrawRectangle(barX, barY, barW, (int)(10 * scale), (Color){ 40, 90, 170, 255 });
|
||||
|
||||
const char* note = "Computing spectrogram — the window will be";
|
||||
const char* note2 = "unresponsive until this finishes.";
|
||||
int nW = MeasureTextScaled(note, 12);
|
||||
int n2W = MeasureTextScaled(note2, 12);
|
||||
DrawTextScaled(note, boxX + boxW / 2 - nW / 2, barY + (int)(20 * scale), 12, LIGHTGRAY);
|
||||
DrawTextScaled(note2, boxX + boxW / 2 - n2W / 2, barY + (int)(36 * scale), 12, LIGHTGRAY);
|
||||
|
||||
// Segment count gives a sense of scale for a long capture.
|
||||
if (app.stft.numSegments > 0) {
|
||||
char segText[64];
|
||||
snprintf(segText, sizeof(segText), "%d segments", app.stft.numSegments);
|
||||
int sW = MeasureTextScaled(segText, 12);
|
||||
DrawTextScaled(segText, boxX + boxW / 2 - sW / 2, barY + (int)(56 * scale), 12, GRAY);
|
||||
}
|
||||
}
|
||||
|
||||
static bool IsAppActive(void)
|
||||
{
|
||||
if (IsUserInteracting()) return true;
|
||||
@@ -234,6 +285,13 @@ void ResetForNewSignal(void)
|
||||
app.selectedAnnotation = -1;
|
||||
// Indices point into the events array we just freed.
|
||||
app.hoverStackCount = 0;
|
||||
// Collision analysis indexes the events we just freed.
|
||||
free(app.collisionFlags);
|
||||
app.collisionFlags = NULL;
|
||||
app.collisionCount = 0;
|
||||
app.collisionRegionCount = 0;
|
||||
app.currentCollision = -1;
|
||||
app.jumpCollisionRequest = 0;
|
||||
// Segment range belongs to the previous file's STFT; 0/0 means "whole file"
|
||||
// and lets the first rebuild pick the range for the new one.
|
||||
app.reassignSegFirst = 0;
|
||||
@@ -500,6 +558,70 @@ static void ActionZoomToStart(void)
|
||||
app.visibleTextureValid = false;
|
||||
}
|
||||
|
||||
// Centre the view on a collision region, keeping the current zoom unless the
|
||||
// region is wider than the window (then widen just enough to hold it, plus a
|
||||
// margin so its edges aren't flush against the viewport).
|
||||
static void JumpToCollisionRegion(int idx)
|
||||
{
|
||||
if (idx < 0 || idx >= app.collisionRegionCount) return;
|
||||
if (app.signal.duration <= 0.0f) return;
|
||||
|
||||
const CollisionRegion* r = &app.collisionRegions[idx];
|
||||
float t0 = (float)(r->t0 / app.signal.duration);
|
||||
float t1 = (float)(r->t1 / app.signal.duration);
|
||||
float centre = (t0 + t1) * 0.5f;
|
||||
|
||||
float span = app.view.end - app.view.start;
|
||||
float need = (t1 - t0) * 1.6f;
|
||||
if (need > span) span = need;
|
||||
float minSpan = MinTimeViewWidth();
|
||||
if (span < minSpan) span = minSpan;
|
||||
if (span > 1.0f) span = 1.0f;
|
||||
|
||||
app.view.start = centre - span * 0.5f;
|
||||
app.view.end = centre + span * 0.5f;
|
||||
if (app.view.start < 0.0f) { app.view.start = 0.0f; app.view.end = span; }
|
||||
if (app.view.end > 1.0f) { app.view.end = 1.0f; app.view.start = 1.0f - span; }
|
||||
|
||||
app.currentCollision = idx;
|
||||
app.visibleTextureValid = false;
|
||||
// Make the jump self-explanatory: without the overlay on, the view simply
|
||||
// moves somewhere with no indication of why.
|
||||
app.showCollisions = true;
|
||||
snprintf(app.exportMessage, sizeof(app.exportMessage),
|
||||
"Collision %d/%d - %d frames at %.2fs",
|
||||
idx + 1, app.collisionRegionCount, r->count, r->t0);
|
||||
app.exportMessageTimer = 3.0f;
|
||||
}
|
||||
|
||||
// Next/previous collision relative to where the view is now, not to the last
|
||||
// jump — so it still does the right thing after the user pans away by hand.
|
||||
static void ActionNextCollision(void)
|
||||
{
|
||||
if (app.collisionRegionCount <= 0 || app.signal.duration <= 0.0f) return;
|
||||
double centre = (app.view.start + app.view.end) * 0.5 * app.signal.duration;
|
||||
for (int i = 0; i < app.collisionRegionCount; i++) {
|
||||
if (app.collisionRegions[i].t0 > centre + 1e-6) { JumpToCollisionRegion(i); return; }
|
||||
}
|
||||
JumpToCollisionRegion(0); // wrap
|
||||
}
|
||||
|
||||
static void ActionPrevCollision(void)
|
||||
{
|
||||
if (app.collisionRegionCount <= 0 || app.signal.duration <= 0.0f) return;
|
||||
double centre = (app.view.start + app.view.end) * 0.5 * app.signal.duration;
|
||||
for (int i = app.collisionRegionCount - 1; i >= 0; i--) {
|
||||
if (app.collisionRegions[i].t0 < centre - 1e-6) { JumpToCollisionRegion(i); return; }
|
||||
}
|
||||
JumpToCollisionRegion(app.collisionRegionCount - 1); // wrap
|
||||
}
|
||||
|
||||
static void ActionCollisionNav(void)
|
||||
{
|
||||
if (IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT)) ActionPrevCollision();
|
||||
else ActionNextCollision();
|
||||
}
|
||||
|
||||
static const KeyBinding KEYMAP[] = {
|
||||
{ KEY_O, KEYGATE_MODAL, ActionOpenBrowser, "O", "open file browser" },
|
||||
{ KEY_P, KEYGATE_NONE, ActionToggleScope, "P", "show / hide waveform scope" },
|
||||
@@ -511,6 +633,7 @@ static const KeyBinding KEYMAP[] = {
|
||||
{ KEY_W, KEYGATE_MODAL | KEYGATE_STFT, ActionExportWav, "W", "export selection WAV" },
|
||||
{ KEY_M, KEYGATE_MODAL | KEYGATE_LOADED,ActionToggleMarker, "M", "marker / ruler tool" },
|
||||
{ KEY_S, KEYGATE_MODAL | KEYGATE_STFT, ActionToggleSpectrum, "S", "spectrum slice (PSD)" },
|
||||
{ KEY_N, KEYGATE_MODAL | KEYGATE_LOADED,ActionCollisionNav, "N", "next collision (Shift+N = prev)" },
|
||||
// Order-sensitive: handled inline (see main loop), listed here for the overlay.
|
||||
{ KEY_SPACE, KEYGATE_NONE, NULL, "Space", "play / stop selection" },
|
||||
{ KEY_ESCAPE,KEYGATE_NONE, NULL, "Esc", "clear selection / close dialog" },
|
||||
@@ -617,6 +740,7 @@ static int RunHeadlessRender(const char* inputArg, const char* renderOut,
|
||||
}
|
||||
ResetForNewSignal();
|
||||
LoadMlnlFromWav(pathToLoad, &app.annotations);
|
||||
ComputeCollisions();
|
||||
|
||||
if (annoChoice == 0) app.showAnnotations = false;
|
||||
else if (annoChoice == 1) app.showAnnotations = true;
|
||||
@@ -842,6 +966,7 @@ int main(int argc, char* argv[])
|
||||
app.hoveredTimelineEvent = -1;
|
||||
app.selectedAnnotation = -1;
|
||||
app.hoverStackCount = 0;
|
||||
app.currentCollision = -1;
|
||||
for (int i = 0; i < MLNL_KIND_MAX; i++) app.annotationKindEnabled[i] = true;
|
||||
app.showScope = true;
|
||||
app.dividerY = 0.6f; // Start with 60% spectro, 40% scope
|
||||
@@ -875,6 +1000,7 @@ int main(int argc, char* argv[])
|
||||
fileLoaded = true;
|
||||
ResetForNewSignal();
|
||||
LoadMlnlFromWav(pathToLoad, &app.annotations);
|
||||
ComputeCollisions();
|
||||
TraceLog(LOG_INFO, "File loaded successfully");
|
||||
}
|
||||
}
|
||||
@@ -883,6 +1009,11 @@ int main(int argc, char* argv[])
|
||||
|
||||
while (!WindowShouldClose())
|
||||
{
|
||||
// Set when the window is in the background but still has compute to
|
||||
// finish: the frame runs its logic and skips presenting. See the
|
||||
// power-management block below.
|
||||
bool headlessCompute = false;
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
// Track the browser viewport (fill + reflow on resize, like desktop).
|
||||
SyncCanvasToWindow();
|
||||
@@ -899,12 +1030,31 @@ int main(int argc, char* argv[])
|
||||
|
||||
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);
|
||||
|
||||
// Work that must finish whether or not anyone is looking: the
|
||||
// initial STFT and the background high-res fill. Loading a long
|
||||
// capture takes minutes, and the user should be able to put the
|
||||
// window behind something else and come back to a finished file
|
||||
// rather than having to keep it focused to make progress.
|
||||
bool hasPendingWork = (app.loaded && !app.stftComputed) ||
|
||||
(app.isBgProcessing && !app.bgFinished);
|
||||
|
||||
// Active = something needs animating (or just did, within the grace
|
||||
// window). Anything else is a static frame we can sleep on. Pending
|
||||
// work counts as active even unfocused, so the compute keeps running.
|
||||
bool active = (focused && (GetTime() - lastActive < IDLE_GRACE_SECONDS)) ||
|
||||
hasPendingWork;
|
||||
|
||||
if (active) {
|
||||
if (waiting != 0) { DisableEventWaiting(); SetTargetFPS(ACTIVE_FPS); waiting = 0; }
|
||||
// Working with the window in the background: run the compute
|
||||
// without drawing. Presenting a frame nobody can see costs GPU
|
||||
// time and, with vsync, pins the loop to the refresh rate —
|
||||
// and the fill advances a fixed number of segments per frame,
|
||||
// so that would throttle the very work we're trying to finish.
|
||||
if (!focused && hasPendingWork) {
|
||||
headlessCompute = true;
|
||||
}
|
||||
} else {
|
||||
// Idle: no busy-wait limiter; EndDrawing's PollInputEvents blocks.
|
||||
if (waiting != 1) { SetTargetFPS(0); EnableEventWaiting(); waiting = 1; }
|
||||
@@ -914,8 +1064,11 @@ int main(int argc, char* argv[])
|
||||
// when nothing is playing.
|
||||
if (!app.isPlaying && IsAudioDeviceReady()) ReleaseAudioDevice();
|
||||
if (!focused) {
|
||||
// Unfocused: nothing to show. Block on events (refocus/close)
|
||||
// without drawing at all.
|
||||
// Unfocused with nothing pending: block on events
|
||||
// (refocus/close) without drawing at all. hasPendingWork is
|
||||
// false here — a pending load takes the `active` branch
|
||||
// above and never reaches this, so PollInputEvents can't
|
||||
// stall the compute waiting for an input that isn't coming.
|
||||
PollInputEvents();
|
||||
continue;
|
||||
}
|
||||
@@ -935,6 +1088,7 @@ int main(int argc, char* argv[])
|
||||
if (LoadWavFile(dropped.paths[0], &app.signal)) {
|
||||
ResetForNewSignal();
|
||||
LoadMlnlFromWav(dropped.paths[0], &app.annotations);
|
||||
ComputeCollisions();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -945,6 +1099,14 @@ int main(int argc, char* argv[])
|
||||
// order-sensitive keys (Space, Esc) are handled inline further below.
|
||||
DispatchKeymap();
|
||||
|
||||
// Sidebar collision prev/next (set last frame by DrawSidebar, which
|
||||
// can't reach the static jump helpers directly).
|
||||
if (app.jumpCollisionRequest != 0) {
|
||||
if (app.jumpCollisionRequest < 0) ActionPrevCollision();
|
||||
else ActionNextCollision();
|
||||
app.jumpCollisionRequest = 0;
|
||||
}
|
||||
|
||||
// Check if playback finished naturally
|
||||
if (app.isPlaying && AudioPlaybackSound.frameCount > 0) {
|
||||
// Check if sound stopped playing (IsSoundPlaying returns false when done)
|
||||
@@ -1145,6 +1307,112 @@ int main(int argc, char* argv[])
|
||||
|
||||
}
|
||||
|
||||
// Processing (incremental across frames)
|
||||
if (app.loaded && !app.stftComputed) {
|
||||
#ifdef __EMSCRIPTEN__
|
||||
// Web build: there are no worker threads, and the desktop path's
|
||||
// overview-then-deferred-high-res fill depends on many main-loop
|
||||
// iterations yielding to the browser (which made loading appear to
|
||||
// stall partway). Compute the full-resolution STFT in one shot so
|
||||
// the spectrogram is completely ready as soon as the file loads.
|
||||
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
|
||||
app.skipFactor = 1; // full resolution, no overview stride
|
||||
ComputeSTFTIncremental(&app.signal, &app.stft, app.fftSize, 0); // computes every segment
|
||||
AutoScaleAmplitude(&app.stft);
|
||||
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
|
||||
app.currentSTFTSegment = app.stft.numSegments;
|
||||
app.bgHighResSeg = app.stft.numSegments;
|
||||
app.loadingProgress = 1.0f;
|
||||
app.stftComputed = true;
|
||||
app.highResFinished = true;
|
||||
app.bgFinished = true;
|
||||
app.isBgProcessing = false;
|
||||
app.loadingPhase = 0;
|
||||
SaveToCache();
|
||||
if (app.autocropPending) { ApplyAutoCrop(); app.autocropPending = false; }
|
||||
#else
|
||||
if (app.loadingPhase == 0) {
|
||||
// Initialize STFT once
|
||||
ComputeSTFTInit(&app.signal, &app.stft, app.fftSize);
|
||||
app.skipFactor = ComputeSkipFactor(app.signal.duration);
|
||||
app.bgHighResSeg = 0;
|
||||
app.bgFinished = false;
|
||||
app.isBgProcessing = false;
|
||||
app.currentSTFTSegment = 0;
|
||||
app.loadingPhase = 1;
|
||||
}
|
||||
if (app.loadingPhase == 1) {
|
||||
// Compute the whole overview in ONE blocking call, having first
|
||||
// presented the loading panel so the window isn't blank while it
|
||||
// runs.
|
||||
//
|
||||
// This used to advance 200 segments per frame, which made the
|
||||
// load frame-paced rather than CPU-bound: at ACTIVE_FPS the
|
||||
// limiter, not the FFT, set the pace, so a 478k-segment capture
|
||||
// spent over a minute doing nothing but waiting between frames.
|
||||
// The absurd tell was that backgrounding the window (which skips
|
||||
// presenting entirely) loaded the same file in seconds — the
|
||||
// progress bar was slower precisely because you were watching it.
|
||||
//
|
||||
// The UI is deliberately unresponsive for the duration: this is
|
||||
// a batch compute with nothing to interact with, and pretending
|
||||
// otherwise is what caused the problem. Normal event handling
|
||||
// resumes the moment it completes.
|
||||
//
|
||||
// The panel is drawn and presented here rather than by the main
|
||||
// draw pass, which sits far below and would only run once the
|
||||
// compute had already finished.
|
||||
BeginDrawing();
|
||||
ClearBackground((Color){ 30, 30, 30, 255 });
|
||||
DrawLoadingOverlay();
|
||||
EndDrawing();
|
||||
|
||||
ComputeSTFTIncremental(&app.signal, &app.stft, app.fftSize, 0);
|
||||
app.currentSTFTSegment = app.stft.numSegments;
|
||||
app.loadingProgress = 1.0f;
|
||||
app.loadingPhase = 2;
|
||||
}
|
||||
if (app.loadingPhase == 2) {
|
||||
// Overview loaded — generate texture (NULL segments render as black)
|
||||
// and transition to ready state so background processing can start.
|
||||
AutoScaleAmplitude(&app.stft);
|
||||
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage, &app.spectrogramTexture);
|
||||
app.loadingProgress = 1.0f;
|
||||
app.stftComputed = true;
|
||||
app.loadingPhase = 0; // Reset — background processing runs outside this block
|
||||
app.loadingProgress = 0.0f;
|
||||
// Arm the progressive full-res fill from the start of the file.
|
||||
// A full-resolution overview (skipFactor 1) has nothing missing,
|
||||
// so mark it finished and skip the sweep entirely.
|
||||
app.bgHighResSeg = 0;
|
||||
app.bgFinished = (app.skipFactor <= 1);
|
||||
app.isBgProcessing = !app.bgFinished;
|
||||
TraceLog(LOG_INFO, "STFT overview computed (%d segments, skipFactor=%d)",
|
||||
app.stft.numSegments, app.skipFactor);
|
||||
// Save the overview result to cache (will be overwritten when full-res completes)
|
||||
SaveToCache();
|
||||
// Run auto-crop now that we have both annotations (loaded right
|
||||
// after LoadWavFile) AND an STFT (for the energy fallback).
|
||||
// Gated on autocropPending so an FFT-size change (which routes
|
||||
// through the same loadingPhase=2 block) doesn't re-fire it.
|
||||
if (app.autocropPending) { ApplyAutoCrop(); app.autocropPending = false; }
|
||||
}
|
||||
#endif // __EMSCRIPTEN__
|
||||
}
|
||||
|
||||
#ifndef __EMSCRIPTEN__
|
||||
// Background compute with no visible window: the STFT work above has
|
||||
// run for this frame, so skip the whole draw pass and loop straight
|
||||
// back. PollInputEvents (rather than a blocking wait) keeps refocus and
|
||||
// close responsive while the compute runs at full speed, unthrottled by
|
||||
// vsync — the fill advances a fixed number of segments per frame, so
|
||||
// presenting would cap throughput at the refresh rate.
|
||||
if (headlessCompute) {
|
||||
PollInputEvents();
|
||||
continue;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Keyboard shortcuts (SPACE for play/stop toggle, ESC for clear)
|
||||
if (IsKeyPressed(KEY_SPACE) && !UiModalOpen()) {
|
||||
if (app.isPlaying && AudioPlaybackSound.frameCount > 0) {
|
||||
@@ -1395,128 +1663,6 @@ int main(int argc, char* argv[])
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
// Arm the progressive full-res fill from the start of the file.
|
||||
// A full-resolution overview (skipFactor 1) has nothing missing,
|
||||
// so mark it finished and skip the sweep entirely.
|
||||
app.bgHighResSeg = 0;
|
||||
app.bgFinished = (app.skipFactor <= 1);
|
||||
app.isBgProcessing = !app.bgFinished;
|
||||
TraceLog(LOG_INFO, "STFT overview computed (%d segments, skipFactor=%d)",
|
||||
app.stft.numSegments, app.skipFactor);
|
||||
// Save the overview result to cache (will be overwritten when full-res completes)
|
||||
SaveToCache();
|
||||
// Run auto-crop now that we have both annotations (loaded right
|
||||
// after LoadWavFile) AND an STFT (for the energy fallback).
|
||||
// Gated on autocropPending so an FFT-size change (which routes
|
||||
// through the same loadingPhase=2 block) doesn't re-fire it.
|
||||
if (app.autocropPending) { ApplyAutoCrop(); app.autocropPending = false; }
|
||||
}
|
||||
#endif // __EMSCRIPTEN__
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -1898,6 +2044,8 @@ int main(int argc, char* argv[])
|
||||
FreeBrowserFiles();
|
||||
FreeAllCacheEntries(&app.fftCache);
|
||||
free(app.reassignBuffer);
|
||||
free(app.collisionFlags);
|
||||
FreeWaveEnvelope(&app.scopeView.envelope);
|
||||
FreeMlnl(&app.annotations);
|
||||
FreeSignal(&app.signal);
|
||||
if (IsAudioDeviceReady()) CloseAudioDevice();
|
||||
|
||||
Reference in New Issue
Block a user