Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8026d10547 | ||
|
|
8017954aa1 |
+106
-31
@@ -191,21 +191,58 @@ void GenerateColormapTexture(void)
|
||||
static void ComputeSpectrogramReassignment(StftResult* stft)
|
||||
{
|
||||
if (stft->numSegments == 0) return;
|
||||
int width = stft->numSegments;
|
||||
int height = stft->segments[0].numBins;
|
||||
int fftSize = (height - 1) * 2;
|
||||
float freqPerBin = (float)stft->sampleRate / fftSize;
|
||||
|
||||
// One image column per STFT segment is fine for short files, but a long
|
||||
// capture has far more segments than any texture can hold: a 5.7-hour file
|
||||
// at 12 kHz yields ~478k segments, i.e. a 478000x1025 RGBA image (~2 GB)
|
||||
// that blows past the ~16k GPU texture limit. The upload then fails, the
|
||||
// texture id stays 0, and the spectrogram silently renders as nothing.
|
||||
//
|
||||
// Only the segments currently on screen are rendered, and the width is
|
||||
// capped. Building the whole file at once and cropping afterwards would
|
||||
// either exceed the texture limit (as above) or, if globally downsampled,
|
||||
// permanently throw away the detail that zooming in is supposed to reveal.
|
||||
// Restricting to the visible span keeps resolution tied to the zoom level:
|
||||
// the further in you go, the fewer segments share a column.
|
||||
int segFirst = app.reassignSegFirst;
|
||||
int segLast = app.reassignSegLast;
|
||||
if (segFirst < 0) segFirst = 0;
|
||||
if (segLast > stft->numSegments) segLast = stft->numSegments;
|
||||
if (segLast <= segFirst) { segFirst = 0; segLast = stft->numSegments; }
|
||||
int segSpan = segLast - segFirst;
|
||||
|
||||
// Fold multiple segments into each column when the span still exceeds the
|
||||
// cap, keeping the per-bin MAX (not a mean) so a short burst lights its
|
||||
// column up instead of being averaged into the noise floor — the same
|
||||
// reason the waveform scope draws min/max rather than decimated samples.
|
||||
int segsPerCol = (segSpan + MAX_SPECTRO_IMAGE_WIDTH - 1) / MAX_SPECTRO_IMAGE_WIDTH;
|
||||
if (segsPerCol < 1) segsPerCol = 1;
|
||||
int width = (segSpan + segsPerCol - 1) / segsPerCol;
|
||||
if (width < 1) width = 1;
|
||||
|
||||
// (Re)allocate the cached accumulation buffer for reassigned energy.
|
||||
free(app.reassignBuffer);
|
||||
app.reassignBuffer = (float*)calloc(width * height, sizeof(float));
|
||||
app.reassignBuffer = (float*)calloc((size_t)width * height, sizeof(float));
|
||||
if (app.reassignBuffer == NULL) {
|
||||
app.reassignWidth = 0;
|
||||
app.reassignHeight = 0;
|
||||
return;
|
||||
}
|
||||
app.reassignWidth = width;
|
||||
app.reassignHeight = height;
|
||||
app.reassignSegsPerCol = segsPerCol;
|
||||
float* accumBuffer = app.reassignBuffer;
|
||||
|
||||
// Find max amplitude for normalization (skip NULL segments)
|
||||
// Normalize against the visible span only — scanning the whole file would
|
||||
// put the cost back on total duration, which is what this range-limited
|
||||
// rebuild exists to avoid. It also means the colour scale adapts to what
|
||||
// is on screen rather than to a loud burst somewhere else in the capture.
|
||||
float maxAmplitude = 0.0001f;
|
||||
for (int seg = 0; seg < stft->numSegments; seg++) {
|
||||
for (int seg = segFirst; seg < segLast; seg++) {
|
||||
if (stft->segments[seg].spectrum == NULL) continue;
|
||||
for (int bin = 0; bin < stft->segments[seg].numBins; bin++)
|
||||
if (stft->segments[seg].spectrum[bin].amplitude > maxAmplitude)
|
||||
@@ -215,10 +252,15 @@ static void ComputeSpectrogramReassignment(StftResult* stft)
|
||||
// Noise threshold: only reassign bins with significant energy
|
||||
float noiseThreshold = maxAmplitude * 0.01f; // 1% of max amplitude
|
||||
|
||||
for (int seg = 0; seg < width; seg++) {
|
||||
for (int seg = segFirst; seg < segLast; seg++) {
|
||||
// Skip segments that haven't been computed yet (overview/high-res transition)
|
||||
if (stft->segments[seg].spectrum == NULL) continue;
|
||||
|
||||
// Column this segment lands in, relative to the start of the range.
|
||||
int col = (seg - segFirst) / segsPerCol;
|
||||
if (col >= width) col = width - 1;
|
||||
if (col < 0) col = 0;
|
||||
|
||||
for (int bin = 0; bin < height; bin++) {
|
||||
FrequencyData* V_f = &stft->segments[seg].spectrum[bin];
|
||||
FrequencyData* V_fd = &stft->segments[seg].derivativeSpectrum[bin];
|
||||
@@ -264,11 +306,21 @@ static void ComputeSpectrogramReassignment(StftResult* stft)
|
||||
if (bin1 >= height) bin1 = height - 1;
|
||||
|
||||
float frac = targetBinF - bin0;
|
||||
int idx0 = (height - 1 - bin0) * width + seg;
|
||||
int idx1 = (height - 1 - bin1) * width + seg;
|
||||
int idx0 = (height - 1 - bin0) * width + col;
|
||||
int idx1 = (height - 1 - bin1) * width + col;
|
||||
|
||||
accumBuffer[idx0] += amplitude * (1 - frac);
|
||||
accumBuffer[idx1] += amplitude * frac;
|
||||
// Within a column the bilinear splat accumulates as before. Across
|
||||
// segments folded into one column take the max, so a brief loud
|
||||
// burst isn't diluted by its quiet neighbours.
|
||||
float e0 = amplitude * (1 - frac);
|
||||
float e1 = amplitude * frac;
|
||||
if (segsPerCol == 1) {
|
||||
accumBuffer[idx0] += e0;
|
||||
accumBuffer[idx1] += e1;
|
||||
} else {
|
||||
if (e0 > accumBuffer[idx0]) accumBuffer[idx0] = e0;
|
||||
if (e1 > accumBuffer[idx1]) accumBuffer[idx1] = e1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -979,6 +1031,32 @@ static int BuildEventLines(const MlnlEvent* e, char lines[][96], int maxLines)
|
||||
// topInside=true places the label inside the top of the box (used for tx_bursts
|
||||
// so the box outline still reads clearly above); false places it just above,
|
||||
// falling back to inside if the box is at the top of the viewport.
|
||||
// Label slots already claimed this frame, so overlapping boxes don't stack
|
||||
// their text into an unreadable smear. Each entry is the screen-space extent
|
||||
// of a drawn label; a candidate that would collide with one is dropped and
|
||||
// surfaced on hover instead (the hover stack reports every box under the
|
||||
// cursor, so nothing is lost — it just isn't painted on top of its neighbour).
|
||||
#define MAX_LABEL_SLOTS 256
|
||||
static Rectangle g_labelSlots[MAX_LABEL_SLOTS];
|
||||
static int g_labelSlotCount = 0;
|
||||
|
||||
static void ResetLabelSlots(void) { g_labelSlotCount = 0; }
|
||||
|
||||
static bool ClaimLabelSlot(Rectangle r)
|
||||
{
|
||||
for (int i = 0; i < g_labelSlotCount; i++) {
|
||||
// Pure AABB overlap. Labels are single-line and left-aligned, so any
|
||||
// intersection at all means one would be drawn over the other.
|
||||
if (r.x < g_labelSlots[i].x + g_labelSlots[i].width &&
|
||||
r.x + r.width > g_labelSlots[i].x &&
|
||||
r.y < g_labelSlots[i].y + g_labelSlots[i].height &&
|
||||
r.y + r.height > g_labelSlots[i].y)
|
||||
return false;
|
||||
}
|
||||
if (g_labelSlotCount < MAX_LABEL_SLOTS) g_labelSlots[g_labelSlotCount++] = r;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void DrawBoxLabel(Rectangle box, const char* text, Color color, bool topInside)
|
||||
{
|
||||
if (!text || !*text || box.width < 18.0f) return;
|
||||
@@ -988,6 +1066,14 @@ static void DrawBoxLabel(Rectangle box, const char* text, Color color, bool topI
|
||||
int x = (int)box.x + 3;
|
||||
int y = topInside ? (int)box.y + 2 : (int)(box.y - lineH);
|
||||
if (y < 0) y = (int)box.y + 2;
|
||||
|
||||
// Clip the claim to the box, matching what the scissor actually paints —
|
||||
// otherwise a long label reserves space it never draws into and needlessly
|
||||
// suppresses its neighbours.
|
||||
float drawW = MeasureTextScaled(text, fs);
|
||||
if (drawW > box.width - 4) drawW = box.width - 4;
|
||||
if (!ClaimLabelSlot((Rectangle){ (float)x, (float)y, drawW, lineH })) return;
|
||||
|
||||
BeginScissorMode(x, y, (int)box.width - 4, (int)lineH);
|
||||
DrawTextScaled(text, x, y, fs, color);
|
||||
EndScissorMode();
|
||||
@@ -1073,28 +1159,28 @@ static void DrawHoverStackTooltip(Rectangle bounds, Vector2 anchor, int total)
|
||||
BuildEventSummary(e, rows[i], 96);
|
||||
}
|
||||
|
||||
const char* hint = app.hoverStackPinned ? "click / Esc to unpin" : "click to pin";
|
||||
|
||||
float maxW = MeasureTextScaled(hdr, fontSize);
|
||||
float hintW = MeasureTextScaled(hint, fontSize);
|
||||
if (hintW > maxW) maxW = hintW;
|
||||
for (int i = 0; i < n; i++) {
|
||||
float w = MeasureTextScaled(rows[i], fontSize) + swatchGap;
|
||||
if (w > maxW) maxW = w;
|
||||
}
|
||||
|
||||
int totalRows = n + 2; // header + rows + hint
|
||||
int totalRows = n + 1; // header + rows
|
||||
int boxW = (int)(maxW + padX * 2);
|
||||
int boxH = (int)(totalRows * lineH + padY * 2);
|
||||
float bx = anchor.x + 12, by = anchor.y + 12;
|
||||
if (bx + boxW > bounds.x + bounds.width) bx = anchor.x - boxW - 12;
|
||||
// Sit above the cursor, horizontally centred on it: the boxes being
|
||||
// described are under the pointer, so anything drawn below or beside it
|
||||
// covers the very thing the user is pointing at. Flips below only when
|
||||
// there isn't room above.
|
||||
float bx = anchor.x - boxW * 0.5f;
|
||||
float by = anchor.y - boxH - 14;
|
||||
if (by < bounds.y) by = anchor.y + 18;
|
||||
if (bx < bounds.x) bx = bounds.x;
|
||||
if (bx + boxW > bounds.x + bounds.width) bx = bounds.x + bounds.width - boxW;
|
||||
if (by + boxH > bounds.y + bounds.height) by = bounds.y + bounds.height - boxH;
|
||||
if (by < bounds.y) by = bounds.y;
|
||||
|
||||
Color border = app.hoverStackPinned ? (Color){ 255, 220, 120, 255 } : GRAY;
|
||||
DrawRectangle((int)bx, (int)by, boxW, boxH, (Color){ 0, 0, 0, 235 });
|
||||
DrawRectangleLines((int)bx, (int)by, boxW, boxH, Fade(border, 0.9f));
|
||||
DrawRectangleLines((int)bx, (int)by, boxW, boxH, Fade(GRAY, 0.9f));
|
||||
|
||||
float y = by + padY;
|
||||
DrawTextScaled(hdr, bx + padX, y, fontSize, (Color){ 255, 255, 255, 255 });
|
||||
@@ -1108,8 +1194,6 @@ static void DrawHoverStackTooltip(Rectangle bounds, Vector2 anchor, int total)
|
||||
DrawTextScaled(rows[i], bx + padX + swatchGap, y, fontSize, LIGHTGRAY);
|
||||
y += lineH;
|
||||
}
|
||||
|
||||
DrawTextScaled(hint, bx + padX, y, fontSize, (Color){ 150, 150, 150, 255 });
|
||||
}
|
||||
|
||||
static bool IsPointEvent(const MlnlEvent* e)
|
||||
@@ -1168,16 +1252,14 @@ void DrawAnnotations(Rectangle bounds)
|
||||
{
|
||||
if (!app.loaded || !app.annotations.loaded) return;
|
||||
if (!app.showAnnotations) {
|
||||
// Hiding the overlay must also drop any pinned stack — otherwise the
|
||||
// panel keeps describing boxes that are no longer drawn.
|
||||
app.hoveredEvent = -1;
|
||||
app.hoverStackCount = 0;
|
||||
app.hoverStackPinned = false;
|
||||
return;
|
||||
}
|
||||
if (app.signal.duration <= 0.0f) return;
|
||||
|
||||
app.hoveredEvent = -1;
|
||||
ResetLabelSlots();
|
||||
|
||||
double duration = app.signal.duration;
|
||||
// Annotation freq mapping uses the DISPLAYED top-of-axis: events with
|
||||
@@ -1323,21 +1405,14 @@ void DrawAnnotations(Rectangle bounds)
|
||||
}
|
||||
app.hoveredEvent = hoverEvent;
|
||||
|
||||
// Publish the stack unless it's pinned — a pinned stack is a frozen
|
||||
// snapshot the user is actively reading, so live hover must not clobber it.
|
||||
if (!app.hoverStackPinned) {
|
||||
app.hoverStackCount = stackCount;
|
||||
for (int i = 0; i < stackCount; i++) app.hoverStack[i] = stack[i];
|
||||
}
|
||||
|
||||
// ---- Tooltip ---- Timeline hover takes priority over spectrogram hover
|
||||
// (the lane is the active surface when you're hovering it).
|
||||
int tipFor = (app.hoveredTimelineEvent >= 0) ? app.hoveredTimelineEvent : hoverEvent;
|
||||
|
||||
// A pinned stack outranks both: it's an explicit "show me what's here".
|
||||
if (app.hoverStackPinned && app.hoverStackCount > 0) {
|
||||
DrawHoverStackTooltip(bounds, m, stackTotal);
|
||||
} else if (app.hoverStackCount > 1 && app.hoveredTimelineEvent < 0) {
|
||||
if (app.hoverStackCount > 1 && app.hoveredTimelineEvent < 0) {
|
||||
// Several boxes under the cursor: one line each beats full detail for
|
||||
// one, since the question being asked is "who else is in here?".
|
||||
DrawHoverStackTooltip(bounds, m, stackTotal);
|
||||
|
||||
+55
-16
@@ -234,7 +234,10 @@ void ResetForNewSignal(void)
|
||||
app.selectedAnnotation = -1;
|
||||
// Indices point into the events array we just freed.
|
||||
app.hoverStackCount = 0;
|
||||
app.hoverStackPinned = false;
|
||||
// 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;
|
||||
app.reassignSegLast = 0;
|
||||
app.autocropPending = true; // run once when this file's STFT is ready
|
||||
}
|
||||
|
||||
@@ -839,7 +842,6 @@ int main(int argc, char* argv[])
|
||||
app.hoveredTimelineEvent = -1;
|
||||
app.selectedAnnotation = -1;
|
||||
app.hoverStackCount = 0;
|
||||
app.hoverStackPinned = false;
|
||||
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
|
||||
@@ -1169,10 +1171,6 @@ int main(int argc, char* argv[])
|
||||
app.showAbout = false;
|
||||
} else if (app.showFileBrowser) {
|
||||
app.showFileBrowser = false;
|
||||
} else if (app.hoverStackPinned) {
|
||||
// Release a pinned annotation stack before touching the
|
||||
// selection — it's the most recently opened thing on screen.
|
||||
app.hoverStackPinned = false;
|
||||
} else if (app.markerMode && app.marker.active) {
|
||||
// Clear the marker measurement first when the ruler is active.
|
||||
app.marker.active = false;
|
||||
@@ -1353,13 +1351,6 @@ int main(int argc, char* argv[])
|
||||
app.sel.freqStart = app.sel.freqEnd;
|
||||
app.sel.freqEnd = tmp;
|
||||
}
|
||||
} else if (app.hoverStackPinned || app.hoverStackCount > 1) {
|
||||
// A click on stacked annotations pins (or unpins) the
|
||||
// hit list instead of touching the selection — with
|
||||
// boxes piled up, "what is under here?" is what the
|
||||
// click means. Pinning freezes the stack so it can be
|
||||
// read without holding the cursor perfectly still.
|
||||
app.hoverStackPinned = !app.hoverStackPinned;
|
||||
} else if (!hoverInsideSelection) {
|
||||
// Sub-threshold drag outside any existing selection: treat
|
||||
// as a click on empty space and reset to full range. A
|
||||
@@ -1567,12 +1558,60 @@ int main(int argc, char* argv[])
|
||||
|
||||
// Draw spectrogram (background, in its own area)
|
||||
if (app.loaded && app.stftComputed) {
|
||||
// Rebuild the source image whenever the view moves outside the
|
||||
// segment range it was built for. The image covers the visible span
|
||||
// (plus margin) rather than the whole file — see
|
||||
// ComputeSpectrogramReassignment — so this is what keeps on-screen
|
||||
// resolution tied to the zoom level instead of to total duration.
|
||||
if (app.stft.numSegments > 0) {
|
||||
int want0 = (int)(app.view.start * app.stft.numSegments);
|
||||
int want1 = (int)ceilf(app.view.end * app.stft.numSegments);
|
||||
// Margin so small pans don't re-render every frame.
|
||||
int margin = (want1 - want0) / 4;
|
||||
want0 -= margin; want1 += margin;
|
||||
if (want0 < 0) want0 = 0;
|
||||
if (want1 > app.stft.numSegments) want1 = app.stft.numSegments;
|
||||
if (want1 <= want0) want1 = want0 + 1;
|
||||
|
||||
bool needRebuild = app.reassignBuffer == NULL ||
|
||||
want0 < app.reassignSegFirst ||
|
||||
want1 > app.reassignSegLast;
|
||||
// Also re-render once the view has zoomed in far enough that the
|
||||
// cached image is being magnified — otherwise a deep zoom keeps
|
||||
// stretching the same columns instead of resolving new detail.
|
||||
if (!needRebuild && app.reassignSegsPerCol > 1) {
|
||||
int visSegs = want1 - want0;
|
||||
int visCols = visSegs / app.reassignSegsPerCol;
|
||||
if (visCols < MAX_SPECTRO_IMAGE_WIDTH / 4) needRebuild = true;
|
||||
}
|
||||
if (needRebuild) {
|
||||
app.reassignSegFirst = want0;
|
||||
app.reassignSegLast = want1;
|
||||
GenerateSpectrogramTexture(&app.stft, &app.spectrogramImage,
|
||||
&app.spectrogramTexture);
|
||||
app.visibleTextureValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
int imgWidth = app.spectrogramImage.width;
|
||||
int imgHeight = app.spectrogramImage.height;
|
||||
|
||||
// Calculate visible region (time and frequency)
|
||||
int visibleStartX = (int)(app.view.start * imgWidth);
|
||||
int visibleEndX = (int)(app.view.end * imgWidth);
|
||||
// Calculate visible region (time and frequency). X is relative to
|
||||
// the segment range the image was built for, not the whole file.
|
||||
float rangeStart = 0.0f, rangeEnd = 1.0f;
|
||||
if (app.stft.numSegments > 0 && app.reassignSegLast > app.reassignSegFirst) {
|
||||
rangeStart = (float)app.reassignSegFirst / app.stft.numSegments;
|
||||
rangeEnd = (float)app.reassignSegLast / app.stft.numSegments;
|
||||
}
|
||||
float rangeSpan = rangeEnd - rangeStart;
|
||||
if (rangeSpan <= 0.0f) rangeSpan = 1.0f;
|
||||
float relStart = (app.view.start - rangeStart) / rangeSpan;
|
||||
float relEnd = (app.view.end - rangeStart) / rangeSpan;
|
||||
if (relStart < 0.0f) relStart = 0.0f;
|
||||
if (relEnd > 1.0f) relEnd = 1.0f;
|
||||
|
||||
int visibleStartX = (int)(relStart * imgWidth);
|
||||
int visibleEndX = (int)(relEnd * imgWidth);
|
||||
int visibleWidth = visibleEndX - visibleStartX;
|
||||
|
||||
// Frequency: 0 = bottom of image (bin 0), 1 = top of image (bin max).
|
||||
|
||||
+18
-3
@@ -30,6 +30,14 @@
|
||||
#define MAX_SAMPLE_RATE 48000
|
||||
#define LOUDNESS_FLOOR_DB -80.0f
|
||||
|
||||
// Hard ceiling on the spectrogram image's width in pixels. GL implementations
|
||||
// commonly cap textures at 16384 px per dimension, and a multi-hour capture
|
||||
// produces far more STFT segments than that (~478k for 5.7 h at 12 kHz), so
|
||||
// without a cap the texture upload fails and nothing draws at all. Segments
|
||||
// beyond the cap are folded into columns; see ComputeSpectrogramReassignment.
|
||||
// Kept below the common limit to leave headroom on weaker GL drivers.
|
||||
#define MAX_SPECTRO_IMAGE_WIDTH 8192
|
||||
|
||||
// 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.
|
||||
@@ -198,6 +206,15 @@ typedef struct {
|
||||
float* reassignBuffer;
|
||||
int reassignWidth;
|
||||
int reassignHeight;
|
||||
// STFT segments folded into each image column (1 = one column per segment).
|
||||
// >1 once the visible span has more segments than MAX_SPECTRO_IMAGE_WIDTH,
|
||||
// and needed by anything converting between segment indices and image X.
|
||||
int reassignSegsPerCol;
|
||||
// Segment range the cached image covers, as [first, last). The image is
|
||||
// built for the visible span rather than the whole file, so zooming in
|
||||
// genuinely re-renders at higher resolution instead of magnifying pixels.
|
||||
// A rebuild is triggered when the view leaves this range.
|
||||
int reassignSegFirst, reassignSegLast;
|
||||
|
||||
// Overlays
|
||||
bool showAbout; // About / help dialog
|
||||
@@ -287,11 +304,9 @@ typedef struct {
|
||||
// air at once), and a single hit index silently hid everything underneath —
|
||||
// so the stack is collected during the draw pass and the tooltip reports
|
||||
// all of it. Topmost-last, matching draw order; hoveredEvent is the last
|
||||
// entry. Pinning freezes the stack so it can be read without the cursor
|
||||
// having to stay perfectly still.
|
||||
// entry.
|
||||
int hoverStack[MAX_HOVER_STACK];
|
||||
int hoverStackCount;
|
||||
bool hoverStackPinned; // click-to-pin: survives cursor movement
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user