fix(ios): rebuild a pattern player once it has been stopped - #291
Merged
Merged
Conversation
Apple's contract for `startAtTime:` only promises a rewind for a player that is already playing: "If you call this method on a player that's already playing, it restarts itself at the beginning of the pattern." Nothing covers a stopped player, and the basic CHHapticPatternPlayer has no way to rewind one — seek(toOffset:) exists only on the advanced player. A stopped player stays parked at its stop offset, so starting it again produces no output. Stopping a player therefore unregisters it, and the next play rebuilds it from its pattern through the path already used after an engine reset.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces #289, which changed the same line but justified it only by an A/B. This one states the mechanism, with sources.
Symptom
Audio Sync demo in PulsarApp: play the fanfare, let it reach the end, press Play again — silence. No audio, no haptics. Seeking to a different position and playing works.
Mechanism
playFrom()in the demo callsfanfare.stop()before every play. On the first play that is a no-op (nothing has parsed yet); on a replay at the same positionPresetHandle.ensureParsedreuses the parsed composer, so the sameCHHapticPatternPlayerinstances are reused after having been stopped.Apple's contract for
start(atTime:):The rewind guarantee is scoped to a player that is already playing. Nothing in the headers, the reference docs, the WWDC19 sessions or the release notes says a stopped player can be started again — and the basic player has no way to rewind one:
seek(toOffset:),pause,resume,loopEnabledandplaybackRateall exist only onCHHapticAdvancedPatternPlayer. A DTS engineer in forum thread 694892 confirms a player carries a playback position that persists acrossstart("It is legal to seek a player before you start it - it will start at the new offset"). A stopped player sits at its stop offset with no API to move it, sostartfinds nothing left to play and returns without error.Rebuilding is not a workaround, it is Apple's usage model. WWDC19 session 520:
And the one player-lifecycle case Apple documents, Preparing your app to play haptics, prescribes exactly this remedy:
This also explains the asymmetry we saw: tapping a system preset repeatedly works even though
PresetsWrappercaches onePlayerper preset, because that path never callsstop()— the player always runs to its natural end.Change
One line.
stopPlayer(id:)unregisters the player it just stopped, so the nextplayPlayertakes the rebuild-from-pattern branch that already exists for engine-reset recovery. The registered audio resource is untouched, so the rebuilt player still carries its audio.Scope is narrow:
stopPlayer(id:)has exactly one caller,PatternComposer.stop(). Repeatedplay()with no stop in between — every system preset tap — still hits the registry and reuses its cached player. Nothing else changes.Evidence
Hardware A/B, physical iPhone 13 / iOS 26.6.1. Two builds of PulsarApp differing by exactly this one line, identical JS:
unregisterPlayer(id)This cannot be reproduced or regression-tested on a simulator. Instrumented run on iPhone 16 Pro simulator:
capabilitiesForHardware().supportsHapticsis false there, so no players are created at all and the preset falls back to the synthesized buffer rather than its own audio. Any green simulator run says nothing about this bug.Tests
playingAfterAStopBuildsAFreshPlayerin the CoreHaptics mock suite pins the contract — it fails onmain(playersCreated → 1) == 2) and passes here. It asserts the wrapper rebuilds; it cannot assert that real CoreHaptics stays silent otherwise, which is what the hardware A/B above is for.Pulsar-Package: 63 tests / 11 suites, plus the 7-test isolated mock suite.Deliberately not included
parse()(re-parse creates new players without releasing the old ones, so ~10 seeks start evicting players still in use). Real, separate, not needed for this fix.