feat: scale time zoom by duration, decouple the two axes

The minimum visible time window was a flat 2% of the file, but view.start
and view.end are normalized to the whole signal — so the achievable time
resolution scaled with file length. A 30-minute recording could never show
less than a 36-second span, while a 30-second one reached 0.6 s. Long
captures were effectively unreadable at the sample level no matter how far
you scrolled.

Derive the floor from the STFT hop instead (MinTimeViewWidth): segments sit
fftSize/HOP_RATIO samples apart, so the real limit is the point where only
a handful of segments span the viewport and further zoom would interpolate
rather than reveal. The floor is now a constant ~43 ms at 48 kHz/1024
regardless of duration — an 844x improvement on a 30-minute file, and it
tightens further with a smaller FFT. Guards cover the unloaded (sampleRate
0) and shorter-than-the-floor cases.

Zooming is also no longer forced to move both axes together. The bare wheel
keeps the existing coupled behaviour; Shift+wheel is time-only and
Ctrl+wheel frequency-only, so a long capture can be stretched along time
without collapsing the frequency range to match.

Time-axis labels now pick their precision from the span between adjacent
ticks (1 to 4 decimals, and m:ss.sss past a minute). At the spans this
change makes reachable the old fixed "%.1fs" printed the same value in
every slot, which read as a frozen axis.

Adds a `wheel X Y N [mod]` action to shot_input.sh for exercising zoom
headlessly, and known_bugs.md for behaviour that is unspecified rather
than broken.

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 00:19:36 -07:00
parent 0f9ad03fc5
commit 5c3c88dc22
7 changed files with 173 additions and 9 deletions
+21 -4
View File
@@ -976,18 +976,32 @@ int main(int argc, char* argv[])
float spectroHeight = L.spectroHeight;
Rectangle viewBounds = L.viewBounds;
// Zoom with mouse wheel (zooms both time and frequency to maintain aspect ratio)
// Zoom with mouse wheel. Bare wheel zooms both axes together (keeps
// the aspect ratio); Shift+wheel is time-only and Ctrl+wheel is
// frequency-only, for when you need to stretch one axis alone.
if (GetMousePosition().x > sidebarWidth + 5 && CheckCollisionPointRec(GetMousePosition(), viewBounds)) {
int wheel = GetMouseWheelMove();
if (wheel != 0) {
float zoomFactor = (wheel > 0) ? 0.8f : 1.2f;
bool shiftHeld = IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT);
bool ctrlHeld = IsKeyDown(KEY_LEFT_CONTROL) || IsKeyDown(KEY_RIGHT_CONTROL);
bool zoomTime = !ctrlHeld; // Ctrl = frequency only
bool zoomFreq = !shiftHeld; // Shift = time only
// --- Time axis zoom (around cursor X) ---
if (zoomTime) {
float mouseT = (GetMousePosition().x - viewBounds.x) / viewBounds.width;
mouseT = app.view.start + mouseT * (app.view.end - app.view.start);
float viewWidth = app.view.end - app.view.start;
float newWidth = viewWidth * zoomFactor;
if (newWidth < 0.02f) newWidth = 0.02f;
// Floor the window in SECONDS, not as a fraction of the file.
// A flat 2% floor meant a 30-minute recording could never show
// less than 36 s, while a 30-second one bottomed out at 0.6 s.
// The real limit is the STFT hop: once fewer than a handful of
// segments span the viewport there is no more detail to expose.
float minWidth = MinTimeViewWidth();
if (newWidth < minWidth) newWidth = minWidth;
if (newWidth > 1.0f) newWidth = 1.0f;
float leftOfMouse = mouseT - app.view.start;
float rightOfMouse = app.view.end - mouseT;
@@ -995,8 +1009,10 @@ int main(int argc, char* argv[])
app.view.end = mouseT + rightOfMouse * (newWidth / viewWidth);
if (app.view.start < 0) { app.view.start = 0; app.view.end = newWidth; }
if (app.view.end > 1) { app.view.end = 1; app.view.start = 1 - newWidth; }
}
// --- Frequency axis zoom (around cursor Y) ---
if (zoomFreq) {
float mouseF = 1.0f - (GetMousePosition().y - viewBounds.y) / viewBounds.height;
mouseF = app.view.freqStart + mouseF * (app.view.freqEnd - app.view.freqStart);
float freqWidth = app.view.freqEnd - app.view.freqStart;
@@ -1009,6 +1025,7 @@ int main(int argc, char* argv[])
// Clamp to physical frequency limits [0, 1] — can't see beyond Nyquist or below 0 Hz
if (app.view.freqStart < 0) { app.view.freqStart = 0; app.view.freqEnd = fminf(app.view.freqEnd, 1.0f); }
if (app.view.freqEnd > 1) { app.view.freqEnd = 1; app.view.freqStart = fmaxf(app.view.freqStart, 0.0f); }
}
// Invalidate texture cache
app.visibleTextureValid = false;