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:
2026-08-12 13:58:58 -07:00
parent 8026d10547
commit 4c3c6a955a
8 changed files with 656 additions and 135 deletions
+34 -1
View File
@@ -135,7 +135,8 @@ or pressing **O** for the file browser. Try the bundled sample:
| **Alt+drag** / **middle-drag** | Pan the view | | **Alt+drag** / **middle-drag** | Pan the view |
| **LMB drag** | Select a time + frequency region | | **LMB drag** | Select a time + frequency region |
| **Space** | Play / stop the selected region | | **Space** | Play / stop the selected region |
| **Hover an annotation** | Tooltip with that frame's mLnL detail | | **Hover an annotation** | Tooltip with that frame's mLnL detail; lists **every** overlapping frame under the cursor |
| **N** / **Shift+N** | Jump to the next / previous collision |
| **P** | Show / hide the waveform scope | | **P** | Show / hide the waveform scope |
| **M** | Marker / ruler tool | | **M** | Marker / ruler tool |
| **S** | Spectrum slice (PSD) | | **S** | Spectrum slice (PSD) |
@@ -150,6 +151,28 @@ or pressing **O** for the file browser. Try the bundled sample:
Most controls are also available as buttons in the left sidebar (colormap, floor, Most controls are also available as buttons in the left sidebar (colormap, floor,
dynamic range, annotation opacity, grid, …). dynamic range, annotation opacity, grid, …).
### Inspecting overlapping transmissions
When several stations are on the air at once their annotation boxes stack, and
the one drawn last hides the rest. Two features address that:
- **Hover** any pile-up and the tooltip lists *every* frame under the cursor —
one row per frame with its own colour swatch, led by the fields that actually
tell them apart (node, frame name, position in the PTT, channel). Deep piles
are capped with a `+N more` count.
- **Collisions** (sidebar toggle) highlights where transmissions genuinely
overlap in **both** time and frequency. `N` / `Shift+N`, or the sidebar
`< prev` / `next >` buttons, jump between them; each jump centres the region,
keeps the current zoom unless the region needs more room, and reports its
position (`Collision 7/54 — 3 frames at 1284.95s`).
A collision requires a real overlap in time *and* band, so two frames in
different channels at the same instant are not flagged, and neither are
zero-duration point markers (`control`, assertions), which annotate the run
rather than occupy the air. Markers are drawn only across the band the overlap
occupies, not the full frequency axis. Adjacent collisions merge into one
region, so a busy stretch reads as a single span rather than dozens of bars.
--- ---
## Usage (headless render) ## Usage (headless render)
@@ -231,6 +254,16 @@ paths.
frequency resolution `sampleRate / fftSize` Hz per bin. Amplitude in dB. frequency resolution `sampleRate / fftSize` Hz per bin. Amplitude in dB.
- **Axes** — X = time (s), Y = frequency (Hz, scaled to the file's Nyquist), - **Axes** — X = time (s), Y = frequency (Hz, scaled to the file's Nyquist),
colour = amplitude. colour = amplitude.
- **Long files** — two things keep cost tied to what's on screen rather than to
total duration. The spectrogram image is built for the *visible* segment range
(capped at 8192 px wide), so a multi-hour capture renders at all — an
unbounded full-file image exceeds the GPU texture limit and silently draws
nothing — and zooming in genuinely re-renders at higher resolution instead of
magnifying pixels. The scope draws from a precomputed min/max summary
(1024-sample buckets) rather than rescanning every visible sample each frame,
which on a 5.7-hour file is the difference between ~60 ms and ~0.07 ms per
frame. Keeping both extremes per bucket means a single-sample transient still
shows up when fully zoomed out.
- **Time zoom limit** — the tightest visible window is derived from the STFT hop - **Time zoom limit** — the tightest visible window is derived from the STFT hop
(`fftSize / HOP_RATIO` samples), not from a fixed fraction of the file, so time (`fftSize / HOP_RATIO` samples), not from a fixed fraction of the file, so time
resolution does not degrade as files get longer: a 30-minute recording zooms in resolution does not degrade as files get longer: a 30-minute recording zooms in
+82 -2
View File
@@ -4,9 +4,59 @@
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
// Bucket width for the envelope summary. 1024 samples keeps the table ~0.8%
// of the signal's own footprint (1.9 MB for a 5.7-hour capture) while still
// giving a pixel column several buckets to reduce over at typical zooms.
#define WAVE_BUCKET_SIZE 1024
void FreeWaveEnvelope(WaveEnvelope* env)
{
free(env->buckets);
env->buckets = NULL;
env->bucketCount = 0;
env->samples = NULL;
env->numSamples = 0;
}
void BuildWaveEnvelope(WaveEnvelope* env, const float* samples, int numSamples)
{
// Already summarises this exact buffer — nothing to do. Comparing the
// pointer AND the length catches both a new file and a re-decode that
// happened to land on the same address.
if (env->buckets && env->samples == samples && env->numSamples == numSamples)
return;
FreeWaveEnvelope(env);
if (!samples || numSamples <= 0) return;
int bucketSize = WAVE_BUCKET_SIZE;
int bucketCount = (numSamples + bucketSize - 1) / bucketSize;
env->buckets = (WaveMinMax*)malloc((size_t)bucketCount * sizeof(WaveMinMax));
if (!env->buckets) return; // fall back to the raw-sample path
for (int b = 0; b < bucketCount; b++) {
int a = b * bucketSize;
int e = a + bucketSize;
if (e > numSamples) e = numSamples;
float mn = samples[a], mx = samples[a];
for (int k = a + 1; k < e; k++) {
float v = samples[k];
if (v < mn) mn = v;
if (v > mx) mx = v;
}
env->buckets[b].mn = mn;
env->buckets[b].mx = mx;
}
env->bucketCount = bucketCount;
env->bucketSize = bucketSize;
env->samples = samples;
env->numSamples = numSamples;
}
void InitScopeView(ScopeView* view, WaveformData data, int x, int y, int width, int height) void InitScopeView(ScopeView* view, WaveformData data, int x, int y, int width, int height)
{ {
view->data = data; view->data = data;
view->envelope = (WaveEnvelope){ 0 };
view->x = x; view->x = x;
view->y = y; view->y = y;
view->width = width; view->width = width;
@@ -107,6 +157,15 @@ void DrawScopeView(ScopeView* view, float cursorT)
int spp = (visibleSamples + view->width - 1) / view->width; int spp = (visibleSamples + view->width - 1) / view->width;
if (spp < 1) spp = 1; if (spp < 1) spp = 1;
// Keep the summary current; no-op unless the sample buffer changed.
BuildWaveEnvelope(&view->envelope, view->data.samples, totalSamples);
// Reduce over whole buckets only when a column covers at least one, so the
// summary is never used to answer a question finer than it can. Zoomed in
// past a bucket the raw path runs, and is cheap there by definition.
const WaveEnvelope* env = &view->envelope;
bool useEnvelope = env->buckets != NULL && spp >= env->bucketSize;
// Draw envelope: per-pixel min/max (Audacity-style) // Draw envelope: per-pixel min/max (Audacity-style)
Color waveColor = (Color){ 200, 220, 255, 255 }; Color waveColor = (Color){ 200, 220, 255, 255 };
for (int px = 0; px < view->width; px++) { for (int px = 0; px < view->width; px++) {
@@ -115,13 +174,34 @@ void DrawScopeView(ScopeView* view, float cursorT)
if (s0 >= endSample) s0 = endSample - 1; if (s0 >= endSample) s0 = endSample - 1;
if (s1 > endSample) s1 = endSample; if (s1 > endSample) s1 = endSample;
float minAmp = view->data.samples[s0]; float minAmp, maxAmp;
float maxAmp = view->data.samples[s0];
if (useEnvelope) {
// Bucket range covering [s0, s1). Rounding inward would leave the
// column's edges unsampled, so the span is widened to whole buckets
// — at this zoom a bucket is at most one pixel wide anyway.
int b0 = s0 / env->bucketSize;
int b1 = (s1 + env->bucketSize - 1) / env->bucketSize;
if (b0 < 0) b0 = 0;
if (b1 > env->bucketCount) b1 = env->bucketCount;
if (b1 <= b0) b1 = b0 + 1;
if (b0 >= env->bucketCount) b0 = env->bucketCount - 1;
minAmp = env->buckets[b0].mn;
maxAmp = env->buckets[b0].mx;
for (int b = b0 + 1; b < b1; b++) {
if (env->buckets[b].mn < minAmp) minAmp = env->buckets[b].mn;
if (env->buckets[b].mx > maxAmp) maxAmp = env->buckets[b].mx;
}
} else {
minAmp = view->data.samples[s0];
maxAmp = view->data.samples[s0];
for (int s = s0 + 1; s < s1; s++) { for (int s = s0 + 1; s < s1; s++) {
float v = view->data.samples[s]; float v = view->data.samples[s];
if (v < minAmp) minAmp = v; if (v < minAmp) minAmp = v;
if (v > maxAmp) maxAmp = v; if (v > maxAmp) maxAmp = v;
} }
}
int yTop = AmplitudeToY(view, maxAmp); int yTop = AmplitudeToY(view, maxAmp);
int yBot = AmplitudeToY(view, minAmp); int yBot = AmplitudeToY(view, minAmp);
+32
View File
@@ -10,9 +10,41 @@ typedef struct {
int sampleRate; int sampleRate;
} WaveformData; } WaveformData;
// Per-bucket amplitude extremes, the unit of the envelope summary below.
typedef struct { float mn, mx; } WaveMinMax;
// Precomputed min/max summary of the signal so drawing the waveform costs
// O(pixels) instead of O(visible samples).
//
// Without it the scope rescanned every visible sample every frame: on a
// multi-hour capture that is hundreds of millions of reads (~1 GB of memory
// traffic) to produce a few hundred pixel columns, measured at ~60 ms/frame —
// a 16 fps ceiling before anything else drew. Bucketing collapses that to a
// few reads per column. Keeping the true min AND max per bucket is what lets
// a one-sample transient still show at full zoom-out; plain decimation would
// drop it, which matters when the whole point is spotting brief bursts.
//
// Used only when a pixel column spans at least one whole bucket. Zoomed in
// past that the scope reads raw samples, which is cheap precisely because few
// are visible. `samples`/`numSamples` record what the summary was built from,
// so a new file (or re-decoded buffer) invalidates it automatically.
typedef struct {
WaveMinMax* buckets;
int bucketCount;
int bucketSize; // samples per bucket
const float* samples; // provenance: buffer this was built from
int numSamples;
} WaveEnvelope;
// Build (or rebuild, if the source buffer changed) the envelope summary.
// Safe to call every frame: returns immediately when already current.
void BuildWaveEnvelope(WaveEnvelope* env, const float* samples, int numSamples);
void FreeWaveEnvelope(WaveEnvelope* env);
// Scope view state for time/amplitude waveform display // Scope view state for time/amplitude waveform display
typedef struct { typedef struct {
WaveformData data; WaveformData data;
WaveEnvelope envelope; // cached min/max summary; rebuilt when data changes
// View bounds (in pixels) // View bounds (in pixels)
int x, y; int x, y;
+152
View File
@@ -938,6 +938,115 @@ static Vector2 AnnoToScreen(Rectangle bounds, double t_s, double f_hz,
// Project the event's time+frequency band into a screen rectangle, clipped to // Project the event's time+frequency band into a screen rectangle, clipped to
// the viewport. Events with no freq fields span the full frequency axis. // the viewport. Events with no freq fields span the full frequency axis.
// Returns false if the result is entirely outside the visible area. // Returns false if the result is entirely outside the visible area.
// Can this event collide with anything at all? Only things that actually
// occupy the air can: a transmission has both a real time extent and a
// frequency band. Point markers (control, gain_change, assertions — zero
// duration, no band) are log annotations about the run, not signals, so a
// marker landing inside a burst is not interference and must not be reported
// as one. Treating a missing band as "whole spectrum" (which is how EventRect
// *draws* it) made every such marker collide with whatever it sat inside, and
// accounted for 38% of the reported collisions on a real capture.
static bool EventCanCollide(const MlnlEvent* e)
{
return e->has_freq && e->t_end > e->t_start;
}
// True iff two events occupy the same time AND frequency space — i.e. they
// genuinely collide on the air, rather than merely looking stacked because the
// view is zoomed out. Computed from the event data, never from screen rects,
// so the answer doesn't change with zoom.
static bool EventsCollide(const MlnlEvent* a, const MlnlEvent* b)
{
if (!EventCanCollide(a) || !EventCanCollide(b)) return false;
if (a->t_start >= b->t_end || b->t_start >= a->t_end) return false;
if (a->f_lo_hz >= b->f_hi_hz || b->f_lo_hz >= a->f_hi_hz) return false;
return true;
}
// Recompute which events collide with at least one other. O(n log n)-ish: the
// list is walked in time order and each event only compared against those still
// overlapping it, so a few thousand annotations cost microseconds. Cached in
// app.collisionFlags and only rebuilt when the annotation set changes.
void ComputeCollisions(void)
{
int n = app.annotations.eventCount;
free(app.collisionFlags);
app.collisionFlags = NULL;
app.collisionCount = 0;
app.collisionRegionCount = 0;
if (n <= 0) return;
app.collisionFlags = (unsigned char*)calloc(n, 1);
if (!app.collisionFlags) return;
// Index sorted by start time, so the inner loop can stop early.
int* order = (int*)malloc((size_t)n * sizeof(int));
if (!order) return;
for (int i = 0; i < n; i++) order[i] = i;
for (int i = 1; i < n; i++) { // insertion sort: input is near-sorted already
int key = order[i];
double kt = app.annotations.events[key].t_start;
int j = i - 1;
while (j >= 0 && app.annotations.events[order[j]].t_start > kt) {
order[j + 1] = order[j];
j--;
}
order[j + 1] = key;
}
for (int i = 0; i < n; i++) {
const MlnlEvent* a = &app.annotations.events[order[i]];
for (int j = i + 1; j < n; j++) {
const MlnlEvent* b = &app.annotations.events[order[j]];
if (b->t_start >= a->t_end) break; // sorted: nothing later can overlap
if (EventsCollide(a, b)) {
app.collisionFlags[order[i]] = 1;
app.collisionFlags[order[j]] = 1;
}
}
}
for (int i = 0; i < n; i++) if (app.collisionFlags[i]) app.collisionCount++;
// Merge colliding events into contiguous time regions, so the overlay can
// draw one band per pile-up instead of one per event (which would smear
// into a solid wall when zoomed out on a long capture).
double regionEnd = -1.0;
for (int i = 0; i < n; i++) {
int idx = order[i];
if (!app.collisionFlags[idx]) continue;
const MlnlEvent* e = &app.annotations.events[idx];
if (e->t_start > regionEnd) {
if (app.collisionRegionCount < MAX_COLLISION_REGIONS) {
CollisionRegion* r = &app.collisionRegions[app.collisionRegionCount];
r->t0 = e->t_start;
r->t1 = e->t_end;
// Only banded events reach here (EventCanCollide requires
// has_freq), so f_lo/f_hi are always meaningful.
r->f_lo = e->f_lo_hz;
r->f_hi = e->f_hi_hz;
r->count = 1;
app.collisionRegionCount++;
}
regionEnd = e->t_end;
} else {
if (app.collisionRegionCount > 0) {
CollisionRegion* r = &app.collisionRegions[app.collisionRegionCount - 1];
if (e->t_end > r->t1) r->t1 = e->t_end;
// Union the bands: a region spanning a wideband frame and a
// narrow one has to cover both or the marker would sit off the
// signal it is pointing at.
if (e->f_lo_hz < r->f_lo) r->f_lo = e->f_lo_hz;
if (e->f_hi_hz > r->f_hi) r->f_hi = e->f_hi_hz;
r->count++;
}
if (e->t_end > regionEnd) regionEnd = e->t_end;
}
}
free(order);
}
static bool EventRect(Rectangle bounds, const MlnlEvent* e, static bool EventRect(Rectangle bounds, const MlnlEvent* e,
double duration_s, double nyquist_hz, Rectangle* out) double duration_s, double nyquist_hz, Rectangle* out)
{ {
@@ -1423,6 +1532,49 @@ void DrawAnnotations(Rectangle bounds)
DrawTooltip(bounds, m, lines, n, EventColor(e)); DrawTooltip(bounds, m, lines, n, EventColor(e));
} }
// ---- Collision overlay ----
// Marks where transmissions genuinely overlap in time AND frequency. Drawn
// as a band per merged region rather than per event: at a wide zoom a
// multi-hour capture has thousands of collisions, and one mark each would
// paint the view solid red and say nothing.
// Each marker is confined to the frequency band the collision actually
// occupies (padded, so a narrow overlap is still findable) rather than
// spanning the full axis — a full-height bar hides the signal it is
// pointing at and says nothing about *where* the interference sits.
if (app.showCollisions && app.collisionRegionCount > 0 && duration > 0.0) {
for (int i = 0; i < app.collisionRegionCount; i++) {
const CollisionRegion* cr = &app.collisionRegions[i];
double fLo = cr->f_lo - COLLISION_BAND_PAD_HZ;
double fHi = cr->f_hi + COLLISION_BAND_PAD_HZ;
if (fLo < 0.0) fLo = 0.0;
Vector2 lo = AnnoToScreen(bounds, cr->t0, fLo, duration, nyquist);
Vector2 hi = AnnoToScreen(bounds, cr->t1, fHi, duration, nyquist);
float x0 = fminf(lo.x, hi.x), x1 = fmaxf(lo.x, hi.x);
float y0 = fminf(lo.y, hi.y), y1 = fmaxf(lo.y, hi.y);
if (x1 < bounds.x || x0 > bounds.x + bounds.width) continue;
if (y1 < bounds.y || y0 > bounds.y + bounds.height) continue;
// Keep a very narrow region visible, then clip to the viewport.
if (x1 < x0 + 2.0f) x1 = x0 + 2.0f;
if (y1 < y0 + 2.0f) y1 = y0 + 2.0f;
if (x0 < bounds.x) x0 = bounds.x;
if (y0 < bounds.y) y0 = bounds.y;
if (x1 > bounds.x + bounds.width) x1 = bounds.x + bounds.width;
if (y1 > bounds.y + bounds.height) y1 = bounds.y + bounds.height;
if (x1 <= x0 || y1 <= y0) continue;
// Deeper pile-ups read as more opaque, so density stays legible
// where individual regions are too narrow to separate.
int a = 40 + cr->count * 10;
if (a > 120) a = 120;
Rectangle r = { x0, y0, x1 - x0, y1 - y0 };
DrawRectangleRec(r, (Color){ 255, 60, 60, (unsigned char)a });
DrawRectangleLinesEx(r, 1.0f, (Color){ 255, 100, 100, 220 });
}
}
if (app.annotations.truncated) { if (app.annotations.truncated) {
const char* msg = "mLnL: truncated"; const char* msg = "mLnL: truncated";
float fs = 10.0f; float fs = 10.0f;
+3
View File
@@ -49,6 +49,9 @@ void DrawMarkers(Rectangle bounds);
void DrawSpectrumPanel(Rectangle bounds); void DrawSpectrumPanel(Rectangle bounds);
void DrawPlayhead(Rectangle bounds); void DrawPlayhead(Rectangle bounds);
void DrawAnnotations(Rectangle bounds); void DrawAnnotations(Rectangle bounds);
// Recompute which annotations overlap in time+frequency. Call after the
// annotation set changes; result is cached in app.collisionFlags/Regions.
void ComputeCollisions(void);
// Annotation timeline lane. Updates app.hoveredTimelineEvent and // Annotation timeline lane. Updates app.hoveredTimelineEvent and
// app.selectedAnnotation in response to mouse interaction in `lane`. // app.selectedAnnotation in response to mouse interaction in `lane`.
void DrawTimeline(Rectangle lane); void DrawTimeline(Rectangle lane);
+276 -128
View File
@@ -74,7 +74,7 @@ static bool IsUserInteracting(void)
// idle window otherwise pins the GPU (and, on software GL, the CPU) at the // 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 // 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. // 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 #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. * background STFT, an active drag/pan/divider, or a counting-down notice.
* Everything else is a static frame we can throttle. * 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) static bool IsAppActive(void)
{ {
if (IsUserInteracting()) return true; if (IsUserInteracting()) return true;
@@ -234,6 +285,13 @@ void ResetForNewSignal(void)
app.selectedAnnotation = -1; app.selectedAnnotation = -1;
// Indices point into the events array we just freed. // Indices point into the events array we just freed.
app.hoverStackCount = 0; 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" // 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. // and lets the first rebuild pick the range for the new one.
app.reassignSegFirst = 0; app.reassignSegFirst = 0;
@@ -500,6 +558,70 @@ static void ActionZoomToStart(void)
app.visibleTextureValid = false; 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[] = { static const KeyBinding KEYMAP[] = {
{ KEY_O, KEYGATE_MODAL, ActionOpenBrowser, "O", "open file browser" }, { KEY_O, KEYGATE_MODAL, ActionOpenBrowser, "O", "open file browser" },
{ KEY_P, KEYGATE_NONE, ActionToggleScope, "P", "show / hide waveform scope" }, { 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_W, KEYGATE_MODAL | KEYGATE_STFT, ActionExportWav, "W", "export selection WAV" },
{ KEY_M, KEYGATE_MODAL | KEYGATE_LOADED,ActionToggleMarker, "M", "marker / ruler tool" }, { KEY_M, KEYGATE_MODAL | KEYGATE_LOADED,ActionToggleMarker, "M", "marker / ruler tool" },
{ KEY_S, KEYGATE_MODAL | KEYGATE_STFT, ActionToggleSpectrum, "S", "spectrum slice (PSD)" }, { 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. // Order-sensitive: handled inline (see main loop), listed here for the overlay.
{ KEY_SPACE, KEYGATE_NONE, NULL, "Space", "play / stop selection" }, { KEY_SPACE, KEYGATE_NONE, NULL, "Space", "play / stop selection" },
{ KEY_ESCAPE,KEYGATE_NONE, NULL, "Esc", "clear selection / close dialog" }, { KEY_ESCAPE,KEYGATE_NONE, NULL, "Esc", "clear selection / close dialog" },
@@ -617,6 +740,7 @@ static int RunHeadlessRender(const char* inputArg, const char* renderOut,
} }
ResetForNewSignal(); ResetForNewSignal();
LoadMlnlFromWav(pathToLoad, &app.annotations); LoadMlnlFromWav(pathToLoad, &app.annotations);
ComputeCollisions();
if (annoChoice == 0) app.showAnnotations = false; if (annoChoice == 0) app.showAnnotations = false;
else if (annoChoice == 1) app.showAnnotations = true; else if (annoChoice == 1) app.showAnnotations = true;
@@ -842,6 +966,7 @@ int main(int argc, char* argv[])
app.hoveredTimelineEvent = -1; app.hoveredTimelineEvent = -1;
app.selectedAnnotation = -1; app.selectedAnnotation = -1;
app.hoverStackCount = 0; app.hoverStackCount = 0;
app.currentCollision = -1;
for (int i = 0; i < MLNL_KIND_MAX; i++) app.annotationKindEnabled[i] = true; for (int i = 0; i < MLNL_KIND_MAX; i++) app.annotationKindEnabled[i] = true;
app.showScope = true; app.showScope = true;
app.dividerY = 0.6f; // Start with 60% spectro, 40% scope app.dividerY = 0.6f; // Start with 60% spectro, 40% scope
@@ -875,6 +1000,7 @@ int main(int argc, char* argv[])
fileLoaded = true; fileLoaded = true;
ResetForNewSignal(); ResetForNewSignal();
LoadMlnlFromWav(pathToLoad, &app.annotations); LoadMlnlFromWav(pathToLoad, &app.annotations);
ComputeCollisions();
TraceLog(LOG_INFO, "File loaded successfully"); TraceLog(LOG_INFO, "File loaded successfully");
} }
} }
@@ -883,6 +1009,11 @@ int main(int argc, char* argv[])
while (!WindowShouldClose()) 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__ #ifdef __EMSCRIPTEN__
// Track the browser viewport (fill + reflow on resize, like desktop). // Track the browser viewport (fill + reflow on resize, like desktop).
SyncCanvasToWindow(); SyncCanvasToWindow();
@@ -899,12 +1030,31 @@ int main(int argc, char* argv[])
bool focused = IsWindowFocused(); bool focused = IsWindowFocused();
if (focused && IsAppActive()) lastActive = GetTime(); 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. // Work that must finish whether or not anyone is looking: the
bool active = focused && (GetTime() - lastActive < IDLE_GRACE_SECONDS); // 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 (active) {
if (waiting != 0) { DisableEventWaiting(); SetTargetFPS(ACTIVE_FPS); waiting = 0; } 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 { } else {
// Idle: no busy-wait limiter; EndDrawing's PollInputEvents blocks. // Idle: no busy-wait limiter; EndDrawing's PollInputEvents blocks.
if (waiting != 1) { SetTargetFPS(0); EnableEventWaiting(); waiting = 1; } if (waiting != 1) { SetTargetFPS(0); EnableEventWaiting(); waiting = 1; }
@@ -914,8 +1064,11 @@ int main(int argc, char* argv[])
// when nothing is playing. // when nothing is playing.
if (!app.isPlaying && IsAudioDeviceReady()) ReleaseAudioDevice(); if (!app.isPlaying && IsAudioDeviceReady()) ReleaseAudioDevice();
if (!focused) { if (!focused) {
// Unfocused: nothing to show. Block on events (refocus/close) // Unfocused with nothing pending: block on events
// without drawing at all. // (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(); PollInputEvents();
continue; continue;
} }
@@ -935,6 +1088,7 @@ int main(int argc, char* argv[])
if (LoadWavFile(dropped.paths[0], &app.signal)) { if (LoadWavFile(dropped.paths[0], &app.signal)) {
ResetForNewSignal(); ResetForNewSignal();
LoadMlnlFromWav(dropped.paths[0], &app.annotations); 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. // order-sensitive keys (Space, Esc) are handled inline further below.
DispatchKeymap(); 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 // Check if playback finished naturally
if (app.isPlaying && AudioPlaybackSound.frameCount > 0) { if (app.isPlaying && AudioPlaybackSound.frameCount > 0) {
// Check if sound stopped playing (IsSoundPlaying returns false when done) // 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) // Keyboard shortcuts (SPACE for play/stop toggle, ESC for clear)
if (IsKeyPressed(KEY_SPACE) && !UiModalOpen()) { if (IsKeyPressed(KEY_SPACE) && !UiModalOpen()) {
if (app.isPlaying && AudioPlaybackSound.frameCount > 0) { 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 // Dismiss the About dialog with a click. Handled here, after the
// spectrogram input above (which is gated off while it's open), so 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. // dismissing click can't fall through and start a selection/pan.
@@ -1898,6 +2044,8 @@ int main(int argc, char* argv[])
FreeBrowserFiles(); FreeBrowserFiles();
FreeAllCacheEntries(&app.fftCache); FreeAllCacheEntries(&app.fftCache);
free(app.reassignBuffer); free(app.reassignBuffer);
free(app.collisionFlags);
FreeWaveEnvelope(&app.scopeView.envelope);
FreeMlnl(&app.annotations); FreeMlnl(&app.annotations);
FreeSignal(&app.signal); FreeSignal(&app.signal);
if (IsAudioDeviceReady()) CloseAudioDevice(); if (IsAudioDeviceReady()) CloseAudioDevice();
+33
View File
@@ -38,6 +38,24 @@
// Kept below the common limit to leave headroom on weaker GL drivers. // Kept below the common limit to leave headroom on weaker GL drivers.
#define MAX_SPECTRO_IMAGE_WIDTH 8192 #define MAX_SPECTRO_IMAGE_WIDTH 8192
// Contiguous time span containing one or more colliding annotations. Adjacent
// collisions are merged into a single region so the overlay draws one band per
// pile-up rather than one per event, which would smear into a solid wall when
// zoomed out on a long capture.
typedef struct {
double t0, t1;
double f_lo, f_hi; // union of the colliding events' bands, in Hz
int count; // events involved in this region
} CollisionRegion;
// Vertical padding added to a collision band when drawing it, so a narrow
// overlap is still visible without covering the whole frequency axis.
#define COLLISION_BAND_PAD_HZ 100.0
// Cap on merged collision regions tracked per file. Beyond this the overlay
// still reports the total collision count, it just stops adding bands.
#define MAX_COLLISION_REGIONS 4096
// How many overlapping annotation boxes the cursor-hit stack retains. Deeper // How many overlapping annotation boxes the cursor-hit stack retains. Deeper
// piles than this are counted but not listed individually (the tooltip says // piles than this are counted but not listed individually (the tooltip says
// "+N more"), which keeps a dense pile-up from covering the spectrogram. // "+N more"), which keeps a dense pile-up from covering the spectrogram.
@@ -307,6 +325,21 @@ typedef struct {
// entry. // entry.
int hoverStack[MAX_HOVER_STACK]; int hoverStack[MAX_HOVER_STACK];
int hoverStackCount; int hoverStackCount;
// Collision analysis: which events share time AND frequency with another,
// i.e. genuinely overlap on the air rather than merely looking stacked at
// the current zoom. Computed once per annotation set (see ComputeCollisions)
// because it depends only on the event data, not on the view.
unsigned char* collisionFlags; // one byte per event, 1 = collides
int collisionCount; // events involved in any collision
CollisionRegion collisionRegions[MAX_COLLISION_REGIONS];
int collisionRegionCount; // merged contiguous spans of collisions
bool showCollisions; // overlay toggle
int currentCollision; // region index of the last jump (-1 = none)
// Sidebar prev/next request, consumed by the main loop: -1 back, +1 forward,
// 0 idle. The jump helpers are static to spectrogram.c, so the button can't
// call them directly.
int jumpCollisionRequest;
bool showAnnotations; // master on/off bool showAnnotations; // master on/off
bool annotationsExpanded; // sidebar dropdown open (per-kind checkboxes etc.) bool annotationsExpanded; // sidebar dropdown open (per-kind checkboxes etc.)
bool annotationKindEnabled[MLNL_KIND_MAX]; // per-kind visibility (filters both surfaces) bool annotationKindEnabled[MLNL_KIND_MAX]; // per-kind visibility (filters both surfaces)
+40
View File
@@ -127,6 +127,7 @@ static void LoadSelectedFile(void)
} else if (FileExists(filePath) && LoadWavFile(filePath, &app.signal)) { } else if (FileExists(filePath) && LoadWavFile(filePath, &app.signal)) {
ResetForNewSignal(); ResetForNewSignal();
LoadMlnlFromWav(filePath, &app.annotations); LoadMlnlFromWav(filePath, &app.annotations);
ComputeCollisions();
app.showFileBrowser = false; app.showFileBrowser = false;
TraceLog(LOG_INFO, "Loaded: %s", filePath); TraceLog(LOG_INFO, "Loaded: %s", filePath);
} }
@@ -505,6 +506,45 @@ void DrawSidebar(void)
annExp.x + 9 * scale, annExp.y + 5 * scale, 13, WHITE); annExp.x + 9 * scale, annExp.y + 5 * scale, 13, WHITE);
y += 28 * scale; y += 28 * scale;
// Collision overlay toggle + summary. Only meaningful once something
// actually overlaps, so the row is hidden when nothing does.
if (app.collisionCount > 0) {
Rectangle colBtn = { x, y, sidebarWidth - 10 * scale, 24 * scale };
if (Clicked(colBtn)) app.showCollisions = !app.showCollisions;
DrawPanelBox(colBtn,
app.showCollisions ? (Color){ 90, 35, 35, 255 } : (Color){ 50, 50, 60, 255 },
app.showCollisions ? (Color){ 255, 120, 120, 255 } : GRAY);
DrawTextScaled(app.showCollisions ? "Collisions: ON" : "Collisions: off",
colBtn.x + 10 * scale, colBtn.y + 5 * scale, 13, WHITE);
y += 26 * scale;
DrawTextScaled(TextFormat("%d events in %d regions",
app.collisionCount, app.collisionRegionCount),
x + 8 * scale, y, 11, (Color){ 255, 150, 150, 255 });
y += 16 * scale;
// Prev/next jump. Mirrors the N / Shift+N bindings; having both
// means the feature is findable without reading the help overlay.
float half = (sidebarWidth - 14 * scale) * 0.5f;
Rectangle prevBtn = { x, y, half, 22 * scale };
Rectangle nextBtn = { x + half + 4 * scale, y, half, 22 * scale };
if (Clicked(prevBtn)) app.jumpCollisionRequest = -1;
if (Clicked(nextBtn)) app.jumpCollisionRequest = 1;
DrawPanelBox(prevBtn, (Color){ 55, 40, 40, 255 }, GRAY);
DrawPanelBox(nextBtn, (Color){ 55, 40, 40, 255 }, GRAY);
DrawTextScaled("< prev", prevBtn.x + 8 * scale, prevBtn.y + 4 * scale, 12, LIGHTGRAY);
DrawTextScaled("next >", nextBtn.x + 8 * scale, nextBtn.y + 4 * scale, 12, LIGHTGRAY);
y += 26 * scale;
if (app.currentCollision >= 0) {
DrawTextScaled(TextFormat("at %d/%d", app.currentCollision + 1,
app.collisionRegionCount),
x + 8 * scale, y, 11, GRAY);
y += 16 * scale;
}
}
if (app.annotationsExpanded) { if (app.annotationsExpanded) {
// Two opacity sliders: the spectrogram overlay is drawn at the // Two opacity sliders: the spectrogram overlay is drawn at the
// "Base" alpha by default, and bumps to "Highlight" for any event // "Base" alpha by default, and bumps to "Highlight" for any event