fix: track the playhead against the region actually playing

PlaySelectedRegion copies, bandpasses and normalises the selected span up
front, so the audio in flight is fixed the moment Space is pressed. The
playhead, though, was drawn against the live app.sel and its duration
re-derived from the live selection — so moving or resizing the selection
mid-playback made the marker jump, overrun, or scale against a region
that had nothing to do with what was audible.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ZWfr5XZyyDttvkhJUgHN
This commit is contained in:
2026-08-12 00:19:02 -07:00
parent 46e796cdeb
commit c4687ce80d
4 changed files with 36 additions and 9 deletions
+8
View File
@@ -243,6 +243,14 @@ void PlaySelectedRegion(void)
float* regionSamples = BuildSelectionAudio(&numSamples);
if (!regionSamples) return;
// Snapshot what we're about to play so the playhead tracks THIS region even
// if the user moves the selection mid-playback. Duration comes from the
// buffer we actually built, not from app.signal.duration.
app.playSelStart = app.sel.timeStart;
app.playSelEnd = app.sel.timeEnd;
app.playDuration = (app.signal.sampleRate > 0)
? (float)numSamples / (float)app.signal.sampleRate : 0.0f;
EnsureAudioDevice(); // opened on demand; released again once playback ends
if (AudioPlaybackSound.frameCount != 0) UnloadSound(AudioPlaybackSound);
+2 -1
View File
@@ -1685,7 +1685,8 @@ void DrawPlayhead(Rectangle bounds)
{
if (!app.isPlaying || app.playheadT < 0.0f || app.playheadT > 1.0f) return;
float timePos = app.sel.timeStart + app.playheadT * (app.sel.timeEnd - app.sel.timeStart);
// Against the snapshot of the playing region, not the live selection.
float timePos = app.playSelStart + app.playheadT * (app.playSelEnd - app.playSelStart);
float viewWidth = app.view.end - app.view.start;
float t = (timePos - app.view.start) / viewWidth;
float x = bounds.x + t * bounds.width;
+15 -7
View File
@@ -945,11 +945,14 @@ int main(int argc, char* argv[])
app.isPlaying = false;
app.playbackFinished = true;
}
// Track playhead position manually
// Track playhead position manually, against the length of the buffer
// that's actually playing (see playDuration) rather than a length
// re-derived from the live selection — the user can move the
// selection mid-playback without the marker jumping.
app.playheadElapsed += GetFrameTime();
float selectionDuration = (app.sel.timeEnd - app.sel.timeStart) * app.signal.duration;
if (selectionDuration > 0) {
app.playheadT = app.playheadElapsed / selectionDuration;
if (app.playDuration > 0.0f) {
app.playheadT = app.playheadElapsed / app.playDuration;
if (app.playheadT > 1.0f) app.playheadT = 1.0f;
}
}
@@ -1324,8 +1327,13 @@ int main(int argc, char* argv[])
app.sel.freqStart = app.sel.freqEnd;
app.sel.freqEnd = tmp;
}
} else {
// Drag too small - revert to full range
} else if (!hoverInsideSelection) {
// Sub-threshold drag outside any existing selection: treat
// as a click on empty space and reset to full range. A
// stray click *inside* the current box leaves it alone —
// silently clearing it there changes what Space plays.
// (hoverInsideSelection, not clickInsideSelection: the
// latter is press-frame-only and is always false here.)
ClearSelection();
}
app.sel.isTimeSelecting = false;
@@ -1715,7 +1723,7 @@ int main(int argc, char* argv[])
app.scopeView.data.sampleRate = app.signal.sampleRate;
// Show playhead if playing
if (app.isPlaying) {
DrawScopeView(&app.scopeView, app.sel.timeStart + app.playheadT * (app.sel.timeEnd - app.sel.timeStart));
DrawScopeView(&app.scopeView, app.playSelStart + app.playheadT * (app.playSelEnd - app.playSelStart));
} else {
DrawScopeView(&app.scopeView, -1.0f);
}
+11 -1
View File
@@ -138,9 +138,19 @@ typedef struct {
bool stftComputed;
// Playback state
float playheadT; // 0-1 normalized position in selection
float playheadT; // 0-1 normalized position within the PLAYING region
float playheadElapsed; // Elapsed seconds since play started
// Snapshot of the region actually handed to the audio device, captured at
// PlaySelectedRegion time. The playhead must be measured against this, not
// against the live app.sel — the user can move or resize the selection while
// audio is still playing, and the marker has to keep tracking the sound
// that's really coming out. playDuration comes from the buffer's own sample
// count / sampleRate, so it can't drift from app.signal.duration (which is
// derived pre-mono-downmix and disagrees for stereo files).
float playSelStart, playSelEnd; // sel.timeStart/End when playback began
float playDuration; // true length of the playing buffer, seconds
// Time + frequency box selection and its drag/move interaction state.
Selection sel;