Compare commits

...
4 Commits
Author SHA1 Message Date
tylerandClaude Opus 5 5c3c88dc22 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
2026-08-12 00:19:36 -07:00
tylerandClaude Opus 5 0f9ad03fc5 fix: map scope grid and cursor through the visible time window
DrawScopeView's waveform envelope already drew only viewStart..viewEnd,
but TimeToX ignored both and mapped 0-1 across the full widget width. The
grid lines and the playback cursor were therefore laid out against the
whole signal while the trace beneath them showed a zoomed sub-range, so
the two drifted out of register the moment the view was zoomed or panned —
and out of register with the spectrogram directly above, which shares the
same time axis.

Map through the visible window in TimeToX, space the ten divisions across
that window rather than the whole signal, and keep the existing bounds
check so a cursor outside the current view is dropped instead of clamped
to an edge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN
2026-08-12 00:19:14 -07:00
tylerandClaude Opus 5 c4687ce80d fix: track the playhead against the region actually playing
PlaySelectedRegion copies, bandpasses and normalises the selected span up
front, so the audio in flight is fixed the moment Space is pressed. The
playhead, though, was drawn against the live app.sel and its duration
re-derived from the live selection — so moving or resizing the selection
mid-playback made the marker jump, overrun, or scale against a region
that had nothing to do with what was audible.

Snapshot the played region (playSelStart/playSelEnd) and take the duration
from the buffer's own sample count rather than app.signal.duration, which
is derived pre-mono-downmix and disagrees for stereo files. The playhead
and the scope cursor both read the snapshot; playheadT is clamped at 1.

Also: a sub-threshold click inside an existing selection no longer clears
it. Silently resetting to full range there changes what Space plays, which
is surprising when the click was an aborted drag. A click on empty space
still resets as before.

The *semantics* of editing a selection during playback remain undefined —
whether audio should follow the box, stop, or keep going as it does now.
This commit only makes the marker honest about what is coming out of the
speakers. Recorded in known_bugs.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN
2026-08-12 00:19:02 -07:00
tylerandClaude Opus 5 46e796cdeb fix: align playback and WAV export with the displayed frequency axis
Follows 81bab18, which fixed the same class of bug in the PNG export path.
sel.freq* are fractions of the *displayed* frequency axis (capped at
EffectiveMaxFreqHz), not of true Nyquist, but BuildSelectionAudio and
ExportSelectionWAV both scaled them against sampleRate/2. With a display
crop active this widened the bandpass by 1/DisplayFreqFraction(), so the
audio you heard (and the WAV you exported) covered a higher, wider band
than the box you drew.

Convert through EffectiveMaxFreqHz for the selection bounds; the filter
still reasons in true-Nyquist terms, which is what the separate `nyquist`
local is for. The "is this effectively full-band?" test likewise compares
against nyquist, so an uncropped selection still skips filtering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN
2026-08-12 00:17:49 -07:00
9 changed files with 232 additions and 25 deletions
+13 -1
View File
@@ -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).
+72
View File
@@ -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`.
+17
View File
@@ -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
+17 -3
View File
@@ -204,10 +204,16 @@ static float* BuildSelectionAudio(int* outNumSamples)
if (!regionSamples) return NULL; if (!regionSamples) return NULL;
memcpy(regionSamples, app.signal.samples + startSample, numSamples * sizeof(float)); memcpy(regionSamples, app.signal.samples + startSample, numSamples * sizeof(float));
float maxFreq = (float)app.signal.sampleRate / 2.0f; // sel.freq* are fractions of the *displayed* axis (capped at
// EffectiveMaxFreqHz), not of true Nyquist — same convention the PNG export
// in ui.c uses. Convert through EffectiveMaxFreqHz so a display crop doesn't
// scale the passband up by 1/DisplayFreqFraction(). The filter itself still
// works in true-Nyquist terms, which is what nyquist is for.
float nyquist = (float)app.signal.sampleRate / 2.0f;
float maxFreq = EffectiveMaxFreqHz();
float freqLow = app.sel.freqStart * maxFreq; float freqLow = app.sel.freqStart * maxFreq;
float freqHigh = app.sel.freqEnd * maxFreq; float freqHigh = app.sel.freqEnd * maxFreq;
if (freqLow > 10.0f || freqHigh < maxFreq - 10.0f) { if (freqLow > 10.0f || freqHigh < nyquist - 10.0f) {
TraceLog(LOG_INFO, "Applying bandpass filter: %.0f - %.0f Hz", freqLow, freqHigh); TraceLog(LOG_INFO, "Applying bandpass filter: %.0f - %.0f Hz", freqLow, freqHigh);
ApplyBandpassFilter(regionSamples, numSamples, app.signal.sampleRate, freqLow, freqHigh); ApplyBandpassFilter(regionSamples, numSamples, app.signal.sampleRate, freqLow, freqHigh);
} }
@@ -237,6 +243,14 @@ void PlaySelectedRegion(void)
float* regionSamples = BuildSelectionAudio(&numSamples); float* regionSamples = BuildSelectionAudio(&numSamples);
if (!regionSamples) return; if (!regionSamples) return;
// Snapshot what we're about to play so the playhead tracks THIS region even
// if the user moves the selection mid-playback. Duration comes from the
// buffer we actually built, not from app.signal.duration.
app.playSelStart = app.sel.timeStart;
app.playSelEnd = app.sel.timeEnd;
app.playDuration = (app.signal.sampleRate > 0)
? (float)numSamples / (float)app.signal.sampleRate : 0.0f;
EnsureAudioDevice(); // opened on demand; released again once playback ends EnsureAudioDevice(); // opened on demand; released again once playback ends
if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound); if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound);
@@ -257,7 +271,7 @@ void ExportSelectionWAV(const char* dirPath)
return; return;
} }
float maxFreq = (float)app.signal.sampleRate / 2.0f; float maxFreq = EffectiveMaxFreqHz(); // sel.freq* are display-axis fractions
float t0 = app.sel.timeStart * app.signal.duration; float t0 = app.sel.timeStart * app.signal.duration;
float t1 = app.sel.timeEnd * app.signal.duration; float t1 = app.sel.timeEnd * app.signal.duration;
float f0 = app.sel.freqStart * maxFreq; float f0 = app.sel.freqStart * maxFreq;
+14 -4
View File
@@ -33,9 +33,16 @@ static int AmplitudeToY(ScopeView* view, float amp)
return view->y + view->height - (int)((amp - view->ampMin) / (view->ampMax - view->ampMin) * view->height); return view->y + view->height - (int)((amp - view->ampMin) / (view->ampMax - view->ampMin) * view->height);
} }
// Map a signal-space time (0-1 over the WHOLE signal) to a screen X, honoring
// the visible window. The waveform envelope below already draws only
// viewStart..viewEnd, so grid lines and the cursor have to use the same mapping
// or they drift out of register with the trace (and with the spectrogram above)
// as soon as the user zooms or pans.
static int TimeToX(ScopeView* view, float t) static int TimeToX(ScopeView* view, float t)
{ {
return view->x + (int)(t * view->width); float span = view->viewEnd - view->viewStart;
if (span <= 0.0f) span = 1.0f;
return view->x + (int)((t - view->viewStart) / span * view->width);
} }
void DrawScopeView(ScopeView* view, float cursorT) void DrawScopeView(ScopeView* view, float cursorT)
@@ -56,9 +63,11 @@ void DrawScopeView(ScopeView* view, float cursorT)
if (view->showGrid) { if (view->showGrid) {
Color gridColor = (Color){ view->gridR, view->gridG, view->gridB, (int)(view->gridAlpha * 255) }; Color gridColor = (Color){ view->gridR, view->gridG, view->gridB, (int)(view->gridAlpha * 255) };
// Vertical time divisions // Vertical time divisions — ten evenly spaced lines across the VISIBLE
// window, so the grid stays put under zoom instead of sliding off.
for (int i = 0; i <= 10; i++) { for (int i = 0; i <= 10; i++) {
int x = TimeToX(view, (float)i / 10.0f); float t = view->viewStart + (float)i / 10.0f * (view->viewEnd - view->viewStart);
int x = TimeToX(view, t);
DrawLineV((Vector2){ x, view->y }, (Vector2){ x, view->y + view->height }, gridColor); DrawLineV((Vector2){ x, view->y }, (Vector2){ x, view->y + view->height }, gridColor);
} }
@@ -119,7 +128,8 @@ void DrawScopeView(ScopeView* view, float cursorT)
DrawLine(px + view->x, yTop, px + view->x, yBot, waveColor); DrawLine(px + view->x, yTop, px + view->x, yBot, waveColor);
} }
// Cursor // Cursor. cursorT is signal-space; TimeToX maps it into the visible window,
// and the bounds check below drops it when it falls outside the current view.
if (cursorT >= 0.0f && cursorT <= 1.0f) { if (cursorT >= 0.0f && cursorT <= 1.0f) {
int cursorX = TimeToX(view, cursorT); int cursorX = TimeToX(view, cursorT);
if (cursorX >= view->x && cursorX <= view->x + view->width) { if (cursorX >= view->x && cursorX <= view->x + view->width) {
+24 -4
View File
@@ -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);
} }
@@ -1685,7 +1704,8 @@ void DrawPlayhead(Rectangle bounds)
{ {
if (!app.isPlaying || app.playheadT < 0.0f || app.playheadT > 1.0f) return; if (!app.isPlaying || app.playheadT < 0.0f || app.playheadT > 1.0f) return;
float timePos = app.sel.timeStart + app.playheadT * (app.sel.timeEnd - app.sel.timeStart); // Against the snapshot of the playing region, not the live selection.
float timePos = app.playSelStart + app.playheadT * (app.playSelEnd - app.playSelStart);
float viewWidth = app.view.end - app.view.start; float viewWidth = app.view.end - app.view.start;
float t = (timePos - app.view.start) / viewWidth; float t = (timePos - app.view.start) / viewWidth;
float x = bounds.x + t * bounds.width; float x = bounds.x + t * bounds.width;
+36 -11
View File
@@ -945,11 +945,14 @@ int main(int argc, char* argv[])
app.isPlaying = false; app.isPlaying = false;
app.playbackFinished = true; app.playbackFinished = true;
} }
// Track playhead position manually // Track playhead position manually, against the length of the buffer
// that's actually playing (see playDuration) rather than a length
// re-derived from the live selection — the user can move the
// selection mid-playback without the marker jumping.
app.playheadElapsed += GetFrameTime(); app.playheadElapsed += GetFrameTime();
float selectionDuration = (app.sel.timeEnd - app.sel.timeStart) * app.signal.duration; if (app.playDuration > 0.0f) {
if (selectionDuration > 0) { app.playheadT = app.playheadElapsed / app.playDuration;
app.playheadT = app.playheadElapsed / selectionDuration; if (app.playheadT > 1.0f) app.playheadT = 1.0f;
} }
} }
@@ -973,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;
@@ -992,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;
@@ -1006,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;
@@ -1324,8 +1344,13 @@ 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 { } else if (!hoverInsideSelection) {
// Drag too small - revert to full range // Sub-threshold drag outside any existing selection: treat
// as a click on empty space and reset to full range. A
// stray click *inside* the current box leaves it alone —
// silently clearing it there changes what Space plays.
// (hoverInsideSelection, not clickInsideSelection: the
// latter is press-frame-only and is always false here.)
ClearSelection(); ClearSelection();
} }
app.sel.isTimeSelecting = false; app.sel.isTimeSelecting = false;
@@ -1715,7 +1740,7 @@ int main(int argc, char* argv[])
app.scopeView.data.sampleRate = app.signal.sampleRate; app.scopeView.data.sampleRate = app.signal.sampleRate;
// Show playhead if playing // Show playhead if playing
if (app.isPlaying) { if (app.isPlaying) {
DrawScopeView(&app.scopeView, app.sel.timeStart + app.playheadT * (app.sel.timeEnd - app.sel.timeStart)); DrawScopeView(&app.scopeView, app.playSelStart + app.playheadT * (app.playSelEnd - app.playSelStart));
} else { } else {
DrawScopeView(&app.scopeView, -1.0f); DrawScopeView(&app.scopeView, -1.0f);
} }
+35 -1
View File
@@ -138,9 +138,19 @@ typedef struct {
bool stftComputed; bool stftComputed;
// Playback state // Playback state
float playheadT; // 0-1 normalized position in selection float playheadT; // 0-1 normalized position within the PLAYING region
float playheadElapsed; // Elapsed seconds since play started float playheadElapsed; // Elapsed seconds since play started
// Snapshot of the region actually handed to the audio device, captured at
// PlaySelectedRegion time. The playhead must be measured against this, not
// against the live app.sel — the user can move or resize the selection while
// audio is still playing, and the marker has to keep tracking the sound
// that's really coming out. playDuration comes from the buffer's own sample
// count / sampleRate, so it can't drift from app.signal.duration (which is
// derived pre-mono-downmix and disagrees for stereo files).
float playSelStart, playSelEnd; // sel.timeStart/End when playback began
float playDuration; // true length of the playing buffer, seconds
// Time + frequency box selection and its drag/move interaction state. // Time + frequency box selection and its drag/move interaction state.
Selection sel; Selection sel;
@@ -342,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
+4 -1
View File
@@ -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,