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:
@@ -129,7 +129,9 @@ or pressing **O** for the file browser. Try the bundled sample:
|
|||||||
| Input | Action |
|
| Input | Action |
|
||||||
|-------|--------|
|
|-------|--------|
|
||||||
| **O** | Open file browser |
|
| **O** | Open file browser |
|
||||||
| **Mouse wheel** | Zoom time/frequency |
|
| **Mouse wheel** | Zoom both axes (preserves aspect ratio) |
|
||||||
|
| **Shift+wheel** | Zoom the time axis only |
|
||||||
|
| **Ctrl+wheel** | Zoom the frequency axis only |
|
||||||
| **Alt+drag** / **middle-drag** | Pan the view |
|
| **Alt+drag** / **middle-drag** | Pan the view |
|
||||||
| **LMB drag** | Select a time + frequency region |
|
| **LMB drag** | Select a time + frequency region |
|
||||||
| **Space** | Play / stop the selected region |
|
| **Space** | Play / stop the selected region |
|
||||||
@@ -229,6 +231,13 @@ paths.
|
|||||||
frequency resolution `sampleRate / fftSize` Hz per bin. Amplitude in dB.
|
frequency resolution `sampleRate / fftSize` Hz per bin. Amplitude in dB.
|
||||||
- **Axes** — X = time (s), Y = frequency (Hz, scaled to the file's Nyquist),
|
- **Axes** — X = time (s), Y = frequency (Hz, scaled to the file's Nyquist),
|
||||||
colour = amplitude.
|
colour = amplitude.
|
||||||
|
- **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
|
||||||
|
just as far as a 30-second one. At 48 kHz / 2048-point FFT the floor is ~85 ms
|
||||||
|
across the viewport; a smaller FFT zooms correspondingly tighter. Past that
|
||||||
|
point there are no further STFT segments to show, so the view would only
|
||||||
|
interpolate.
|
||||||
- **Playback / WAV export** share one processing path: the selected time span,
|
- **Playback / WAV export** share one processing path: the selected time span,
|
||||||
FFT-bandpassed to the selected frequency box, peak-normalised.
|
FFT-bandpassed to the selected frequency box, peak-normalised.
|
||||||
- **mLnL parsing** — walks the WAV's RIFF chunks for the four-CC `mLnL` chunk
|
- **mLnL parsing** — walks the WAV's RIFF chunks for the four-CC `mLnL` chunk
|
||||||
@@ -253,3 +262,6 @@ src/
|
|||||||
See [`raylib_for_desktop_applications.md`](raylib_for_desktop_applications.md)
|
See [`raylib_for_desktop_applications.md`](raylib_for_desktop_applications.md)
|
||||||
for the performance / idle-CPU lessons behind the desktop build, and
|
for the performance / idle-CPU lessons behind the desktop build, and
|
||||||
[`AGENTS.md`](AGENTS.md) for the headless-testing playbook.
|
[`AGENTS.md`](AGENTS.md) for the headless-testing playbook.
|
||||||
|
|
||||||
|
Known rough edges — behaviour that is unspecified or awkward rather than simply
|
||||||
|
broken — are tracked in [`known_bugs.md`](known_bugs.md).
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Known bugs & rough edges
|
||||||
|
|
||||||
|
Behaviour that is unspecified, awkward, or known-imperfect — as distinct from
|
||||||
|
outright breakage. Each entry says what happens, why, and what a real fix would
|
||||||
|
need to decide.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Playhead vs. a selection edited mid-playback
|
||||||
|
|
||||||
|
**Status:** partially addressed; underlying semantics still undefined.
|
||||||
|
|
||||||
|
Playback hands a *snapshot* of the selected region to the audio device — the
|
||||||
|
samples are copied, bandpassed, and normalised up front, so the sound coming out
|
||||||
|
of the speakers is fixed the moment **Space** is pressed. The selection box,
|
||||||
|
however, stays live and editable while that audio plays.
|
||||||
|
|
||||||
|
Previously the playhead marker was drawn against the *live* `app.sel`, so moving
|
||||||
|
or resizing the selection during playback made the marker jump, run off the end,
|
||||||
|
or scale to a region that had nothing to do with what was audible. The playhead
|
||||||
|
is now measured against `playSelStart` / `playSelEnd` / `playDuration`, captured
|
||||||
|
at `PlaySelectedRegion()` time, so it tracks the audio that is actually playing.
|
||||||
|
|
||||||
|
What remains undefined is the *product* question, not the drawing math:
|
||||||
|
|
||||||
|
- If the user drags the selection somewhere else mid-playback, should the audio
|
||||||
|
follow (restart / re-seek against the new region), or should playback keep
|
||||||
|
going with the old buffer and the marker stay where it is (current behaviour)?
|
||||||
|
- Should editing the selection during playback simply stop playback?
|
||||||
|
- Should the playhead remain visible when the region it refers to is scrolled
|
||||||
|
off-screen, or has been replaced by a selection elsewhere in the file?
|
||||||
|
|
||||||
|
Current behaviour is the conservative reading: **the sound wins**. The marker
|
||||||
|
always describes real audio, and a mid-playback edit is treated as staging the
|
||||||
|
*next* thing to play rather than modifying the current one. That is defensible
|
||||||
|
but was never explicitly chosen, and the UI gives no feedback that the box on
|
||||||
|
screen and the audio in flight have diverged.
|
||||||
|
|
||||||
|
Related: a sub-threshold click *inside* an existing selection deliberately does
|
||||||
|
not clear it (`hoverInsideSelection` in `spectrogram.c`), because silently
|
||||||
|
clearing changes what **Space** would play. A click on empty space still resets
|
||||||
|
to full range.
|
||||||
|
|
||||||
|
**Touches:** `audio.c` (`PlaySelectedRegion`), `spectrogram.c` (playhead
|
||||||
|
advance, scope cursor), `render.c` (`DrawPlayhead`), `spectrogram_types.h`
|
||||||
|
(`playSelStart` / `playSelEnd` / `playDuration`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Long-file zoom sharpness lags the zoom gesture
|
||||||
|
|
||||||
|
**Status:** working as designed, but reads as a bug.
|
||||||
|
|
||||||
|
`ComputeSkipFactor()` (`stft.c`) strides the initial STFT pass for long files —
|
||||||
|
every 8th segment past 10 minutes — so the overview loads promptly. The missing
|
||||||
|
segments are filled at full resolution afterwards: the visible range first, then
|
||||||
|
a background sweep of the whole file.
|
||||||
|
|
||||||
|
The practical effect is that a hard zoom into a 30-minute file can look blocky
|
||||||
|
for a moment before the foreground fill catches up and it sharpens. The fill is
|
||||||
|
gated on `view.end - view.start <= 0.25f`, so it only runs once reasonably zoomed
|
||||||
|
in. If a view stays blocky indefinitely, the fill is not reaching that range and
|
||||||
|
that *is* a real bug worth chasing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Load time on long files is unbounded and unreported
|
||||||
|
|
||||||
|
A 30-minute 48 kHz file spends a long time in `Processing…` before the UI is
|
||||||
|
usable, and the percentage indicator advances non-linearly (the strided overview
|
||||||
|
completes fast, the high-res fill does not). There is no cancel. Headless/scripted
|
||||||
|
runs must wait this out; see `AGENTS.md`.
|
||||||
@@ -71,6 +71,23 @@ for act in "$@"; do
|
|||||||
xd mouseup 1; sleep 0.1
|
xd mouseup 1; sleep 0.1
|
||||||
xd keyup alt; sleep 0.15
|
xd keyup alt; sleep 0.15
|
||||||
;;
|
;;
|
||||||
|
wheel)
|
||||||
|
# "wheel X Y N [mod]" — N wheel clicks at (X,Y); N<0 scrolls down.
|
||||||
|
# X11 maps wheel up/down to buttons 4/5. Each click needs its own
|
||||||
|
# frame, same edge-detect reason as the click helper above.
|
||||||
|
xd mousemove "$2" "$3"; sleep 0.1
|
||||||
|
_n="$4"; _btn=4
|
||||||
|
if [ "$_n" -lt 0 ]; then _btn=5; _n=$(( -_n )); fi
|
||||||
|
_mod="${5:-}"
|
||||||
|
[ -n "$_mod" ] && { xd keydown "$_mod"; sleep 0.05; }
|
||||||
|
_i=0
|
||||||
|
while [ "$_i" -lt "$_n" ]; do
|
||||||
|
xd click "$_btn"; sleep 0.05
|
||||||
|
_i=$(( _i + 1 ))
|
||||||
|
done
|
||||||
|
[ -n "$_mod" ] && { xd keyup "$_mod"; sleep 0.05; }
|
||||||
|
sleep 0.15
|
||||||
|
;;
|
||||||
*) xd "$@"; sleep 0.15 ;;
|
*) xd "$@"; sleep 0.15 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|||||||
+22
-3
@@ -355,14 +355,33 @@ void DrawLabels(Rectangle bounds)
|
|||||||
int baseFontSize = 12;
|
int baseFontSize = 12;
|
||||||
Color textColor = LIGHTGRAY;
|
Color textColor = LIGHTGRAY;
|
||||||
|
|
||||||
// Time labels
|
// Time labels. Precision tracks the zoom: the span across two adjacent
|
||||||
|
// labels decides how many decimals are meaningful. Without this a deep
|
||||||
|
// zoom prints the same "%.1fs" value in every slot, which reads as a
|
||||||
|
// frozen axis even though the view is moving.
|
||||||
|
float viewSpanSec = (app.view.end - app.view.start) * app.signal.duration;
|
||||||
|
float labelStepSec = viewSpanSec / 10.0f;
|
||||||
|
int decimals;
|
||||||
|
if (labelStepSec >= 1.0f) decimals = 1;
|
||||||
|
else if (labelStepSec >= 0.1f) decimals = 2;
|
||||||
|
else if (labelStepSec >= 0.01f) decimals = 3;
|
||||||
|
else decimals = 4;
|
||||||
|
|
||||||
for (int i = 0; i <= 10; i++) {
|
for (int i = 0; i <= 10; i++) {
|
||||||
float t = (float)i / 10;
|
float t = (float)i / 10;
|
||||||
float timeSec = (app.view.start + t * (app.view.end - app.view.start)) * app.signal.duration;
|
float timeSec = (app.view.start + t * (app.view.end - app.view.start)) * app.signal.duration;
|
||||||
float x = bounds.x + t * bounds.width;
|
float x = bounds.x + t * bounds.width;
|
||||||
char label[32];
|
char label[32];
|
||||||
if (timeSec >= 60) sprintf(label, "%d:%02d", (int)(timeSec / 60), (int)(timeSec) % 60);
|
// Past a minute the m:ss form stays readable only while the step is
|
||||||
else sprintf(label, "%.1fs", timeSec);
|
// coarse; zoomed in we need the fractional seconds inside the minute.
|
||||||
|
if (timeSec >= 60) {
|
||||||
|
int mins = (int)(timeSec / 60);
|
||||||
|
float secs = timeSec - mins * 60.0f;
|
||||||
|
if (labelStepSec >= 1.0f) sprintf(label, "%d:%02d", mins, (int)secs);
|
||||||
|
else sprintf(label, "%d:%0*.*f", mins, decimals + 3, decimals, secs);
|
||||||
|
} else {
|
||||||
|
sprintf(label, "%.*fs", decimals, timeSec);
|
||||||
|
}
|
||||||
DrawTextScaled(label, x, bounds.y + bounds.height + 5, baseFontSize, textColor);
|
DrawTextScaled(label, x, bounds.y + bounds.height + 5, baseFontSize, textColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-2
@@ -976,18 +976,32 @@ int main(int argc, char* argv[])
|
|||||||
float spectroHeight = L.spectroHeight;
|
float spectroHeight = L.spectroHeight;
|
||||||
Rectangle viewBounds = L.viewBounds;
|
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)) {
|
if (GetMousePosition().x > sidebarWidth + 5 && CheckCollisionPointRec(GetMousePosition(), viewBounds)) {
|
||||||
int wheel = GetMouseWheelMove();
|
int wheel = GetMouseWheelMove();
|
||||||
if (wheel != 0) {
|
if (wheel != 0) {
|
||||||
float zoomFactor = (wheel > 0) ? 0.8f : 1.2f;
|
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) ---
|
// --- Time axis zoom (around cursor X) ---
|
||||||
|
if (zoomTime) {
|
||||||
float mouseT = (GetMousePosition().x - viewBounds.x) / viewBounds.width;
|
float mouseT = (GetMousePosition().x - viewBounds.x) / viewBounds.width;
|
||||||
mouseT = app.view.start + mouseT * (app.view.end - app.view.start);
|
mouseT = app.view.start + mouseT * (app.view.end - app.view.start);
|
||||||
float viewWidth = app.view.end - app.view.start;
|
float viewWidth = app.view.end - app.view.start;
|
||||||
float newWidth = viewWidth * zoomFactor;
|
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;
|
if (newWidth > 1.0f) newWidth = 1.0f;
|
||||||
float leftOfMouse = mouseT - app.view.start;
|
float leftOfMouse = mouseT - app.view.start;
|
||||||
float rightOfMouse = app.view.end - mouseT;
|
float rightOfMouse = app.view.end - mouseT;
|
||||||
@@ -995,8 +1009,10 @@ int main(int argc, char* argv[])
|
|||||||
app.view.end = mouseT + rightOfMouse * (newWidth / viewWidth);
|
app.view.end = mouseT + rightOfMouse * (newWidth / viewWidth);
|
||||||
if (app.view.start < 0) { app.view.start = 0; app.view.end = newWidth; }
|
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; }
|
if (app.view.end > 1) { app.view.end = 1; app.view.start = 1 - newWidth; }
|
||||||
|
}
|
||||||
|
|
||||||
// --- Frequency axis zoom (around cursor Y) ---
|
// --- Frequency axis zoom (around cursor Y) ---
|
||||||
|
if (zoomFreq) {
|
||||||
float mouseF = 1.0f - (GetMousePosition().y - viewBounds.y) / viewBounds.height;
|
float mouseF = 1.0f - (GetMousePosition().y - viewBounds.y) / viewBounds.height;
|
||||||
mouseF = app.view.freqStart + mouseF * (app.view.freqEnd - app.view.freqStart);
|
mouseF = app.view.freqStart + mouseF * (app.view.freqEnd - app.view.freqStart);
|
||||||
float freqWidth = 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
|
// 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.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); }
|
if (app.view.freqEnd > 1) { app.view.freqEnd = 1; app.view.freqStart = fmaxf(app.view.freqStart, 0.0f); }
|
||||||
|
}
|
||||||
|
|
||||||
// Invalidate texture cache
|
// Invalidate texture cache
|
||||||
app.visibleTextureValid = false;
|
app.visibleTextureValid = false;
|
||||||
|
|||||||
@@ -352,6 +352,30 @@ static inline float DisplayFreqFraction(void)
|
|||||||
return EffectiveMaxFreqHz() / nyq;
|
return EffectiveMaxFreqHz() / nyq;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tightest allowed time window, as a fraction of the whole file.
|
||||||
|
//
|
||||||
|
// This MUST be derived from the file's duration rather than being a flat
|
||||||
|
// fraction: view.start/end are normalized to the file, so a constant floor
|
||||||
|
// makes the achievable time resolution scale with file length (a flat 2%
|
||||||
|
// capped a 30-minute recording at a 36-second window, while a 30-second one
|
||||||
|
// reached 0.6 s). The physical limit is the STFT hop — segments sit
|
||||||
|
// fftSize/HOP_RATIO samples apart, so once only a few segments span the
|
||||||
|
// viewport there is no further detail to reveal and zooming past that just
|
||||||
|
// interpolates. MIN_VISIBLE_SEGMENTS sets how many must stay in view.
|
||||||
|
#define MIN_VISIBLE_SEGMENTS 8
|
||||||
|
static inline float MinTimeViewWidth(void)
|
||||||
|
{
|
||||||
|
if (app.signal.sampleRate <= 0 || app.signal.duration <= 0.0f) return 0.02f;
|
||||||
|
int hopSamples = app.fftSize / HOP_RATIO;
|
||||||
|
if (hopSamples < 1) hopSamples = 1;
|
||||||
|
float hopSec = (float)hopSamples / (float)app.signal.sampleRate;
|
||||||
|
float minSpanSec = hopSec * (float)MIN_VISIBLE_SEGMENTS;
|
||||||
|
float w = minSpanSec / app.signal.duration;
|
||||||
|
if (w > 1.0f) w = 1.0f; // file shorter than the floor: whole file is the min
|
||||||
|
if (w < 1e-7f) w = 1e-7f; // guard float precision in the view math
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Keymap — single source of truth for global key bindings.
|
// Keymap — single source of truth for global key bindings.
|
||||||
// The dispatcher (DispatchKeymap in spectrogram.c) runs every entry whose
|
// The dispatcher (DispatchKeymap in spectrogram.c) runs every entry whose
|
||||||
|
|||||||
@@ -765,7 +765,10 @@ void DrawAboutDialog(void)
|
|||||||
DrawTextScaled(TextFormat(" %-5s %s", km[i].label, km[i].help), px, py, 13, LIGHTGRAY);
|
DrawTextScaled(TextFormat(" %-5s %s", km[i].label, km[i].help), px, py, 13, LIGHTGRAY);
|
||||||
py += 16 * scale;
|
py += 16 * scale;
|
||||||
}
|
}
|
||||||
DrawTextScaled(" Mouse wheel = zoom, Alt+drag = pan, drag = select box",
|
DrawTextScaled(" Mouse wheel = zoom (Shift = time only, Ctrl = freq only)",
|
||||||
|
px, py, 13, LIGHTGRAY);
|
||||||
|
py += 16 * scale;
|
||||||
|
DrawTextScaled(" Alt+drag = pan, drag = select box",
|
||||||
px, py, 13, LIGHTGRAY);
|
px, py, 13, LIGHTGRAY);
|
||||||
|
|
||||||
DrawTextScaled("F1 / Esc / click to close", panel.x + pw - 196 * scale,
|
DrawTextScaled("F1 / Esc / click to close", panel.x + pw - 196 * scale,
|
||||||
|
|||||||
Reference in New Issue
Block a user