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:
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
+96
-34
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+41
-1
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user