Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,47 +33,59 @@ 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
Comment thread
qiin2333 marked this conversation as resolved.
Resolved
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
bodyLevel = 0
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
}
Expand All @@ -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
Expand All @@ -94,6 +133,8 @@ internal class RichTapAuthoredHaptics(
bodyUntil = 0
renewAt = 0
bodyLevel = 0
parameterCarrier = false
mixedUntil = 0
if (active) { active = false; cancel() }
}

Expand All @@ -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<Method, Method>? {
if (Build.VERSION.SDK_INT < 26) return null
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ class RichTapAuthoredHapticsTest {
@Test fun isolatedPointsRetainGradedNativeTransients() {
val packets = mutableListOf<IntArray>()
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])
Expand All @@ -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))
Expand All @@ -41,17 +41,18 @@ 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)
assertNull(sink.render(0f, .1f, false))
}
@Test fun steadyBodyIsRenewedWithoutQueuingOrCancellingEachInputUpdate() {
val packets = mutableListOf<IntArray>()
val gains = mutableListOf<Int>()
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
Expand All @@ -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)
Expand All @@ -76,33 +77,40 @@ class RichTapAuthoredHapticsTest {

@Test fun mixedEnvelopePreservesTheBodyAndProtectsFreshPointsFromBodyRefresh() {
val packets = mutableListOf<IntArray>()
val gains = mutableListOf<Int>()
val updates = mutableListOf<Int>()
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))
val mixed = packets.last()
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<IntArray>()
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)
Expand All @@ -114,4 +122,64 @@ class RichTapAuthoredHapticsTest {
assertEquals(0, packets.last()[17])
}

@Test fun fullBackgroundReservesGradedPointHeadroomAndThenRecovers() {
var now = 0L
val packets = mutableListOf<IntArray>()
val gains = mutableListOf<Int>()
val sink = RichTapAuthoredHaptics({ raw, gain -> packets += raw; gains += gain }, {}, { now }, 1, {})
sink.render(.3f, 0f, false)
val peaks = mutableListOf<Int>()
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<Int>()
val updates = mutableListOf<Int>()
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<IntArray>()
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] })
}

}
Loading