Skip to content

feat(android): simulate intensity & frequency via PWM on no-amplitude devices#95

Open
piaskowyk wants to merge 2 commits into
mainfrom
claude/hungry-dubinsky-b0f936
Open

feat(android): simulate intensity & frequency via PWM on no-amplitude devices#95
piaskowyk wants to merge 2 commits into
mainfrom
claude/hungry-dubinsky-b0f936

Conversation

@piaskowyk

Copy link
Copy Markdown
Member

Problem

On older Android phones without amplitude control (CompatibilityMode.LIMITED_SUPPORT), continuous presets all felt like one undifferentiated buzz. convertToTimingWaveform only checked intensity > 0 and collapsed each segment into a solid ON block — discarding both the intensity level and the frequency entirely.

Approach

A no-amplitude LRA/ERM driven via VibrationEffect.createWaveform(timings, -1) is the exact on/off-only equivalent of the web navigator.vibrate(array). So this ports the web engine's PWM / duty-cycle simulation (web/Pulsar/src/PatternComposer.ts + RealtimeComposer.ts):

  • intensity → ON pulse length (duty cycle)
  • frequency → OFF gap between pulses (inverted: higher freq = shorter gap = faster pulsing)

Changes

  • New PwmTimingSimulator — shared duty-cycle math. ERM-tuned bounds with the minimum pulse floored near the actuator engagement threshold (~40 ms) so low intensities stay perceptible instead of falling into the weak-buzz dead zone (same reasoning the web RealtimeComposer documents).
  • convertToTimingWaveform (static patterns/presets) — rewritten to walk the timeline and tile [shot, pause, …], sampling intensity/frequency at each pulse start. Genuine silent regions (intensity 0) stay off; pulses are clamped to the remaining duration.
  • RealtimePrimitiveComposer (realtime gestures, LIMITED + API < 33) — encodes intensity as the ON pulse length instead of the ignored createOneShot amplitude, and sets the loop interval to shot + pause. API 33+ keeps scalable composition primitives, so no regression on capable devices.

Verification

  • ./gradlew :Pulsar:compileDebugKotlin / :Pulsar:assembleDebug — ✅ build succeeds.
  • No-amplitude vs amplitude behavior cannot be felt on the emulator — a physical ERM device is needed. Two simulation notes for a capable test phone:
    • Static path: forceHapticsSupportLevel(LIMITED_SUPPORT) still routes to the amplitude waveform on a device that has amplitude (branch is isAmplitudeSupported() && forced >= LIMITED); force NO_SUPPORT to exercise convertToTimingWaveform.
    • Realtime path: usesPwmFallback requires API < 33, so it only runs on actual old phones.

🤖 Generated with Claude Code

piaskowyk and others added 2 commits June 17, 2026 21:45
… devices

Older Android phones without amplitude control collapsed every continuous
segment into one solid buzz: convertToTimingWaveform only checked
`intensity > 0` and discarded both the intensity level and the frequency,
so distinct presets all felt identical.

Port the web engine's PWM / duty-cycle approach (PatternComposer +
RealtimeComposer) to the on/off-only Android path:

- Add PwmTimingSimulator: intensity -> ON pulse length (duty cycle),
  frequency -> OFF gap (inverted). ERM-tuned bounds with the min pulse
  floored near the actuator engagement threshold.
- Rewrite convertToTimingWaveform to tile [shot, pause, ...] across the
  timeline, sampling intensity/frequency per pulse; genuine silent
  regions stay off.
- Fix RealtimePrimitiveComposer LIMITED/<API33 path: encode intensity as
  pulse length instead of the ignored createOneShot amplitude, and set the
  loop interval to shot+pause. API 33+ keeps scalable primitives.

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