Skip to content

feat(core): serial-timeline waveform pipeline (overlap, fall-ramp, handle lifecycle) - #85

Merged
l2hyunwoo merged 16 commits into
mainfrom
feature/serial-timeline-merge
Jul 9, 2026
Merged

feat(core): serial-timeline waveform pipeline (overlap, fall-ramp, handle lifecycle)#85
l2hyunwoo merged 16 commits into
mainfrom
feature/serial-timeline-merge

Conversation

@l2hyunwoo

@l2hyunwoo l2hyunwoo commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Reworks how the Android executor turns a HapticPattern into a VibrationEffect, around a new serial-timeline primitive, and fixes several correctness issues that surfaced along the way. Most of the logic now lives in commonMain so iOS shares it where applicable.

The Android executor renders a pattern to a single createWaveform call, which can only play one amplitude at a time. The old toWaveform walked events directly and broke once they overlapped (already reachable via include). The core change introduces mergeToSerial, which flattens overlapping events into one serial timeline, and builds the rest of the pipeline on top of it.

What's included

Serial-timeline primitive (commonMain)

  • mergeToSerial flattens (possibly overlapping) events into non-overlapping HapticSegments. Overlaps resolve to the highest-intensity event, not a sum (amplitude clamps at 255). This is the shared building block the items below reuse.

Overlap and completion-timing fixes (Android)

  • Overlapping events are now serialized correctly instead of being appended back-to-back.
  • execute() awaits the exact played waveform length (including the primer/trailing compat segments), so the caller resumes when the vibration truly ends rather than a few ms early.
  • A zero-duration or all-gap pattern is now a no-op instead of emitting the compat-only primer/trailing buzz.

Fall ramp for amplitude drops (Android, LRA only)

  • createWaveform is a pure step function, and a sudden drop to amplitude 0 makes an LRA ring for 50ms+. insertFallRamps borrows the front of each following gap to ease the amplitude down, preserving total duration. Gated on hasAmplitudeControl() (on ERM every non-zero amplitude rounds up to full, so a ramp would do nothing). active -> active transitions are left untouched so intentional rhythms are not smoothed over.

Handle lifecycle fix (commonMain, both platforms)

  • HapticHandle.isActive previously stayed true until cancel() because neither platform observes natural completion (the OS gives no per-effect callback). A shared HandleExpiry now estimates completion from the expected playback length (pull-based, no coroutine/scope/timer), so isActive reflects a finished vibration. Documented as best-effort (±OS scheduling jitter); cancel() remains exact. Android and iOS fixed in lockstep.

How it works

The toWaveform pipeline

Worked example: Haptic(100ms, STRONG) + Delay(50ms) + Haptic(100ms, MEDIUM) on an LRA. STRONG = 0.75 (amplitude 191), MEDIUM = 0.5 (amplitude 127).

input events        STRONG |##########|              MEDIUM |##########|
(timeline)                 0         100           150            250

1. mergeToSerial    [100 @0.75][50 @gap][100 @0.5]
   (serialize)      one serial track; gaps explicit. Overlaps (not shown here)
                    would resolve to the louder event, never a sum.

2. insertFallRamps  [100 @0.75][8 @0.38][42 @gap][100 @0.5]
   (LRA only)                  \__ramp__/
                    the 50ms gap lends its first 8ms to a fade-down step, so the
                    drop from 191 to 0 doesn't ring. active segments untouched.

3. quantize         timings    = [100,  8, 42, 100]
   (0..1 -> 0..255) amplitudes = [191, 95,  0, 127]
                    a real gap stays 0; an active slice floors to 1 (an event with
                    0f intensity still registers, never silent-by-rounding).

4. applyDeviceCompat timings    = [100, 8, 42, 100, 1]
   (compat tail)     amplitudes = [191,95,  0, 127, 0]
                     trailing 1ms-off terminates cleanly. A single-event pattern
                     also gets a Samsung primer (1ms off + 1ms on).

Returns null (so nothing plays) when mergeToSerial yields no active slice, i.e. an empty, zero-duration, or all-gap pattern. Without that guard step 4 would still append the compat tail and the motor would buzz for a pattern meant to be silent.

Why mergeToSerial resolves overlaps by max, not sum

input         A |##########|  (HIGH)         two events overlapping on [50,100)
              B       |##########|  (MEDIUM)
                0    50   100  150

boundaries    |    |     |    |               split at every start/end
              0    50   100  150

per slice     [0,50)  -> A active        -> HIGH
              [50,100) -> A and B active -> HIGH   (max wins; summing would
              [100,150)-> B active       -> MEDIUM  clip past amplitude 255)

How a fall ramp borrows from the gap (duration is conserved)

before   [ active 100 @0.75 ][ gap 50 @0 ]
                              ^ hard 191 -> 0 drop rings on an LRA

after    [ active 100 @0.75 ][ ramp 8 @0.38 ][ gap 42 @0 ]
                              \____ borrowed from the gap front ____/
         active untouched; 8 + 42 == 50, so total duration is unchanged.

Thresholds: a gap at or below MIN_RAMP_MS (4ms) is left whole; a gap shorter than the full FALL_RAMP_MS (16ms) window shrinks the ramp to fit (e.g. a 10ms gap yields a 5ms ramp). The leftover gap absorbs the integer-division remainder, so the sum is conserved for any gap length (covered by a property test).

Structure

  • toWaveform is split into the four single-purpose stages shown above. It returns null when no active slice remains.
  • The new executor helpers are one declaration per file (HapticSegment, mergeToSerial, insertFallRamps, HandleExpiry), matching the rest of the module.
  • The JUnit vintage engine is enabled so the existing JUnit4 Robolectric tests actually run under the JUnit Platform; without it they were silently skipped.

Tests

  • commonTest: MergeToSerialTest (merge policy, gaps, full containment, tie-break, order-independence), InsertFallRampsTest (ramp interpolation, shrink/skip thresholds, untouched transitions, plus property tests asserting duration conservation across gap parities), HandleExpiryTest (expiry boundaries with an injected time source).
  • androidHostTest: overlap serialization, LRA ramp vs ERM gate, await duration (single-event and overlap), zero-duration no-op, sub-threshold gap, and AndroidHapticHandle wiring with injected time.

Notes

  • iOS is intentionally unchanged for the waveform path: Core Haptics treats relativeTime as an absolute offset and mixes overlapping events natively. Only the shared handle-expiry fix touches iOS.
  • No public API change. All new symbols are internal; the .api/.klib.api dumps are unchanged.

Summary by CodeRabbit

  • New Features

    • Smoother transitions between active and silent periods by softening active→gap boundaries on amplitude-capable devices.
    • Deterministic overlapping-event behavior: overlapping events are not summed; the strongest active event takes precedence.
  • Bug Fixes

    • Improved “isActive”/expiry behavior for both zero-duration and empty/silent patterns (they remain inactive).
    • More accurate playback duration estimation for merged/overlapping sequences, including device-compat timing.
  • Documentation

    • Updated API docs with examples clarifying overlap dominance for included patterns.
  • Tests

    • Expanded unit/integration coverage for ramp insertion, serialization/merging rules, and handle expiry/cancellation.

@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds serial haptic segment conversion and fall-ramp shaping, updates Android and iOS executors to track playback duration, and expands build wiring plus tests for overlap, gaps, ramps, expiry, and waveform execution.

Changes

Haptic waveform serialization and execution

Layer / File(s) Summary
Serialization model and merge logic
jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/HapticSegment.kt, jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/MergeToSerial.kt, documentation/content/docs/api/jindong-core/core-api.mdx, jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/executor/MergeToSerialTest.kt
Introduces HapticSegment and mergeToSerial(...), and adds documentation and tests for overlap dominance, explicit gaps, ties, zero-intensity events, and input order.
Fall-ramp transform
jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/InsertFallRamps.kt, jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/executor/InsertFallRampsTest.kt
Adds insertFallRamps(...) and rampThenGap(...) to reshape active-to-gap transitions, with tests covering ramp insertion, short gaps, and unchanged cases.
Platform handle expiry and waveform execution
gradle/libs.versions.toml, jindong-core/build.gradle.kts, jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/HandleExpiry.kt, jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/HapticHandle.kt, jindong-core/src/androidMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.android.kt, jindong-core/src/iosMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.ios.kt, jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/executor/HandleExpiryTest.kt, jindong-core/src/androidHostTest/kotlin/io/github/compose/jindong/core/executor/AndroidHapticHandleTest.kt, jindong-core/src/androidHostTest/kotlin/io/github/jindong/android/AndroidVibratorTest.kt
Adds the JUnit Vintage engine and Kotest property dependency, introduces shared expiry timing, updates Android/iOS handle activeness and waveform-duration timing, and adds tests for handle expiry, Android handle lifecycle, and Android waveform shaping.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • compose-jindong/jindong#66: Both PRs modify AndroidHapticHandle’s isActive and cancel() behavior around atomic cancellation.
  • compose-jindong/jindong#72: Both PRs touch the Android executor implementation in HapticExecutor.android.kt, including waveform generation and handle timing.
  • compose-jindong/jindong#86: Both PRs use the Android amplitude-control path to gate waveform shaping behavior.

Suggested reviewers: wisemuji

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the core changes: serial timeline waveform processing, fall-ramp handling, and handle lifecycle updates.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/serial-timeline-merge

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@l2hyunwoo
l2hyunwoo requested a review from wisemuji June 27, 2026 03:00
@l2hyunwoo
l2hyunwoo marked this pull request as draft June 27, 2026 03:00

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
jindong-core/src/androidHostTest/kotlin/io/github/jindong/android/AndroidVibratorTest.kt (1)

231-311: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Please add a virtual-time assertion for execute().

These tests lock in waveform shaping, but the PR’s user-visible fix is also the suspension length of execute() for overlaps. A small runTest case that asserts virtual time advances by the merged serial duration would keep that regression covered.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@jindong-core/src/androidHostTest/kotlin/io/github/jindong/android/AndroidVibratorTest.kt`
around lines 231 - 311, Add a virtual-time assertion around
HapticExecutor.execute() in AndroidVibratorTest to cover the overlap path, since
the current tests only verify waveform shaping. Use the existing executor
instance and a runTest block to assert that the merged-serial overlap case
advances test scheduler time by the expected duration, referencing execute(),
HapticPattern, and ScheduledHapticEvent so the regression in suspension length
is locked in.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@jindong-core/src/androidMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.android.kt`:
- Around line 100-113: Treat the empty merged timeline as a no-op in
HapticPattern.toWaveform(): when mergeToSerial(events) produces no segments,
short-circuit before building timings/amplitudes or calling applyDeviceCompat,
so zero-length input does not emit a compat-only waveform or trigger a spurious
buzz. Use the existing HapticPattern.toWaveform, mergeToSerial, and
applyDeviceCompat flow to add the empty-check, or alternatively enforce
durationMs > 0 at the model boundary.

---

Nitpick comments:
In
`@jindong-core/src/androidHostTest/kotlin/io/github/jindong/android/AndroidVibratorTest.kt`:
- Around line 231-311: Add a virtual-time assertion around
HapticExecutor.execute() in AndroidVibratorTest to cover the overlap path, since
the current tests only verify waveform shaping. Use the existing executor
instance and a runTest block to assert that the merged-serial overlap case
advances test scheduler time by the expected duration, referencing execute(),
HapticPattern, and ScheduledHapticEvent so the regression in suspension length
is locked in.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 478ba721-34c7-43fa-8548-35045204b9fa

📥 Commits

Reviewing files that changed from the base of the PR and between 9f7bf95 and 61e4f3b.

📒 Files selected for processing (7)
  • gradle/libs.versions.toml
  • jindong-core/build.gradle.kts
  • jindong-core/src/androidHostTest/kotlin/io/github/jindong/android/AndroidVibratorTest.kt
  • jindong-core/src/androidMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.android.kt
  • jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/MergeToSerial.kt
  • jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/executor/InsertFallRampsTest.kt
  • jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/executor/MergeToSerialTest.kt

@l2hyunwoo
l2hyunwoo force-pushed the feature/serial-timeline-merge branch from e633ba5 to 084f635 Compare June 28, 2026 08:43
@l2hyunwoo
l2hyunwoo marked this pull request as ready for review June 28, 2026 09:11
@l2hyunwoo l2hyunwoo changed the title fix(android): serialize overlapping events and soften amplitude drops feat(core): serial-timeline waveform pipeline (overlap, fall-ramp, handle lifecycle) Jun 28, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
jindong-core/src/androidMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.android.kt (1)

165-173: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Place the Samsung primer before the first active slice.

Line 165 currently appends the 1ms off + 1ms on pair after the real waveform, so single-event patterns still start cold and instead get an extra buzz at the end. This also makes execute() / isActive count those extra 2ms at the wrong end of playback. Insert that pair ahead of the first non-zero amplitude instead.

Suggested direction
-    if (isSingleEvent) {
-      timings += 1L
-      amplitudes += 0
-      timings += 1L
-      amplitudes += 1
-    }
+    if (isSingleEvent) {
+      val firstActive = amplitudes.indexOfFirst { it != 0 }
+      if (firstActive >= 0) {
+        timings.add(firstActive, 1L)
+        amplitudes.add(firstActive, 0)
+        timings.add(firstActive + 1, 1L)
+        amplitudes.add(firstActive + 1, 1)
+      }
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@jindong-core/src/androidMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.android.kt`
around lines 165 - 173, The Samsung primer is being appended after the real
waveform in HapticExecutor.android.kt, which shifts the extra 1ms off + 1ms on
slice to the end instead of priming the first active event. Update the
waveform-building logic in the execute() path so the primer pair is inserted
before the first non-zero amplitude slice when isSingleEvent is true, and ensure
any isActive / duration accounting still reflects the reordered timings.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@jindong-core/src/androidHostTest/kotlin/io/github/jindong/android/AndroidVibratorTest.kt`:
- Around line 314-330: The zero-duration vibrator test in AndroidVibratorTest
only verifies the final idle state, so it can miss a brief compat-only trigger.
Update the `should not vibrate a zero-duration event` test to also assert that
Robolectric’s `shadowVibrator` recorded no played pattern/effect after
`executor.execute(pattern)`, in addition to `isVibrating` being false, so the
`execute` path is confirmed to be a true no-op.

---

Outside diff comments:
In
`@jindong-core/src/androidMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.android.kt`:
- Around line 165-173: The Samsung primer is being appended after the real
waveform in HapticExecutor.android.kt, which shifts the extra 1ms off + 1ms on
slice to the end instead of priming the first active event. Update the
waveform-building logic in the execute() path so the primer pair is inserted
before the first non-zero amplitude slice when isSingleEvent is true, and ensure
any isActive / duration accounting still reflects the reordered timings.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 699a5558-2cab-4b9d-b244-82a760716939

📥 Commits

Reviewing files that changed from the base of the PR and between 61e4f3b and 4c518a1.

📒 Files selected for processing (13)
  • gradle/libs.versions.toml
  • jindong-core/build.gradle.kts
  • jindong-core/src/androidHostTest/kotlin/io/github/compose/jindong/core/executor/AndroidHapticHandleTest.kt
  • jindong-core/src/androidHostTest/kotlin/io/github/jindong/android/AndroidVibratorTest.kt
  • jindong-core/src/androidMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.android.kt
  • jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/HandleExpiry.kt
  • jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/HapticHandle.kt
  • jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/HapticSegment.kt
  • jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/InsertFallRamps.kt
  • jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/MergeToSerial.kt
  • jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/executor/HandleExpiryTest.kt
  • jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/executor/InsertFallRampsTest.kt
  • jindong-core/src/iosMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.ios.kt
💤 Files with no reviewable changes (1)
  • jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/MergeToSerial.kt
✅ Files skipped from review due to trivial changes (1)
  • jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/HapticHandle.kt
🚧 Files skipped from review as they are similar to previous changes (3)
  • jindong-core/build.gradle.kts
  • jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/executor/InsertFallRampsTest.kt
  • gradle/libs.versions.toml

@wisemuji wisemuji left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the work!

Comment thread jindong-core/build.gradle.kts
@l2hyunwoo
l2hyunwoo force-pushed the feature/serial-timeline-merge branch from 650cafd to 812098d Compare July 7, 2026 11:22
@l2hyunwoo
l2hyunwoo requested a review from wisemuji July 7, 2026 23:23
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.

2 participants