Compare commits

..
2 Commits
Author SHA1 Message Date
tylerandClaude Opus 5 8026d10547 fix: stop annotation labels overlapping, drop click-to-pin
Every box drew its label unconditionally, so overlapping transmissions
stacked their text into an unreadable smear — several frame names painted
over each other at the same pixels.

DrawBoxLabel now claims a screen-space rect before drawing and skips the
label if it would intersect one already placed this frame. Draw order
decides the winner, so the topmost box keeps its text and the ones beneath
go quiet instead of smearing. The claim is clipped to the box width, so a
long label reserves only what the scissor actually paints rather than
silencing neighbours over space it never uses. Nothing is lost: hovering
still reports every box under the cursor.

The stacked-hover tooltip now sits above the cursor and centred on it,
flipping below only when there is no room. It previously opened down and
to the right, covering the very boxes being described.

Also removes click-to-pin. It was never asked for, it did not reliably
work, and hover alone answers the question it was meant to serve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN
2026-08-12 12:50:30 -07:00
tylerandClaude Opus 5 8017954aa1 fix: render the spectrogram from the visible segment range
The source image was sized at one column per STFT segment with no upper
bound. That is fine for short captures but impossible for long ones: a
5.7-hour file at 12 kHz yields ~478k segments, i.e. a 478450x1025 RGBA
image (~2 GB) roughly 29x past the ~16k-per-dimension texture limit every
GL implementation enforces. The upload failed, the texture id stayed 0,
and the draw was skipped — so the spectrogram was simply blank, with no
error anywhere to say why.

Build the image for the segment range actually on screen instead of the
whole file, capped at MAX_SPECTRO_IMAGE_WIDTH. Simply clamping the
full-file width would have fixed the blank render while permanently
discarding the detail zooming is meant to reveal (478k segments into 8k
columns is 59:1, no matter how far in you go). Tying the range to the view
keeps resolution proportional to zoom: 59 segments per column at full
zoom-out, reaching 1:1 by ~1% zoom, with the image never exceeding ~33 MB.

Where several segments do share a column their per-bin MAX is kept rather
than a sum or mean, so a short burst lights its column instead of being
diluted by quiet neighbours — the same reasoning behind a min/max waveform
envelope. Amplitude normalisation is likewise scoped to the visible span,
which keeps rebuild cost independent of total duration and lets the colour
scale follow what is on screen instead of a loud burst hours away.

Rebuilds trigger when the view leaves the cached range, which is padded by
25% so ordinary panning re-renders about once every six frames rather than
every frame. The headless --render path sets no range and so still covers
the whole file, capped; verified end to end on a 5.67-hour capture, which
now renders 8110x1025 with visible structure where it previously produced
nothing at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN
2026-08-12 12:50:16 -07:00
3 changed files with 181 additions and 52 deletions
+108 -33
View File
@@ -191,21 +191,58 @@ void GenerateColormapTexture(void)
static void ComputeSpectrogramReassignment(StftResult* stft) static void ComputeSpectrogramReassignment(StftResult* stft)
{ {
if (stft->numSegments == 0) return; if (stft->numSegments == 0) return;
int width = stft->numSegments;
int height = stft->segments[0].numBins; int height = stft->segments[0].numBins;
int fftSize = (height - 1) * 2; int fftSize = (height - 1) * 2;
float freqPerBin = (float)stft->sampleRate / fftSize; 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. // (Re)allocate the cached accumulation buffer for reassigned energy.
free(app.reassignBuffer); 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.reassignWidth = width;
app.reassignHeight = height; app.reassignHeight = height;
app.reassignSegsPerCol = segsPerCol;
float* accumBuffer = app.reassignBuffer; float* accumBuffer = app.reassignBuffer;
// Find max amplitude for normalization (skip NULL segments) // 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; 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; if (stft->segments[seg].spectrum == NULL) continue;
for (int bin = 0; bin < stft->segments[seg].numBins; bin++) for (int bin = 0; bin < stft->segments[seg].numBins; bin++)
if (stft->segments[seg].spectrum[bin].amplitude > maxAmplitude) 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 // Noise threshold: only reassign bins with significant energy
float noiseThreshold = maxAmplitude * 0.01f; // 1% of max amplitude 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) // Skip segments that haven't been computed yet (overview/high-res transition)
if (stft->segments[seg].spectrum == NULL) continue; 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++) { for (int bin = 0; bin < height; bin++) {
FrequencyData* V_f = &stft->segments[seg].spectrum[bin]; FrequencyData* V_f = &stft->segments[seg].spectrum[bin];
FrequencyData* V_fd = &stft->segments[seg].derivativeSpectrum[bin]; FrequencyData* V_fd = &stft->segments[seg].derivativeSpectrum[bin];
@@ -264,11 +306,21 @@ static void ComputeSpectrogramReassignment(StftResult* stft)
if (bin1 >= height) bin1 = height - 1; if (bin1 >= height) bin1 = height - 1;
float frac = targetBinF - bin0; float frac = targetBinF - bin0;
int idx0 = (height - 1 - bin0) * width + seg; int idx0 = (height - 1 - bin0) * width + col;
int idx1 = (height - 1 - bin1) * width + seg; int idx1 = (height - 1 - bin1) * width + col;
accumBuffer[idx0] += amplitude * (1 - frac); // Within a column the bilinear splat accumulates as before. Across
accumBuffer[idx1] += amplitude * frac; // 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 // 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, // 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. // 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) static void DrawBoxLabel(Rectangle box, const char* text, Color color, bool topInside)
{ {
if (!text || !*text || box.width < 18.0f) return; 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 x = (int)box.x + 3;
int y = topInside ? (int)box.y + 2 : (int)(box.y - lineH); int y = topInside ? (int)box.y + 2 : (int)(box.y - lineH);
if (y < 0) y = (int)box.y + 2; 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); BeginScissorMode(x, y, (int)box.width - 4, (int)lineH);
DrawTextScaled(text, x, y, fs, color); DrawTextScaled(text, x, y, fs, color);
EndScissorMode(); EndScissorMode();
@@ -1073,28 +1159,28 @@ static void DrawHoverStackTooltip(Rectangle bounds, Vector2 anchor, int total)
BuildEventSummary(e, rows[i], 96); BuildEventSummary(e, rows[i], 96);
} }
const char* hint = app.hoverStackPinned ? "click / Esc to unpin" : "click to pin";
float maxW = MeasureTextScaled(hdr, fontSize); float maxW = MeasureTextScaled(hdr, fontSize);
float hintW = MeasureTextScaled(hint, fontSize);
if (hintW > maxW) maxW = hintW;
for (int i = 0; i < n; i++) { for (int i = 0; i < n; i++) {
float w = MeasureTextScaled(rows[i], fontSize) + swatchGap; float w = MeasureTextScaled(rows[i], fontSize) + swatchGap;
if (w > maxW) maxW = w; 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 boxW = (int)(maxW + padX * 2);
int boxH = (int)(totalRows * lineH + padY * 2); int boxH = (int)(totalRows * lineH + padY * 2);
float bx = anchor.x + 12, by = anchor.y + 12; // Sit above the cursor, horizontally centred on it: the boxes being
if (bx + boxW > bounds.x + bounds.width) bx = anchor.x - boxW - 12; // 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 < 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 + 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 }); 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; float y = by + padY;
DrawTextScaled(hdr, bx + padX, y, fontSize, (Color){ 255, 255, 255, 255 }); 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); DrawTextScaled(rows[i], bx + padX + swatchGap, y, fontSize, LIGHTGRAY);
y += lineH; y += lineH;
} }
DrawTextScaled(hint, bx + padX, y, fontSize, (Color){ 150, 150, 150, 255 });
} }
static bool IsPointEvent(const MlnlEvent* e) static bool IsPointEvent(const MlnlEvent* e)
@@ -1168,16 +1252,14 @@ void DrawAnnotations(Rectangle bounds)
{ {
if (!app.loaded || !app.annotations.loaded) return; if (!app.loaded || !app.annotations.loaded) return;
if (!app.showAnnotations) { 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.hoveredEvent = -1;
app.hoverStackCount = 0; app.hoverStackCount = 0;
app.hoverStackPinned = false;
return; return;
} }
if (app.signal.duration <= 0.0f) return; if (app.signal.duration <= 0.0f) return;
app.hoveredEvent = -1; app.hoveredEvent = -1;
ResetLabelSlots();
double duration = app.signal.duration; double duration = app.signal.duration;
// Annotation freq mapping uses the DISPLAYED top-of-axis: events with // Annotation freq mapping uses the DISPLAYED top-of-axis: events with
@@ -1323,21 +1405,14 @@ void DrawAnnotations(Rectangle bounds)
} }
app.hoveredEvent = hoverEvent; app.hoveredEvent = hoverEvent;
// Publish the stack unless it's pinned — a pinned stack is a frozen app.hoverStackCount = stackCount;
// snapshot the user is actively reading, so live hover must not clobber it. for (int i = 0; i < stackCount; i++) app.hoverStack[i] = stack[i];
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 // ---- Tooltip ---- Timeline hover takes priority over spectrogram hover
// (the lane is the active surface when you're hovering it). // (the lane is the active surface when you're hovering it).
int tipFor = (app.hoveredTimelineEvent >= 0) ? app.hoveredTimelineEvent : hoverEvent; int tipFor = (app.hoveredTimelineEvent >= 0) ? app.hoveredTimelineEvent : hoverEvent;
// A pinned stack outranks both: it's an explicit "show me what's here". if (app.hoverStackCount > 1 && app.hoveredTimelineEvent < 0) {
if (app.hoverStackPinned && app.hoverStackCount > 0) {
DrawHoverStackTooltip(bounds, m, stackTotal);
} else if (app.hoverStackCount > 1 && app.hoveredTimelineEvent < 0) {
// Several boxes under the cursor: one line each beats full detail for // Several boxes under the cursor: one line each beats full detail for
// one, since the question being asked is "who else is in here?". // one, since the question being asked is "who else is in here?".
DrawHoverStackTooltip(bounds, m, stackTotal); DrawHoverStackTooltip(bounds, m, stackTotal);
+55 -16
View File
@@ -234,7 +234,10 @@ 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;
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 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.hoveredTimelineEvent = -1;
app.selectedAnnotation = -1; app.selectedAnnotation = -1;
app.hoverStackCount = 0; app.hoverStackCount = 0;
app.hoverStackPinned = false;
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
@@ -1169,10 +1171,6 @@ int main(int argc, char* argv[])
app.showAbout = false; app.showAbout = false;
} else if (app.showFileBrowser) { } else if (app.showFileBrowser) {
app.showFileBrowser = false; 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) { } else if (app.markerMode && app.marker.active) {
// Clear the marker measurement first when the ruler is active. // Clear the marker measurement first when the ruler is active.
app.marker.active = false; app.marker.active = false;
@@ -1353,13 +1351,6 @@ int main(int argc, char* argv[])
app.sel.freqStart = app.sel.freqEnd; app.sel.freqStart = app.sel.freqEnd;
app.sel.freqEnd = tmp; 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) { } else if (!hoverInsideSelection) {
// Sub-threshold drag outside any existing selection: treat // Sub-threshold drag outside any existing selection: treat
// as a click on empty space and reset to full range. A // 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) // Draw spectrogram (background, in its own area)
if (app.loaded && app.stftComputed) { 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 imgWidth = app.spectrogramImage.width;
int imgHeight = app.spectrogramImage.height; int imgHeight = app.spectrogramImage.height;
// Calculate visible region (time and frequency) // Calculate visible region (time and frequency). X is relative to
int visibleStartX = (int)(app.view.start * imgWidth); // the segment range the image was built for, not the whole file.
int visibleEndX = (int)(app.view.end * imgWidth); 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; int visibleWidth = visibleEndX - visibleStartX;
// Frequency: 0 = bottom of image (bin 0), 1 = top of image (bin max). // Frequency: 0 = bottom of image (bin 0), 1 = top of image (bin max).
+18 -3
View File
@@ -30,6 +30,14 @@
#define MAX_SAMPLE_RATE 48000 #define MAX_SAMPLE_RATE 48000
#define LOUDNESS_FLOOR_DB -80.0f #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 // 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.
@@ -198,6 +206,15 @@ typedef struct {
float* reassignBuffer; float* reassignBuffer;
int reassignWidth; int reassignWidth;
int reassignHeight; 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 // Overlays
bool showAbout; // About / help dialog bool showAbout; // About / help dialog
@@ -287,11 +304,9 @@ typedef struct {
// air at once), and a single hit index silently hid everything underneath — // 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 // 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 // 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 // entry.
// having to stay perfectly still.
int hoverStack[MAX_HOVER_STACK]; int hoverStack[MAX_HOVER_STACK];
int hoverStackCount; int hoverStackCount;
bool hoverStackPinned; // click-to-pin: survives cursor movement
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)