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 - **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, chunk; hover a box (or its region on the scope) for per-frame detail (sequence,
channel, rate, scheduling offset…). 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. - **Region selection** — box a time *and* frequency range with the mouse.
- **Filtered playback** — play just the selected region, band-limited to the - **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. 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. - **Waveform scope** — toggleable time-domain view beneath the spectrum.
- **Marker / ruler** and a **spectrum slice (PSD)** readout. - **Marker / ruler** and a **spectrum slice (PSD)** readout.
- **Export** — save the view as a PNG, or the selected region as a WAV. - **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 | | **Ctrl+wheel** | Zoom the frequency axis only |
| **Wheel on a scrollbar** | Pan that axis (**Shift** to zoom it) | | **Wheel on a scrollbar** | Pan that axis (**Shift** to zoom it) |
| **Space** | Play / stop the selected region | | **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 | | **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 | | **N** / **Shift+N** | Jump to the next / previous collision |
| **O** | Open file browser | | **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 overlays toggled); panning and zooming just move the rectangle drawn on top, so
navigation costs nothing. 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 ### Inspecting overlapping transmissions
When several stations are on the air at once their annotation boxes stack, and 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) ## Driving the GUI headlessly (agents / CI)
The app can be run, screenshotted, and clicked on a virtual X display with no The app runs, screenshots, and takes synthetic input on a virtual X display with
monitor or GPU (Mesa software GL under Xvfb). The full playbook lives in no monitor or GPU (Mesa software GL under Xvfb). The playbook is in
[`AGENTS.md`](AGENTS.md); the working reference implementation is [`AGENTS.md`](AGENTS.md); [`shot_input.sh`](shot_input.sh) is the working
[`shot_input.sh`](shot_input.sh). reference implementation.
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.
--- ---
+20 -2
View File
@@ -195,8 +195,16 @@ static float* BuildSelectionAudio(int* outNumSamples)
{ {
if (!app.loaded || !app.stftComputed) return NULL; if (!app.loaded || !app.stftComputed) return NULL;
int startSample = (int)(app.sel.timeStart * app.signal.numSamples); // playFromT lets playback begin partway into the selection (the user
int endSample = (int)(app.sel.timeEnd * app.signal.numSamples); // 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; int numSamples = endSample - startSample;
if (numSamples <= 0 || startSample < 0 || endSample > app.signal.numSamples) return NULL; 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 // 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 // if the user moves the selection mid-playback. Duration comes from the
// buffer we actually built, not from app.signal.duration. // 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.playSelStart = app.sel.timeStart;
app.playSelEnd = app.sel.timeEnd; 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) app.playDuration = (app.signal.sampleRate > 0)
? (float)numSamples / (float)app.signal.sampleRate : 0.0f; ? (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 EnsureAudioDevice(); // opened on demand; released again once playback ends
if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound); if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound);
+206 -8
View File
@@ -2,6 +2,7 @@
#include "render.h" #include "render.h"
#include "stft.h" // ComputeSpectralStats for the selection panel #include "stft.h" // ComputeSpectralStats for the selection panel
#include "utils.h" // ComputeSignalStats #include "utils.h" // ComputeSignalStats
#include "audio.h" // PlaySelectedRegion for the selection transport
#include <math.h> #include <math.h>
#include <stdlib.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) void DrawSelection(Rectangle bounds)
{ {
// Only draw if selection is not full range AND not currently dragging // 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 mouseInBounds = CheckCollisionPointRec(m, bounds);
bool suppressHover = app.sel.isTimeSelecting || app.sel.isFreqSelecting || bool suppressHover = app.sel.isTimeSelecting || app.sel.isFreqSelecting ||
app.sel.isDragging || app.view.isPanning || app.marker.dragging || 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; int hoverEvent = -1;
// hover prefers later layers (assertions over bursts, point markers over both), // hover prefers later layers (assertions over bursts, point markers over both),
@@ -2316,21 +2496,39 @@ void DrawTimeline(Rectangle lane)
// Playhead // 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. // Against the snapshot of the playing region, not the live selection.
float timePos = app.playSelStart + app.playheadT * (app.playSelEnd - app.playSelStart); 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;
if (viewWidth <= 0.0f) return -1.0f;
float t = (timePos - app.view.start) / viewWidth; 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)); x = fmaxf(bounds.x, fminf(bounds.x + bounds.width, x));
// Draw vertical line Color c = app.isPlaying ? RED : (Color){ 255, 120, 120, 255 };
DrawLine(x, bounds.y, x, bounds.y + bounds.height, RED); DrawLine(x, bounds.y, x, bounds.y + bounds.height, c);
// Draw semi-transparent overlay to make it stand out
DrawRectangle(x - 2, bounds.y, 4, bounds.height, (Color){ 255, 0, 0, 60 }); 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 DrawSpectrogramGrid(Rectangle bounds, int numCellsX, int numCellsY, Color color);
void DrawLabels(Rectangle bounds); void DrawLabels(Rectangle bounds);
void DrawSelection(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 DrawSelectionDrag(Rectangle bounds);
void DrawCursorReadout(Rectangle bounds); void DrawCursorReadout(Rectangle bounds);
void DrawMarkers(Rectangle bounds); void DrawMarkers(Rectangle bounds);
void DrawSpectrumPanel(Rectangle bounds); void DrawSpectrumPanel(Rectangle bounds);
void DrawPlayhead(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); void DrawAnnotations(Rectangle bounds);
// --- Minimap --- // --- Minimap ---
+94 -32
View File
@@ -292,7 +292,7 @@ void ResetForNewSignal(void)
if (app.isPlaying && AudioPlaybackSound.frameCount > 0) StopSound(AudioPlaybackSound); if (app.isPlaying && AudioPlaybackSound.frameCount > 0) StopSound(AudioPlaybackSound);
app.isPlaying = false; app.isPlaying = false;
app.playbackFinished = false; app.playbackFinished = false;
app.playheadElapsed = 0.0f; app.playheadSeekT = 0.0f;
app.playheadT = 0.0f; app.playheadT = 0.0f;
// Invalidate the cached visible texture. // Invalidate the cached visible texture.
@@ -1097,6 +1097,11 @@ int main(int argc, char* argv[])
{ {
g_frameCounter++; 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 // Set when the window is in the background but still has compute to
// finish: the frame runs its logic and skips presenting. See the // finish: the frame runs its logic and skips presenting. See the
// power-management block below. // power-management block below.
@@ -1199,17 +1204,29 @@ int main(int argc, char* argv[])
if (app.isPlaying && AudioPlaybackSound.frameCount > 0) { if (app.isPlaying && AudioPlaybackSound.frameCount > 0) {
// Check if sound stopped playing (IsSoundPlaying returns false when done) // Check if sound stopped playing (IsSoundPlaying returns false when done)
if (!IsSoundPlaying(AudioPlaybackSound)) { 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.isPlaying = false;
app.playbackFinished = true; app.playbackFinished = true;
} }
// Track playhead position manually, against the length of the buffer }
// that's actually playing (see playDuration) rather than a length // Derive the playhead from the wall clock, not from summed frame
// re-derived from the live selection — the user can move the // times. Audio plays on its own clock; accumulating GetFrameTime()
// selection mid-playback without the marker jumping. // counted every frame's render work as playback time, so the marker
app.playheadElapsed += GetFrameTime(); // 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) { 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 > 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; float spectroHeight = L.spectroHeight;
Rectangle viewBounds = L.viewBounds; 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 // Minimap scrub. Runs BEFORE the pan/zoom handlers below so a press
// inside the minimap is claimed here; those handlers are gated on // inside the minimap is claimed here; those handlers are gated on
// MinimapCapturesMouse() and will skip it. Both writing app.view in // 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. // Never start a pan on a click that belongs to the UI chrome.
bool overView = CheckCollisionPointRec(GetMousePosition(), viewBounds) && bool overView = CheckCollisionPointRec(GetMousePosition(), viewBounds) &&
!MenubarCapturesMouse() && !SidebarCapturesMouse() && !MenubarCapturesMouse() && !SidebarCapturesMouse() &&
!MinimapCapturesMouse(); !MinimapCapturesMouse() && !app.playheadDragging &&
!TransportCapturesMouse(viewBounds);
if (overView && ((canPan && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) || if (overView && ((canPan && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) ||
IsMouseButtonPressed(MOUSE_BUTTON_MIDDLE))) { IsMouseButtonPressed(MOUSE_BUTTON_MIDDLE))) {
app.view.isPanning = true; app.view.isPanning = true;
@@ -1574,8 +1629,20 @@ int main(int argc, char* argv[])
StopSound(AudioPlaybackSound); StopSound(AudioPlaybackSound);
app.isPlaying = false; app.isPlaying = false;
app.playbackFinished = false; app.playbackFinished = false;
app.playheadElapsed = 0; // Leave the marker where playback stopped rather than snapping it
app.playheadT = 0; // 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) { } else if (app.playbackFinished) {
// Playback finished naturally - restart from beginning // Playback finished naturally - restart from beginning
PlaySelectedRegion(); PlaySelectedRegion();
@@ -1622,7 +1689,8 @@ int main(int argc, char* argv[])
// Parking the cursor off-screen is enough to make every hit test below // Parking the cursor off-screen is enough to make every hit test below
// miss without threading a flag through each one. // miss without threading a flag through each one.
if (MenubarCapturesMouse() || SidebarCapturesMouse() || if (MenubarCapturesMouse() || SidebarCapturesMouse() ||
MinimapCapturesMouse()) mousePos = (Vector2){ -1000, -1000 }; MinimapCapturesMouse() || app.playheadDragging ||
TransportCapturesMouse(selBounds)) mousePos = (Vector2){ -1000, -1000 };
// Calculate divider screen position (for hover detection) // 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 // LMB drag = box select (time + frequency) OR drag existing selection
if (app.loaded && !UiModalOpen() && CheckCollisionPointRec(mousePos, selBounds)) { if (app.loaded && !UiModalOpen() && CheckCollisionPointRec(mousePos, selBounds)) {
// Set cursor to resize all when near divider // Cursor for the spectrogram area. One decision, stated as a
if (mouseNearDivider && !app.isDividing) { // request — a second block used to run unconditionally before this
SetMouseCursor(MOUSE_CURSOR_RESIZE_ALL); // one and the two disagreed, which is what made the shape flicker.
} else if (app.sel.isDragging) { if (app.sel.isDragging || app.view.isPanning) {
SetMouseCursor(MOUSE_CURSOR_RESIZE_ALL); // 4-way arrow while dragging 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) { } else if (hoverInsideSelection && selSelects) {
SetMouseCursor(MOUSE_CURSOR_POINTING_HAND); // Pointing hand on hover RequestCursor(MOUSE_CURSOR_POINTING_HAND, CURSOR_PRI_HOVER);
} else if (selSelects) { } else if (selSelects) {
// Crosshair whenever a left-drag would draw a box, so the two // Crosshair whenever a left-drag would draw a box, so the two
// gestures are distinguishable without guessing. // gestures are distinguishable without guessing.
SetMouseCursor(MOUSE_CURSOR_CROSSHAIR); RequestCursor(MOUSE_CURSOR_CROSSHAIR, CURSOR_PRI_HOVER);
} else if (app.view.isPanning) {
SetMouseCursor(MOUSE_CURSOR_RESIZE_ALL);
} else {
SetMouseCursor(MOUSE_CURSOR_DEFAULT); // Normal arrow
} }
if (app.markerMode) { if (app.markerMode) {
// Marker/ruler mode: LMB press drops point A, dragging moves B, // Marker/ruler mode: LMB press drops point A, dragging moves B,
// release finalizes. Alt / middle-drag still pans (handled // release finalizes. Alt / middle-drag still pans (handled
// above), so don't drop a marker while panning. // 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) || bool altPan = IsKeyDown(KEY_LEFT_ALT) || IsKeyDown(KEY_RIGHT_ALT) ||
IsMouseButtonDown(MOUSE_BUTTON_MIDDLE); IsMouseButtonDown(MOUSE_BUTTON_MIDDLE);
if (!altPan) { if (!altPan) {
@@ -1734,6 +1791,7 @@ int main(int argc, char* argv[])
app.sel.isTimeSelecting = true; app.sel.isTimeSelecting = true;
app.sel.isFreqSelecting = true; app.sel.isFreqSelecting = true;
app.sel.selectStartPos = mousePos; app.sel.selectStartPos = mousePos;
app.playFromT = 0.0f; // new region plays from its start
// Convert screen position to signal coordinates (accounting for zoom) // Convert screen position to signal coordinates (accounting for zoom)
float viewportT = (mousePos.x - selBounds.x) / selBounds.width; 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); if (L.timelineHeight > 0) DrawTimeline(L.timelineBounds);
DrawAnnotations(viewBounds); DrawAnnotations(viewBounds);
DrawSelection(viewBounds); DrawSelection(viewBounds);
DrawSelectionTransport(viewBounds);
DrawSelectionDrag(viewBounds); DrawSelectionDrag(viewBounds);
DrawMarkers(viewBounds); DrawMarkers(viewBounds);
DrawPlayhead(viewBounds); DrawPlayhead(viewBounds);
@@ -2276,6 +2335,9 @@ int main(int argc, char* argv[])
if (app.exportMessageTimer <= 0.0f) app.exportMessage[0] = '\0'; if (app.exportMessageTimer <= 0.0f) app.exportMessage[0] = '\0';
} }
// Apply the frame's cursor decision (see RequestCursor).
SetMouseCursor(app.cursorRequest);
EndDrawing(); EndDrawing();
} }
+41 -1
View File
@@ -202,7 +202,28 @@ typedef struct {
// Playback state // Playback state
float playheadT; // 0-1 normalized position within the PLAYING region 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 // Snapshot of the region actually handed to the audio device, captured at
// PlaySelectedRegion time. The playhead must be measured against this, not // PlaySelectedRegion time. The playhead must be measured against this, not
@@ -449,11 +470,30 @@ static inline bool UiModalOpen(void)
return app.showFileBrowser || app.showAbout; 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). // Reset the box selection to the full signal (the "no selection" state).
static inline void ClearSelection(void) static inline void ClearSelection(void)
{ {
app.sel.timeStart = 0.0f; app.sel.timeEnd = 1.0f; app.sel.timeStart = 0.0f; app.sel.timeEnd = 1.0f;
app.sel.freqStart = 0.0f; app.sel.freqEnd = 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 // 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); StopSound(AudioPlaybackSound);
app.isPlaying = false; app.isPlaying = false;
app.playbackFinished = false; app.playbackFinished = false;
app.playheadElapsed = 0; // Leave the marker where playback stopped rather than snapping it
app.playheadT = 0; // 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 { } else {
PlaySelectedRegion(); PlaySelectedRegion();
app.isPlaying = true; 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; y += btn + gap;