Skip to content

fix(ios): let a preset play again after it has been stopped - #289

Closed
piaskowyk wants to merge 1 commit into
mainfrom
claude/ios-replay-after-stop
Closed

piaskowyk wants to merge 1 commit into
mainfrom
claude/ios-replay-after-stop

Conversation

@piaskowyk

@piaskowyk piaskowyk commented Sep 9, 2026 •

Copy link
Copy Markdown
Member

What

The Audio Sync demo in PulsarApp plays once. When the preset reaches its end and you hit Play again, nothing happens — no sound, no haptics.

playFrom() in the demo stops before every play. On the first play that stop is a no-op (nothing has parsed yet). On a replay at the same position it is not: PresetHandle.ensureParsed reuses the parsed composer, so the same CHHapticPatternPlayers are reused — and stopPlayer(id:) kept them in the registry after stopping them, so playPlayer called start(atTime: 0) on players CoreHaptics had already stopped. That silently does nothing, and since the discrete player carries the preset's audio event, both the haptics and the audio go quiet.

Seeking kept working because a new position re-parses and builds fresh players. Same-position replay is the only broken path, which is also why it slipped past qa-demos-audio-sync.yaml — that flow taps replay, but it can only assert the JS clock, not whether anything actually played.

Changes

  • HapticEngineWrapper — stopping a player unregisters it, so the next playPlayer takes the existing rebuild-from-pattern path (which also restarts the engine if it went down). The registered audio resource is untouched, so the rebuilt player still carries its audio.
  • PatternComposer — parse() releases the players it replaces. They were leaked into the engine registry on every re-parse, so roughly ten seeks were enough for eviction to start stopping players still in use.

Verified on hardware

Confirmed on a physical iPhone 13 (iOS 26.6.1) by A/B-ing two builds of PulsarApp that differ by exactly one line — the unregisterPlayer(id) call in stopPlayer(id:) — with the demo's JS untouched in both:

build Play, let it finish, Play again
with the call audio + haptics play
without it silent

Worth stating plainly: a stopped CHHapticPatternPlayer does not start again. That is the behaviour this change works around, and it is now measured rather than assumed.

Tests

  • New playingAfterAStopBuildsAFreshPlayer in the CoreHaptics mock suite. It fails on main (playersCreated → 1) == 2) and passes here.
  • Pulsar-Package: 63 tests / 11 suites pass, plus the 7-test isolated mock suite.

A CHHapticPatternPlayer that CoreHaptics has already stopped does not
start again, so replaying a preset at the same position — which reuses
the parsed composer — was silent: no audio, no haptics. Stopping a
player now unregisters it, and the next play rebuilds it from its
pattern through the path that already existed for recreated players.

Re-parsing also releases the players it replaces, instead of leaking
them into the engine registry until eviction stopped ones still in use.
@piaskowyk

Copy link
Copy Markdown
Member Author

Closing pending a proper investigation. The A/B held up on hardware, but it was a fix arrived at by inference and then confirmed, not one derived from understanding the failure — and that is not a good enough basis for changing engine-wide player lifecycle. Reopening or replacing this once the actual mechanism is established with a debugger.

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