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
This commit is contained in:
2026-08-12 12:50:16 -07:00
parent 98e42b0a6a
commit 8017954aa1
3 changed files with 132 additions and 12 deletions
+55 -4
View File
@@ -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
}
@@ -1567,12 +1570,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).