Skip to content

Commit 3e29f0b

Browse files
eriedclaude
andcommitted
Motor sound: multi-section composition for all 14 sampled engines
New CompositionEnginePlayer plays per-section clips inside one or more raw resources (idle_loop + rev_loop crossfaded by RPM, one-shot startup / decel / shutdown / pops). Each engine in EngineProfile gains a sampleSections map and optional popSections list — populated from the regions the user picked in deletelater.html. Fall-through stays: composition → SampledEnginePlayer single-asset → procedural EngineSynth. Volume smoothing in update() gives engine-start fade-in (5-8 telemetry ticks ≈ ~300 ms) and natural idle ↔ rev crossfade. Loop seams stay hard cuts — ExoPlayer ClippingMediaSource would be required for seamless loops. Sources include 16 BigSoundBank CC0 clips, 16 Internet Archive CC0 clips (car-engines collection), and 32 Freesound CC0 clips grabbed via authed playwright session. Per-engine bests via the user's iterative composition pass; "latest wins" on duplicate section types. Also bumps DB schema to v35 to absorb the AppSettings fields staged for the upcoming speed-based auto-volume feature. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c051910 commit 3e29f0b

86 files changed

Lines changed: 476 additions & 37 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ release/
2626
*.btsnoop
2727
*.cfa
2828
*.log
29+
deletelater.html
30+
delete.html
2931

3032
# Stray build-output paths that have leaked into the repo before
3133
**/eucplanet_*.png
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
package com.eried.eucplanet.audio
2+
3+
import android.annotation.SuppressLint
4+
import android.content.Context
5+
import android.media.AudioAttributes
6+
import android.media.MediaPlayer
7+
import android.media.PlaybackParams
8+
import android.os.Handler
9+
import android.os.Looper
10+
import android.util.Log
11+
import kotlin.math.abs
12+
13+
/**
14+
* Multi-section playback for sampled engines. The procedural [SampledEnginePlayer]
15+
* plays a single OGG looped — this player composes the engine sound from named
16+
* sections inside one or more raw resources (the user picks them in deletelater.html).
17+
*
18+
* Sections used:
19+
* - "idle_loop" — sustained low-RPM loop (required)
20+
* - "rev_loop" — sustained high-RPM loop (optional — without it, idle pitch-shifts up)
21+
* - "startup" — one-shot transient played on engine start (optional)
22+
* - "decel" — one-shot transient played when throttle closes sharply (optional)
23+
* - "shutdown" — one-shot transient played on engine stop (optional)
24+
*
25+
* The two looping players (idle, rev) play simultaneously with crossfaded gains.
26+
* One-shot players are spawned on demand for startup/decel/shutdown.
27+
*
28+
* Each section is clipped from its host file via `seekTo(startMs)` + a 50 ms position
29+
* poll that seeks back to startMs when the player crosses endMs. There's a ~50 ms
30+
* audible seam at the loop point — acceptable for v1; ExoPlayer ClippingMediaSource
31+
* would give us seamless looping when we upgrade.
32+
*/
33+
class CompositionEnginePlayer(private val context: Context) {
34+
35+
private val mainHandler = Handler(Looper.getMainLooper())
36+
37+
@Volatile private var profile: EngineProfile? = null
38+
@Volatile private var idle: SectionPlayer? = null
39+
@Volatile private var rev: SectionPlayer? = null
40+
private val oneShots = mutableListOf<SectionPlayer>()
41+
42+
@Volatile private var lastVolume: Float = 0f
43+
@Volatile private var lastRpmNorm: Float = 0f
44+
@Volatile private var lastIdleVol: Float = -1f
45+
@Volatile private var lastRevVol: Float = -1f
46+
@Volatile private var lastSpeed: Float = -1f
47+
// Smoothed actual volumes — drives both the engine-start fade-in (each starts
48+
// at 0 and ramps toward target) and the idle↔rev crossfade. Alpha 0.15 gives
49+
// a ~85 ms time constant at ~100 Hz telemetry, comfortable but not sluggish.
50+
@Volatile private var smoothedIdleVol: Float = 0f
51+
@Volatile private var smoothedRevVol: Float = 0f
52+
private val volSmoothingAlpha = 0.15f
53+
54+
fun isPlaying(): Boolean = idle != null
55+
56+
/** Starts both loops at zero volume. The next [update] call sets real gains by RPM. */
57+
fun start(profile: EngineProfile) {
58+
if (this.profile?.key == profile.key && idle != null) return
59+
stop()
60+
val sections = profile.sampleSections ?: return
61+
this.profile = profile
62+
63+
sections["idle_loop"]?.let { sec ->
64+
idle = SectionPlayer(context, sec, looping = true).also {
65+
if (it.prepare()) it.play() else { Log.w(TAG, "idle_loop failed to prepare"); idle = null }
66+
}
67+
}
68+
sections["rev_loop"]?.let { sec ->
69+
rev = SectionPlayer(context, sec, looping = true).also {
70+
if (it.prepare()) it.play() else { Log.w(TAG, "rev_loop failed to prepare"); rev = null }
71+
}
72+
}
73+
// Engine start transient (fire and forget).
74+
sections["startup"]?.let { fireOneShot(it, gain = 1f) }
75+
}
76+
77+
fun stop() {
78+
// Engine-off transient before we tear down.
79+
profile?.sampleSections?.get("shutdown")?.let { fireOneShot(it, gain = lastVolume) }
80+
81+
idle?.release()
82+
rev?.release()
83+
idle = null
84+
rev = null
85+
synchronized(oneShots) {
86+
oneShots.forEach { it.release() }
87+
oneShots.clear()
88+
}
89+
profile = null
90+
lastIdleVol = -1f
91+
lastRevVol = -1f
92+
lastSpeed = -1f
93+
smoothedIdleVol = 0f
94+
smoothedRevVol = 0f
95+
}
96+
97+
/**
98+
* Called from [EngineSoundEngine.emit] every telemetry tick.
99+
* Cross-fades idle ↔ rev by [rpmNorm] (0..1) and scales overall gain by [volume].
100+
*/
101+
@SuppressLint("NewApi")
102+
fun update(rpmNorm: Float, volume: Float) {
103+
lastRpmNorm = rpmNorm.coerceIn(0f, 1f)
104+
lastVolume = volume.coerceIn(0f, 1f)
105+
106+
// Equal-power crossfade: idle dominates at low RPM, rev at high RPM.
107+
// At rpm=0.5 both contribute ~0.71×, summing close to 1 in perceived loudness.
108+
val curve = lastRpmNorm
109+
val idleGain = (1f - curve).let { kotlin.math.sqrt(it) } * lastVolume
110+
val revGain = curve.let { kotlin.math.sqrt(it) } * lastVolume
111+
// If we don't have a rev loop, idle alone handles everything.
112+
val targetIdle = if (rev == null) lastVolume else idleGain
113+
val targetRev = if (rev == null) 0f else revGain
114+
115+
// Move smoothed values toward target. This gives engine-start fade-in
116+
// (starts at 0, climbs over ~5-8 ticks ≈ ~300 ms) and naturally smooths
117+
// every rapid rpm-driven crossfade so we never write a clicky jump to
118+
// MediaPlayer.setVolume.
119+
smoothedIdleVol += (targetIdle - smoothedIdleVol) * volSmoothingAlpha
120+
smoothedRevVol += (targetRev - smoothedRevVol) * volSmoothingAlpha
121+
122+
if (abs(smoothedIdleVol - lastIdleVol) > 0.005f) {
123+
idle?.setVolume(smoothedIdleVol); lastIdleVol = smoothedIdleVol
124+
}
125+
if (abs(smoothedRevVol - lastRevVol) > 0.005f) {
126+
rev?.setVolume(smoothedRevVol); lastRevVol = smoothedRevVol
127+
}
128+
129+
// Mild speed modulation within each loop so the same 1.5 s clip doesn't sound
130+
// perfectly static across a 0-50 km/h sweep. Range chosen narrow so we don't
131+
// ruin the timbre of the underlying recording.
132+
val speed = 0.9f + 0.20f * lastRpmNorm // 0.90 .. 1.10
133+
if (abs(speed - lastSpeed) > 0.02f) {
134+
try {
135+
idle?.setSpeed(speed)
136+
rev?.setSpeed(speed)
137+
lastSpeed = speed
138+
} catch (e: Throwable) {
139+
Log.w(TAG, "setSpeed failed", e)
140+
}
141+
}
142+
}
143+
144+
/** Called by [EngineSoundEngine] when it sees a sharp throttle drop. */
145+
fun fireDecel() {
146+
profile?.sampleSections?.get("decel")?.let { fireOneShot(it, gain = lastVolume) }
147+
}
148+
149+
private fun fireOneShot(section: SampleSection, gain: Float) {
150+
val sp = SectionPlayer(context, section, looping = false)
151+
if (!sp.prepare()) { sp.release(); return }
152+
sp.setVolume(gain.coerceIn(0f, 1f))
153+
sp.play()
154+
synchronized(oneShots) { oneShots.add(sp) }
155+
// Auto-cleanup after the section's natural duration plus 50 ms grace.
156+
mainHandler.postDelayed({
157+
synchronized(oneShots) { oneShots.remove(sp) }
158+
sp.release()
159+
}, section.durationMs + 50L)
160+
}
161+
162+
companion object {
163+
private const val TAG = "CompositionEnginePlayer"
164+
}
165+
}
166+
167+
/**
168+
* Plays a [SampleSection] from a res/raw resource. Loops the window between
169+
* [SampleSection.startMs] and [SampleSection.endMs] when [looping] is true, or
170+
* plays once and stops at endMs otherwise.
171+
*
172+
* Section looping is approximate: a 50 ms position poll seeks back to startMs
173+
* when the player crosses endMs, leaving a small audible seam. Acceptable for v1.
174+
*/
175+
private class SectionPlayer(
176+
private val context: Context,
177+
private val section: SampleSection,
178+
private val looping: Boolean,
179+
) {
180+
private val mp = MediaPlayer()
181+
private val handler = Handler(Looper.getMainLooper())
182+
private var pollRunnable: Runnable? = null
183+
@Volatile private var released = false
184+
185+
fun prepare(): Boolean {
186+
val resId = context.resources.getIdentifier(section.rawAsset, "raw", context.packageName)
187+
if (resId == 0) {
188+
Log.w("SectionPlayer", "raw/${section.rawAsset} not found")
189+
return false
190+
}
191+
return try {
192+
mp.setAudioAttributes(
193+
AudioAttributes.Builder()
194+
.setUsage(AudioAttributes.USAGE_MEDIA)
195+
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
196+
.build()
197+
)
198+
val afd = context.resources.openRawResourceFd(resId) ?: return false
199+
afd.use { mp.setDataSource(it.fileDescriptor, it.startOffset, it.length) }
200+
mp.setVolume(0f, 0f)
201+
mp.prepare()
202+
mp.seekTo(section.startMs)
203+
true
204+
} catch (e: Throwable) {
205+
Log.e("SectionPlayer", "prepare failed for ${section.rawAsset}", e)
206+
false
207+
}
208+
}
209+
210+
fun play() {
211+
if (released) return
212+
try { mp.start() } catch (e: Throwable) { Log.w("SectionPlayer", "start failed", e); return }
213+
// Poll position every 50 ms to enforce the section endMs boundary.
214+
val poll = object : Runnable {
215+
override fun run() {
216+
if (released) return
217+
try {
218+
if (mp.isPlaying && mp.currentPosition >= section.endMs) {
219+
if (looping) {
220+
mp.seekTo(section.startMs)
221+
} else {
222+
mp.pause()
223+
return
224+
}
225+
}
226+
} catch (_: Throwable) { /* released between checks */ }
227+
handler.postDelayed(this, 50L)
228+
}
229+
}
230+
pollRunnable = poll
231+
handler.postDelayed(poll, 50L)
232+
}
233+
234+
fun setVolume(v: Float) {
235+
if (released) return
236+
try { mp.setVolume(v, v) } catch (_: Throwable) {}
237+
}
238+
239+
@SuppressLint("NewApi")
240+
fun setSpeed(s: Float) {
241+
if (released) return
242+
mp.playbackParams = (mp.playbackParams ?: PlaybackParams()).setSpeed(s)
243+
}
244+
245+
fun release() {
246+
released = true
247+
pollRunnable?.let { handler.removeCallbacks(it) }
248+
pollRunnable = null
249+
try { mp.stop() } catch (_: Throwable) {}
250+
try { mp.release() } catch (_: Throwable) {}
251+
}
252+
}

0 commit comments

Comments
 (0)