Skip to content

fix(ios): rebuild a pattern player once it has been stopped - #291

Merged
piaskowyk merged 1 commit into
mainfrom
claude/ios-replay-after-stop-v2
Sep 10, 2026
Merged

piaskowyk merged 1 commit into
mainfrom
claude/ios-replay-after-stop-v2

Conversation

@piaskowyk

Copy link
Copy Markdown
Member

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 calls fanfare.stop() before every play. On the first play that is a no-op (nothing has parsed yet); on a replay at the same position PresetHandle.ensureParsed reuses the parsed composer, so the same CHHapticPatternPlayer instances are reused after having been stopped.

Apple's contract for start(atTime:):

If you call this method on a player that's already playing, it restarts itself at the beginning of the pattern.

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, loopEnabled and playbackRate all exist only on CHHapticAdvancedPatternPlayer. A DTS engineer in forum thread 694892 confirms a player carries a playback position that persists across start ("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, so start finds nothing left to play and returns without error.

Rebuilding is not a workaround, it is Apple's usage model. WWDC19 session 520:

Notice that the app does not hold on to the instance of this player. Its pattern is guaranteed to continue playing until it is finished. So the application can simply fire and forget it.

And the one player-lifecycle case Apple documents, Preparing your app to play haptics, prescribes exactly this remedy:

Recreate all haptic pattern players you had created, using makePlayer(with:).

This also explains the asymmetry we saw: tapping a system preset repeatedly works even though PresetsWrapper caches one Player per preset, because that path never calls stop() — the player always runs to its natural end.

Change

One line. stopPlayer(id:) unregisters the player it just stopped, so the next playPlayer takes 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(). Repeated play() 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:

build Play, let it finish, Play again
with unregisterPlayer(id) audio + haptics play
without it silent

This cannot be reproduced or regression-tested on a simulator. Instrumented run on iPhone 16 Pro simulator:

PROBE canPlayHaptics=false enabled=true active=true supported=false
PROBE parse hasSound=false continuousId=nil discreteId=nil audioBuffer=true
PROBE composer.play hasSound=false continuousId=nil discreteId=nil

capabilitiesForHardware().supportsHaptics is 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

playingAfterAStopBuildsAFreshPlayer in the CoreHaptics mock suite pins the contract — it fails on main (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

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.
@piaskowyk
piaskowyk merged commit 8a8ed6b into main Sep 10, 2026
1 check passed
@piaskowyk
piaskowyk deleted the claude/ios-replay-after-stop-v2 branch September 10, 2026 07:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant