יצירת אפקטים פיזיים בהתאמה אישית

בדף הזה מופיעות דוגמאות לשימוש בממשקי API שונים של משוב הפטי כדי ליצור אפקטים מותאמים אישית מעבר לצורות הגל הסטנדרטיות של הרטט באפליקציית Android.

בדף הזה מופיעות הדוגמאות הבאות:

דוגמאות נוספות זמינות במאמר הוספת משוב הפטי לאירועים. חשוב תמיד לפעול בהתאם לכללי העיצוב של משוב הפטי.

שימוש בחלופות כדי לטפל בתאימות של המכשיר

כשמטמיעים אפקט מותאם אישית, חשוב לקחת בחשבון את הנקודות הבאות:

  • אילו יכולות של המכשיר נדרשות כדי להשתמש באפקט
  • מה עושים כשהמכשיר לא יכול להפעיל את האפקט

מאמרי העזרה של ה-API של Android haptics מספקים פרטים על איך לבדוק אם יש תמיכה ברכיבים שקשורים ל-haptics, כדי שהאפליקציה תוכל לספק חוויה עקבית באופן כללי.

בהתאם לתרחיש השימוש, יכול להיות שתרצו להשבית אפקטים מותאמים אישית או לספק אפקטים מותאמים אישית חלופיים על סמך יכולות פוטנציאליות שונות.

תכנון בהתאם לסוגים הבאים של יכולות המכשיר:

  • אם אתם משתמשים בפרימיטיבים של משוב הפטי: מכשירים שתומכים בפרימיטיבים האלה שנדרשים לאפקטים המותאמים אישית. (פרטים על פרימיטיבים מופיעים בקטע הבא).

  • מכשירים עם שליטה בעוצמת הקול.

  • מכשירים עם תמיכה בבסיסית ברטט (הפעלה/השבתה) – במילים אחרות, מכשירים שאין בהם שליטה בעוצמת הרטט.

אם האפליקציה שלכם מאפשרת לבחור אפקט הפטי שמביא בחשבון את הקטגוריות האלה, חוויית המשתמש ההפטית צריכה להישאר צפויה בכל מכשיר בנפרד.

שימוש ברכיבים פרימיטיביים של משוב מישושי

Android כוללת כמה פרימיטיבים של משוב פיזי, שמשתנים גם במשרעת וגם בתדירות. אפשר להשתמש בפרימיטיב אחד או בשילוב של כמה פרימיטיבים כדי ליצור אפקטים עשירים של משוב הפטי.

  • מומלץ להשתמש בהשהיות של 50 אלפיות השנייה או יותר כדי ליצור פערים ברורים בין שני רכיבים פרימיטיביים, וגם לקחת בחשבון את משך הרכיב הפרימיטיבי אם אפשר.
  • כדאי להשתמש בסולמות שונים עם יחס של 1.4 או יותר, כדי שההבדל בעוצמה יהיה מורגש יותר.
  • משתמשים בסולמות של 0.5,‏ 0.7 ו-1.0 כדי ליצור גרסה של פרימיטיב בעוצמה נמוכה, בינונית וגבוהה.

יצירת תבניות רטט מותאמות אישית

תבניות רטט משמשות לעיתים קרובות במשוב פיזי שנועד למשוך את תשומת הלב, כמו התראות ורינגטונים. שירות Vibrator יכול להפעיל דפוסי רטט ארוכים שמשנים את עוצמת הרטט לאורך זמן. אפקטים כאלה נקראים צורות גל.

בדרך כלל אפשר להרגיש את האפקטים של צורת הגל, אבל אם מפעילים רטט ארוך ופתאומי בסביבה שקטה, הוא עלול להבהיל את המשתמש. הגברה מהירה מדי של עוצמת הקול עד לרמה הרצויה עלולה גם היא ליצור רעשי זמזום שניתן לשמוע. עיצוב דפוסי צורת גל כדי להחליק את המעברים של האמפליטודה וליצור אפקטים של עלייה וירידה.

דוגמאות לתבניות רטט

בקטעים הבאים מופיעות כמה דוגמאות לדפוסי רטט:

תבנית הגדלת נפח החשיפה

צורות גל מוצגות כ-VibrationEffect עם שלושה פרמטרים:

  1. Timings: מערך של משכי זמן, באלפיות השנייה, לכל מקטע של צורת הגל.
  2. אמפליטודות: אמפליטודת הרטט הרצויה לכל משך זמן שצוין בארגומנט הראשון, שמיוצגת על ידי ערך שלם מ-0 עד 255, כאשר 0 מייצג את מצב הרטט 'כבוי' ו-255 מייצג את האמפליטודה המקסימלית של המכשיר.
  3. אינדקס החזרה: האינדקס במערך שצוין בארגומנט הראשון שבו מתחילה החזרה על צורת הגל, או -1 אם צריך להפעיל את התבנית רק פעם אחת.

זוהי דוגמה לצורת גל שמתרחשת בה פעמיים פעימה עם השהיה של 350 אלפיות השנייה בין הפעימות. הדופק הראשון הוא עלייה חלקה לאמפליטודה המקסימלית, והדופק השני הוא עלייה מהירה לאמפליטודה המקסימלית. ההגדרה של עצירה בסוף היא ערך שלילי של אינדקס החזרה.

Kotlin

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))

Java

long[] timings = new long[] {
    50, 50, 50, 50, 50, 100, 350, 25, 25, 25, 25, 200 };
int[] amplitudes = new int[] {
    33, 51, 75, 113, 170, 255, 0, 38, 62, 100, 160, 255 };
int repeatIndex = -1; // Don't repeat.

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

דפוס שחוזר על עצמו

אפשר גם להפעיל את צורות הגל שוב ושוב עד שמבטלים את ההפעלה. כדי ליצור צורת גל חוזרת, צריך להגדיר פרמטר repeat לא שלילי. כשמפעילים צורת גל חוזרת, הרטט נמשך עד שמבטלים אותו באופן מפורש בשירות:

Kotlin

void 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.
VibrationEffect repeatingEffect = VibrationEffect.createWaveform(
    timings, amplitudes, repeat)
// repeatingEffect can be used in multiple places.

vibrator.vibrate(repeatingEffect)
}

void stopVibrating() {
vibrator.cancel()
}

Java

void startVibrating() {
long[] timings = new long[] { 50, 50, 100, 50, 50 };
int[] amplitudes = new int[] { 64, 128, 255, 128, 64 };
int repeat = 1; // Repeat from the second entry, index = 1.
VibrationEffect repeatingEffect = VibrationEffect.createWaveform(
    timings, amplitudes, repeat);
// repeatingEffect can be used in multiple places.

vibrator.vibrate(repeatingEffect);
}

void stopVibrating() {
vibrator.cancel();
}

האפשרות הזו שימושית מאוד לאירועים לסירוגין שדורשים פעולה מצד המשתמש כדי לאשר אותם. דוגמאות לאירועים כאלה: שיחות טלפון נכנסות ושעונים מעוררים שהופעלו.

תבנית עם חזרה למצב ראשוני

שליטה באמפליטודה של רטט היא יכולת שתלויה בחומרה. הפעלת צורת גל במכשיר נמוך ללא היכולת הזו גורמת למכשיר לרטוט במשרעת המקסימלית עבור כל ערך חיובי במערך המשרעת. אם האפליקציה שלכם צריכה להתאים למכשירים כאלה, אתם יכולים להשתמש בדפוס שלא יוצר אפקט של זמזום כשהוא מושמע בתנאי הזה, או ליצור דפוס פשוט יותר של הפעלה/השבתה שאפשר להשתמש בו כחלופה.

Kotlin

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

Java

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

יצירת קומפוזיציות של רטט

בקטע הזה מוצגות דרכים לשלב רטטים ליצירת אפקטים מותאמים אישית ארוכים ומורכבים יותר, וגם מעבר לכך – אפקטים הפטיים עשירים באמצעות יכולות חומרה מתקדמות יותר. אפשר להשתמש בשילובים של אפקטים שמשנים את האמפליטודה והתדירות כדי ליצור אפקטים הפטיים מורכבים יותר במכשירים עם מפעילים הפטיים שיש להם רוחב פס תדרים רחב יותר.

בתהליך יצירת תבניות רטט בהתאמה אישית, שמתואר בהמשך הדף הזה, מוסבר איך לשלוט במשרעת הרטט כדי ליצור אפקטים חלקים של הגברה והנמכה. המושג הזה משתפר באמצעות אפקטים הפטיים עשירים, שמתבססים על טווח התדרים הרחב יותר של מנגנון הרטט במכשיר כדי להפוך את האפקט לחלק עוד יותר. צורות הגל האלה יעילות במיוחד ליצירת אפקט של קרשנדו או דימינואנדו.

הפרימיטיבים של ההרכב, שמתוארים בהמשך הדף הזה, מיושמים על ידי יצרן המכשיר. הן מספקות רטט חד, קצר ונעים, בהתאם לעקרונות המשוב הפיזי, כדי ליצור משוב פיזי ברור. פרטים נוספים על היכולות האלה ועל אופן הפעולה שלהן זמינים במאמר מבוא למפעילי רטט.

מערכת Android לא מספקת חלופות ליצירות מוזיקליות עם פרימיטיבים שלא נתמכים. לכן, צריך לבצע את השלבים הבאים:

  1. לפני שמפעילים את התכונה המתקדמת של משוב הפטי, צריך לוודא שהמכשיר תומך בכל הפרימיטיבים שבהם משתמשים.

  2. משביתים את קבוצת החוויות העקבית שלא נתמכת, ולא רק את האפקטים שחסר להם פרימיטיב.

בקטעים הבאים מוסבר איך לבדוק אם המכשיר נתמך.

יצירת אפקטים מורכבים של רטט

אפשר ליצור אפקטים מורכבים של רטט באמצעות VibrationEffect.Composition. דוגמה לאפקט של עלייה הדרגתית בעוצמה ואחריו אפקט של קליק חד:

Kotlin

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

Java

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

קומפוזיציה נוצרת על ידי הוספת פרימיטיבים להפעלה ברצף. כל אלמנט פרימיטיבי ניתן להרחבה, כך שאפשר לשלוט בעוצמת הרטט שנוצר על ידי כל אחד מהם. הסולם מוגדר כערך בין 0 ל-1, כאשר 0 ממפה למעשה למשרעת מינימלית שבה המשתמש יכול (בקושי) להרגיש את הפרימיטיב הזה.

יצירת וריאציות בפרימיטיבים של רטט

אם רוצים ליצור גרסה חלשה וגרסה חזקה של אותו פרימיטיב, צריך ליצור יחסי חוזק של 1.4 ומעלה, כדי שההבדל בעוצמה יהיה מורגש בקלות. לא כדאי לנסות ליצור יותר משלוש רמות עוצמה של אותו פרימיטיב, כי הן לא נבדלות זו מזו מבחינה תפיסתית. לדוגמה, אפשר להשתמש בסולמות של 0.5,‏ 0.7 ו-1.0 כדי ליצור גרסאות בעוצמה נמוכה, בינונית וגבוהה של פרימיטיב.

הוספת פערים בין רכיבי רטט פרימיטיביים

הקומפוזיציה יכולה גם לציין השהיות שיוספו בין פרימיטיבים עוקבים. העיכוב הזה מבוטא באלפיות השנייה מאז סיום הפרימיטיב הקודם. באופן כללי, פער של 5 עד 10 אלפיות השנייה בין שני פרימיטיבים הוא קצר מדי כדי שאפשר יהיה לזהות אותו. אם רוצים ליצור פער מובחן בין שני פרימיטיבים, צריך להשתמש בפער של 50 מילי-שניות או יותר. דוגמה להרכב עם השהיות:

Kotlin

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()
)

Java

int 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());

בדיקה של הפרימיטיבים הנתמכים

אפשר להשתמש בממשקי ה-API הבאים כדי לבדוק את התמיכה במכשיר עבור פרימיטיבים ספציפיים:

Kotlin

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.
}

Java

int 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.
}

אפשר גם לבדוק כמה פרימיטיבים ואז להחליט אילו מהם להרכיב על סמך רמת התמיכה במכשיר:

Kotlin

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

Java

int[] primitives = new int[] {
VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
VibrationEffect.Composition.PRIMITIVE_TICK,
VibrationEffect.Composition.PRIMITIVE_CLICK
};
boolean[] supported = vibrator.arePrimitivesSupported(effects);

דוגמאות להרכבי רטט

בקטעים הבאים מופיעות כמה דוגמאות להגדרות של רטט, שנלקחו מאפליקציית הדוגמה של Haptics ב-GitHub.

התנגדות (עם סימון נמוך)

אתם יכולים לשלוט באמפליטודה של הרטט הבסיסי כדי להעביר משוב שימושי על פעולה שנמצאת בתהליך. אפשר להשתמש בערכי קנה מידה צפופים כדי ליצור אפקט קרשנדו חלק של פרימיטיב. אפשר גם להגדיר באופן דינמי את פרק הזמן שחלף בין פרימיטיבים עוקבים בהתאם לאינטראקציה של המשתמש. הדוגמה הבאה ממחישה את זה: אנימציה של תצוגה שנשלטת על ידי תנועת גרירה ומועשרת במשוב פיזי.

אנימציה של עיגול שנגרר למטה.
תרשים של צורת הגל של הרטט של הקלט.

איור 1. צורת הגל הזו מייצגת את תאוצת הפלט של הרטט במכשיר.

Kotlin

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

    // Only vibrates while the user is dragging
    if (isDragging) {
        LaunchedEffect(Unit) {
        // Continuously run the effect for vibration to occur even when the view
        // is not being drawn, when user stops dragging midway through gesture.
        while (true) {
            // 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()
            )
        }
        }
    }

    Screen() {
        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)
        }
    }
}

Java

class DragListener implements View.OnTouchListener {
    // Control variables for the dragging of the indicator.
    private int startY;
    private int vibrationInterval;
    private float vibrationScale;

    @Override
    public boolean onTouch(View view, MotionEvent event) {
        switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            startY = event.getRawY();
            vibrationInterval = calculateVibrationInterval(0);
            vibrationScale = calculateVibrationScale(0);
            startVibration();
            break;
        case MotionEvent.ACTION_MOVE:
            float dragOffset = event.getRawY() - startY;
            // Calculate the interval inversely proportional to the drag offset.
            vibrationInterval = calculateVibrationInterval(dragOffset);
            // Calculate the scale directly proportional to the drag offset.
            vibrationScale = calculateVibrationScale(dragOffset);
            // Build the indicator UI based on how much the user has dragged it.
            updateIndicator(dragOffset);
            break;
        case MotionEvent.ACTION_CANCEL:
        case MotionEvent.ACTION_UP:
            // Only vibrates while the user is dragging
            cancelVibration();
            break;
        }
        return true;
    }

    private void startVibration() {
        vibrator.vibrate(
            VibrationEffect.startComposition()
                .addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
                        vibrationScale)
                .compose());

        // Continuously run the effect for vibration to occur even when the view
        // is not being drawn, when user stops dragging midway through gesture.
        handler.postDelayed(this::startVibration, vibrationInterval);
    }

    private void cancelVibration() {
        handler.removeCallbacksAndMessages(null);
    }
}

הרחבה (עם עלייה וירידה)

יש שני פרימיטיבים להגברת עוצמת הרטט: ‫PRIMITIVE_QUICK_RISE ו-PRIMITIVE_SLOW_RISE. שניהם מגיעים לאותו יעד, אבל במשך תקופות שונות. יש רק פרימיטיב אחד להפחתת עוצמת האור, PRIMITIVE_QUICK_FALL. הפרימיטיבים האלה פועלים טוב יותר ביחד כדי ליצור קטע של צורת גל שמתחזק ואז נחלש. אפשר ליישר פרימיטיבים מותאמים כדי למנוע קפיצות פתאומיות באמפליטודה ביניהם, וזה גם פתרון טוב להארכת משך ההשפעה הכולל. מבחינה תפיסתית, אנשים תמיד שמים לב יותר לחלק העולה מאשר לחלק היורד, ולכן אפשר להשתמש בחלק העולה כדי להסיט את הדגש לחלק היורד.

דוגמה לשימוש בהרכב הזה להרחבה ולכיווץ של עיגול. אפקט העלייה יכול לשפר את תחושת ההתרחבות במהלך האנימציה. השילוב של אפקטי העלייה והירידה עוזר להדגיש את הקריסה בסוף האנימציה.

אנימציה של מעגל מתרחב.
תרשים של צורת הגל של הרטט של הקלט.

איור 2. צורת הגל הזו מייצגת את תאוצת הפלט של הרטט במכשיר.

Kotlin

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)

    Screen() {
        Column(
        Modifier
            .clickable(
            {
                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)
        }
    }
}

Java

class ClickListener implements View.OnClickListener {
    private final Animation expandAnimation;
    private final Animation collapseAnimation;
    private boolean isExpanded;

    ClickListener(Context context) {
        expandAnimation = AnimationUtils.loadAnimation(context, R.anim.expand);
        expandAnimation.setAnimationListener(new Animation.AnimationListener() {

        @Override
        public void onAnimationStart(Animation animation) {
            vibrator.vibrate(
            VibrationEffect.startComposition()
                .addPrimitive(
                    VibrationEffect.Composition.PRIMITIVE_SLOW_RISE, 0.3f)
                .addPrimitive(
                    VibrationEffect.Composition.PRIMITIVE_QUICK_FALL, 0.3f)
                .compose());
        }
        });

        collapseAnimation = AnimationUtils
                .loadAnimation(context, R.anim.collapse);
        collapseAnimation.setAnimationListener(new Animation.AnimationListener() {

            @Override
            public void onAnimationStart(Animation animation) {
                vibrator.vibrate(
                VibrationEffect.startComposition()
                    .addPrimitive(
                        VibrationEffect.Composition.PRIMITIVE_SLOW_RISE)
                    .compose());
            }
        });
    }

    @Override
    public void onClick(View view) {
        view.startAnimation(isExpanded ? collapseAnimation : expandAnimation);
        isExpanded = !isExpanded;
    }
}

תנודתיות (עם סיבובים)

אחד מעקרונות המישוש המרכזיים הוא לשמח את המשתמשים. דרך נחמדה להוסיף אפקט של רטט נעים ולא צפוי היא באמצעות PRIMITIVE_SPIN. הפרימיטיב הזה הכי יעיל כשקוראים לו יותר מפעם אחת. אפשר ליצור אפקט של תנודה וחוסר יציבות על ידי שרשור של כמה ספינים, ואפשר לשפר את האפקט הזה עוד יותר על ידי החלת שינוי קנה מידה אקראי במידה מסוימת על כל פרימיטיב. אפשר גם להתנסות בפער בין פרימיטיבים עוקבים של ספין. שתי ספינים ללא פער (0 ms ביניהם) יוצרים תחושת ספין הדוקה. הגדלת הפער בין הסיבובים מ-10 ל-50 אלפיות השנייה מובילה לתחושת סיבוב חופשית יותר, ואפשר להשתמש בה כדי להתאים את משך הזמן של סרטון או אנימציה.

לא מומלץ להשתמש בפערים ארוכים מ-100 אלפיות השנייה, כי אז המעברים בין הסיבובים לא יהיו חלקים ויראו כמו אפקטים נפרדים.

דוגמה לצורה אלסטית שחוזרת למקומה אחרי שגוררים אותה למטה ואז משחררים אותה. האנימציה משופרת באמצעות שני אפקטים של סיבוב, שמופעלים בעוצמות שונות שפרופורציונליות להעתקה של הקפיצה.

אנימציה של צורה אלסטית שקופצת
תצוגה גרפית של צורת הגל של הרטט של הקלט

איור 3. צורת הגל הזו מייצגת את תאוצת הפלט של הרטט במכשיר.

Kotlin

@Composable
fun WobbleScreen() {
    // Control variables for the dragging and animating state of the elastic.
    var dragDistance by remember { mutableStateOf(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
        ),
    )

    if (isWobbling) {
        LaunchedEffect(Unit) {
            while (true) {
                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 = {
                    isWobbling = true
                    dragDistance = 0f
                },
                orientation = Orientation.Vertical,
                state = rememberDraggableState { delta ->
                    isWobbling = false
                    dragDistance += delta
                }
            )
    ) {
        // Draw the wobbling shape using the animated spring-like value.
        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)
}

Java

class AnimationListener implements DynamicAnimation.OnAnimationUpdateListener {
    private final Random vibrationRandom = new Random(seed);
    private final long lastVibrationUptime;

    @Override
    public void onAnimationUpdate(
        DynamicAnimation animation, float value, float velocity) {
        // 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.
        if (SystemClock.uptimeMillis() - lastVibrationUptime < VIBRATION_DURATION) {
            return;
        }

        float displacement = calculateRelativeDisplacement(value);

        // Use some sort of minimum displacement so the final few frames
        // of animation don't generate a vibration.
        if (displacement < SPIN_MIN_DISPLACEMENT) {
            return;
        }

        lastVibrationUptime = SystemClock.uptimeMillis();
        vibrator.vibrate(
        VibrationEffect.startComposition()
            .addPrimitive(VibrationEffect.Composition.PRIMITIVE_SPIN,
            nextSpinScale(displacement))
            .addPrimitive(VibrationEffect.Composition.PRIMITIVE_SPIN,
            nextSpinScale(displacement))
            .compose());
    }

    // Calculate a random scale for each spin to vary the full effect.
    float nextSpinScale(float displacement) {
        // 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.
        float randomOffset = vibrationRandom.nextFloat() * 0.2f - 0.1f
        return MathUtils.clamp(displacement + randomOffset, 0f, 1f)
    }
}

קפיצה (עם חבטות)

שימוש מתקדם נוסף באפקטים של רטט הוא סימולציה של אינטראקציות פיזיות. PRIMITIVE_THUD יכול ליצור אפקט חזק ומהדהד, שאפשר לשלב עם הדמיה של השפעה, למשל בסרטון או באנימציה, כדי לשפר את החוויה הכוללת.

הנה דוגמה לאנימציה של כדור שנופל, עם אפקט של חבטה שמופעל בכל פעם שהכדור קופץ מתחתית המסך:

אנימציה של כדור שנפל וקופץ מהחלק התחתון של המסך.
תרשים של צורת הגל של הרטט של הקלט.

איור 4. צורת הגל הזו מייצגת את תאוצת הפלט של הרטט במכשיר.

Kotlin

enum class BallPosition {
    Start,
    End
}

@Composable
fun BounceScreen() {
    // Control variable for the state of the ball.
    var ballPosition by remember { mutableStateOf(BallPosition.Start) }
    var bounceCount by remember { mutableStateOf(0) }

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

    // Ball is about to contact floor, only vibrating once per collision.
    var hasVibratedForBallContact by remember { mutableStateOf(false) }
    if (collisionData.collisionWithFloor) {
        if (!hasVibratedForBallContact) {
        val vibrationScale = 0.7.pow(bounceCount++).toFloat()
        vibrator.vibrate(
            VibrationEffect.startComposition().addPrimitive(
            VibrationEffect.Composition.PRIMITIVE_THUD,
            vibrationScale
            ).compose()
        )
        hasVibratedForBallContact = true
        }
    } else {
        // Reset for next contact with floor.
        hasVibratedForBallContact = false
    }

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

Java

class ClickListener implements View.OnClickListener {
    @Override
    public void onClick(View view) {
        view.animate()
        .translationY(targetY)
        .setDuration(3000)
        .setInterpolator(new BounceInterpolator())
        .setUpdateListener(new AnimatorUpdateListener() {

            boolean hasVibratedForBallContact = false;
            int bounceCount = 0;

            @Override
            public void onAnimationUpdate(ValueAnimator animator) {
            boolean valueBeyondThreshold = (float) animator.getAnimatedValue() > 0.98;
            if (valueBeyondThreshold) {
                if (!hasVibratedForBallContact) {
                float vibrationScale = (float) Math.pow(0.7, bounceCount++);
                vibrator.vibrate(
                    VibrationEffect.startComposition()
                    .addPrimitive(
                        VibrationEffect.Composition.PRIMITIVE_THUD,
                        vibrationScale)
                    .compose());
                hasVibratedForBallContact = true;
                }
            } else {
                // Reset for next contact with floor.
                hasVibratedForBallContact = false;
            }
            }
        });
    }
}

צורת גל של רטט עם מעטפות

במהלך יצירת תבניות רטט בהתאמה אישית, אתם יכולים לשלוט במשרעת הרטט כדי ליצור אפקטים חלקים של הגברה והנמכה. בקטע הזה מוסבר איך ליצור אפקטים דינמיים של משוב מישוש באמצעות מעטפות של צורות גל, שמאפשרות שליטה מדויקת במשרעת ובתדירות של הרטט לאורך זמן. כך תוכלו ליצור חוויות עשירות ומורכבות יותר של משוב הפטי.

החל מ-Android 16 (רמת API‏ 36), המערכת מספקת את ממשקי ה-API הבאים ליצירת מעטפת של צורת גל של רטט על ידי הגדרת רצף של נקודות בקרה:

  • BasicEnvelopeBuilder: גישה נגישה ליצירת אפקטים של משוב מישוש שלא תלויים בחומרה.
  • WaveformEnvelopeBuilder: גישה מתקדמת יותר ליצירת אפקטים של משוב פיזי. נדרשת היכרות עם חומרה של משוב פיזי.

‫Android לא מספקת חלופות לאפקטים של מעטפות. כדי לקבל תמיכה כזו, צריך לבצע את השלבים הבאים:

  1. כדי לבדוק אם מכשיר מסוים תומך באפקטים של מעטפות, משתמשים ב-Vibrator.areEnvelopeEffectsSupported().
  2. משביתים את חוויית השימוש העקבית שלא נתמכת, או משתמשים בדפוסי רטט מותאמים אישית או בקומפוזיציות כחלופות.

כדי ליצור אפקטים בסיסיים יותר של מעטפות, משתמשים ב-BasicEnvelopeBuilder עם הפרמטרים הבאים:

  • ערך העוצמה בטווח \( [0, 1] \), שמייצג את העוצמה המורגשת של הרטט. לדוגמה, ערך של \( 0.5 \) נתפס כמחצית מהעוצמה המקסימלית הגלובלית שאפשר להשיג באמצעות המכשיר.
  • ערך החדות בטווח \( [0, 1] \), שמייצג את הבהירות של הרטט. ערכים נמוכים יותר יוצרים תחושה חלקה יותר של רטט, בעוד שערכים גבוהים יותר יוצרים תחושה חדה יותר.

  • ערך המשך, שמייצג את הזמן, באלפיות השנייה, שנדרש למעבר מנקודת הבקרה האחרונה – כלומר, זוג של עוצמה וחדות – לנקודת הבקרה החדשה.

זוהי דוגמה לצורת גל שבה עוצמת הרטט עולה בהדרגה מדרגה נמוכה לדרגה גבוהה, עד לדרגה המקסימלית, במשך 500 אלפיות השנייה, ואז יורדת בהדרגה בחזרה ל-\( 0 \) (כבוי) במשך 100 אלפיות השנייה.

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

אם יש לכם ידע מתקדם יותר בתחום של משוב הפטי, אתם יכולים להגדיר אפקטים של מעטפת באמצעות WaveformEnvelopeBuilder. כשמשתמשים באובייקט הזה, אפשר לגשת אל מיפוי התאוצה של התדר לפלט (FOAM) דרך VibratorFrequencyProfile.

  • ערך אמפליטודה בטווח \( [0, 1] \), שמייצג את עוצמת הרטט שאפשר להשיג בתדר נתון, כפי שנקבע על ידי ה-FOAM של המכשיר. לדוגמה, ערך של \( 0.5 \) מייצר חצי מהתאוצה המקסימלית של הפלט שאפשר להשיג בתדר הנתון.
  • ערך התדירות, שצוין בהרץ.

  • ערך המשך, שמייצג את הזמן, במילי-שניות, שנדרש למעבר מנקודת הבקרה האחרונה לנקודת הבקרה החדשה.

הקוד הבא מציג צורת גל לדוגמה שמגדירה אפקט של רטט למשך 400 אלפיות השנייה. הוא מתחיל בעלייה של 50 ms באמפליטודה, ממצב כבוי למצב מלא, בתדר קבוע של 60 Hz. לאחר מכן, התדר עולה ל-120 Hz במהלך 100 ms הבאים ונשאר ברמה הזו למשך 200 ms. לבסוף, האמפליטודה יורדת ל- \( 0 \), והתדר חוזר ל-60 Hz במהלך 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()
)

בקטעים הבאים מופיעות כמה דוגמאות לצורות גל של רטט עם מעטפות.

קפיץ קופץ

בדוגמה הקודמת השתמשנו ב-PRIMITIVE_THUD כדי לדמות אינטראקציות של קפיצה פיזית. ממשק ה-API הבסיסי של מעטפת מציע שליטה מדויקת יותר, ומאפשר לכם להתאים במדויק את עוצמת הרטט והחדות שלו. כך מתקבל משוב הפטי שמדויק יותר בהתאם לאירועים מונפשים.

דוגמה לקפיץ שנופל חופשי, עם אנימציה שמשופרת באמצעות אפקט מעטפת בסיסי שמופעל בכל פעם שהקפיץ קופץ מהחלק התחתון של המסך:

אנימציה של קפיץ שנפל וקופץ מתחתית המסך.
תרשים של צורת הגל של הרטט של הקלט.

איור 5. גרף של צורת גל של תאוצת פלט עבור רטט שמדמה קפיץ מקפץ.

@Composable
fun BouncingSpringAnimation() {
  var springX by remember { mutableStateOf(SPRING_WIDTH) }
  var springY by remember { mutableStateOf(SPRING_HEIGHT) }
  var velocityX by remember { mutableFloatStateOf(INITIAL_VELOCITY) }
  var velocityY by remember { mutableFloatStateOf(INITIAL_VELOCITY) }
  var sharpness by remember { mutableFloatStateOf(INITIAL_SHARPNESS) }
  var intensity by remember { mutableFloatStateOf(INITIAL_INTENSITY) }
  var multiplier by remember { mutableFloatStateOf(INITIAL_MULTIPLIER) }
  var bottomBounceCount by remember { mutableIntStateOf(0) }
  var animationStartTime by remember { mutableLongStateOf(0L) }
  var isAnimating by remember { mutableStateOf(false) }

  val (screenHeight, screenWidth) = getScreenDimensions(context)

  LaunchedEffect(isAnimating) {
    animationStartTime = System.currentTimeMillis()
    isAnimating = true

    while (isAnimating) {
      velocityY += GRAVITY
      springX += velocityX.dp
      springY += velocityY.dp

      // Handle bottom collision
      if (springY > screenHeight - FLOOR_HEIGHT - SPRING_HEIGHT / 2) {
        // Set the spring's y-position to the bottom bounce point, to keep it
        // above the floor.
        springY = screenHeight - FLOOR_HEIGHT - SPRING_HEIGHT / 2

        // Reverse the vertical velocity and apply damping to simulate a bounce.
        velocityY *= -BOUNCE_DAMPING
        bottomBounceCount++

        // Calculate the fade-out duration of the vibration based on the
        // vertical velocity.
        val fadeOutDuration =
            ((abs(velocityY) / GRAVITY) * FRAME_DELAY_MS).toLong()

        // Create a "boing" envelope vibration effect that fades out.
        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(intensity, sharpness, 20L)

                // Add a control point to fade out the vibration intensity while
                // maintaining sharpness.
                .addControlPoint(0f, sharpness, fadeOutDuration)
                .build()
        )

        // Decrease the intensity and sharpness of the vibration for subsequent
        // bounces, and reduce the multiplier to create a fading effect.
        intensity *= multiplier
        sharpness *= multiplier
        multiplier -= 0.1f
      }

      if (springX > screenWidth - SPRING_WIDTH / 2) {
        // Prevent the spring from moving beyond the right edge of the screen.
        springX = screenWidth - SPRING_WIDTH / 2
      }

      // Check for 3 bottom bounces and then slow down.
      if (bottomBounceCount >= MAX_BOTTOM_BOUNCE &&
            System.currentTimeMillis() - animationStartTime > 1000) {
        velocityX *= 0.9f
        velocityY *= 0.9f
      }

      delay(FRAME_DELAY_MS) // Control animation speed.

      // Determine if the animation should continue based on the spring's
      // position and velocity.
      isAnimating = (springY < screenHeight + SPRING_HEIGHT ||
            springX < screenWidth + SPRING_WIDTH)
        && (velocityX >= 0.1f || velocityY >= 0.1f)
    }
  }

  Box(
    modifier = Modifier
      .fillMaxSize()
      .noRippleClickable {
        if (!isAnimating) {
          resetAnimation()
        }
      }
      .width(screenWidth)
      .height(screenHeight)
  ) {
    DrawSpring(mutableStateOf(springX), mutableStateOf(springY))
    DrawFloor()
    if (!isAnimating) {
      DrawText("Tap to restart")
    }
  }
}

שיגור טיל

בדוגמה הקודמת אפשר לראות איך משתמשים בממשק API בסיסי של מעטפה כדי לדמות תגובה של קפיץ קופצני. ה-WaveformEnvelopeBuilder מאפשר שליטה מדויקת בטווח התדרים המלא של המכשיר, וכך ליצור אפקטים הפטיים מותאמים אישית. כשמשלבים את זה עם נתוני FOAM, אפשר להתאים את הרטט ליכולות תדר ספציפיות.

הנה דוגמה שממחישה סימולציה של שיגור טיל באמצעות תבנית רטט דינמית. האפקט מתחיל מהתפוקה המינימלית של תאוצת התדר הנתמכת, 0.1 G, ומגיע לתדר התהודה, תוך שמירה על קלט של 10% משרעת. כך האפקט מתחיל עם פלט חזק יחסית, והעוצמה והחדות הנתפסות גדלות, למרות שאמפליטודת ההנעה נשארת זהה. כשמגיעים לתהודה, תדירות האפקט יורדת חזרה למינימום, וזה נתפס כהפחתה בעוצמה ובחדות. כך נוצרת תחושה של התנגדות ראשונית ואז שחרור, שמדמה שיגור לחלל.

ההשפעה הזו לא אפשרית עם ה-API הבסיסי של המעטפת, כי הוא מסתיר את המידע הספציפי למכשיר לגבי תדר התהודה ועקומת תאוצת הפלט. הגדלת החדות יכולה להעלות את התדר המקביל מעל התדר העצמי, ועלולה לגרום לירידה לא מכוונת בתאוצה.

אנימציה של חללית שממריאה מתחתית המסך.
תרשים של צורת הגל של הרטט של הקלט.

איור 6. גרף של צורת גל של האצת פלט של רטט שמדמה שיגור של רקטה.

@Composable
fun RocketLaunchAnimation() {
  val context = LocalContext.current
  val screenHeight = remember { mutableFloatStateOf(0f) }
  var rocketPositionY by remember { mutableFloatStateOf(0f) }
  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)
        )
      ) {
        rocketPositionY = screenHeight.floatValue * value
      }
      animation.snapTo(0f)
      rocketPositionY = 0f;
      isLaunched = false;
    }
  }

  Box(
    modifier = Modifier
      .fillMaxSize()
      .noRippleClickable {
        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, 0.7f)
          isLaunched = true
        }
      }
      .background(Color(context.getColor(R.color.background)))
      .onSizeChanged { screenHeight.floatValue = it.height.toFloat() }
  ) {
    drawRocket(rocketPositionY)
  }
}

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 - rampUpDuration - 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

כמו בדוגמה של שיגור רקטה, ה-API‏ WaveformEnvelopeBuilder מאפשר לתכנן הרבה אפקטים מורכבים של משוב הפטי על ידי שליטה בפלחים של אמפליטודה ותדירות ברטט. דוגמה נוספת לעיצוב כזה היא הדמיה של תחושות פיזיות מופשטות יותר, כמו 'חיוניות'.

אפשר לעשות את זה על ידי ייצוג של ביומרקרים של אות אלקטרוקרדיוגרם (ECG) טיפוסי עם פלחי רטט של אמפליטודות ותדרים מסוימים. ‫LavaBeats היא דוגמה שבה שני מקטעים אופייניים של הקלטת אק"ג מיוצגים כשני פולסים שמופרדים על ידי השהיה בזמן. המאפיין הראשון של הדופק הוא קומפלקס QRS, שמוצג כשיא חד של אמפליטודה גבוהה ומשך קצר. הגל השני הוא גל T, שיש לו אמפליטודה נמוכה יותר, משך ארוך יותר וצורה חלקה יותר (ראו איור 7).

משתמשים ב-WaveformEnvelopeBuilder כדי ליצור חזרות שונות של שני הפולסים האלה, עם השהיה קבועה בין הפולס הראשון לשני. הפעימה הראשונה יכולה להיות אות ציוץ שמתחיל בתדר נמוך ומסתיים בתדר גבוה יותר במשך זמן קצר. אפשר לייצג את הפולס השני כפרק זמן יחיד של סינוסואיד בתדר נמוך. אפשר ליצור פעימה משני הפולסים, ולחזור על הפעימה כמה פעמים עם השהיה ביניהן, לפי קצב פעימות לדקה (bpm) אופייני. התוצאה היא אפקט מישושי שמזכיר פעימות לב.

אתם יכולים לנסות את LavaBeats באפליקציה לדוגמה שלנו בנושא משוב הפטי ב-GitHub ולהרגיש את האפקט בליווי הדמיה של מנורת לבה שפועמת באותו קצב של המשוב ההפטי. אפשר גם לשנות את ההגדרות של האפקט כדי ליצור תחושות פעימה שונות, על ידי שינוי המשרעת, התדירות, משך הזמן וההשהיות של שני הפולסים.

אנימציה של מנורת לבה שפועמת בדפוס של פעימת לב עם משוב מישושי.
גרף של מקטע אק&quot;ג וצורות הגלים האופייניות שלו

איור 7. קטע של הקלטת אק"ג עם קומפלקס QRS וגל T

@RequiresApi(Build.VERSION_CODES.BAKLAVA)
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 */
@Stable
data class BeatParameter(
    val description: String = "",
    val value: Float = 0f,
    val range: ClosedFloatingPointRange<Float> = 0f..1f,
    val steps: Int = 0,
    val isFrequencyType: Boolean = false,
)