Skip to content

fix(android): play impulse-only presets on hardware without primitive support#93

Open
piaskowyk wants to merge 2 commits into
mainfrom
claude/clever-hoover-29aa17
Open

fix(android): play impulse-only presets on hardware without primitive support#93
piaskowyk wants to merge 2 commits into
mainfrom
claude/clever-hoover-29aa17

Conversation

@piaskowyk

Copy link
Copy Markdown
Member

What

Impulse-only presets — the percussive ones built from just discrete signals (Castanets, Stomp, Gavel, FingerDrum, HoofBeat, WarDrum… ~45 presets where the continuous amplitude is empty) — were silent on weaker/older Android hardware, while firing fine on capable phones and on web.

Why

These presets play via VibrationEffect.Composition primitives (addPrimitive(PRIMITIVE_TICK/CLICK…)), gated on areAllPrimitivesSupported() and API ≥ 30. Weaker/older actuators report no primitive support, so ImpulseCompositionHapticBuilder.createCompositionEffect() returns null and execution falls through to the continuous-envelope path (PeakLineBuilder + ControlLineBuilder.getStepsPoints @ 13 Hz). That path destroys transients:

  • A single impulse at t=0 becomes a ~2 ms waveform — the step loop starts at currentTime=0 and discards the peak's negative-time pre-ramp, and the peak is only ~14 ms wide (2 ms at full amplitude) to begin with.
  • On amplitude-control devices the energy is averaged across ~76 ms buckets down to ~7–27/255 — below a weak motor's start threshold.

So the motor never spins up (silent), or on timing-only devices the rhythm smears into one long buzz. A 2 ms pulse does fire on a fast LRA (Pixel-class), which is why it works on better phones — and web works because navigator.vibrate is binary on/off at full power (no amplitude dilution).

Fix

Add a crisp-waveform fallback scoped to impulse-only presets, tried right after the primitive path returns null (in HapticBuilder.createVibrationEffect):

  • ImpulseCompositionHapticBuilder.createWaveformEffect / buildImpulseWaveform render each impulse as a 20 ms, (near-)full-power pulse via createWaveform, amplitude floored at 64/255, preserving inter-impulse gaps — mirroring the web Vibration API behavior that's confirmed working on the same hardware.
  • Capable devices (primitive support) and mixed discrete+continuous presets (Poke, Snap, Blip) are untouched.

Tests

New JVM unit tests for the pure buildImpulseWaveform builder (Pulsar/src/test/.../ImpulseCompositionHapticBuilderTest.kt) — 4 tests, all passing; library compiles.

Reviewer notes

  • Perceptual verification needs a physical weak device — it can't be felt on an emulator. The logic and code path are verified statically + via unit tests.
  • Related, not addressed here: the realtime discrete path uses VibrationEffect.createOneShot(10, …) (10 ms) in RealtimePrimitiveComposer / RealtimeEnvelopeWithDiscretePrimitivesComposer, which likely has the same too-short-for-weak-hardware issue. Left out to keep this change scoped to preset playback.

🤖 Generated with Claude Code

piaskowyk and others added 2 commits June 17, 2026 21:36
… support

Impulse-only presets (Castanets, Stomp, Gavel, etc.) play via
VibrationEffect.Composition primitives, gated on areAllPrimitivesSupported()
and API >= 30. On weaker/older actuators that report no primitive support,
createCompositionEffect() returned null and execution fell through to the
continuous-envelope path. That path destroys transients: a single impulse
collapses to a ~2 ms waveform (the peak's pre-ramp is discarded since the
step loop starts at t=0) and, on amplitude-control devices, its energy is
averaged across ~76 ms buckets down to ~7-27/255 — below a weak motor's
start threshold. The result is silent taps on cheap/old Android, even though
the same preset fires on fast LRA hardware and on web (navigator.vibrate is
binary on/off at full power).

Add a crisp-waveform fallback (createWaveformEffect / buildImpulseWaveform)
that renders each impulse as a 20 ms (near-)full-power pulse with a 64/255
amplitude floor, preserving inter-impulse gaps — mirroring the web Vibration
API behavior. It runs only for impulse-only presets, immediately after the
primitive path returns null, so devices with primitive support and mixed
discrete+continuous presets are unaffected.

Covered by JVM unit tests for buildImpulseWaveform.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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