Skip to content

Commit d20e119

Browse files
eriedclaude
andcommitted
Motor sound: sampled-path fixes + per-engine feature flags
- Sampled path now picks up revDetector's revBump in rpmNorm so a throttle blip pitches the sample up briefly, matching the synth path. - SampledEnginePlayer pauses MediaPlayer when the idle envelope fully fades out (volume < 0.01) and resumes on the next non-silent tick. Save the decode loop on long parked stretches. - Reset pwmEverNonZero / smoothed state on disconnect so a fresh BLE session re-discovers PWM capability instead of inheriting the last wheel's stale "trust PWM" flag. - Beef out EngineProfile with supportsMuffler / supportsPops / supportsBrakeWhine plus optional popSampleAsset / brakeWhineSampleAsset fields. ICE/synth default true (in-house DSP). All 14 sampled profiles flip to false for now — groundwork for SoundPool sidecar + per-engine UI disables once we ship the SFX assets. - DiagnosticsLogger.info() milestone logging when engine type changes or sound is toggled. Off when service mode is off; cheap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 930bb29 commit d20e119

3 files changed

Lines changed: 97 additions & 18 deletions

File tree

app/src/main/java/com/eried/eucplanet/audio/EngineProfile.kt

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,24 @@ data class EngineProfile(
6262
* When set, the engine is rendered by playing back and pitch-shifting these
6363
* samples instead of the procedural [EngineSynth]. Null = procedural.
6464
*/
65-
val sampleAssetBase: String? = null
65+
val sampleAssetBase: String? = null,
66+
// --- Per-engine feature support ---
67+
// ICE / SYNTH profiles use the in-house DSP so every effect is "free" and these default to true.
68+
// Sampled profiles can only do what MediaPlayer + SoundPool sidecars give us:
69+
// - muffler LPF: only the DSP path has a filter — set false on sampled profiles
70+
// - pops / brake whine: only available if a SoundPool side-channel asset is wired
71+
// (popSampleAsset / brakeWhineSampleAsset). Until the sidecars ship, sampled
72+
// profiles set these false so the UI can grey-out the controls per engine.
73+
/** Whether the muffler/exhaust LPF row applies to this engine. */
74+
val supportsMuffler: Boolean = true,
75+
/** Whether the decel-pops/backfire row applies to this engine. */
76+
val supportsPops: Boolean = true,
77+
/** Whether the engine-brake whine row applies to this engine. */
78+
val supportsBrakeWhine: Boolean = true,
79+
/** Optional res/raw name (without extension) for a sampled pop SFX, played via SoundPool. */
80+
val popSampleAsset: String? = null,
81+
/** Optional res/raw name (without extension) for a sampled brake-whine loop. */
82+
val brakeWhineSampleAsset: String? = null
6683
) {
6784
enum class Kind { ICE, SYNTH }
6885

@@ -314,99 +331,113 @@ data class EngineProfile(
314331
displayName = "V8 (Cobra, sampled)",
315332
kind = Kind.ICE, gearless = true,
316333
idleRpm = 700, maxRpm = 6500,
317-
sampleAssetBase = "engine_v8_cobra"
334+
sampleAssetBase = "engine_v8_cobra",
335+
supportsMuffler = false, supportsPops = false, supportsBrakeWhine = false
318336
),
319337
EngineProfile(
320338
key = "SAMPLED_VTWIN_DUCATI",
321339
displayName = "V-twin (Ducati, sampled)",
322340
kind = Kind.ICE, gearless = true,
323341
idleRpm = 950, maxRpm = 9000,
324-
sampleAssetBase = "engine_vtwin_ducati"
342+
sampleAssetBase = "engine_vtwin_ducati",
343+
supportsMuffler = false, supportsPops = false, supportsBrakeWhine = false
325344
),
326345
EngineProfile(
327346
key = "SAMPLED_DIESEL_IVECO",
328347
displayName = "Diesel truck (sampled)",
329348
kind = Kind.ICE, gearless = true,
330349
idleRpm = 800, maxRpm = 3200,
331-
sampleAssetBase = "engine_diesel_iveco"
350+
sampleAssetBase = "engine_diesel_iveco",
351+
supportsMuffler = false, supportsPops = false, supportsBrakeWhine = false
332352
),
333353
EngineProfile(
334354
key = "SAMPLED_MOTORCYCLE",
335355
displayName = "Motorcycle (sampled)",
336356
kind = Kind.ICE, gearless = true,
337357
idleRpm = 1100, maxRpm = 10500,
338-
sampleAssetBase = "engine_motorcycle"
358+
sampleAssetBase = "engine_motorcycle",
359+
supportsMuffler = false, supportsPops = false, supportsBrakeWhine = false
339360
),
340361
EngineProfile(
341362
key = "SAMPLED_CITY_CAR",
342363
displayName = "City car (sampled)",
343364
kind = Kind.ICE, gearless = true,
344365
idleRpm = 850, maxRpm = 6000,
345-
sampleAssetBase = "engine_citycar_saxo"
366+
sampleAssetBase = "engine_citycar_saxo",
367+
supportsMuffler = false, supportsPops = false, supportsBrakeWhine = false
346368
),
347369
EngineProfile(
348370
key = "SAMPLED_HELICOPTER",
349371
displayName = "Helicopter (sampled)",
350372
kind = Kind.ICE, gearless = true,
351373
// Helicopter rotor "RPM" is metaphorical here — drives playback speed mapping
352374
idleRpm = 250, maxRpm = 1100,
353-
sampleAssetBase = "engine_helicopter"
375+
sampleAssetBase = "engine_helicopter",
376+
supportsMuffler = false, supportsPops = false, supportsBrakeWhine = false
354377
),
355378
EngineProfile(
356379
key = "SAMPLED_TRACTOR",
357380
displayName = "Tractor (sampled)",
358381
kind = Kind.ICE, gearless = true,
359382
idleRpm = 600, maxRpm = 2600,
360-
sampleAssetBase = "engine_tractor"
383+
sampleAssetBase = "engine_tractor",
384+
supportsMuffler = false, supportsPops = false, supportsBrakeWhine = false
361385
),
362386
EngineProfile(
363387
key = "SAMPLED_LAWNMOWER",
364388
displayName = "Lawn mower (sampled)",
365389
kind = Kind.ICE, gearless = true,
366390
idleRpm = 1800, maxRpm = 3600,
367-
sampleAssetBase = "engine_lawnmower"
391+
sampleAssetBase = "engine_lawnmower",
392+
supportsMuffler = false, supportsPops = false, supportsBrakeWhine = false
368393
),
369394
EngineProfile(
370395
key = "SAMPLED_STEAM_LOCO",
371396
displayName = "Steam locomotive (sampled)",
372397
kind = Kind.ICE, gearless = true,
373398
idleRpm = 200, maxRpm = 900,
374-
sampleAssetBase = "engine_steam_loco"
399+
sampleAssetBase = "engine_steam_loco",
400+
supportsMuffler = false, supportsPops = false, supportsBrakeWhine = false
375401
),
376402
EngineProfile(
377403
key = "SAMPLED_CAR_CRUISE",
378404
displayName = "Car cruise (sampled)",
379405
kind = Kind.ICE, gearless = true,
380406
idleRpm = 1000, maxRpm = 5500,
381-
sampleAssetBase = "engine_car_cruise"
407+
sampleAssetBase = "engine_car_cruise",
408+
supportsMuffler = false, supportsPops = false, supportsBrakeWhine = false
382409
),
383410
EngineProfile(
384411
key = "SAMPLED_ASTON_MARTIN",
385412
displayName = "Aston Martin (sampled)",
386413
kind = Kind.ICE, gearless = true,
387414
idleRpm = 900, maxRpm = 7500,
388-
sampleAssetBase = "engine_aston_martin"
415+
sampleAssetBase = "engine_aston_martin",
416+
supportsMuffler = false, supportsPops = false, supportsBrakeWhine = false
389417
),
390418
EngineProfile(
391419
key = "SAMPLED_BIG_DIESEL",
392420
displayName = "Big diesel (sampled)",
393421
kind = Kind.ICE, gearless = true,
394422
idleRpm = 600, maxRpm = 2800,
395-
sampleAssetBase = "engine_big_diesel"
423+
sampleAssetBase = "engine_big_diesel",
424+
supportsMuffler = false, supportsPops = false, supportsBrakeWhine = false
396425
),
397426
EngineProfile(
398427
key = "SAMPLED_BROKEN_EXHAUST",
399428
displayName = "Broken exhaust (sampled)",
400429
kind = Kind.ICE, gearless = true,
401430
idleRpm = 800, maxRpm = 5000,
402-
sampleAssetBase = "engine_damaged_muffler"
431+
sampleAssetBase = "engine_damaged_muffler",
432+
supportsMuffler = false, supportsPops = false, supportsBrakeWhine = false
403433
),
404434
EngineProfile(
405435
key = "SAMPLED_QUAD_ATV",
406436
displayName = "Quad ATV (sampled)",
407437
kind = Kind.ICE, gearless = true,
408438
idleRpm = 900, maxRpm = 6500,
409-
sampleAssetBase = "engine_quad_atv"
439+
sampleAssetBase = "engine_quad_atv",
440+
supportsMuffler = false, supportsPops = false, supportsBrakeWhine = false
410441
)
411442
)
412443

app/src/main/java/com/eried/eucplanet/audio/EngineSoundEngine.kt

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import android.media.AudioTrack
99
import android.os.Build
1010
import android.util.Log
1111
import com.eried.eucplanet.data.model.AppSettings
12+
import com.eried.eucplanet.diagnostics.DiagnosticsLogger
1213
import dagger.hilt.android.qualifiers.ApplicationContext
1314
import javax.inject.Inject
1415
import javax.inject.Singleton
@@ -81,6 +82,8 @@ class EngineSoundEngine @Inject constructor(
8182
private var pendingPops: Int = 0
8283
private var voiceActive: Boolean = false
8384
private var voiceDuckGain: Float = 1f
85+
/** Tracks the previous applySettings()'s enabled flag so we only log toggle edges. */
86+
private var lastAppliedEnabled: Boolean = false
8487

8588
private val revDetector = RevDetector()
8689
private var connected: Boolean = false
@@ -91,6 +94,7 @@ class EngineSoundEngine @Inject constructor(
9194
/** Apply settings — called whenever AppSettings changes. */
9295
fun applySettings(s: AppSettings) {
9396
val previousProfileKey = profile.key
97+
val previousEnabled = lastAppliedEnabled
9498
profile = EngineProfile.byKey(s.engineType)
9599
masterVolume = s.engineVolume.coerceIn(0f, 1f)
96100
mufflerKey = s.engineMuffler
@@ -100,6 +104,21 @@ class EngineSoundEngine @Inject constructor(
100104
engineBrakeMode = s.engineBrake
101105
duckMode = s.engineDuckOnVoice
102106
headphonesOnly = s.engineHeadphonesOnly
107+
lastAppliedEnabled = s.engineSoundEnabled
108+
109+
// Service-Mode log: only milestone events (engine selected, sound toggled),
110+
// never per-tick telemetry. DiagnosticsLogger.info() is a no-op when service
111+
// mode is off, so this costs nothing in normal operation.
112+
if (s.engineSoundEnabled && previousProfileKey != profile.key) {
113+
val pathKind = if (profile.sampleAssetBase != null) "sampled" else profile.kind.name.lowercase()
114+
DiagnosticsLogger.info("engine: ${profile.key} ($pathKind)")
115+
}
116+
if (previousEnabled != s.engineSoundEnabled) {
117+
DiagnosticsLogger.info(
118+
if (s.engineSoundEnabled) "engine: sound enabled (${profile.key})"
119+
else "engine: sound disabled"
120+
)
121+
}
103122

104123
if (!s.engineSoundEnabled) {
105124
stop()
@@ -120,6 +139,18 @@ class EngineSoundEngine @Inject constructor(
120139
start()
121140
} else if (!isConnected) {
122141
stop()
142+
// Reset telemetry-state so the next wheel session starts clean:
143+
// pwmEverNonZero is sticky to avoid flapping back to derivative-load
144+
// mid-ride on a momentary 0% PWM read, but across BLE sessions
145+
// (potentially a different wheel) we want to re-discover capability.
146+
pwmEverNonZero = false
147+
lastPwm = 0f
148+
lastSpeedKmh = 0f
149+
lastTelemetryAtMs = 0L
150+
smoothedRpm = 0f
151+
smoothedLoad = 0f
152+
brakeEnvelope = 0f
153+
decelEnvelope = 0f
123154
}
124155
}
125156

@@ -408,7 +439,10 @@ class EngineSoundEngine @Inject constructor(
408439
// Sample-based engine takes its parameters from the same snapshot.
409440
if (sampledPlayer.isPlaying()) {
410441
val rpmRange = (profile.maxRpm - profile.idleRpm).coerceAtLeast(1)
411-
val rpmNorm = ((smoothedRpm - profile.idleRpm) / rpmRange).coerceIn(0f, 1f)
442+
// Include revBump so a quick throttle blip pitches the sample up briefly,
443+
// matching what the synth path does for the same telemetry.
444+
val effRpm = smoothedRpm + revDetector.currentBump(now)
445+
val rpmNorm = ((effRpm - profile.idleRpm) / rpmRange).coerceIn(0f, 1f)
412446
sampledPlayer.update(rpmNorm, gain * idleEnvelope.coerceIn(0f, 1f))
413447
}
414448
}

app/src/main/java/com/eried/eucplanet/audio/SampledEnginePlayer.kt

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,22 @@ class SampledEnginePlayer(private val context: Context) {
8888
@SuppressLint("NewApi") // PlaybackParams is API 23+, our minSdk is at least 24
8989
fun update(rpmNorm: Float, volume: Float) {
9090
val mp = player ?: return
91+
val vol = volume.coerceIn(0f, 1f)
92+
// When the idle envelope fades us all the way out (parked + FADE elapsed) keep the
93+
// MediaPlayer paused instead of looping silently. Resume on the first non-silent tick.
94+
if (vol < MUTE_THRESHOLD) {
95+
if (mp.isPlaying) {
96+
try { mp.pause() } catch (_: Throwable) {}
97+
}
98+
lastVolume = 0f
99+
return
100+
}
101+
91102
// Map rpmNorm -> playback speed. At idle (rpmNorm=0) we want ~0.6x for a deeper rumble,
92103
// at full revs (rpmNorm=1) we want ~1.8x for the high-rev wail. A slight curve makes it feel less linear.
93104
val curved = rpmNorm.coerceIn(0f, 1f).let { it * (0.4f + 0.6f * it) }
94105
val targetSpeed = 0.6f + 1.2f * curved // 0.6..1.8
95-
if (abs(targetSpeed - lastSpeed) > 0.01f) {
106+
if (abs(targetSpeed - lastSpeed) > 0.01f || !mp.isPlaying) {
96107
try {
97108
if (!mp.isPlaying) mp.start()
98109
mp.playbackParams = PlaybackParams().setSpeed(targetSpeed)
@@ -101,7 +112,6 @@ class SampledEnginePlayer(private val context: Context) {
101112
Log.w(TAG, "setPlaybackParams failed", e)
102113
}
103114
}
104-
val vol = volume.coerceIn(0f, 1f)
105115
if (abs(vol - lastVolume) > 0.01f) {
106116
try {
107117
mp.setVolume(vol, vol)
@@ -112,5 +122,9 @@ class SampledEnginePlayer(private val context: Context) {
112122

113123
companion object {
114124
private const val TAG = "SampledEnginePlayer"
125+
// Below this gain the listener can't hear anything anyway — pause the player
126+
// to save the decode loop. Hysteresis-free: resume kicks in the moment the
127+
// engine asks for non-trivial volume again.
128+
private const val MUTE_THRESHOLD = 0.01f
115129
}
116130
}

0 commit comments

Comments
 (0)