diff --git a/README.md b/README.md index c9eaba7..efedf31 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,8 @@ or pressing **O** for the file browser. Try the bundled sample: | **Alt+drag** / **middle-drag** | Pan the view | | **LMB drag** | Select a time + frequency 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 | | **M** | Marker / ruler tool | | **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, 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) @@ -231,6 +254,16 @@ paths. frequency resolution `sampleRate / fftSize` Hz per bin. Amplitude in dB. - **Axes** — X = time (s), Y = frequency (Hz, scaled to the file's Nyquist), 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 (`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 diff --git a/src/primitives.c b/src/primitives.c index a273277..08d772e 100644 --- a/src/primitives.c +++ b/src/primitives.c @@ -4,9 +4,59 @@ #include #include +// 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) { view->data = data; + view->envelope = (WaveEnvelope){ 0 }; view->x = x; view->y = y; view->width = width; @@ -107,6 +157,15 @@ void DrawScopeView(ScopeView* view, float cursorT) int spp = (visibleSamples + view->width - 1) / view->width; 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) Color waveColor = (Color){ 200, 220, 255, 255 }; for (int px = 0; px < view->width; px++) { @@ -115,12 +174,33 @@ void DrawScopeView(ScopeView* view, float cursorT) if (s0 >= endSample) s0 = endSample - 1; if (s1 > endSample) s1 = endSample; - float minAmp = view->data.samples[s0]; - float maxAmp = view->data.samples[s0]; - for (int s = s0 + 1; s < s1; s++) { - float v = view->data.samples[s]; - if (v < minAmp) minAmp = v; - if (v > maxAmp) maxAmp = v; + float minAmp, maxAmp; + + 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++) { + float v = view->data.samples[s]; + if (v < minAmp) minAmp = v; + if (v > maxAmp) maxAmp = v; + } } int yTop = AmplitudeToY(view, maxAmp); diff --git a/src/primitives.h b/src/primitives.h index f9a899b..6eebda2 100644 --- a/src/primitives.h +++ b/src/primitives.h @@ -10,9 +10,41 @@ typedef struct { int sampleRate; } 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 typedef struct { WaveformData data; + WaveEnvelope envelope; // cached min/max summary; rebuilt when data changes // View bounds (in pixels) int x, y; diff --git a/src/render.c b/src/render.c index 1ab8909..569c391 100644 --- a/src/render.c +++ b/src/render.c @@ -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 // the viewport. Events with no freq fields span the full frequency axis. // 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, double duration_s, double nyquist_hz, Rectangle* out) { @@ -1423,6 +1532,49 @@ void DrawAnnotations(Rectangle bounds) 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) { const char* msg = "mLnL: truncated"; float fs = 10.0f; diff --git a/src/render.h b/src/render.h index b3583e7..749018a 100644 --- a/src/render.h +++ b/src/render.h @@ -49,6 +49,9 @@ void DrawMarkers(Rectangle bounds); void DrawSpectrumPanel(Rectangle bounds); void DrawPlayhead(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 // app.selectedAnnotation in response to mouse interaction in `lane`. void DrawTimeline(Rectangle lane); diff --git a/src/spectrogram.c b/src/spectrogram.c index 59a69e8..6a40e92 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -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(); diff --git a/src/spectrogram_types.h b/src/spectrogram_types.h index 7b46f47..a83e657 100644 --- a/src/spectrogram_types.h +++ b/src/spectrogram_types.h @@ -38,6 +38,24 @@ // Kept below the common limit to leave headroom on weaker GL drivers. #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 // piles than this are counted but not listed individually (the tooltip says // "+N more"), which keeps a dense pile-up from covering the spectrogram. @@ -307,6 +325,21 @@ typedef struct { // entry. int hoverStack[MAX_HOVER_STACK]; 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 annotationsExpanded; // sidebar dropdown open (per-kind checkboxes etc.) bool annotationKindEnabled[MLNL_KIND_MAX]; // per-kind visibility (filters both surfaces) diff --git a/src/ui.c b/src/ui.c index 058d15c..8bad4e4 100644 --- a/src/ui.c +++ b/src/ui.c @@ -127,6 +127,7 @@ static void LoadSelectedFile(void) } else if (FileExists(filePath) && LoadWavFile(filePath, &app.signal)) { ResetForNewSignal(); LoadMlnlFromWav(filePath, &app.annotations); + ComputeCollisions(); app.showFileBrowser = false; TraceLog(LOG_INFO, "Loaded: %s", filePath); } @@ -505,6 +506,45 @@ void DrawSidebar(void) annExp.x + 9 * scale, annExp.y + 5 * scale, 13, WHITE); 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) { // Two opacity sliders: the spectrogram overlay is drawn at the // "Base" alpha by default, and bumps to "Highlight" for any event