From 56b166685060f5aa7040cdea24fac8348fc67551 Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 12 Aug 2026 15:56:33 -0700 Subject: [PATCH] fix: playhead drift, replay, and cursor flicker; add selection transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Playhead ran ahead of the audio.** It was dead-reckoned by summing GetFrameTime() every frame, so every frame's *render* work counted as playback time and the error compounded — by seconds over a long region, and worst when zoomed out where each frame does the most work. That is why it tracked fine zoomed in. It is now derived from a wall-clock instant captured when the buffer is handed to the device, so it cannot drift from the audio regardless of frame timing. **Replay after a natural finish left the marker stuck at the end.** The rail's play button never cleared playbackFinished, so the stale playheadT > 1.0 persisted and DrawPlayhead early-returned while the audio played from the top. The Space path already handled this; the button did not. **No way to replay a subrange.** The playhead is now drawn while stopped (with a grab tab) and can be dragged to set where the next play starts *within* the selection, leaving the region itself intact. Stopping parks the marker where it stopped rather than snapping to the start. playheadT is a fraction of the *played* span, which stops being the selection once a scrub offset exists — so every conversion goes through absolute file time, the only frame the two share. Getting this wrong made stop-after-scrub jump backwards. **Selection transport bar.** Rewind / play-pause / stop / loop, attached above the selection box. Pause resumes where it left off; loop restarts from the top of the region rather than repeating whatever tail the last play started from. **Cursor flicker.** Twelve unconditional SetMouseCursor calls ran per frame and the last one won. Two blocks in particular both ran every frame: one set a cursor regardless of mouse position, and a second overrode it only when the mouse was inside the spectrogram — so when a capture guard had parked mousePos off-screen, the first block's stale choice stuck. That is the fight between finger/pointer, resize/pointer and crosshair/pointer. Handlers now record a prioritised request (active drag > hover hint > default) and it is applied once at the end of the frame. The divider hint is RESIZE_NS rather than the 4-way arrow while here. The rewind glyph drew its bar and triangle with a gap between them and read as a lone vertical bar; verified the fix by rendering it offscreen and dumping pixels rather than eyeballing the geometry. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN --- README.md | 42 ++++---- src/audio.c | 22 ++++- src/render.c | 214 ++++++++++++++++++++++++++++++++++++++-- src/render.h | 12 +++ src/spectrogram.c | 130 +++++++++++++++++------- src/spectrogram_types.h | 42 +++++++- src/ui.c | 20 +++- 7 files changed, 415 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index ccf60d2..2f0dcfb 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,15 @@ format. - **mLnL annotation overlay** — labelled boxes from the WAV's embedded annotation chunk; hover a box (or its region on the scope) for per-frame detail (sequence, channel, rate, scheduling offset…). -- **Zoom & pan** the time/frequency view. +- **Zoom & pan** the time/frequency view, with a **minimap** for navigating a + long capture without zooming out and back in. +- **Collision detection** — finds transmissions that genuinely overlap in *both* + time and frequency, and jumps between them. - **Region selection** — box a time *and* frequency range with the mouse. - **Filtered playback** — play just the selected region, band-limited to the selected frequency box via an FFT bandpass. What you hear is what you'd export. + Transport controls (play/pause, rewind, loop) attach to the selection, and the + playhead can be dragged to replay a subrange. - **Waveform scope** — toggleable time-domain view beneath the spectrum. - **Marker / ruler** and a **spectrum slice (PSD)** readout. - **Export** — save the view as a PNG, or the selected region as a WAV. @@ -141,6 +146,7 @@ Middle-drag always pans. | **Ctrl+wheel** | Zoom the frequency axis only | | **Wheel on a scrollbar** | Pan that axis (**Shift** to zoom it) | | **Space** | Play / stop the selected region | +| **Drag the playhead** | (while stopped) set where the next play starts | | **Hover an annotation** | Tooltip with that frame's mLnL detail; lists **every** overlapping frame under the cursor | | **N** / **Shift+N** | Jump to the next / previous collision | | **O** | Open file browser | @@ -180,6 +186,17 @@ texture and only rebuilt when its *content* changes (new file, colormap, overlays toggled); panning and zooming just move the rectangle drawn on top, so navigation costs nothing. +### Playback + +**Space** plays the selected region, band-limited to the selected frequency box. +Selecting a region also brings up a small transport bar above it — rewind, +play/pause, stop, and loop. Pause resumes where it left off; loop repeats the +whole region. + +While stopped, the playhead stays where it is and can be dragged: that sets +where the next play begins *within* the selection, so a subrange can be replayed +without redrawing the region. Clearing or redrawing the selection resets it. + ### Inspecting overlapping transmissions When several stations are on the air at once their annotation boxes stack, and @@ -255,25 +272,10 @@ Annotation kinds: `tx_frame`, `tx_burst`, `control`, `channel_up`, ## Driving the GUI headlessly (agents / CI) -The app can be run, screenshotted, and clicked on a virtual X display with no -monitor or GPU (Mesa software GL under Xvfb). The full playbook lives in -[`AGENTS.md`](AGENTS.md); the working reference implementation is -[`shot_input.sh`](shot_input.sh). - -The loop in one breath: - -```bash -Xvfb :99 -screen 0 1280x800x24 >/tmp/xvfb.log 2>&1 & # 1. fake screen -DISPLAY=:99 ./bin/Debug/rspektrum mlnl_samples.wav \ - >/tmp/app.log 2>&1 & # 2. run on it -sleep 2 # 3. reach a steady frame -DISPLAY=:99 import -window root /tmp/shot.png # 4. grab the frame -``` - -Prerequisites (Debian/Ubuntu): `sudo apt-get install xvfb imagemagick xdotool` -(plus `libgl1-mesa-dri` and `LIBGL_ALWAYS_SOFTWARE=1` if GL fails / frames are -black). Synthesize input with `xdotool` against `DISPLAY=:99` to exercise UI -paths. +The app runs, screenshots, and takes synthetic input on a virtual X display with +no monitor or GPU (Mesa software GL under Xvfb). The playbook is in +[`AGENTS.md`](AGENTS.md); [`shot_input.sh`](shot_input.sh) is the working +reference implementation. --- diff --git a/src/audio.c b/src/audio.c index d7111e2..bdf10b5 100644 --- a/src/audio.c +++ b/src/audio.c @@ -195,8 +195,16 @@ static float* BuildSelectionAudio(int* outNumSamples) { if (!app.loaded || !app.stftComputed) return NULL; - int startSample = (int)(app.sel.timeStart * app.signal.numSamples); - int endSample = (int)(app.sel.timeEnd * app.signal.numSamples); + // playFromT lets playback begin partway into the selection (the user + // scrubbed the playhead) without disturbing the selection itself — the + // whole point is to replay a subrange while keeping the region intact. + float t0 = app.sel.timeStart; + float t1 = app.sel.timeEnd; + if (app.playFromT > 0.0f && app.playFromT < 1.0f) + t0 = t0 + app.playFromT * (t1 - t0); + + int startSample = (int)(t0 * app.signal.numSamples); + int endSample = (int)(t1 * app.signal.numSamples); int numSamples = endSample - startSample; if (numSamples <= 0 || startSample < 0 || endSample > app.signal.numSamples) return NULL; @@ -246,10 +254,20 @@ void PlaySelectedRegion(void) // 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. + // Snapshot the span actually handed to the device, which is the selection + // narrowed by any scrub offset — the playhead is measured against this. app.playSelStart = app.sel.timeStart; app.playSelEnd = app.sel.timeEnd; + if (app.playFromT > 0.0f && app.playFromT < 1.0f) + app.playSelStart += app.playFromT * (app.sel.timeEnd - app.sel.timeStart); app.playDuration = (app.signal.sampleRate > 0) ? (float)numSamples / (float)app.signal.sampleRate : 0.0f; + // Anchor the playhead to the wall clock (see playStartTime). Set here, at + // the moment the buffer is handed to the device, so the marker can't drift + // from the audio no matter how long a frame takes. + app.playStartTime = GetTime(); + app.playheadSeekT = 0.0f; + app.playheadT = 0.0f; EnsureAudioDevice(); // opened on demand; released again once playback ends if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound); diff --git a/src/render.c b/src/render.c index d34b23d..e7cdec6 100644 --- a/src/render.c +++ b/src/render.c @@ -2,6 +2,7 @@ #include "render.h" #include "stft.h" // ComputeSpectralStats for the selection panel #include "utils.h" // ComputeSignalStats +#include "audio.h" // PlaySelectedRegion for the selection transport #include #include @@ -579,6 +580,181 @@ static void DrawStatPanel(Rectangle bounds, Rectangle sel) } } + +// ===== Selection transport bar ===== +// Play controls attached to the selection box, so the common actions are where +// the user is already looking instead of over on the rail. Appears only when a +// region is selected. +// +// Geometry is split from drawing (SelectionTransportRect / TransportButtonRect) +// because input handling runs long before the draw pass, and a click here must +// be claimed before the pan/select handlers see it. + +// Number of buttons: rewind-to-start, play/pause, stop, loop. +#define TRANSPORT_BUTTONS 4 + +Rectangle SelectionTransportRect(Rectangle bounds) +{ + bool hasSelection = (app.sel.timeStart > 0.001f || app.sel.timeEnd < 0.999f || + app.sel.freqStart > 0.001f || app.sel.freqEnd < 0.999f); + if (!app.loaded || !hasSelection || app.sel.isTimeSelecting || app.sel.isDragging) + return (Rectangle){ 0, 0, 0, 0 }; + + float scale = GetUIScale(); + float bw = 26.0f * scale; // per-button + float w = bw * TRANSPORT_BUTTONS + 8.0f * scale; + float h = 24.0f * scale; + + float viewWidth = app.view.end - app.view.start; + if (viewWidth <= 0.0f) return (Rectangle){ 0, 0, 0, 0 }; + float x0 = bounds.x + ((app.sel.timeStart - app.view.start) / viewWidth) * bounds.width; + float x1 = bounds.x + ((app.sel.timeEnd - app.view.start) / viewWidth) * bounds.width; + + float freqWidth = app.view.freqEnd - app.view.freqStart; + float yTop = bounds.y + bounds.height - + ((app.sel.freqEnd - app.view.freqStart) / freqWidth) * bounds.height; + + // Centred on the selection, just above its top edge; flipped below when + // there isn't room, and clamped so it stays inside the viewport. + float cx = (fmaxf(x0, bounds.x) + fminf(x1, bounds.x + bounds.width)) * 0.5f; + float bx = cx - w * 0.5f; + float by = yTop - h - 6.0f * scale; + if (by < bounds.y + 2.0f) by = yTop + 6.0f * scale; + if (by + h > bounds.y + bounds.height) by = bounds.y + bounds.height - h - 2.0f; + if (bx < bounds.x) bx = bounds.x; + if (bx + w > bounds.x + bounds.width) bx = bounds.x + bounds.width - w; + + // Off-screen selection: nothing to attach to. + if (x1 < bounds.x || x0 > bounds.x + bounds.width) return (Rectangle){ 0, 0, 0, 0 }; + return (Rectangle){ bx, by, w, h }; +} + +static Rectangle TransportButtonRect(Rectangle bar, int i) +{ + float scale = GetUIScale(); + float bw = 26.0f * scale; + return (Rectangle){ bar.x + 4.0f * scale + i * bw, bar.y + 2.0f * scale, + bw, bar.height - 4.0f * scale }; +} + +bool TransportCapturesMouse(Rectangle bounds) +{ + Rectangle bar = SelectionTransportRect(bounds); + if (bar.width <= 0.0f) return false; + return CheckCollisionPointRec(GetMousePosition(), bar); +} + +// Act on a click. Separate from drawing so it runs during the input phase. +void UpdateSelectionTransport(Rectangle bounds) +{ + Rectangle bar = SelectionTransportRect(bounds); + if (bar.width <= 0.0f) return; + if (!IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) return; + + Vector2 m = GetMousePosition(); + for (int i = 0; i < TRANSPORT_BUTTONS; i++) { + if (!CheckCollisionPointRec(m, TransportButtonRect(bar, i))) continue; + switch (i) { + case 0: // rewind to the start of the selection + app.playFromT = 0.0f; + app.playheadT = 0.0f; + if (app.isPlaying) { + StopSound(AudioPlaybackSound); + PlaySelectedRegion(); + app.playbackFinished = false; + } + break; + case 1: // play / pause + if (app.isPlaying) { + // Pause: remember where we are so play resumes from here. + float stopAbs = app.playSelStart + + app.playheadT * (app.playSelEnd - app.playSelStart); + float selSpan = app.sel.timeEnd - app.sel.timeStart; + app.playFromT = (selSpan > 1e-6f) + ? Clamp((stopAbs - app.sel.timeStart) / selSpan, 0.0f, 0.999f) + : 0.0f; + StopSound(AudioPlaybackSound); + app.isPlaying = false; + app.playbackFinished = false; + } else { + PlaySelectedRegion(); + app.isPlaying = true; + app.playbackFinished = false; + } + break; + case 2: // stop: halt and rewind + if (app.isPlaying) StopSound(AudioPlaybackSound); + app.isPlaying = false; + app.playbackFinished = false; + app.playFromT = 0.0f; + app.playheadT = 0.0f; + break; + case 3: + app.loopPlayback = !app.loopPlayback; + break; + } + break; + } +} + +void DrawSelectionTransport(Rectangle bounds) +{ + Rectangle bar = SelectionTransportRect(bounds); + if (bar.width <= 0.0f) return; + + float scale = GetUIScale(); + Vector2 m = GetMousePosition(); + + DrawRectangleRec(bar, (Color){ 26, 26, 32, 235 }); + DrawRectangleLinesEx(bar, 1, (Color){ 110, 110, 130, 255 }); + + for (int i = 0; i < TRANSPORT_BUTTONS; i++) { + Rectangle b = TransportButtonRect(bar, i); + bool over = CheckCollisionPointRec(m, b); + bool on = (i == 3) ? app.loopPlayback : false; + if (over) DrawRectangleRec(b, (Color){ 60, 60, 76, 255 }); + if (on) DrawRectangleRec(b, (Color){ 50, 80, 60, 255 }); + + Color c = on ? (Color){ 150, 230, 170, 255 } : (Color){ 210, 210, 220, 255 }; + float cx = b.x + b.width * 0.5f, cy = b.y + b.height * 0.5f; + float s = b.height * 0.26f; + + switch (i) { + case 0: // |< rewind to start + // Bar on the left, triangle pointing INTO it. raylib wants + // counter-clockwise vertices; the previous winding put the apex + // to the right of the base, so the triangle never rendered and + // this read as a lone vertical bar. + DrawRectangleRec((Rectangle){ cx - s, cy - s, 2.0f * scale, s * 2 }, c); + DrawTriangle((Vector2){ cx - s + 3 * scale, cy }, + (Vector2){ cx + s, cy + s }, + (Vector2){ cx + s, cy - s }, c); + break; + case 1: // play / pause + if (app.isPlaying) { + DrawRectangleRec((Rectangle){ cx - s, cy - s, s * 0.7f, s * 2 }, c); + DrawRectangleRec((Rectangle){ cx + s * 0.3f, cy - s, s * 0.7f, s * 2 }, c); + } else { + DrawTriangle((Vector2){ cx - s * 0.7f, cy - s }, + (Vector2){ cx - s * 0.7f, cy + s }, + (Vector2){ cx + s, cy }, c); + } + break; + case 2: // stop + DrawRectangleRec((Rectangle){ cx - s * 0.85f, cy - s * 0.85f, + s * 1.7f, s * 1.7f }, c); + break; + case 3: // loop + DrawRectangleLinesEx((Rectangle){ cx - s, cy - s * 0.7f, s * 2, s * 1.4f }, + 1.4f, c); + DrawTriangle((Vector2){ cx + s * 0.2f, cy - s * 1.3f }, + (Vector2){ cx + s * 0.2f, cy - s * 0.1f }, + (Vector2){ cx + s * 1.1f, cy - s * 0.7f }, c); + break; + } + } +} + void DrawSelection(Rectangle bounds) { // Only draw if selection is not full range AND not currently dragging @@ -1395,7 +1571,11 @@ void DrawAnnotations(Rectangle bounds) bool mouseInBounds = CheckCollisionPointRec(m, bounds); bool suppressHover = app.sel.isTimeSelecting || app.sel.isFreqSelecting || app.sel.isDragging || app.view.isPanning || app.marker.dragging || - app.markerMode; + app.markerMode || + // The transport bar floats over the spectrogram; an + // annotation tooltip firing underneath it would cover + // the buttons being reached for. + TransportCapturesMouse(bounds); int hoverEvent = -1; // hover prefers later layers (assertions over bursts, point markers over both), @@ -2316,21 +2496,39 @@ void DrawTimeline(Rectangle lane) // Playhead // ============================================================================ -void DrawPlayhead(Rectangle bounds) +// Screen X of the playhead within `bounds`, or a negative value when there is +// nothing to draw. Shared with the scrub hit-test so the marker and its grab +// region can't disagree about where it is. +float PlayheadScreenX(Rectangle bounds) { - if (!app.isPlaying || app.playheadT < 0.0f || app.playheadT > 1.0f) return; + if (!app.loaded || app.playheadT < 0.0f || app.playheadT > 1.0f) return -1.0f; + if (app.playSelEnd <= app.playSelStart) return -1.0f; // 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; + if (viewWidth <= 0.0f) return -1.0f; float t = (timePos - app.view.start) / viewWidth; - float x = bounds.x + t * bounds.width; + return bounds.x + t * bounds.width; +} - // Clamp to bounds +void DrawPlayhead(Rectangle bounds) +{ + // Drawn while stopped too, so the marker stays put and can be dragged to + // replay a subrange without redoing the selection. + float x = PlayheadScreenX(bounds); + if (x < 0.0f) return; + if (x < bounds.x - 4 || x > bounds.x + bounds.width + 4) return; x = fmaxf(bounds.x, fminf(bounds.x + bounds.width, x)); - // Draw vertical line - DrawLine(x, bounds.y, x, bounds.y + bounds.height, RED); - // Draw semi-transparent overlay to make it stand out + Color c = app.isPlaying ? RED : (Color){ 255, 120, 120, 255 }; + DrawLine(x, bounds.y, x, bounds.y + bounds.height, c); DrawRectangle(x - 2, bounds.y, 4, bounds.height, (Color){ 255, 0, 0, 60 }); + + // Grab tab at the top, so the marker reads as draggable when stopped. + if (!app.isPlaying) { + DrawTriangle((Vector2){ x - 5, bounds.y }, + (Vector2){ x + 5, bounds.y }, + (Vector2){ x, bounds.y + 8 }, c); + } } diff --git a/src/render.h b/src/render.h index 8c8166a..63ed5fa 100644 --- a/src/render.h +++ b/src/render.h @@ -45,11 +45,23 @@ void DrawAnnotationsToImage(Image* img, Font font); void DrawSpectrogramGrid(Rectangle bounds, int numCellsX, int numCellsY, Color color); void DrawLabels(Rectangle bounds); void DrawSelection(Rectangle bounds); + +// --- Selection transport bar --- +// Play controls attached to the selection box. Geometry and input are split +// from drawing so a click can be claimed during the input phase, before the +// pan/select handlers run. +Rectangle SelectionTransportRect(Rectangle bounds); +bool TransportCapturesMouse(Rectangle bounds); +void UpdateSelectionTransport(Rectangle bounds); +void DrawSelectionTransport(Rectangle bounds); void DrawSelectionDrag(Rectangle bounds); void DrawCursorReadout(Rectangle bounds); void DrawMarkers(Rectangle bounds); void DrawSpectrumPanel(Rectangle bounds); void DrawPlayhead(Rectangle bounds); +// Playhead X within bounds, or <0 when there's nothing to draw. Used by the +// scrub hit-test so the marker and its grab region agree. +float PlayheadScreenX(Rectangle bounds); void DrawAnnotations(Rectangle bounds); // --- Minimap --- diff --git a/src/spectrogram.c b/src/spectrogram.c index c43a58e..139c277 100644 --- a/src/spectrogram.c +++ b/src/spectrogram.c @@ -292,7 +292,7 @@ void ResetForNewSignal(void) if (app.isPlaying && AudioPlaybackSound.frameCount > 0) StopSound(AudioPlaybackSound); app.isPlaying = false; app.playbackFinished = false; - app.playheadElapsed = 0.0f; + app.playheadSeekT = 0.0f; app.playheadT = 0.0f; // Invalidate the cached visible texture. @@ -1097,6 +1097,11 @@ int main(int argc, char* argv[]) { g_frameCounter++; + // Cursor is decided once per frame: handlers below record a request and + // the highest-priority one is applied at the end. Reset here. + app.cursorRequest = MOUSE_CURSOR_DEFAULT; + app.cursorPriority = -1; + // Set when the window is in the background but still has compute to // finish: the frame runs its logic and skips presenting. See the // power-management block below. @@ -1199,17 +1204,29 @@ int main(int argc, char* argv[]) if (app.isPlaying && AudioPlaybackSound.frameCount > 0) { // Check if sound stopped playing (IsSoundPlaying returns false when done) if (!IsSoundPlaying(AudioPlaybackSound)) { - app.isPlaying = false; - app.playbackFinished = true; + if (app.loopPlayback) { + // Restart from the top of the selection, ignoring any scrub + // offset: a loop should repeat the whole region, not the + // tail the last play happened to start from. + app.playFromT = 0.0f; + PlaySelectedRegion(); + app.isPlaying = true; + app.playbackFinished = false; + } else { + app.isPlaying = false; + app.playbackFinished = true; + } } - // 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(); + // Derive the playhead from the wall clock, not from summed frame + // times. Audio plays on its own clock; accumulating GetFrameTime() + // counted every frame's render work as playback time, so the marker + // ran ahead — by seconds on a long region, and worst when zoomed out + // where each frame does the most work. if (app.playDuration > 0.0f) { - app.playheadT = app.playheadElapsed / app.playDuration; + double elapsed = GetTime() - app.playStartTime; + app.playheadT = app.playheadSeekT + (float)(elapsed / app.playDuration); if (app.playheadT > 1.0f) app.playheadT = 1.0f; + if (app.playheadT < 0.0f) app.playheadT = 0.0f; } } @@ -1233,6 +1250,43 @@ int main(int argc, char* argv[]) float spectroHeight = L.spectroHeight; Rectangle viewBounds = L.viewBounds; + // Selection transport. Claims its clicks before the pan/select + // handlers below, which are gated on TransportCapturesMouse(). + UpdateSelectionTransport(viewBounds); + + // Playhead scrub. Only while stopped: dragging the marker sets + // where the next play starts within the selection, so a subrange + // can be replayed without redrawing the region. Claims the press + // ahead of the pan handler, which is gated on playheadDragging. + if (!app.isPlaying && app.loaded && app.playSelEnd > app.playSelStart) { + float phx = PlayheadScreenX(viewBounds); + Vector2 pm = GetMousePosition(); + bool nearPlayhead = phx >= 0.0f && fabsf(pm.x - phx) <= 6.0f * viewScale && + CheckCollisionPointRec(pm, viewBounds); + if (nearPlayhead && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) + app.playheadDragging = true; + if (!IsMouseButtonDown(MOUSE_LEFT_BUTTON)) app.playheadDragging = false; + + if (app.playheadDragging) { + RequestCursor(MOUSE_CURSOR_RESIZE_EW, CURSOR_PRI_ACTIVE); + // Screen X -> time -> fraction of the SELECTION, which is + // what playFromT means. + float vt = (pm.x - viewBounds.x) / viewBounds.width; + float tAbs = app.view.start + vt * (app.view.end - app.view.start); + float selSpan = app.sel.timeEnd - app.sel.timeStart; + if (selSpan > 1e-6f) { + app.playFromT = Clamp((tAbs - app.sel.timeStart) / selSpan, 0.0f, 1.0f); + // Keep the drawn marker under the cursor: it is measured + // against playSelStart/End, not the selection. + app.playSelStart = app.sel.timeStart; + app.playSelEnd = app.sel.timeEnd; + app.playheadT = app.playFromT; + } + } else if (nearPlayhead) { + RequestCursor(MOUSE_CURSOR_RESIZE_EW, CURSOR_PRI_HOVER); + } + } + // Minimap scrub. Runs BEFORE the pan/zoom handlers below so a press // inside the minimap is claimed here; those handlers are gated on // MinimapCapturesMouse() and will skip it. Both writing app.view in @@ -1335,7 +1389,8 @@ int main(int argc, char* argv[]) // Never start a pan on a click that belongs to the UI chrome. bool overView = CheckCollisionPointRec(GetMousePosition(), viewBounds) && !MenubarCapturesMouse() && !SidebarCapturesMouse() && - !MinimapCapturesMouse(); + !MinimapCapturesMouse() && !app.playheadDragging && + !TransportCapturesMouse(viewBounds); if (overView && ((canPan && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) || IsMouseButtonPressed(MOUSE_BUTTON_MIDDLE))) { app.view.isPanning = true; @@ -1574,8 +1629,20 @@ int main(int argc, char* argv[]) StopSound(AudioPlaybackSound); app.isPlaying = false; app.playbackFinished = false; - app.playheadElapsed = 0; - app.playheadT = 0; + // Leave the marker where playback stopped rather than snapping it + // to the start: it stays grabbable, and the next play resumes + // from here. playheadT is a fraction of the PLAYED span, which is + // not the selection once a scrub offset is in play — convert + // through absolute file time, the only frame the two share. + { + float stopAbs = app.playSelStart + + app.playheadT * (app.playSelEnd - app.playSelStart); + float selSpan = app.sel.timeEnd - app.sel.timeStart; + app.playFromT = (selSpan > 1e-6f) + ? Clamp((stopAbs - app.sel.timeStart) / selSpan, 0.0f, 0.999f) + : 0.0f; + } + app.playheadSeekT = 0.0f; } else if (app.playbackFinished) { // Playback finished naturally - restart from beginning PlaySelectedRegion(); @@ -1622,7 +1689,8 @@ int main(int argc, char* argv[]) // Parking the cursor off-screen is enough to make every hit test below // miss without threading a flag through each one. if (MenubarCapturesMouse() || SidebarCapturesMouse() || - MinimapCapturesMouse()) mousePos = (Vector2){ -1000, -1000 }; + MinimapCapturesMouse() || app.playheadDragging || + TransportCapturesMouse(selBounds)) mousePos = (Vector2){ -1000, -1000 }; // Calculate divider screen position (for hover detection) @@ -1670,39 +1738,28 @@ int main(int argc, char* argv[]) } } - // Set cursor based on context - if (app.sel.isDragging) { - SetMouseCursor(MOUSE_CURSOR_RESIZE_ALL); // 4-way arrow while dragging - } else if (hoverInsideSelection) { - SetMouseCursor(MOUSE_CURSOR_POINTING_HAND); // Pointing hand on hover - } else { - SetMouseCursor(MOUSE_CURSOR_DEFAULT); // Normal arrow - } - // LMB drag = box select (time + frequency) OR drag existing selection if (app.loaded && !UiModalOpen() && CheckCollisionPointRec(mousePos, selBounds)) { - // Set cursor to resize all when near divider - if (mouseNearDivider && !app.isDividing) { - SetMouseCursor(MOUSE_CURSOR_RESIZE_ALL); - } else if (app.sel.isDragging) { - SetMouseCursor(MOUSE_CURSOR_RESIZE_ALL); // 4-way arrow while dragging + // Cursor for the spectrogram area. One decision, stated as a + // request — a second block used to run unconditionally before this + // one and the two disagreed, which is what made the shape flicker. + if (app.sel.isDragging || app.view.isPanning) { + RequestCursor(MOUSE_CURSOR_RESIZE_ALL, CURSOR_PRI_ACTIVE); + } else if (mouseNearDivider && !app.isDividing) { + RequestCursor(MOUSE_CURSOR_RESIZE_NS, CURSOR_PRI_HOVER); } else if (hoverInsideSelection && selSelects) { - SetMouseCursor(MOUSE_CURSOR_POINTING_HAND); // Pointing hand on hover + RequestCursor(MOUSE_CURSOR_POINTING_HAND, CURSOR_PRI_HOVER); } else if (selSelects) { // Crosshair whenever a left-drag would draw a box, so the two // gestures are distinguishable without guessing. - SetMouseCursor(MOUSE_CURSOR_CROSSHAIR); - } else if (app.view.isPanning) { - SetMouseCursor(MOUSE_CURSOR_RESIZE_ALL); - } else { - SetMouseCursor(MOUSE_CURSOR_DEFAULT); // Normal arrow + RequestCursor(MOUSE_CURSOR_CROSSHAIR, CURSOR_PRI_HOVER); } if (app.markerMode) { // Marker/ruler mode: LMB press drops point A, dragging moves B, // release finalizes. Alt / middle-drag still pans (handled // above), so don't drop a marker while panning. - SetMouseCursor(MOUSE_CURSOR_CROSSHAIR); + RequestCursor(MOUSE_CURSOR_CROSSHAIR, CURSOR_PRI_HOVER); bool altPan = IsKeyDown(KEY_LEFT_ALT) || IsKeyDown(KEY_RIGHT_ALT) || IsMouseButtonDown(MOUSE_BUTTON_MIDDLE); if (!altPan) { @@ -1734,6 +1791,7 @@ int main(int argc, char* argv[]) app.sel.isTimeSelecting = true; app.sel.isFreqSelecting = true; app.sel.selectStartPos = mousePos; + app.playFromT = 0.0f; // new region plays from its start // Convert screen position to signal coordinates (accounting for zoom) float viewportT = (mousePos.x - selBounds.x) / selBounds.width; @@ -2142,6 +2200,7 @@ int main(int argc, char* argv[]) if (L.timelineHeight > 0) DrawTimeline(L.timelineBounds); DrawAnnotations(viewBounds); DrawSelection(viewBounds); + DrawSelectionTransport(viewBounds); DrawSelectionDrag(viewBounds); DrawMarkers(viewBounds); DrawPlayhead(viewBounds); @@ -2276,6 +2335,9 @@ int main(int argc, char* argv[]) if (app.exportMessageTimer <= 0.0f) app.exportMessage[0] = '\0'; } + // Apply the frame's cursor decision (see RequestCursor). + SetMouseCursor(app.cursorRequest); + EndDrawing(); } diff --git a/src/spectrogram_types.h b/src/spectrogram_types.h index bc497bc..4f282ce 100644 --- a/src/spectrogram_types.h +++ b/src/spectrogram_types.h @@ -202,7 +202,28 @@ typedef struct { // Playback state float playheadT; // 0-1 normalized position within the PLAYING region - float playheadElapsed; // Elapsed seconds since play started + // Wall-clock instant (GetTime()) at which the current buffer started + // playing. The playhead is derived from this rather than accumulated per + // frame: summing GetFrameTime() drifts, because every frame spent on + // rendering work is counted as playback time, and the error compounds over + // a long region. Audio runs on its own clock, so the playhead has to as + // well. Offset by playheadSeekT when the user scrubs. + double playStartTime; + float playheadSeekT; // 0-1 offset into the buffer that playback began at + // Where the next play should start within the selection (0-1). Set by + // dragging the playhead while stopped; reset once the selection changes. + float playFromT; + bool playheadDragging; + // Repeat the selection when it reaches the end, instead of stopping. + bool loopPlayback; + + // Mouse cursor requested for this frame, applied once at the end of it. + // Several handlers have an opinion about the cursor and they run in + // sequence, each calling SetMouseCursor unconditionally — so whichever ran + // last won, and two of them disagreeing produced a visible flicker between + // shapes. Handlers now record a request and the highest-priority one wins. + int cursorRequest; + int cursorPriority; // Snapshot of the region actually handed to the audio device, captured at // PlaySelectedRegion time. The playhead must be measured against this, not @@ -449,11 +470,30 @@ static inline bool UiModalOpen(void) return app.showFileBrowser || app.showAbout; } +// Request a mouse cursor for this frame. Higher priority wins; ties go to the +// first caller. Applied once per frame (see the end of the main loop), so +// handlers can state their preference without fighting each other. +static inline void RequestCursor(int shape, int priority) +{ + if (priority > app.cursorPriority) { + app.cursorPriority = priority; + app.cursorRequest = shape; + } +} + +// Cursor priorities: an active drag outranks a hover hint, which outranks the +// default. Keeps "what am I doing" ahead of "what could I do". +#define CURSOR_PRI_DEFAULT 0 +#define CURSOR_PRI_HOVER 10 +#define CURSOR_PRI_ACTIVE 20 + // Reset the box selection to the full signal (the "no selection" state). static inline void ClearSelection(void) { app.sel.timeStart = 0.0f; app.sel.timeEnd = 1.0f; app.sel.freqStart = 0.0f; app.sel.freqEnd = 1.0f; + // A scrub offset is meaningless against a selection that no longer exists. + app.playFromT = 0.0f; } // Effective top of the displayed frequency axis (Hz). Capped at the actual diff --git a/src/ui.c b/src/ui.c index a9de023..a532299 100644 --- a/src/ui.c +++ b/src/ui.c @@ -873,11 +873,27 @@ void DrawSidebar(void) StopSound(AudioPlaybackSound); app.isPlaying = false; app.playbackFinished = false; - app.playheadElapsed = 0; - app.playheadT = 0; + // Leave the marker where playback stopped rather than snapping it + // to the start: it stays grabbable, and the next play resumes + // from here. playheadT is a fraction of the PLAYED span, which is + // not the selection once a scrub offset is in play — convert + // through absolute file time, the only frame the two share. + { + float stopAbs = app.playSelStart + + app.playheadT * (app.playSelEnd - app.playSelStart); + float selSpan = app.sel.timeEnd - app.sel.timeStart; + app.playFromT = (selSpan > 1e-6f) + ? Clamp((stopAbs - app.sel.timeStart) / selSpan, 0.0f, 0.999f) + : 0.0f; + } + app.playheadSeekT = 0.0f; } else { PlaySelectedRegion(); app.isPlaying = true; + // Must clear: PlaySelectedRegion restarts from the top, and leaving + // this set left the playhead parked at the end of the previous run + // while the audio played from the beginning. + app.playbackFinished = false; } } y += btn + gap;