Skip to content

Commit 95956c9

Browse files
eriedclaude
andcommitted
Motor sound: SoundPool pops sidecar + per-engine UI grey-out
Sampled engines route through MediaPlayer, which has no per-sample mix buffer to inject pops into. New SfxSidecar layers SoundPool one-shots on top of the engine loop at the system mixer level — pops now land on the 8 sampled engines that physically should pop (petrol ICE), with random pitch jitter so consecutive pops don't sound robotic. Two BigSoundBank CC-BY 3.0 clips bundled: - sfx_pop_crack.ogg — firecracker-with-wick (V12 / motorcycle / V8 / V-twin) - sfx_pop_snap.ogg — bang-snaps (city car / car cruise, lighter pops) Diesels, helicopter, steam loco and lawnmower keep supportsPops=false because they physically don't backfire. SettingsScreen reads the per-engine support flags and hides the muffler / decel character / engine-brake rows when the active profile doesn't support that effect. Switching engines re-evaluates the UI; no settings get lost since the stored AppSettings keys stay valid even when hidden. docs/SFX_SOURCES.md lists the remaining Freesound CC0 clips to grab manually (login-gated) for brake-whine loops on diesel / V12 / steam / helicopter and the per-engine truth table for the rollout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d20e119 commit 95956c9

7 files changed

Lines changed: 278 additions & 42 deletions

File tree

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

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -332,15 +332,17 @@ data class EngineProfile(
332332
kind = Kind.ICE, gearless = true,
333333
idleRpm = 700, maxRpm = 6500,
334334
sampleAssetBase = "engine_v8_cobra",
335-
supportsMuffler = false, supportsPops = false, supportsBrakeWhine = false
335+
supportsMuffler = false, supportsBrakeWhine = false,
336+
supportsPops = true, popSampleAsset = "sfx_pop_crack"
336337
),
337338
EngineProfile(
338339
key = "SAMPLED_VTWIN_DUCATI",
339340
displayName = "V-twin (Ducati, sampled)",
340341
kind = Kind.ICE, gearless = true,
341342
idleRpm = 950, maxRpm = 9000,
342343
sampleAssetBase = "engine_vtwin_ducati",
343-
supportsMuffler = false, supportsPops = false, supportsBrakeWhine = false
344+
supportsMuffler = false, supportsBrakeWhine = false,
345+
supportsPops = true, popSampleAsset = "sfx_pop_crack"
344346
),
345347
EngineProfile(
346348
key = "SAMPLED_DIESEL_IVECO",
@@ -356,15 +358,17 @@ data class EngineProfile(
356358
kind = Kind.ICE, gearless = true,
357359
idleRpm = 1100, maxRpm = 10500,
358360
sampleAssetBase = "engine_motorcycle",
359-
supportsMuffler = false, supportsPops = false, supportsBrakeWhine = false
361+
supportsMuffler = false, supportsBrakeWhine = false,
362+
supportsPops = true, popSampleAsset = "sfx_pop_crack"
360363
),
361364
EngineProfile(
362365
key = "SAMPLED_CITY_CAR",
363366
displayName = "City car (sampled)",
364367
kind = Kind.ICE, gearless = true,
365368
idleRpm = 850, maxRpm = 6000,
366369
sampleAssetBase = "engine_citycar_saxo",
367-
supportsMuffler = false, supportsPops = false, supportsBrakeWhine = false
370+
supportsMuffler = false, supportsBrakeWhine = false,
371+
supportsPops = true, popSampleAsset = "sfx_pop_snap"
368372
),
369373
EngineProfile(
370374
key = "SAMPLED_HELICOPTER",
@@ -405,15 +409,17 @@ data class EngineProfile(
405409
kind = Kind.ICE, gearless = true,
406410
idleRpm = 1000, maxRpm = 5500,
407411
sampleAssetBase = "engine_car_cruise",
408-
supportsMuffler = false, supportsPops = false, supportsBrakeWhine = false
412+
supportsMuffler = false, supportsBrakeWhine = false,
413+
supportsPops = true, popSampleAsset = "sfx_pop_snap"
409414
),
410415
EngineProfile(
411416
key = "SAMPLED_ASTON_MARTIN",
412417
displayName = "Aston Martin (sampled)",
413418
kind = Kind.ICE, gearless = true,
414419
idleRpm = 900, maxRpm = 7500,
415420
sampleAssetBase = "engine_aston_martin",
416-
supportsMuffler = false, supportsPops = false, supportsBrakeWhine = false
421+
supportsMuffler = false, supportsBrakeWhine = false,
422+
supportsPops = true, popSampleAsset = "sfx_pop_crack"
417423
),
418424
EngineProfile(
419425
key = "SAMPLED_BIG_DIESEL",
@@ -429,15 +435,17 @@ data class EngineProfile(
429435
kind = Kind.ICE, gearless = true,
430436
idleRpm = 800, maxRpm = 5000,
431437
sampleAssetBase = "engine_damaged_muffler",
432-
supportsMuffler = false, supportsPops = false, supportsBrakeWhine = false
438+
supportsMuffler = false, supportsBrakeWhine = false,
439+
supportsPops = true, popSampleAsset = "sfx_pop_crack"
433440
),
434441
EngineProfile(
435442
key = "SAMPLED_QUAD_ATV",
436443
displayName = "Quad ATV (sampled)",
437444
kind = Kind.ICE, gearless = true,
438445
idleRpm = 900, maxRpm = 6500,
439446
sampleAssetBase = "engine_quad_atv",
440-
supportsMuffler = false, supportsPops = false, supportsBrakeWhine = false
447+
supportsMuffler = false, supportsBrakeWhine = false,
448+
supportsPops = true, popSampleAsset = "sfx_pop_crack"
441449
)
442450
)
443451

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ import kotlin.math.sqrt
3333
*/
3434
@Singleton
3535
class EngineSoundEngine @Inject constructor(
36-
@ApplicationContext private val context: Context
36+
@ApplicationContext private val context: Context,
37+
private val sfxSidecar: SfxSidecar
3738
) {
3839

3940
private val sampleRate = 44100
@@ -444,6 +445,13 @@ class EngineSoundEngine @Inject constructor(
444445
val effRpm = smoothedRpm + revDetector.currentBump(now)
445446
val rpmNorm = ((effRpm - profile.idleRpm) / rpmRange).coerceIn(0f, 1f)
446447
sampledPlayer.update(rpmNorm, gain * idleEnvelope.coerceIn(0f, 1f))
448+
// The synth path consumes pendingPops by rendering them into its own buffer.
449+
// For the sampled path MediaPlayer has no mix point, so we layer pops as one-shot
450+
// SoundPool plays. Same gating: only when the profile actually supports pops.
451+
if (pops > 0 && profile.supportsPops && profile.popSampleAsset != null) {
452+
val popVolume = gain * idleEnvelope.coerceIn(0f, 1f)
453+
repeat(pops) { sfxSidecar.firePop(profile.popSampleAsset, popVolume) }
454+
}
447455
}
448456
}
449457

@@ -457,6 +465,10 @@ class EngineSoundEngine @Inject constructor(
457465
running = true
458466
requestAudioFocus()
459467
if (profile.sampleAssetBase != null) {
468+
// Preload pop/brake SFX so the first trigger doesn't get dropped while
469+
// SoundPool decodes. firePop() drops the call if the asset isn't ready yet,
470+
// but we want pops to land from the first decel event.
471+
sfxSidecar.load(profile)
460472
sampledPlayer.start(profile)
461473
return
462474
}
@@ -508,6 +520,7 @@ class EngineSoundEngine @Inject constructor(
508520
}
509521
audioTrack = null
510522
sampledPlayer.stop()
523+
sfxSidecar.release()
511524
abandonAudioFocus()
512525
synth.reset()
513526
revDetector.reset()
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package com.eried.eucplanet.audio
2+
3+
import android.content.Context
4+
import android.media.AudioAttributes
5+
import android.media.SoundPool
6+
import android.util.Log
7+
import dagger.hilt.android.qualifiers.ApplicationContext
8+
import javax.inject.Inject
9+
import javax.inject.Singleton
10+
11+
/**
12+
* One-shot SFX overlay for the sampled-engine path. The procedural [EngineSynth]
13+
* already renders pops, decel grit and engine-brake whine into its own PCM buffer,
14+
* but the sampled path goes through [SampledEnginePlayer]'s MediaPlayer where we
15+
* have no mix point. So we use Android's SoundPool to layer short SFX *on top of*
16+
* MediaPlayer's stream at the system level.
17+
*
18+
* Wired by [EngineSoundEngine]:
19+
* - [load] when a sampled profile becomes active — preloads its pop and brake
20+
* whine assets if the profile declares them.
21+
* - [firePop] when [EngineSoundEngine.emit] dequeues a pending pop.
22+
* - [unload] / [release] on profile switch / engine stop.
23+
*
24+
* Cheap: SoundPool keeps decoded PCM in memory, so triggering a pop is allocation-free
25+
* and runs on the audio thread Android picks. We cap at 4 simultaneous streams since
26+
* pops should overlap but not stack endlessly.
27+
*/
28+
@Singleton
29+
class SfxSidecar @Inject constructor(
30+
@ApplicationContext private val context: Context
31+
) {
32+
@Volatile private var pool: SoundPool? = null
33+
/** asset basename → SoundPool sound ID (after load completes). */
34+
private val loaded = HashMap<String, Int>()
35+
/** asset basename → true once the SoundPool callback fires (only then is play() valid). */
36+
private val ready = HashSet<String>()
37+
38+
private var rngState = 0x5A6B7C8D
39+
40+
private fun nextRand01(): Float {
41+
var x = rngState
42+
x = x xor (x shl 13); x = x xor (x ushr 17); x = x xor (x shl 5)
43+
rngState = x
44+
return (x and 0x7FFFFFFF) / Int.MAX_VALUE.toFloat()
45+
}
46+
47+
/** Lazily create the SoundPool. Safe to call repeatedly. */
48+
private fun ensurePool(): SoundPool {
49+
pool?.let { return it }
50+
val attrs = AudioAttributes.Builder()
51+
.setUsage(AudioAttributes.USAGE_MEDIA)
52+
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
53+
.build()
54+
val sp = SoundPool.Builder()
55+
.setMaxStreams(MAX_STREAMS)
56+
.setAudioAttributes(attrs)
57+
.build()
58+
sp.setOnLoadCompleteListener { _, soundId, status ->
59+
if (status == 0) {
60+
synchronized(this) {
61+
loaded.entries.firstOrNull { it.value == soundId }?.let { ready.add(it.key) }
62+
}
63+
} else {
64+
Log.w(TAG, "SoundPool load failed for soundId=$soundId, status=$status")
65+
}
66+
}
67+
pool = sp
68+
return sp
69+
}
70+
71+
/**
72+
* Preload the SFX assets declared by [profile] so they're ready when a pop fires.
73+
* No-op for assets already loaded; SoundPool dedupes by resource ID internally,
74+
* but we dedupe at our level to avoid duplicate load() calls during settings churn.
75+
*/
76+
fun load(profile: EngineProfile) {
77+
val sp = ensurePool()
78+
profile.popSampleAsset?.let { name ->
79+
if (loaded.containsKey(name)) return@let
80+
val resId = context.resources.getIdentifier(name, "raw", context.packageName)
81+
if (resId == 0) {
82+
Log.w(TAG, "SFX asset raw/$name not found")
83+
return@let
84+
}
85+
val id = sp.load(context, resId, 1)
86+
synchronized(this) { loaded[name] = id }
87+
}
88+
profile.brakeWhineSampleAsset?.let { name ->
89+
if (loaded.containsKey(name)) return@let
90+
val resId = context.resources.getIdentifier(name, "raw", context.packageName)
91+
if (resId == 0) {
92+
Log.w(TAG, "SFX asset raw/$name not found")
93+
return@let
94+
}
95+
val id = sp.load(context, resId, 1)
96+
synchronized(this) { loaded[name] = id }
97+
}
98+
}
99+
100+
/**
101+
* Trigger one playback of [assetName] with master gain [volume] (0..1).
102+
* Randomizes pitch slightly so consecutive pops don't sound identical.
103+
* No-op if the asset isn't loaded yet — pops are non-critical, dropping the
104+
* first one or two during cold-start is acceptable.
105+
*/
106+
fun firePop(assetName: String?, volume: Float) {
107+
if (assetName == null) return
108+
val sp = pool ?: return
109+
val soundId = synchronized(this) {
110+
if (assetName !in ready) return
111+
loaded[assetName] ?: return
112+
}
113+
val v = volume.coerceIn(0f, 1f)
114+
// Pitch jitter 0.92..1.08 so the same sample doesn't sound robotic when repeated.
115+
val rate = 0.92f + 0.16f * nextRand01()
116+
sp.play(soundId, v, v, /* priority */ 1, /* loop */ 0, rate)
117+
}
118+
119+
/** Unload all assets and free the SoundPool. Called on engine stop / profile switch. */
120+
fun release() {
121+
val sp = pool ?: return
122+
synchronized(this) {
123+
loaded.clear()
124+
ready.clear()
125+
}
126+
try { sp.release() } catch (_: Throwable) {}
127+
pool = null
128+
}
129+
130+
companion object {
131+
private const val TAG = "SfxSidecar"
132+
private const val MAX_STREAMS = 4
133+
}
134+
}

app/src/main/java/com/eried/eucplanet/ui/settings/SettingsScreen.kt

Lines changed: 46 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1939,6 +1939,13 @@ private fun EngineSoundSection(
19391939
onPreview = if (parked) { { viewModel.previewEngine(it) } } else null
19401940
)
19411941

1942+
// Resolve the active profile so unsupported rows (e.g. decel pops on a diesel
1943+
// or muffler LPF on any sampled engine, since MediaPlayer has no filter point)
1944+
// are hidden from the UI. EngineProfile carries the per-engine support booleans.
1945+
val currentProfile = remember(settings.engineType) {
1946+
com.eried.eucplanet.audio.EngineProfile.byKey(settings.engineType)
1947+
}
1948+
19421949
if (!parked) {
19431950
Text(
19441951
stringResource(R.string.engine_preview_parked_only),
@@ -1957,17 +1964,19 @@ private fun EngineSoundSection(
19571964
onValueChange = { viewModel.updateEngineVolume(it / 100f) }
19581965
)
19591966

1960-
SegmentedChoice(
1961-
label = stringResource(R.string.engine_muffler_label),
1962-
options = listOf(
1963-
"OPEN" to stringResource(R.string.engine_muffler_open),
1964-
"HALF" to stringResource(R.string.engine_muffler_half),
1965-
"MUFFLED" to stringResource(R.string.engine_muffler_muffled)
1966-
),
1967-
current = settings.engineMuffler,
1968-
onChange = { viewModel.updateEngineMuffler(it) },
1969-
onPreview = if (parked) { { viewModel.previewEngineSection("DEFAULT") } } else null
1970-
)
1967+
if (currentProfile.supportsMuffler) {
1968+
SegmentedChoice(
1969+
label = stringResource(R.string.engine_muffler_label),
1970+
options = listOf(
1971+
"OPEN" to stringResource(R.string.engine_muffler_open),
1972+
"HALF" to stringResource(R.string.engine_muffler_half),
1973+
"MUFFLED" to stringResource(R.string.engine_muffler_muffled)
1974+
),
1975+
current = settings.engineMuffler,
1976+
onChange = { viewModel.updateEngineMuffler(it) },
1977+
onPreview = if (parked) { { viewModel.previewEngineSection("DEFAULT") } } else null
1978+
)
1979+
}
19711980

19721981
SegmentedChoice(
19731982
label = stringResource(R.string.engine_gearbox_label),
@@ -1992,29 +2001,33 @@ private fun EngineSoundSection(
19922001
onChange = { viewModel.updateEngineIdleBehavior(it) }
19932002
)
19942003

1995-
SegmentedChoice(
1996-
label = stringResource(R.string.engine_decel_label),
1997-
options = listOf(
1998-
"SMOOTH" to stringResource(R.string.engine_decel_smooth),
1999-
"STANDARD" to stringResource(R.string.engine_decel_standard),
2000-
"BACKFIRE" to stringResource(R.string.engine_decel_backfire)
2001-
),
2002-
current = settings.engineDecelChar,
2003-
onChange = { viewModel.updateEngineDecelChar(it) },
2004-
onPreview = if (parked) { { viewModel.previewEngineSection("DECEL") } } else null
2005-
)
2004+
if (currentProfile.supportsPops) {
2005+
SegmentedChoice(
2006+
label = stringResource(R.string.engine_decel_label),
2007+
options = listOf(
2008+
"SMOOTH" to stringResource(R.string.engine_decel_smooth),
2009+
"STANDARD" to stringResource(R.string.engine_decel_standard),
2010+
"BACKFIRE" to stringResource(R.string.engine_decel_backfire)
2011+
),
2012+
current = settings.engineDecelChar,
2013+
onChange = { viewModel.updateEngineDecelChar(it) },
2014+
onPreview = if (parked) { { viewModel.previewEngineSection("DECEL") } } else null
2015+
)
2016+
}
20062017

2007-
SegmentedChoice(
2008-
label = stringResource(R.string.engine_brake_label),
2009-
options = listOf(
2010-
"OFF" to stringResource(R.string.engine_brake_off),
2011-
"LIGHT" to stringResource(R.string.engine_brake_light),
2012-
"STRONG" to stringResource(R.string.engine_brake_strong)
2013-
),
2014-
current = settings.engineBrake,
2015-
onChange = { viewModel.updateEngineBrake(it) },
2016-
onPreview = if (parked) { { viewModel.previewEngineSection("BRAKE") } } else null
2017-
)
2018+
if (currentProfile.supportsBrakeWhine) {
2019+
SegmentedChoice(
2020+
label = stringResource(R.string.engine_brake_label),
2021+
options = listOf(
2022+
"OFF" to stringResource(R.string.engine_brake_off),
2023+
"LIGHT" to stringResource(R.string.engine_brake_light),
2024+
"STRONG" to stringResource(R.string.engine_brake_strong)
2025+
),
2026+
current = settings.engineBrake,
2027+
onChange = { viewModel.updateEngineBrake(it) },
2028+
onPreview = if (parked) { { viewModel.previewEngineSection("BRAKE") } } else null
2029+
)
2030+
}
20182031

20192032
SegmentedChoice(
20202033
label = stringResource(R.string.engine_duck_label),
28 KB
Binary file not shown.
24.5 KB
Binary file not shown.

0 commit comments

Comments
 (0)