diff --git a/README.md b/README.md index dfdfc87..c9eaba7 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,9 @@ or pressing **O** for the file browser. Try the bundled sample: | Input | Action | |-------|--------| | **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 | | **LMB drag** | Select a time + frequency region | | **Space** | Play / stop the selected region | @@ -229,6 +231,13 @@ paths. frequency resolution `sampleRate / fftSize` Hz per bin. Amplitude in dB. - **Axes** — X = time (s), Y = frequency (Hz, scaled to the file's Nyquist), 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, FFT-bandpassed to the selected frequency box, peak-normalised. - **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) for the performance / idle-CPU lessons behind the desktop build, and [`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). diff --git a/known_bugs.md b/known_bugs.md new file mode 100644 index 0000000..df95ea0 --- /dev/null +++ b/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`. diff --git a/shot_input.sh b/shot_input.sh index 96d76ac..d41c748 100755 --- a/shot_input.sh +++ b/shot_input.sh @@ -71,6 +71,23 @@ for act in "$@"; do xd mouseup 1; sleep 0.1 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 ;; esac done diff --git a/src/render.c b/src/render.c index 6fb5988..4f56f18 100644 --- a/src/render.c +++ b/src/render.c @@ -355,14 +355,33 @@ void DrawLabels(Rectangle bounds) int baseFontSize = 12; 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++) { float t = (float)i / 10; float timeSec = (app.view.start + t * (app.view.end - app.view.start)) * app.signal.duration; float x = bounds.x + t * bounds.width; char label[32]; - if (timeSec >= 60) sprintf(label, "%d:%02d", (int)(timeSec / 60), (int)(timeSec) % 60); - else sprintf(label, "%.1fs", timeSec); + // Past a minute the m:ss form stays readable only while the step is + // 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); } diff --git a/src/spectrogram.c b/src/spectrogram.c index 337e82e..556b361 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -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; diff --git a/src/spectrogram_types.h b/src/spectrogram_types.h index fbb8a83..8e4e69c 100644 --- a/src/spectrogram_types.h +++ b/src/spectrogram_types.h @@ -352,6 +352,30 @@ static inline float DisplayFreqFraction(void) 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. // The dispatcher (DispatchKeymap in spectrogram.c) runs every entry whose diff --git a/src/ui.c b/src/ui.c index 2037d32..058d15c 100644 --- a/src/ui.c +++ b/src/ui.c @@ -765,7 +765,10 @@ void DrawAboutDialog(void) DrawTextScaled(TextFormat(" %-5s %s", km[i].label, km[i].help), px, py, 13, LIGHTGRAY); 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); DrawTextScaled("F1 / Esc / click to close", panel.x + pw - 196 * scale,