diff --git a/app/src/main/java/com/limelight/binding/input/haptics/RichTapAuthoredHaptics.kt b/app/src/main/java/com/limelight/binding/input/haptics/RichTapAuthoredHaptics.kt index de37e1b7e0..ef96c0ab09 100644 --- a/app/src/main/java/com/limelight/binding/input/haptics/RichTapAuthoredHaptics.kt +++ b/app/src/main/java/com/limelight/binding/input/haptics/RichTapAuthoredHaptics.kt @@ -9,17 +9,19 @@ import android.os.VibrationEffect import android.os.Vibrator import com.limelight.LimeLog import java.lang.reflect.Method +import kotlin.math.pow import kotlin.math.roundToInt -/** A single finite HE2 voice for body and points. State updates replace the envelope; - * they never append work to the vendor timeline. Source expiry and ownership remain +/** A single finite HE2 voice for body and points. Body gain can update in place; + * point envelopes never append work to the vendor timeline. Source expiry and ownership remain * with the existing IR coordinator and serial device worker. */ internal class RichTapAuthoredHaptics( - private val play: (IntArray) -> Unit, + private val play: (IntArray, Int) -> Unit, private val cancel: () -> Unit, private val clockMs: () -> Long, - private val processId: Int + private val processId: Int, + private val updateAmplitude: (Int) -> Unit ) : NativeAuthoredHaptics { override val backendName = "RichTap" @Volatile override var ready = true @@ -31,39 +33,50 @@ internal class RichTapAuthoredHaptics( private var bodyUntil = 0L private var renewAt = 0L private var bodyLevel = 0 + private var parameterCarrier = false + private var mixedUntil = 0L override fun render(body: Float, point: Float, keepWarm: Boolean): Long? { if (!ready) return null val now = clockMs() - val pointValue = level(point) + val pointValue = level(point, .6f) val requestedBody = level(body) + if (pointValue > 0) mixedUntil = now + MIXED_HEADROOM_MS // Hysteresis keeps small analysis fluctuations from restarting the carrier. - val target = if (requestedBody > 0 && now < bodyUntil && + val requestedTarget = if (requestedBody > 0 && now < bodyUntil && kotlin.math.abs(requestedBody - bodyLevel) < BODY_STEP) bodyLevel else (requestedBody + BODY_STEP / 2) / BODY_STEP * BODY_STEP + // A single quantization step can be only a filtered impact tail. Do not + // start a long carrier for it or turn a dominant point into a mixed voice. + val target = if (requestedTarget <= BODY_STEP && (pointValue > 0 || now >= bodyUntil)) 0 + else if (now < mixedUntil) minOf(requestedTarget, MIXED_BODY_LIMIT) else requestedTarget if (pointValue == 0 && now < pointUntil) return pointUntil return try { + // Use global gain only for continuous levels. Keep points in a native + // envelope and protect them from intervening parameter writes. + if (target > 0 && pointValue == 0 && + (parameterCarrier || target != bodyLevel || now >= renewAt)) { + return renderParameters(target, now) + } when { target > 0 -> { - val from = if (now < bodyUntil) bodyLevel else 0 - if (pointValue > 0 || target != bodyLevel || now >= renewAt) { - val peak = (target + pointValue).coerceAtMost(100) - val curve = if (pointValue > 0) intArrayOf( - 0, from, 0, 2, peak, 0, 12, target, 0, - BODY_DURATION_MS - 2, target, 0, BODY_DURATION_MS, target, 0 - ) else intArrayOf( - 0, from, 0, 2, target, 0, + if (pointValue > 0) { + val from = if (now < bodyUntil) bodyLevel else 0 + val peak = mixedPeak(target, pointValue) + val curve = intArrayOf( + 0, minOf(from, target), 0, 2, peak, 0, 12, target, 0, BODY_DURATION_MS - 2, target, 0, BODY_DURATION_MS, target, 0 ) active = true + parameterCarrier = false play(pattern(intArrayOf(4096, 6 + curve.size, 0, 0, 100, - FREQUENCY, BODY_DURATION_MS, curve.size / 3) + curve)) + FREQUENCY, BODY_DURATION_MS, curve.size / 3) + curve), 255) bodyLevel = target bodyUntil = now + BODY_DURATION_MS renewAt = now + BODY_RENEW_MS - if (pointValue > 0) pointUntil = now + MIXED_POINT_HOLD_MS + pointUntil = now + MIXED_POINT_HOLD_MS } - if (now < pointUntil) minOf(pointUntil, renewAt) else renewAt + nextWake(now) } pointValue > 0 -> { val hadBody = now < bodyUntil @@ -71,7 +84,8 @@ internal class RichTapAuthoredHaptics( bodyUntil = 0 renewAt = 0 active = true - play(pattern(intArrayOf(4097, 5, 0, 0, pointValue, FREQUENCY, 0))) + parameterCarrier = false + play(pattern(intArrayOf(4097, 5, 0, 0, pointValue, FREQUENCY, 0)), 255) pointUntil = now + if (hadBody) MIXED_POINT_HOLD_MS else POINT_HOLD_MS pointUntil } @@ -85,6 +99,31 @@ internal class RichTapAuthoredHaptics( } } + private fun renderParameters(target: Int, now: Long): Long { + if (!parameterCarrier || now >= renewAt) { + // A constant native carrier is started with the requested global gain; + // subsequent gain changes do not replace its five-second envelope. + // HE2 continuous events require at least four curve points. + val curve = intArrayOf(0, 0, 0, 2, 100, 0, + BODY_DURATION_MS - 2, 100, 0, BODY_DURATION_MS, 100, 0) + active = true + play(pattern(intArrayOf(4096, 6 + curve.size, 0, 0, 100, + FREQUENCY, BODY_DURATION_MS, curve.size / 3) + curve), amplitude(target)) + parameterCarrier = true + bodyLevel = target + bodyUntil = now + BODY_DURATION_MS + renewAt = now + BODY_RENEW_MS + } else if (target != bodyLevel) { + updateAmplitude(amplitude(target)) + bodyLevel = target + } + return nextWake(now) + } + + private fun nextWake(now: Long): Long = minOf(renewAt, + pointUntil.takeIf { it > now } ?: Long.MAX_VALUE, + mixedUntil.takeIf { it > now } ?: Long.MAX_VALUE) + private fun pattern(event: IntArray): IntArray = intArrayOf( 2, 2, processId, (1 shl 16) or (++sequence and 65535), 0x10001, 0, 0, 1 ) + event @@ -94,6 +133,8 @@ internal class RichTapAuthoredHaptics( bodyUntil = 0 renewAt = 0 bodyLevel = 0 + parameterCarrier = false + mixedUntil = 0 if (active) { active = false; cancel() } } @@ -106,10 +147,19 @@ internal class RichTapAuthoredHaptics( private const val BODY_RENEW_MS = 4_000L private const val POINT_HOLD_MS = 40L private const val MIXED_POINT_HOLD_MS = 100L + private const val MIXED_HEADROOM_MS = 1_500L + private const val MIXED_BODY_LIMIT = 70 // Device rendering gain, not a physical-force percentage. User strength is applied // upstream; use the tested HE range without changing the host's energy evidence. - private fun level(gain: Float) = - (ControllerRumbleState.normalizeAmplitude(gain) / .3f * 100).roundToInt().coerceIn(0, 100) + private fun level(gain: Float, fullScale: Float = .3f) = + (ControllerRumbleState.normalizeAmplitude(gain) / fullScale * 100).roundToInt().coerceIn(0, 100) + private fun amplitude(level: Int): Int { + // HE curve intensity is nonlinear; the external amplitude is linear. + // Match the same body level when switching between the two controls. + val intensity = level / 100.0 + return (intensity * 1.14.pow(10 * (intensity - 1)) * 255).roundToInt() + } + private fun mixedPeak(body: Int, point: Int) = body + ((100 - body) * point + 50) / 100 private fun api(): Pair? { if (Build.VERSION.SDK_INT < 26) return null @@ -128,24 +178,29 @@ internal class RichTapAuthoredHaptics( fun supported(): Boolean = api() != null + @Suppress("DEPRECATION") fun create(context: Context): NativeAuthoredHaptics? { if (Build.VERSION.SDK_INT < 26) return null val (factory, parameter) = api() ?: return null return try { - @Suppress("DEPRECATION") val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator if (!vibrator.hasVibrator()) return null val attributes = AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_GAME) .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION).build() RichTapAuthoredHaptics( - play = { raw -> vibrator.vibrate(factory.invoke(null, raw, 1, 0, 255, 0) as VibrationEffect, attributes) }, + play = { raw, gain -> + vibrator.vibrate(factory.invoke(null, raw, 1, 0, gain, 0) as VibrationEffect, attributes) + }, cancel = { // Parameter zero stops the native voice without the blocking cancel/restart // sequence seen on vendor services. Use framework cancellation only // if that native stop is rejected. try { vibrator.vibrate(parameter.invoke(null, 0, 0, 0) as VibrationEffect, attributes) } catch (_: Exception) { vibrator.cancel() } - }, clockMs = SystemClock::elapsedRealtime, processId = Process.myPid()) + }, clockMs = SystemClock::elapsedRealtime, processId = Process.myPid(), + updateAmplitude = { gain -> + vibrator.vibrate(parameter.invoke(null, -1, gain, -1) as VibrationEffect, attributes) + }) } catch (error: Exception) { LimeLog.warning("RichTap authored haptics unavailable: ${error.message}") null diff --git a/app/src/test/java/com/limelight/binding/input/haptics/RichTapAuthoredHapticsTest.kt b/app/src/test/java/com/limelight/binding/input/haptics/RichTapAuthoredHapticsTest.kt index 78f868fbe1..dae0da9a96 100644 --- a/app/src/test/java/com/limelight/binding/input/haptics/RichTapAuthoredHapticsTest.kt +++ b/app/src/test/java/com/limelight/binding/input/haptics/RichTapAuthoredHapticsTest.kt @@ -7,12 +7,12 @@ class RichTapAuthoredHapticsTest { @Test fun isolatedPointsRetainGradedNativeTransients() { val packets = mutableListOf() var now = 0L - val sink = RichTapAuthoredHaptics({ packets += it }, {}, { now }, 42) + val sink = RichTapAuthoredHaptics({ raw, _ -> packets += raw }, {}, { now }, 42, {}) for (gain in listOf(.06f, .15f, 1f)) { sink.render(0f, gain, false) now += 100 } - assertEquals(listOf(20, 50, 100), packets.map { it[12] }) + assertEquals(listOf(10, 25, 100), packets.map { it[12] }) assertTrue(packets.all { it.size == 15 && it[8] == 4097 && it[14] == 0 }) assertTrue(packets.map { it[3] }.distinct().size == 3) assertEquals(42, packets.first()[2]) @@ -22,7 +22,7 @@ class RichTapAuthoredHapticsTest { var now = 0L var writes = 0 var stops = 0 - val sink = RichTapAuthoredHaptics({ writes++ }, { stops++ }, { now }, 1) + val sink = RichTapAuthoredHaptics({ _, _ -> writes++ }, { stops++ }, { now }, 1, {}) assertEquals(40L, sink.render(0f, .15f, false)) now = 20 assertEquals(60L, sink.render(0f, .2f, false)) @@ -41,7 +41,7 @@ class RichTapAuthoredHapticsTest { @Test fun rejectedVendorWriteDisablesNativeRoutingAndCancelsPartialPlayback() { var stops = 0 - val sink = RichTapAuthoredHaptics({ throw IllegalStateException("vendor rejected") }, { stops++ }, { 0L }, 1) + val sink = RichTapAuthoredHaptics({ _, _ -> throw IllegalStateException("vendor rejected") }, { stops++ }, { 0L }, 1, {}) assertNull(sink.render(0f, .1f, false)) assertFalse(sink.ready) assertEquals(1, stops) @@ -49,9 +49,10 @@ class RichTapAuthoredHapticsTest { } @Test fun steadyBodyIsRenewedWithoutQueuingOrCancellingEachInputUpdate() { val packets = mutableListOf() + val gains = mutableListOf() var now = 0L var stops = 0 - val sink = RichTapAuthoredHaptics({ packets += it }, { stops++ }, { now }, 7) + val sink = RichTapAuthoredHaptics({ raw, gain -> packets += raw; gains += gain }, { stops++ }, { now }, 7, {}) assertEquals(4000L, sink.render(.15f, 0f, false)) for (time in 20L..3980L step 20L) { now = time @@ -63,7 +64,7 @@ class RichTapAuthoredHapticsTest { now = 4000 sink.render(.15f, 0f, false) assertEquals(2, packets.size) - assertEquals(50, packets.last()[17]) + assertEquals(gains.first(), gains.last()) assertEquals(0, stops) now = 4010 sink.render(0f, 0f, false) @@ -76,8 +77,10 @@ class RichTapAuthoredHapticsTest { @Test fun mixedEnvelopePreservesTheBodyAndProtectsFreshPointsFromBodyRefresh() { val packets = mutableListOf() + val gains = mutableListOf() + val updates = mutableListOf() var now = 0L - val sink = RichTapAuthoredHaptics({ packets += it }, {}, { now }, 7) + val sink = RichTapAuthoredHaptics({ raw, gain -> packets += raw; gains += gain }, {}, { now }, 7, { updates += it }) sink.render(.15f, 0f, false) now = 20 assertEquals(120L, sink.render(.15f, .12f, false)) @@ -85,24 +88,29 @@ class RichTapAuthoredHapticsTest { assertEquals(4096, mixed[8]) assertEquals(5, mixed[15]) val levels = mixed.drop(16).chunked(3).map { it[1] } - assertEquals(listOf(50, 90, 50, 50, 50), levels) + assertEquals(listOf(50, 60, 50, 50, 50), levels) now = 40 sink.render(.21f, 0f, false) assertEquals(2, packets.size) + assertTrue(updates.isEmpty()) now = 60 sink.render(.21f, .09f, false) assertEquals(3, packets.size) - assertEquals(100, packets.last()[20]) + assertEquals(75, packets.last()[20]) now = 160 sink.render(.06f, 0f, false) assertEquals(4, packets.size) - assertEquals(20, packets.last()[20]) + assertTrue(gains.last() < gains.first()) + now = 180 + sink.render(.15f, 0f, false) + assertEquals(4, packets.size) + assertEquals(gains.first(), updates.single()) } @Test fun expiredCarrierStartsFreshAndSilenceResetsItsState() { val packets = mutableListOf() var now = 0L - val sink = RichTapAuthoredHaptics({ packets += it }, {}, { now }, 7) + val sink = RichTapAuthoredHaptics({ raw, _ -> packets += raw }, {}, { now }, 7, {}) sink.render(.15f, 0f, false) now = 6000 sink.render(.21f, 0f, false) @@ -114,4 +122,64 @@ class RichTapAuthoredHapticsTest { assertEquals(0, packets.last()[17]) } + @Test fun fullBackgroundReservesGradedPointHeadroomAndThenRecovers() { + var now = 0L + val packets = mutableListOf() + val gains = mutableListOf() + val sink = RichTapAuthoredHaptics({ raw, gain -> packets += raw; gains += gain }, {}, { now }, 1, {}) + sink.render(.3f, 0f, false) + val peaks = mutableListOf() + for (point in listOf(.12f, .3f, .6f)) { + now += 300 + sink.render(.3f, point, false) + val levels = packets.last().drop(16).chunked(3).map { it[1] } + assertEquals(70, levels[0]) + assertTrue(levels[1] > levels[0]) + assertEquals(70, levels[2]) + assertEquals(70, levels[3]) + peaks += levels[1] + } + assertEquals(listOf(76, 85, 100), peaks) + now += 1500 + sink.render(.3f, 0f, false) + assertEquals(4, packets.last()[15]) + assertEquals(100, packets.last()[20]) + assertEquals(255, gains.last().toInt()) + } + + @Test fun parameterCarrierTracksAShortBodyPulseWithoutReplacingItsEnvelope() { + var now = 0L + val starts = mutableListOf() + val updates = mutableListOf() + val sink = RichTapAuthoredHaptics( + { raw, gain -> + assertEquals(4, raw[15]) + assertEquals(0, raw[17]) + starts += gain + }, {}, { now }, 1, { updates += it }) + sink.render(.03f, 0f, false) + for ((time, body) in listOf(20L to .10f, 40L to .20f, 60L to .10f, 80L to .03f)) { + now = time + sink.render(body, 0f, false) + } + assertEquals(1, starts.size) + assertEquals(4, updates.size) + assertTrue(updates[0] > starts.single()) + assertTrue(updates[1] > updates[0]) + assertEquals(updates[0], updates[2]) + assertEquals(starts.single(), updates[3]) + } + + @Test fun aQuietImpactTailDoesNotStartACarrierOrChangeTheNextPointIntoAContinuousEffect() { + var now = 0L + val packets = mutableListOf() + val sink = RichTapAuthoredHaptics({ raw, _ -> packets += raw }, {}, { now }, 1, + { fail("Unexpected tail update") }) + sink.render(.009f, 0f, false) + sink.render(.009f, .12f, false) + now = 100 + sink.render(.009f, .12f, false) + assertEquals(listOf(4097, 4097), packets.map { it[8] }) + } + }