fix: playhead drift, replay, and cursor flicker; add selection transport

**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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN
This commit is contained in:
2026-08-12 15:56:33 -07:00
parent cce96659b1
commit 56b1666850
7 changed files with 415 additions and 67 deletions
+22 -20
View File
@@ -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.
---
+20 -2
View File
@@ -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);
+206 -8
View File
@@ -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 <math.h>
#include <stdlib.h>
@@ -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);
}
}
+12
View File
@@ -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 ---
+94 -32
View File
@@ -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)) {
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();
}
+41 -1
View File
@@ -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
+18 -2
View File
@@ -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;