Twórz własne efekty haptyczne

Na tej stronie znajdziesz przykłady użycia różnych interfejsów API haptyki do tworzenia niestandardowych efektów wykraczających poza standardowe przebiegi wibracji w aplikacji na Androida.

Na tej stronie znajdziesz te przykłady:

Więcej przykładów znajdziesz w artykule Dodawanie reakcji haptycznej do zdarzeń. Zawsze przestrzegaj zasad projektowania reakcji haptycznych.

Używanie kreacji zastępczych do obsługi zgodności urządzeń

Podczas wdrażania niestandardowych efektów haptycznych zgodność urządzenia i działanie kreacji zastępczej zależą od wybranego interfejsu API:

  • VibrationEffect.Builder (zalecane): od Androida 16 (IV kwartał 2026 r.) efekty utworzone za pomocą VibrationEffect.Builder obejmują automatyczne zastępowanie na poziomie platformy. Jeśli urządzenie nie obsługuje natywnie żądanego Preset lub podstawowego Envelope, platforma automatycznie tłumaczy go na odpowiednią alternatywę w momencie odtwarzania. Nie musisz ręcznie sprawdzać możliwości urządzenia w zakresie poszczególnych elementów podstawowych przed odtworzeniem efektów utworzonych za pomocą interfejsu VibrationEffect.Builder.
    • Wyjątek: zaawansowane obwiednie fali utworzone za pomocą WaveformEnvelopeBuilder nie obsługują automatycznego przełączania, ponieważ zależą od konkretnych mapowań częstotliwości sprzętowych (FOAM). Jeśli nie są obsługiwane, nie będą odtwarzane.
  • VibrationEffect.Composition: kompozycje utworzone za pomocą interfejsu API startComposition() nie mają automatycznych kreacji zastępczych. Jeśli kompozycja zawiera choćby 1 nieobsługiwany element podstawowy, cała wibracja nie zostanie odtworzona. Możliwości musisz sprawdzać ręcznie za pomocą funkcji vibrator.arePrimitivesSupported().
  • Kształty fal z regulacją amplitudy: amplitudy inne niż zero są zaokrąglane do 100% na urządzeniach bez regulacji amplitudy. Sprawdź vibrator.hasAmplitudeControl() i w razie potrzeby wróć do wzorca ON/OFF.

Użycie elementów haptycznych

Android zawiera kilka elementów podstawowych haptyki, które różnią się amplitudą i częstotliwością. Aby uzyskać bogate efekty haptyczne, możesz użyć jednego elementu podstawowego lub kilku elementów podstawowych w połączeniu.

  • Aby uzyskać wyraźne przerwy między 2 elementami podstawowymi, użyj opóźnień o długości co najmniej 50 ms, biorąc pod uwagę, jeśli to możliwe, czas trwania elementu podstawowego.
  • Używaj skal, które różnią się o co najmniej 1,4, aby różnica w intensywności była lepiej widoczna.
  • Użyj skal 0,5, 0,7 i 1,0, aby utworzyć wersję elementu podstawowego o niskiej, średniej i wysokiej intensywności.

Tworzenie niestandardowych wzorów wibracji

Wzorce wibracji są często używane w przypadku reakcji haptycznych przyciągających uwagę, takich jak powiadomienia i dzwonki. Usługa Vibrator może odtwarzać długie wzorce wibracji, które zmieniają amplitudę wibracji w czasie. Takie efekty nazywamy przebiegami falowymi.

Efekty fali są zwykle wyczuwalne, ale nagłe, długie wibracje mogą zaskoczyć użytkownika, jeśli zostaną odtworzone w cichym otoczeniu. Zbyt szybkie zwiększanie amplitudy do wartości docelowej może też powodować słyszalne brzęczenie. Zaprojektuj wzorce przebiegu fal, aby wygładzić przejścia amplitudy i uzyskać efekty narastania i opadania.

Przykłady wzorów wibracji

W sekcjach poniżej znajdziesz kilka przykładów wzorców wibracji:

Wzorzec zwiększania wyświetlania

Kształty fal są reprezentowane jako VibrationEffect z 3 parametrami:

  1. Timings: tablica czasów trwania (w milisekundach) poszczególnych segmentów fali.
  2. Amplitudes: żądana amplituda wibracji dla każdego czasu trwania określonego w pierwszym argumencie, reprezentowana przez liczbę całkowitą z zakresu od 0 do 255, gdzie 0 oznacza „stan wyłączenia” wibracji, a 255 – maksymalną amplitudę urządzenia.
  3. Indeks powtórzenia: indeks w tablicy określony w pierwszym argumencie, od którego ma się rozpocząć powtarzanie fali, lub -1, jeśli wzorzec ma zostać odtworzony tylko raz.

Oto przykładowy kształt fali, który pulsuje 2 razy z przerwą 350 ms między impulsami. Pierwszy impuls to płynne narastanie do maksymalnej amplitudy, a drugi to szybkie narastanie do maksymalnej amplitudy. Zatrzymanie na końcu jest definiowane przez ujemną wartość indeksu powtórzeń.

val timings: LongArray = longArrayOf(
    50, 50, 50, 50, 50, 100, 350, 25, 25, 25, 25, 200
)
val amplitudes: IntArray = intArrayOf(
    33, 51, 75, 113, 170, 255, 0, 38, 62, 100, 160, 255
)
val repeatIndex = -1 // Don't repeat.

vibrator.vibrate(
    VibrationEffect.createWaveform(
        timings, amplitudes, repeatIndex
    )
)

Powtarzający się wzór

Można też odtwarzać je wielokrotnie, dopóki nie zostaną anulowane. Aby utworzyć powtarzający się kształt fali, ustaw nieujemny parametr repeat. Gdy odtwarzasz powtarzający się przebieg falowy, wibracje trwają, dopóki nie zostaną wyraźnie anulowane w usłudze:

fun startVibrating() {
    val timings: LongArray = longArrayOf(50, 50, 100, 50, 50)
    val amplitudes: IntArray = intArrayOf(64, 128, 255, 128, 64)
    val repeat = 1 // Repeat from the second entry, index = 1.
    val repeatingEffect: VibrationEffect = VibrationEffect.createWaveform(
        timings, amplitudes, repeat
    )
    // repeatingEffect can be used in multiple places.

    vibrator.vibrate(repeatingEffect)
}

fun stopVibrating() {
    vibrator.cancel()
}

Jest to bardzo przydatne w przypadku zdarzeń występujących sporadycznie, które wymagają działania użytkownika w celu ich potwierdzenia. Przykłady takich zdarzeń to połączenia przychodzące i uruchomione alarmy.

Wzorzec z wartością zastępczą

Sterowanie amplitudą wibracji to funkcja zależna od sprzętu. Odtwarzanie fali na urządzeniu niższej klasy bez tej funkcji powoduje, że urządzenie wibruje z maksymalną amplitudą dla każdego dodatniego wpisu w tablicy amplitud. Jeśli Twoja aplikacja musi obsługiwać takie urządzenia, użyj wzorca, który nie generuje efektu brzęczenia, gdy jest odtwarzany w takich warunkach, lub zaprojektuj prostszy wzorzec włączania/wyłączania, który może być odtwarzany jako zastępczy.

if (vibrator.hasAmplitudeControl()) {
    vibrator.vibrate(
        VibrationEffect.createWaveform(
            smoothTimings, amplitudes, smoothRepeatIdx
        )
    )
} else {
    vibrator.vibrate(
        VibrationEffect.createWaveform(
            onOffTimings, onOffRepeatIdx
        )
    )
}

Kompozycje zakotwiczone na osi czasu z VibrationEffect.Builder

Od Androida 16 (26Q4) VibrationEffect.Builder jest preferowanym interfejsem API do tworzenia złożonych efektów i kompozycji wibracji. Umożliwia tworzenie ekspresyjnych wrażeń haptycznych przez sekwencjonowanie dyskretnych elementów haptycznych na bezwzględnej osi czasu za pomocą startTimeMillis.

VibrationEffect.Builder obsługuje łączenie wielu typów wibracji:

Wibracje utworzone za pomocą VibrationEffect.Builder mają wbudowane automatyczne zastępowanie na poziomie platformy we wszystkich elementach (w tym w ustawieniach wstępnych, podstawowych obwiedniach i połączonych efektach wibracji). Zapewnia to spójne wrażenia użytkownika na różnych urządzeniach bez konieczności ręcznego sprawdzania możliwości. (Zaawansowane obwiednie fali utworzone za pomocą WaveformEnvelopeBuilder wymagają obsługi sprzętowej i nie mają automatycznej obsługi kreacji zastępczych).

Tworzenie za pomocą gotowych ustawień

Użyj VibrationEffect.Preset, aby dodać do kompozycji typowe, wstępnie zdefiniowane krótkie impulsy haptyczne (takie jak PRESET_CLICK, PRESET_TICK lub PRESET_LOW_TICK). Gotowe ustawienia są implementowane przez producenta urządzenia, aby zapewnić wyraźne, krótkie i przyjemne wibracje, które są zgodne z zasadami dotyczącymi haptyki i zapewniają wyraźne reakcje haptyczne. Więcej informacji o tych funkcjach i ich działaniu znajdziesz w podstawowych informacjach o siłownikach wibracyjnych.

Gotowe ustawienia zastępują krótkie elementy podstawowe z interfejsu API VibrationEffect.Composition. W przypadku dłuższych lub ciągłych wrażeń haptycznych, takich jak narastanie i zanikanie, używaj fal obwiedni (PWLE).

Każdemu ustawieniu wstępnemu można przypisać opcjonalną skalę od 0.0f do 1.0f. Jest ono umieszczane w określonym czasie rozpoczęcia (w milisekundach) od początku kompozycji.

Kotlin

val clickPreset = VibrationEffect.Preset.create(
    VibrationEffect.Preset.PRESET_CLICK, /* scale= */ 0.8f
)
val tickPreset = VibrationEffect.Preset.create(
    VibrationEffect.Preset.PRESET_TICK, /* scale= */ 0.5f
)

val effect = VibrationEffect.Builder()
    .addPreset(/* startTimeMillis= */ 0L, clickPreset)
    .addPreset(/* startTimeMillis= */ 100L, tickPreset)
    .build()

vibrator.vibrate(effect)

Java

VibrationEffect.Preset clickPreset = VibrationEffect.Preset.create(
    VibrationEffect.Preset.PRESET_CLICK, /* scale= */ 0.8f
);
VibrationEffect.Preset tickPreset = VibrationEffect.Preset.create(
    VibrationEffect.Preset.PRESET_TICK, /* scale= */ 0.5f
);

VibrationEffect effect = new VibrationEffect.Builder()
    .addPreset(/* startTimeMillis= */ 0L, clickPreset)
    .addPreset(/* startTimeMillis= */ 100L, tickPreset)
    .build();

vibrator.vibrate(effect);

Tworzenie kompozycji za pomocą obwiedni i gotowych ustawień

Możesz płynnie łączyć instancje VibrationEffect.Envelope (utworzone za pomocą BasicEnvelopeBuilder lub WaveformEnvelopeBuilder) z gotowymi ustawieniami, aby tworzyć bogate, wielosegmentowe wzorce haptyczne.

Oto przykład, który odtwarza płynne narastanie i wygaszanie, a następnie ostre kliknięcie:

Kotlin

val basicEnvelope = VibrationEffect.Envelope.create(
    VibrationEffect.BasicEnvelopeBuilder()
        .setInitialSharpness(0.0f)
        .addControlPoint(1.0f, 1.0f, 300L)
        .addControlPoint(0.0f, 0.5f, 100L)
)
val clickPreset = VibrationEffect.Preset.create(
    VibrationEffect.Preset.PRESET_CLICK, 0.9f
)

val mixedEffect = VibrationEffect.Builder()
    .addEnvelope(/* startTimeMillis= */ 0L, basicEnvelope)
    .addPreset(/* startTimeMillis= */ 450L, clickPreset)
    .build()

vibrator.vibrate(mixedEffect)

Java

VibrationEffect.Envelope basicEnvelope = VibrationEffect.Envelope.create(
    new VibrationEffect.BasicEnvelopeBuilder()
        .setInitialSharpness(0.0f)
        .addControlPoint(1.0f, 1.0f, 300L)
        .addControlPoint(0.0f, 0.5f, 100L)
);
VibrationEffect.Preset clickPreset = VibrationEffect.Preset.create(
    VibrationEffect.Preset.PRESET_CLICK, 0.9f
);

VibrationEffect mixedEffect = new VibrationEffect.Builder()
    .addEnvelope(/* startTimeMillis= */ 0L, basicEnvelope)
    .addPreset(/* startTimeMillis= */ 450L, clickPreset)
    .build();

vibrator.vibrate(mixedEffect);

Ponowne używanie i przesuwanie istniejących zdarzeń

Aby ponownie użyć lub połączyć istniejący obiekt VibrationEffect (w tym VibrationEffect.Composition), pobierz listę obiektów VibrationEffect.Event za pomocą getEvents() i dołącz je z przesunięciem za pomocą addEvents(startTimeShiftMillis, events) (lub przekaż efekt bezpośrednio do konstruktora VibrationEffect.Builder(effect)). Podczas importowania instancji VibrationEffect.Composition w ten sposób platforma automatycznie konwertuje ich elementy podstawowe na gotowe ustawienia, co umożliwia obsługę kreacji zastępczych w czasie działania.

Kotlin

val existingEffect = VibrationEffect.Builder()
    .addPreset(
        0L,
        VibrationEffect.Preset.create(VibrationEffect.Preset.PRESET_CLICK)
    )
    .addPreset(
        80L,
        VibrationEffect.Preset.create(VibrationEffect.Preset.PRESET_TICK)
    )
    .build()

// Shift and append the existing events 200ms into the new composition.
val combinedEffect = VibrationEffect.Builder()
    .addEvents(/* startTimeShiftMillis= */ 200L, existingEffect.events)
    .build()

vibrator.vibrate(combinedEffect)

Java

VibrationEffect existingEffect = new VibrationEffect.Builder()
    .addPreset(
        0L,
        VibrationEffect.Preset.create(VibrationEffect.Preset.PRESET_CLICK)
    )
    .addPreset(
        80L,
        VibrationEffect.Preset.create(VibrationEffect.Preset.PRESET_TICK)
    )
    .build();

// Shift and append the existing events 200ms into the new composition.
VibrationEffect combinedEffect = new VibrationEffect.Builder()
    .addEvents(/* startTimeShiftMillis= */ 200L, existingEffect.getEvents())
    .build();

vibrator.vibrate(combinedEffect);

Tworzenie powtarzających się kompozycji

Użyj setRepeatingEffect(startTimeMillis, repeatingEffect, durationMillis), aby dodać do kompozycji powtarzający się wzór:

Kotlin

val repeatingPattern = VibrationEffect.Builder()
    .addPreset(
        0L,
        VibrationEffect.Preset.create(
            VibrationEffect.Preset.PRESET_CLICK, 1.0f
        )
    )
    .addPreset(
        150L,
        VibrationEffect.Preset.create(
            VibrationEffect.Preset.PRESET_LOW_TICK, 0.6f
        )
    )
    .build()

val repeatingEffect = VibrationEffect.Builder()
    .setRepeatingEffect(
        /* startTimeMillis= */ 0L,
        /* effect= */ repeatingPattern,
        /* durationMillis= */ 300L
    )
    .build()

vibrator.vibrate(repeatingEffect)

Java

VibrationEffect repeatingPattern = new VibrationEffect.Builder()
    .addPreset(
        0L,
        VibrationEffect.Preset.create(
            VibrationEffect.Preset.PRESET_CLICK, 1.0f
        )
    )
    .addPreset(
        150L,
        VibrationEffect.Preset.create(
            VibrationEffect.Preset.PRESET_LOW_TICK, 0.6f
        )
    )
    .build();

VibrationEffect repeatingEffect = new VibrationEffect.Builder()
    .setRepeatingEffect(
        /* startTimeMillis= */ 0L,
        /* effect= */ repeatingPattern,
        /* durationMillis= */ 300L
    )
    .build();

vibrator.vibrate(repeatingEffect);

Czas, weryfikacja i zarządzanie dryfem

Podczas projektowania kompozycji z użyciem VibrationEffect.Builder pamiętaj o tych regułach dotyczących czasu i weryfikacji:

  • Ściśle rosnące czasy rozpoczęcia: każdy element dodany do kreatora musi mieć wartość startTimeMillis, która jest ściśle większa od czasu rozpoczęcia poprzedniego elementu lub mu równa.
  • Weryfikacja w czasie kompilacji: kreator przeprowadza weryfikację w czasie build(), używając znanych czasów trwania elementów (lub co najmniej 1 ms w przypadku gotowych ustawień). Jeśli wykryje niemożliwe nakładanie się, zgłasza błąd IllegalArgumentException.
  • Sekwencyjne przesunięcie odtwarzania: jeśli poprzedni element wibracji jest nadal fizycznie odtwarzany, gdy nadejdzie czas rozpoczęcia następnego elementu, platforma automatycznie przesunie następny element do najwcześniejszego dostępnego przedziału czasowego. Dzięki temu zdarzenia nie nakładają się na siebie i żadne wibracje nie są pomijane, choć może to powodować niewielkie przesunięcie czasowe, jeśli zdarzenia są zaplanowane zbyt blisko siebie. Aby zminimalizować dryf, zachowaj wystarczający odstęp czasu (np. 50 ms lub więcej) między kolejnymi zdarzeniami haptycznymi.

Tworzenie kompozycji prymitywów wibracji

W tej sekcji opisano, jak tworzyć wibracje za pomocą interfejsu VibrationEffect.Composition. Elementy podstawowe kompozycji, opisane wcześniej na tej stronie, są implementowane przez producenta urządzenia. Zapewniają wyraźne, krótkie i przyjemne wibracje, które są zgodne z zasadami dotyczącymi haptyki, co zapewnia wyraźne wrażenia dotykowe. Więcej informacji o tych funkcjach i ich działaniu znajdziesz w podstawowych informacjach o siłownikach wibracyjnych.

W przeciwieństwie do VibrationEffect.Builder interfejs API VibrationEffect.Composition nie ma automatycznych kreacji zastępczych dla nieobsługiwanych elementów podstawowych. Dlatego:

  1. Przed aktywowaniem zaawansowanych funkcji haptycznych sprawdź, czy dane urządzenie obsługuje wszystkie używane przez Ciebie elementy podstawowe.

  2. Wyłącz spójny zestaw funkcji, które nie są obsługiwane, a nie tylko efekty, w których brakuje elementu podstawowego.

  • Kotlin: val fallbackEffect = VibrationEffect.Builder(compositionEffect).build() (lub .addEvents(0L, compositionEffect.events))
  • Java: VibrationEffect fallbackEffect = new VibrationEffect.Builder(compositionEffect).build(); (lub .addEvents(0L, compositionEffect.getEvents()))

W przypadku tworzenia za pomocą VibrationEffect.Builder platforma konwertuje elementy kompozycji na gotowe ustawienia i automatycznie zapewnia zastępcze środowisko wykonawcze, jeśli któryś z elementów nie jest obsługiwany na urządzeniu użytkownika.

Złożone efekty wibracji

Możesz tworzyć złożone efekty wibracji za pomocą VibrationEffect.Composition. Oto przykład efektu powolnego narastania, po którym następuje efekt ostrego kliknięcia:

vibrator.vibrate(
    VibrationEffect.startComposition().addPrimitive(
        VibrationEffect.Composition.PRIMITIVE_SLOW_RISE
    ).addPrimitive(
        VibrationEffect.Composition.PRIMITIVE_CLICK
    ).compose()
)

Kompozycję tworzy się przez dodanie elementów podstawowych, które mają być odtwarzane po kolei. Każdy element podstawowy jest też skalowalny, dzięki czemu możesz kontrolować amplitudę generowanych przez nie wibracji. Skala jest określana jako wartość od 0 do 1, gdzie 0 odpowiada minimalnej amplitudzie, przy której ten element podstawowy może być (ledwo) odczuwalny przez użytkownika.

Tworzenie wariantów w elementach podstawowych wibracji

Jeśli chcesz utworzyć słabą i silną wersję tego samego elementu podstawowego, utwórz współczynniki siły o wartości 1,4 lub większej, aby różnica w intensywności była łatwo zauważalna. Nie twórz więcej niż 3 poziomów intensywności tego samego elementu podstawowego, ponieważ nie będą się one różnić pod względem percepcji. Możesz na przykład użyć skal 0,5, 0,7 i 1,0, aby utworzyć wersje elementu podstawowego o niskiej, średniej i wysokiej intensywności.

Dodawanie przerw między elementami podstawowymi wibracji

Kompozycja może też określać opóźnienia, które mają być dodawane między kolejnymi elementami podstawowymi. To opóźnienie jest wyrażone w milisekundach od zakończenia poprzedniego elementu podstawowego. Ogólnie rzecz biorąc, odstęp od 5 do 10 ms między 2 elementami podstawowymi jest zbyt krótki, aby można go było wykryć. Jeśli chcesz utworzyć zauważalną przerwę między 2 elementami podstawowymi, użyj przerwy o długości co najmniej 50 ms. Oto przykład kompozycji z opóźnieniami:

val delayMs = 100
vibrator.vibrate(
    VibrationEffect.startComposition().addPrimitive(
        VibrationEffect.Composition.PRIMITIVE_SPIN, 0.8f
    ).addPrimitive(
        VibrationEffect.Composition.PRIMITIVE_SPIN, 0.6f
    ).addPrimitive(
        VibrationEffect.Composition.PRIMITIVE_THUD, 1.0f, delayMs
    ).compose()
)

Sprawdzanie, które elementy podstawowe są obsługiwane

Aby sprawdzić, czy urządzenie obsługuje określone elementy podstawowe, możesz użyć tych interfejsów API:

val primitive = VibrationEffect.Composition.PRIMITIVE_LOW_TICK

if (vibrator.areAllPrimitivesSupported(primitive)) {
    vibrator.vibrate(
        VibrationEffect.startComposition()
            .addPrimitive(primitive).compose()
    )
} else {
    // Play a predefined effect or custom pattern as a fallback.
}

Można też sprawdzić kilka elementów podstawowych, a następnie zdecydować, które z nich połączyć, na podstawie poziomu obsługi urządzenia:

val primitives: IntArray = intArrayOf(
    VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
    VibrationEffect.Composition.PRIMITIVE_TICK,
    VibrationEffect.Composition.PRIMITIVE_CLICK
)
val supported: BooleanArray = vibrator.arePrimitivesSupported(*primitives)

Przykłady kompozycji wibracji

W sekcjach poniżej znajdziesz kilka przykładów kompozycji wibracji z przykładowej aplikacji haptycznej na GitHubie.

Opór (z niską liczbą tyknięć)

Możesz kontrolować amplitudę podstawowych wibracji, aby przekazywać przydatne informacje zwrotne dotyczące wykonywanego działania. Wartości skali o niewielkich odstępach mogą służyć do tworzenia płynnego efektu crescendo elementu podstawowego. Opóźnienie między kolejnymi elementami podstawowymi można też ustawić dynamicznie na podstawie interakcji użytkownika. Ilustruje to poniższy przykład animacji widoku kontrolowanej gestem przeciągania i wzbogaconej o reakcje haptyczne.

Animacja przedstawiająca przeciąganie kółka w dół.
Wykres fali wibracji wejściowych.

Rysunek 1. Ta fala reprezentuje przyspieszenie wyjściowe wibracji na urządzeniu.

@Composable
fun ResistScreen() {
    // Control variables for the dragging of the indicator.
    var isDragging by remember { mutableStateOf(false) }
    var dragOffset by remember { mutableFloatStateOf(0f) }

    // Only vibrates while the user is dragging
    LaunchedEffect(isDragging) {
        // Continuously run the effect for vibration to occur even when the view
        // is not being drawn, when user stops dragging midway through gesture.
        while (isDragging) {
            // Calculate the interval inversely proportional to the drag offset.
            val vibrationInterval = calculateVibrationInterval(dragOffset)
            // Calculate the scale directly proportional to the drag offset.
            val vibrationScale = calculateVibrationScale(dragOffset)

            delay(vibrationInterval)
            vibrator.vibrate(
                VibrationEffect.startComposition().addPrimitive(
                    VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
                    vibrationScale
                ).compose()
            )
        }
    }

    Column(
        Modifier
            .draggable(
                orientation = Orientation.Vertical,
                onDragStarted = {
                    isDragging = true
                },
                onDragStopped = {
                    isDragging = false
                },
                state = rememberDraggableState { delta ->
                    dragOffset += delta
                }
            )
    ) {
        // Build the indicator UI based on how much the user has dragged it.
        ResistIndicator(dragOffset)
    }
}

Rozwiń (ze wzrostem i spadkiem)

Istnieją 2 elementy podstawowe, które pozwalają zwiększyć odczuwalną intensywność wibracji: PRIMITIVE_QUICK_RISE i PRIMITIVE_SLOW_RISE. Obie osiągają ten sam cel, ale w różnym czasie. Istnieje tylko 1 element podstawowy do zmniejszania: PRIMITIVE_QUICK_FALL. Te elementy podstawowe lepiej ze sobą współdziałają, tworząc segment fali, który zwiększa intensywność, a następnie zanika. Możesz wyrównać elementy podstawowe, aby zapobiec nagłym skokom amplitudy między nimi, co również dobrze sprawdza się w przypadku wydłużenia ogólnego czasu trwania efektu. Ludzie zawsze bardziej zwracają uwagę na część rosnącą niż opadającą, więc skrócenie części rosnącej w stosunku do opadającej może być wykorzystane do przesunięcia nacisku na część opadającą.

Oto przykład zastosowania tej kompozycji do rozwijania i zwijania okręgu. Efekt podnoszenia może wzmocnić wrażenie rozszerzania się podczas animacji. Połączenie efektów podnoszenia i opadania pomaga podkreślić zwijanie na końcu animacji.

Animacja przedstawiająca powiększający się okrąg.
Wykres fali wibracji wejściowych.

Rysunek 2. Ten przebieg fali przedstawia przyspieszenie wyjściowe wibracji na urządzeniu.

enum class ExpandShapeState {
    Collapsed,
    Expanded
}

@Composable
fun ExpandScreen() {
    // Control variable for the state of the indicator.
    var currentState by remember { mutableStateOf(ExpandShapeState.Collapsed) }

    // Animation between expanded and collapsed states.
    val transitionData = updateTransitionData(currentState)

    Column(
        Modifier
            .clickable(
                onClick = {
                    if (currentState == ExpandShapeState.Collapsed) {
                        currentState = ExpandShapeState.Expanded
                        vibrator.vibrate(
                            VibrationEffect.startComposition().addPrimitive(
                                VibrationEffect.Composition.PRIMITIVE_SLOW_RISE,
                                0.3f
                            ).addPrimitive(
                                VibrationEffect.Composition.PRIMITIVE_QUICK_FALL,
                                0.3f
                            ).compose()
                        )
                    } else {
                        currentState = ExpandShapeState.Collapsed
                        vibrator.vibrate(
                            VibrationEffect.startComposition().addPrimitive(
                                VibrationEffect.Composition.PRIMITIVE_SLOW_RISE
                            ).compose()
                        )
                    }
                }
            )
    ) {
        // Build the indicator UI based on the current state.
        ExpandIndicator(transitionData)
    }
}

Wobble (z obrotami)

Jedną z głównych zasad dotyczących haptyki jest zachwycanie użytkowników. Ciekawym sposobem na wprowadzenie przyjemnego, nieoczekiwanego efektu wibracji jest użycie PRIMITIVE_SPIN. Ta funkcja podstawowa jest najbardziej skuteczna, gdy jest wywoływana więcej niż raz. Połączenie kilku obrotów może wywołać efekt chwiania się i niestabilności, który można dodatkowo wzmocnić, stosując nieco losowe skalowanie każdego elementu podstawowego. Możesz też eksperymentować z odstępem między kolejnymi elementami podstawowymi obrotu. Dwa obroty bez przerwy (0 ms między nimi) dają wrażenie szybkiego obracania się. Zwiększenie odstępu między obrotami z 10 do 50 ms powoduje, że obrót wydaje się luźniejszy, i może być używane do dopasowania czasu trwania filmu lub animacji.

Nie używaj odstępu dłuższego niż 100 ms, ponieważ kolejne obroty nie będą się dobrze integrować i zaczną sprawiać wrażenie pojedynczych efektów.

Oto przykład elastycznego kształtu, który po przeciągnięciu w dół i zwolnieniu wraca do pierwotnego stanu. Animacja jest ulepszona za pomocą 2 efektów obrotu, które są odtwarzane z różną intensywnością proporcjonalną do przemieszczenia odbicia.

Animacja przedstawiająca odbijający się elastyczny kształt
Wykres fali wibracji wejściowych

Rysunek 3. Ta fala reprezentuje przyspieszenie wyjściowe wibracji na urządzeniu.

@Composable
fun WobbleScreen() {
    // Control variables for the dragging and animating state of the elastic.
    var dragDistance by remember { mutableFloatStateOf(0f) }
    var isWobbling by remember { mutableStateOf(false) }

    // Use drag distance to create an animated float value behaving like a spring.
    val dragDistanceAnimated by animateFloatAsState(
        targetValue = if (dragDistance > 0f) dragDistance else 0f,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioHighBouncy,
            stiffness = Spring.StiffnessMedium
        ),
        // The spring ends on its target. An end value of 0f means that the
        // shape is back at rest, so the wobble is over.
        finishedListener = { endValue -> if (endValue == 0f) isWobbling = false },
    )

    LaunchedEffect(isWobbling) {
        while (isWobbling) {
            val displacement = dragDistanceAnimated / MAX_DRAG_DISTANCE
            // Use some sort of minimum displacement so the final few frames
            // of animation don't generate a vibration.
            if (displacement > SPIN_MIN_DISPLACEMENT) {
                vibrator.vibrate(
                    VibrationEffect.startComposition().addPrimitive(
                        VibrationEffect.Composition.PRIMITIVE_SPIN,
                        nextSpinScale(displacement)
                    ).addPrimitive(
                        VibrationEffect.Composition.PRIMITIVE_SPIN,
                        nextSpinScale(displacement)
                    ).compose()
                )
            }
            // Delay the next check for a sufficient duration until the
            // current composition finishes. Note that you can use
            // Vibrator.getPrimitiveDurations API to calculcate the delay.
            delay(VIBRATION_DURATION)
        }
    }

    Box(
        Modifier
            .fillMaxSize()
            .draggable(
                onDragStopped = {
                    // Wobble only if the shape is far enough from rest to vibrate.
                    // It then springs back to 0f, and the finishedListener ends
                    // the wobble.
                    val displacement = dragDistanceAnimated / MAX_DRAG_DISTANCE
                    isWobbling = displacement > SPIN_MIN_DISPLACEMENT
                    dragDistance = 0f
                },
                orientation = Orientation.Vertical,
                state = rememberDraggableState { delta ->
                    isWobbling = false
                    dragDistance += delta
                }
            )
    ) {
        // Draw the wobbling shape using the animated spring-like value. The
        // lambda defers the read to the draw phase, so the animation doesn't
        // recompose this screen on every frame.
        WobbleShape { dragDistanceAnimated }
    }
}

// Calculate a random scale for each spin to vary the full effect.
fun nextSpinScale(displacement: Float): Float {
    // Generate a random offset in the range [-0.1, +0.1] to be added to the
    // vibration scale so the spin effects have slightly different values.
    val randomOffset: Float = Random.Default.nextFloat() * 0.2f - 0.1f
    return (displacement + randomOffset).absoluteValue.coerceIn(0f, 1f)
}

Odbicie (z głuchym odgłosem)

Innym zaawansowanym zastosowaniem efektów wibracji jest symulowanie interakcji fizycznych. PRIMITIVE_THUD może wywołać silny i pogłosowy efekt, który można połączyć z wizualizacją uderzenia, na przykład w filmie lub animacji, aby wzmocnić ogólne wrażenia.

Oto przykład animacji spadającej piłki, która została ulepszona przez dodanie efektu uderzenia odtwarzanego za każdym razem, gdy piłka odbija się od dolnej krawędzi ekranu:

Animacja przedstawiająca upuszczoną piłkę odbijającą się od dołu ekranu.
Wykres fali wibracji wejściowych.

Rysunek 4. Ta fala reprezentuje przyspieszenie wyjściowe wibracji na urządzeniu.

enum class BallPosition {
    Start,
    End
}

@Composable
fun BounceScreen() {
    // Control variable for the state of the ball.
    var ballPosition by remember { mutableStateOf(BallPosition.Start) }
    // Plain holder for the bounce count. It's read only in the effect and in
    // the click handler, never in composition, so it needs no snapshot state.
    val bounceCount = remember { intArrayOf(0) }

    // Animation for the bouncing ball.
    val transitionData = updateTransitionData(ballPosition)
    val collisionData = updateCollisionData(transitionData)

    // Ball is about to contact floor. The effect runs only when the collision
    // state changes, so it vibrates once per collision. Nothing here suspends,
    // so a keyed SideEffect does the job without launching a coroutine.
    SideEffect(collisionData.collisionWithFloor) {
        if (collisionData.collisionWithFloor) {
            val vibrationScale = 0.7.pow(bounceCount[0]++).toFloat()
            vibrator.vibrate(
                VibrationEffect.startComposition().addPrimitive(
                    VibrationEffect.Composition.PRIMITIVE_THUD,
                    vibrationScale
                ).compose()
            )
        }
    }

    Box(
        Modifier
            .fillMaxSize()
            .clickable {
                if (transitionData.isAtStart) {
                    ballPosition = BallPosition.End
                } else {
                    ballPosition = BallPosition.Start
                    bounceCount[0] = 0
                }
            },
    ) {
        // Build the ball UI based on the current state.
        BouncingBall(transitionData)
    }
}

Kształt fali wibracji z obwiedniami

Proces tworzenia niestandardowych wzorów wibracji umożliwia kontrolowanie amplitudy wibracji w celu tworzenia płynnych efektów narastania i zanikania. W tej sekcji wyjaśniamy, jak tworzyć dynamiczne efekty haptyczne za pomocą obwiedni fali, które umożliwiają precyzyjne kontrolowanie amplitudy i częstotliwości wibracji w czasie. Dzięki temu możesz tworzyć bogatsze i bardziej zniuansowane wrażenia haptyczne.

Od Androida 16 (poziom API 36) system udostępnia te interfejsy API do tworzenia obwiedni fali wibracji przez zdefiniowanie sekwencji punktów kontrolnych:

  • BasicEnvelopeBuilder: przystępne podejście do tworzenia efektów haptycznych niezależnych od sprzętu.
  • WaveformEnvelopeBuilder: bardziej zaawansowane podejście do tworzenia efektów haptycznych, które wymaga znajomości sprzętu haptycznego.

Android nie udostępnia efektów zastępczych dla efektów obwiedni. Jeśli potrzebujesz takiej pomocy, wykonaj te czynności:

  1. Sprawdź, czy dane urządzenie obsługuje efekty koperty, używając Vibrator.areEnvelopeEffectsSupported().
  2. Wyłącz spójny zestaw nieobsługiwanych funkcji lub użyj niestandardowych wzorców wibracji lub kompozycji jako alternatywy.

Aby utworzyć bardziej podstawowe efekty obwiedni, użyj BasicEnvelopeBuilder z tymi parametrami:

  • Wartość intensywności w zakresie \( [0, 1] \), która reprezentuje odczuwalną siłę wibracji. Na przykład wartość \( 0.5 \)jest postrzegana jako połowa maksymalnej globalnej intensywności, jaką może osiągnąć urządzenie.
  • Wartość ostrości w zakresie \( [0, 1] \), która reprezentuje wyrazistość wibracji. Niższe wartości oznaczają płynniejsze wibracje, a wyższe – bardziej wyraziste odczucia.

  • Wartość duration, która reprezentuje czas (w milisekundach) potrzebny na przejście od ostatniego punktu kontrolnego (czyli pary intensywności i ostrości) do nowego.

Oto przykład fali, która zwiększa intensywność od niskiej do wysokiej częstotliwości, osiągając maksymalną siłę wibracji w ciągu 500 ms, a następnie zmniejsza ją do\( 0 \) (wyłączone) w ciągu 100 ms.

vibrator.vibrate(
    VibrationEffect.BasicEnvelopeBuilder()
        .setInitialSharpness(0.0f)
        .addControlPoint(1.0f, 1.0f, 500)
        .addControlPoint(0.0f, 1.0f, 100)
        .build()
)

Jeśli masz bardziej zaawansowaną wiedzę na temat haptyki, możesz zdefiniować efekty obwiedni za pomocą WaveformEnvelopeBuilder. Podczas korzystania z tego obiektu możesz uzyskać dostęp do mapowania częstotliwości na przyspieszenie wyjściowe (FOAM) za pomocą VibratorFrequencyProfile.

  • Wartość amplitudy w zakresie \( [0, 1] \), która reprezentuje osiągalną siłę wibracji przy danej częstotliwości, określoną przez FOAM urządzenia. Na przykład wartość \( 0.5 \) generuje połowę maksymalnego przyspieszenia wyjściowego, które można osiągnąć przy danej częstotliwości.
  • Wartość częstotliwości podana w hercach.

  • Wartość duration, która reprezentuje czas w milisekundach potrzebny na przejście od ostatniego punktu kontrolnego do nowego.

Poniższy kod pokazuje przykładowy kształt fali, który definiuje efekt wibracji trwający 400 ms. Zaczyna się od 50-milisekundowego wzrostu amplitudy od zera do maksimum przy stałej częstotliwości 60 Hz. Następnie częstotliwość wzrasta do 120 Hz w ciągu kolejnych 100 ms i pozostaje na tym poziomie przez 200 ms. Na koniec amplituda spada do \( 0 \), a częstotliwość wraca do 60 Hz w ciągu ostatnich 50 ms:

vibrator.vibrate(
    VibrationEffect.WaveformEnvelopeBuilder()
        .addControlPoint(1.0f, 60f, 50)
        .addControlPoint(1.0f, 120f, 100)
        .addControlPoint(1.0f, 120f, 200)
        .addControlPoint(0.0f, 60f, 50)
        .build()
)

W sekcjach poniżej znajdziesz kilka przykładów przebiegów wibracji z obwiedniami.

Sprężyna

W poprzednim przykładzie użyto PRIMITIVE_THUD do symulowania fizycznych interakcji odbicia. Podstawowy interfejs API obwiedni zapewnia znacznie większą kontrolę, umożliwiając precyzyjne dostosowanie intensywności i ostrości wibracji. Dzięki temu reakcja haptyczna dokładniej odzwierciedla animowane zdarzenia.

Oto przykład swobodnie spadającej sprężyny z animacją ulepszoną o podstawowy efekt obwiedni odtwarzany za każdym razem, gdy sprężyna odbija się od dołu ekranu:

Animacja przedstawiająca sprężynę, która spada i odbija się od dołu ekranu.
Wykres fali wibracji wejściowych.

Rysunek 5. Wykres fali przyspieszenia wyjściowego dla wibracji symulujących odbijającą się sprężynę.

@Composable
fun BouncingSpringAnimation() {
    // The spring's height above the floor: 1f is the drop height, 0f is the floor.
    val springHeight = remember { Animatable(1f) }
    var isAnimating by remember { mutableStateOf(true) }

    LaunchedEffect(isAnimating) {
        if (!isAnimating) return@LaunchedEffect
        springHeight.snapTo(1f)

        // Each bounce reaches a fraction of the previous height.
        var bounceHeight = 1f
        while (bounceHeight > MIN_BOUNCE_HEIGHT) {
            // Fall to the floor, speeding up like a falling object. A fall from
            // a greater height takes longer.
            val fallDurationMs = (DROP_DURATION_MS * sqrt(bounceHeight)).toInt()
            springHeight.animateTo(0f, tween(fallDurationMs, easing = EaseInQuad))

            // The spring hits the floor: play a "boing" envelope effect that fades
            // out. A higher bounce gives a stronger and sharper vibration.
            vibrator.vibrate(
                VibrationEffect.BasicEnvelopeBuilder()
                    // Starting from zero sharpness here, will simulate a smoother
                    // "boing" effect.
                    .setInitialSharpness(0f)
                    // Add a control point to reach the target intensity and
                    // sharpness very quickly.
                    .addControlPoint(bounceHeight, bounceHeight * MAX_SHARPNESS, 20L)
                    // Add a control point to fade out the vibration intensity while
                    // maintaining sharpness.
                    .addControlPoint(0f, bounceHeight * MAX_SHARPNESS, fallDurationMs.toLong())
                    .build()
            )

            // Bounce back up to a lower height, slowing down like a thrown object.
            // When the next bounce is too small, the spring stays on the floor.
            bounceHeight *= BOUNCE_DAMPING
            if (bounceHeight > MIN_BOUNCE_HEIGHT) {
                val riseDurationMs = (DROP_DURATION_MS * sqrt(bounceHeight)).toInt()
                springHeight.animateTo(bounceHeight, tween(riseDurationMs, easing = EaseOutQuad))
            }
        }
        isAnimating = false
    }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .clickable(enabled = !isAnimating) { isAnimating = true }
            .drawBehind {
                // Read the height in the draw phase, and scale it to the distance
                // between the top of the box and the floor.
                val floorTop = size.height - FLOOR_HEIGHT.toPx()
                val dropDistance = floorTop - SPRING_HEIGHT.toPx()
                drawSpring(topY = dropDistance * (1f - springHeight.value))
                drawFloor(top = floorTop)
            }
    ) {
        if (!isAnimating) {
            Text("Tap to restart", Modifier.align(Alignment.Center))
        }
    }
}

Start rakiety

Poprzedni przykład pokazuje, jak za pomocą podstawowego interfejsu API obwiedni symulować reakcję sprężystą. WaveformEnvelopeBuilder zapewnia precyzyjną kontrolę nad pełnym zakresem częstotliwości urządzenia, umożliwiając tworzenie wysoce spersonalizowanych efektów haptycznych. Łącząc te informacje z danymi FOAM, możesz dostosować wibracje do konkretnych możliwości częstotliwości.

Oto przykład symulacji startu rakiety z użyciem dynamicznego wzorca wibracji. Efekt przechodzi od minimalnej obsługiwanej częstotliwości przyspieszenia (0,1 G) do częstotliwości rezonansowej, zawsze utrzymując amplitudę wejściową na poziomie 10%. Dzięki temu efekt zaczyna się od dość silnego sygnału wyjściowego i zwiększa postrzeganą intensywność i ostrość, mimo że amplituda sterująca jest taka sama. Po osiągnięciu rezonansu częstotliwość efektu spada z powrotem do minimum, co jest odczuwane jako spadek intensywności i ostrości. Daje to wrażenie początkowego oporu, po którym następuje zwolnienie, co naśladuje start w kosmos.

Ten efekt nie jest możliwy do uzyskania przy użyciu podstawowego interfejsu API obwiedni, ponieważ nie zawiera on informacji o częstotliwości rezonansowej i krzywej przyspieszenia wyjściowego urządzenia. Zwiększenie ostrości może spowodować, że równoważna częstotliwość przekroczy rezonans, co może doprowadzić do niezamierzonego spadku przyspieszenia.

Animacja przedstawiająca rakietę startującą z dołu ekranu.
Wykres fali wibracji wejściowych.

Rysunek 6. Wykres fali przyspieszenia wyjściowego dla wibracji symulujących start rakiety.

@Composable
fun RocketLaunchAnimation() {
    var isLaunched by remember { mutableStateOf(false) }
    val animation = remember { Animatable(0f) }

    val animationDuration = 3000
    LaunchedEffect(isLaunched) {
        if (isLaunched) {
            animation.animateTo(
                1.2f, // Overshoot so that the rocket goes off the screen.
                animationSpec = tween(
                    durationMillis = animationDuration,
                    // Applies an easing curve with a slow start and rapid acceleration
                    // towards the end.
                    easing = CubicBezierEasing(1f, 0f, 0.75f, 1f)
                )
            )
            animation.snapTo(0f)
            isLaunched = false
        }
    }

    Canvas(
        modifier = Modifier
            .fillMaxSize()
            .clickable {
                if (!isLaunched) {
                    // Play vibration with same duration as the animation, using 70% of
                    // the time for the rise of the vibration, to match the easing curve
                    // defined previously.
                    playVibration(vibrator, animationDuration.toLong(), 0.7f)
                    isLaunched = true
                }
            }
            .background(colorResource(R.color.background))
    ) {
        // Read the animation in the draw phase, and scale it to the canvas height.
        drawRocket(size.height * animation.value)
    }
}

private fun playVibration(
    vibrator: Vibrator,
    totalDurationMs: Long,
    riseBias: Float,
    minOutputAccelerationGs: Float = 0.1f,
) {
    require(riseBias in 0f..1f) { "Rise bias must be between 0 and 1." }

    if (!vibrator.areEnvelopeEffectsSupported()) {
        return
    }

    val resonantFrequency = vibrator.resonantFrequency
    if (resonantFrequency.isNaN()) {
        // Device doesn't have or expose a resonant frequency.
        return
    }

    val startFrequency = vibrator.frequencyProfile?.getFrequencyRange(minOutputAccelerationGs)?.lower ?: return

    if (startFrequency >= resonantFrequency) {
        // Vibrator can't generate the minimum required output at lower frequencies.
        return
    }

    val minDurationMs = vibrator.envelopeEffectInfo.minControlPointDurationMillis
    val rampUpDurationMs = (riseBias * totalDurationMs).toLong() - minDurationMs
    val rampDownDurationMs = totalDurationMs - rampUpDurationMs - minDurationMs

    vibrator.vibrate(
        VibrationEffect.WaveformEnvelopeBuilder()
            // Quickly reach the target output at the start frequency
            .addControlPoint(0.1f, startFrequency, minDurationMs)
            .addControlPoint(0.1f, resonantFrequency, rampUpDurationMs)
            .addControlPoint(0.1f, startFrequency, rampDownDurationMs)
            // Controlled ramp down to zero to avoid ringing after the vibration.
            .addControlPoint(0.0f, startFrequency, minDurationMs)
            .build()
    )
}

LavaBeats

Podobnie jak w przykładzie Wystrzelenie rakiety, interfejs API WaveformEnvelopeBuilder umożliwia projektowanie wielu złożonych efektów haptycznych dzięki możliwości kontrolowania segmentów amplitudy i częstotliwości wibracji. Innym przykładem takiego projektu jest emulacja bardziej abstrakcyjnych wrażeń fizycznych, takich jak „żywotność”.

Można to osiągnąć, przedstawiając biomarkery typowego sygnału elektrokardiogramu (EKG) za pomocą segmentów wibracji o określonych amplitudach i częstotliwościach. LavaBeats to przykład, w którym 2 charakterystyczne segmenty zapisu EKG są przedstawione jako 2 impulsy oddzielone opóźnieniem czasowym. Pierwszym charakterystycznym impulsem jest zespół QRS, który ma postać ostrego szczytu o wysokiej amplitudzie i krótkim czasie trwania. Drugi impuls to fala T, która ma mniejszą amplitudę, dłuższy czas trwania i bardziej gładki kształt (patrz rysunek 7).

Użyj WaveformEnvelopeBuilder, aby utworzyć różne powtórzenia tych 2 impulsów oddzielonych stałym opóźnieniem między pierwszym a drugim impulsem. Pierwszy impuls może być sygnałem chirp, który zaczyna się od niskiej częstotliwości i kończy na wyższej częstotliwości w krótkim czasie. Drugi impuls można przedstawić jako pojedynczy okres sinusoidy o niskiej częstotliwości. Możemy połączyć te 2 impulsy w uderzenie i powtórzyć tę kompozycję kilka razy z opóźnieniem, zgodnie z typową liczbą uderzeń na minutę (bpm). W efekcie powstaje efekt haptyczny, który przypomina bicie serca.

Możesz wypróbować LavaBeats w naszej przykładowej aplikacji haptycznej na GitHubie i poczuć efekt, któremu towarzyszy wizualizacja lampy lawowej, która pulsuje w tym samym rytmie co efekt haptyczny. Możesz też zmienić ustawienia efektu, aby uzyskać różne wrażenia bicia, modyfikując amplitudy, częstotliwości, czas trwania i opóźnienia obu impulsów.

Animacja lampy lawowej, która pulsuje w rytmie bicia serca.
Wykres segmentu EKG i jego charakterystyczne przebiegi falowe

Rysunek 7. Fragment zapisu EKG z zespołem QRS i falą T

private fun createEnvelopeEffect(
    beatParameters: List<BeatParameter>
): VibrationEffect =
    VibrationEffect.WaveformEnvelopeBuilder()
        .apply {
            repeat(beatParameters.getNumBeats()) {
                // First pulse chirp
                addControlPoint(
                    beatParameters.getFirstPulseAmplitude(),
                    beatParameters.getFirstPulseStartFreq(),
                    ENVELOPE_RAMP_DURATION_MILLIS,
                )
                addControlPoint(
                    beatParameters.getFirstPulseAmplitude(),
                    beatParameters.getFirstPulseEndFreq(),
                    beatParameters.getFirstPulseDurationMillis().toLong(),
                )
                addControlPoint(
                    0f,
                    beatParameters.getFirstPulseEndFreq(),
                    ENVELOPE_RAMP_DURATION_MILLIS,
                )

                // Delay between first and second pulse
                addControlPoint(
                    0f,
                    beatParameters.getFirstPulseEndFreq(),
                    beatParameters.getFirstToSecondPulseDelayMillis().toLong(),
                )

                // Second pulse
                addControlPoint(
                    beatParameters.getSecondPulseAmplitude(),
                    beatParameters.getSecondPulseFreq(),
                    ENVELOPE_RAMP_DURATION_MILLIS,
                )
                addControlPoint(
                    beatParameters.getSecondPulseAmplitude(),
                    beatParameters.getSecondPulseFreq(),
                    (1_000 / (2f * beatParameters.getSecondPulseFreq())).toLong(),
                )
                addControlPoint(
                    0f,
                    beatParameters.getSecondPulseFreq(),
                    ENVELOPE_RAMP_DURATION_MILLIS,
                )
                addControlPoint(
                    0f,
                    beatParameters.getSecondPulseFreq(),
                    beatParameters.getBeatDelayMillis().toLong(),
                )
            }
        }
        .build()

/** A parameter of a haptic beat effect that represents an ECG signal parameter */
data class BeatParameter(
    val description: String = "",
    val value: Float = 0f,
    val range: ClosedFloatingPointRange<Float> = 0f..1f,
    val steps: Int = 0,
    val isFrequencyType: Boolean = false,
)