Correct sound duration and playback timing - #9249
Conversation
Build size reportThis PR changes the size of the minified bundles.
|
mvaligursky
left a comment
There was a problem hiding this comment.
🤖 Automated PR review — posted on my behalf by Claude Code (Opus 4.8). Not a human review. The points below are suggestions to weigh as possible improvements, not changes that necessarily need to be addressed.
Ran the new tests and lint locally — both clean, and the change is correctly scoped to the getters (_createInstance passes the raw _duration, so no playback config shifts).
One gap, detailed inline: the clamp ignores startTime, so the "getters agree with playback" invariant still doesn't hold whenever startTime > 0. Not a regression (it predates the modulo→clamp change), but it's the same invariant this PR is establishing and it's a one-token extension of the same expression.
Secondary: neither new test suite exercises startTime. The shorter/equal/longer matrix is otherwise thorough, but with startTime left at its default of 0 the interaction is entirely uncovered — which is why the above slips through green. A case like { startTime: 2, duration: 6 } on the 4s fixture would pin it (expected 2, currently 4).
mvaligursky
left a comment
There was a problem hiding this comment.
🤖 Automated re-review (commit b36f6a7) — posted on my behalf by Claude Code (Opus 4.8). Not a human review. The points below are suggestions to weigh as possible improvements, not changes that necessarily need to be addressed.
The startTime gap is resolved. I re-ran the getter-vs-playback comparison at this head across 14 configurations and it now agrees in every one — including the loop path, which the commit doesn't touch directly (loopEnd = min(loopStart + _duration, buffer.duration) turns out to be algebraically identical to loopStart + this.duration, so the loop window matches the getter for free). 18 new tests pass, 116 across the adjacent sound suites, lint clean.
The _currentOffset re-basing is a nice catch beyond what I raised: set pitch does this._currentOffset = this.currentTime, and before this commit those two were in different coordinate spaces (buffer-absolute vs startTime-relative), so that assignment was silently wrong whenever startTime > 0. It's now consistent.
Three points, all minor and mostly about surfacing the new semantics rather than the code.
The PR description is now stale. The API Changes section still lists only the original resource-length clamp, but this commit adds two further observable changes:
- The
durationgetters now subtract the normalizedstartTime, so the headline example is incomplete — the sameslot.duration = 45on that 38.22s asset returns something different again oncestartTime > 0. set currentTimenow normalizes eagerly (see inline). Previouslyinstance.currentTime = 5withduration = 3read back as5and only wrapped when playback started; it now reads back2immediately. The new test asserts this deliberately, so it's clearly intended — it just deserves a line in the API notes, since it changes a property round-trip.
| if (value < 0) return; | ||
|
|
||
| const duration = this.duration; | ||
| const currentTime = duration ? capTime(value, duration) : value; |
There was a problem hiding this comment.
This is the eager normalization worth calling out in the PR's API Changes section. Before this commit the setter stored the raw value and get currentTime returned it verbatim via _startOffset, so currentTime = 5 on a 3s duration read back as 5 and only wrapped at play(). It now reads back 2.
Matching the documented "it will wrap from the beginning" immediately is the better behavior, and the new test pins it — this is purely about the description not mentioning a changed property round-trip.
One ordering consequence, if you think it's worth caring about: because the wrap is applied at assignment time using the duration as of that moment, the caller's original intent is discarded. Setting currentTime = 5 and then widening duration afterwards leaves the offset at 2, whereas the old lazy wrap would have re-derived 5 % newDuration. Both setters restart playback so there's no torn state — it's just that assignment order now matters where it didn't. Probably fine to accept; a @remarks note would cover it.
| if (this._duration) { | ||
| return capTime(this._duration, this._sound.duration); | ||
| const soundDuration = this._sound.duration; | ||
| const startTime = capTime(this._startTime, soundDuration); |
There was a problem hiding this comment.
"the normalized start time" in the new doc wording is carrying more meaning than a reader will unpack. capTime is a modulo wrap, so two non-obvious rules follow:
startTime = 6on a 4s sound normalizes to2— playback silently begins 2s in rather than erroring or clamping to the end.startTime === sound.durationwraps to0, i.e. asking to start exactly at the end restarts from the beginning (your{ startTime: 4, duration: 6 }test encodes this).
Both match what _playAudioImmediate already did with capTime(this._startTime + currentOffset, this._sound.duration), so aligning the getter to it is right. Just worth stating the wrap outright in the JSDoc ("start times beyond the resource length wrap modulo its duration") so the behavior is discoverable from the API docs rather than only from this expression.
| // != intentional | ||
| if (this._duration != null) { | ||
| return this._duration % (assetDuration || 1); | ||
| const startTime = (this._startTime % assetDuration) || 0; |
There was a problem hiding this comment.
This inlines the same rule SoundInstance#duration expresses as capTime(this._startTime, soundDuration) — presumably because capTime is module-private to instance.js. Two encodings of one rule, and the || 0 is load-bearing in both: it converts the NaN from x % 0 when the asset isn't loaded, and collapses the startTime === duration case to 0.
The two getters are now required to agree (that's the invariant this PR establishes), so the duplication is the kind that drifts quietly — a future tweak to one wouldn't fail any test that compares them, since none does. Exporting capTime from instance.js (or lifting it somewhere shared) and calling it from both would make them structurally impossible to diverge.
Keep sound duration reporting and playback position consistent with configured start times and the underlying audio resource.
Changes:
SoundInstance.durationandSoundSlot.durationto the playable time after the normalized start time.SoundInstance.currentTimerelative tostartTimeand wrap assigned values immediately.API Changes:
SoundInstance#durationandSoundSlot#durationnow return the playable duration afterstartTimewhen a duration is configured.SoundInstance#currentTimenow consistently reports a position relative tostartTime, including immediately after assignment and across pause/resume.