11package com.eried.eucplanet.audio
22
3- import android.annotation.SuppressLint
43import android.content.Context
5- import android.media.AudioAttributes
6- import android.media.MediaPlayer
7- import android.media.PlaybackParams
4+ import android.net.Uri
85import android.os.Handler
96import android.os.Looper
107import android.util.Log
8+ import androidx.annotation.OptIn
9+ import androidx.media3.common.AudioAttributes
10+ import androidx.media3.common.C
11+ import androidx.media3.common.MediaItem
12+ import androidx.media3.common.Player
13+ import androidx.media3.common.util.UnstableApi
14+ import androidx.media3.datasource.DataSource
15+ import androidx.media3.datasource.RawResourceDataSource
16+ import androidx.media3.exoplayer.ExoPlayer
17+ import androidx.media3.exoplayer.source.ClippingMediaSource
18+ import androidx.media3.exoplayer.source.ProgressiveMediaSource
1119import kotlin.math.abs
1220
1321/* *
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).
22+ * Multi-section playback for sampled engines. Procedural [EngineSynth] plays a
23+ * single buffer it owns; this player composes the engine sound from named
24+ * sections inside one or more raw resources (picked in deletelater.html).
1725 *
18- * Sections used :
26+ * Section types :
1927 * - "idle_loop" — sustained low-RPM loop (required)
2028 * - "rev_loop" — sustained high-RPM loop (optional — without it, idle pitch-shifts up)
2129 * - "startup" — one-shot transient played on engine start (optional)
2230 * - "decel" — one-shot transient played when throttle closes sharply (optional)
2331 * - "shutdown" — one-shot transient played on engine stop (optional)
2432 *
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.
33+ * Looping sections use [ExoPlayer] with [ClippingMediaSource] + REPEAT_MODE_ONE so
34+ * the decoder seamlessly stitches the startMs..endMs window back to its start —
35+ * no audible click at the seam. The two loops (idle, rev) play simultaneously and
36+ * are crossfaded by RPM. One-shots are spawned on demand for startup/decel/shutdown.
3237 */
38+ @OptIn(UnstableApi ::class )
3339class CompositionEnginePlayer (private val context : Context ) {
3440
3541 private val mainHandler = Handler (Looper .getMainLooper())
@@ -44,16 +50,14 @@ class CompositionEnginePlayer(private val context: Context) {
4450 @Volatile private var lastIdleVol: Float = - 1f
4551 @Volatile private var lastRevVol: Float = - 1f
4652 @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.
53+ // Smoothed actual volumes — drives engine-start fade-in plus the idle ↔ rev
54+ // crossfade. Alpha 0.15 ≈ ~85 ms time constant at ~100 Hz telemetry.
5055 @Volatile private var smoothedIdleVol: Float = 0f
5156 @Volatile private var smoothedRevVol: Float = 0f
5257 private val volSmoothingAlpha = 0.15f
5358
5459 fun isPlaying (): Boolean = idle != null
5560
56- /* * Starts both loops at zero volume. The next [update] call sets real gains by RPM. */
5761 fun start (profile : EngineProfile ) {
5862 if (this .profile?.key == profile.key && idle != null ) return
5963 stop()
@@ -62,60 +66,65 @@ class CompositionEnginePlayer(private val context: Context) {
6266
6367 sections[" idle_loop" ]?.let { sec ->
6468 idle = SectionPlayer (context, sec, looping = true ).also {
65- if (it.prepare()) it.play() else { Log .w(TAG , " idle_loop failed to prepare" ); idle = null }
69+ if (! it.prepare()) { it.release(); idle = null ; Log .w(TAG , " idle_loop failed" ) }
70+ else { it.setVolume(0f ); it.play() }
6671 }
6772 }
6873 sections[" rev_loop" ]?.let { sec ->
6974 rev = SectionPlayer (context, sec, looping = true ).also {
70- if (it.prepare()) it.play() else { Log .w(TAG , " rev_loop failed to prepare" ); rev = null }
75+ if (! it.prepare()) { it.release(); rev = null ; Log .w(TAG , " rev_loop failed" ) }
76+ else { it.setVolume(0f ); it.play() }
7177 }
7278 }
73- // Engine start transient (fire and forget).
7479 sections[" startup" ]?.let { fireOneShot(it, gain = 1f ) }
7580 }
7681
7782 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+ val shutdown = profile?.sampleSections?.get(" shutdown" )
84+ val fadeDurMs = shutdown?.durationMs?.coerceIn(200 , 2500 ) ? : 300
85+ shutdown?.let { fireOneShot(it, gain = lastVolume) }
86+
87+ val idleSnap = idle
88+ val revSnap = rev
89+ val startIdle = smoothedIdleVol
90+ val startRev = smoothedRevVol
8391 idle = null
8492 rev = null
85- synchronized(oneShots) {
86- oneShots.forEach { it.release() }
87- oneShots.clear()
88- }
8993 profile = null
94+ val steps = 24
95+ val stepMs = (fadeDurMs / steps).coerceAtLeast(8 )
96+ for (i in 1 .. steps) {
97+ val gain = 1f - (i.toFloat() / steps)
98+ mainHandler.postDelayed({
99+ idleSnap?.setVolume(startIdle * gain)
100+ revSnap?.setVolume(startRev * gain)
101+ }, (i * stepMs).toLong())
102+ }
103+ mainHandler.postDelayed({
104+ idleSnap?.release()
105+ revSnap?.release()
106+ synchronized(oneShots) {
107+ oneShots.forEach { it.release() }
108+ oneShots.clear()
109+ }
110+ }, (fadeDurMs + 100 ).toLong())
111+
90112 lastIdleVol = - 1f
91113 lastRevVol = - 1f
92114 lastSpeed = - 1f
93115 smoothedIdleVol = 0f
94116 smoothedRevVol = 0f
95117 }
96118
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" )
102119 fun update (rpmNorm : Float , volume : Float ) {
103120 lastRpmNorm = rpmNorm.coerceIn(0f , 1f )
104121 lastVolume = volume.coerceIn(0f , 1f )
105122
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.
123+ // Equal-power crossfade between idle and rev. At rpm=0.5 each contributes
124+ // sqrt(0.5)≈0.71, summing close to 1 in perceived loudness.
125+ val targetIdle = if (rev == null ) lastVolume else kotlin.math.sqrt(1f - lastRpmNorm) * lastVolume
126+ val targetRev = if (rev == null ) 0f else kotlin.math.sqrt(lastRpmNorm) * lastVolume
127+
119128 smoothedIdleVol + = (targetIdle - smoothedIdleVol) * volSmoothingAlpha
120129 smoothedRevVol + = (targetRev - smoothedRevVol) * volSmoothingAlpha
121130
@@ -126,22 +135,16 @@ class CompositionEnginePlayer(private val context: Context) {
126135 rev?.setVolume(smoothedRevVol); lastRevVol = smoothedRevVol
127136 }
128137
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.
138+ // Mild playback-speed modulation within each loop so 1-2 s clips don't sound
139+ // perfectly static across a 0-50 km/h sweep. Range narrow so the timbre survives.
132140 val speed = 0.9f + 0.20f * lastRpmNorm // 0.90 .. 1.10
133141 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- }
142+ idle?.setSpeed(speed)
143+ rev?.setSpeed(speed)
144+ lastSpeed = speed
141145 }
142146 }
143147
144- /* * Called by [EngineSoundEngine] when it sees a sharp throttle drop. */
145148 fun fireDecel () {
146149 profile?.sampleSections?.get(" decel" )?.let { fireOneShot(it, gain = lastVolume) }
147150 }
@@ -152,11 +155,10 @@ class CompositionEnginePlayer(private val context: Context) {
152155 sp.setVolume(gain.coerceIn(0f , 1f ))
153156 sp.play()
154157 synchronized(oneShots) { oneShots.add(sp) }
155- // Auto-cleanup after the section's natural duration plus 50 ms grace.
156158 mainHandler.postDelayed({
157159 synchronized(oneShots) { oneShots.remove(sp) }
158160 sp.release()
159- }, section.durationMs + 50L )
161+ }, section.durationMs + 200L ) // grace beyond the natural duration
160162 }
161163
162164 companion object {
@@ -165,88 +167,87 @@ class CompositionEnginePlayer(private val context: Context) {
165167}
166168
167169/* *
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.
170+ * Plays a [SampleSection] from a res/raw resource via [ExoPlayer]. When
171+ * [looping] is true, ExoPlayer's [ClippingMediaSource] reports a duration
172+ * matching the section window and REPEAT_MODE_ONE handles a gapless restart —
173+ * no perceptible seam.
174174 */
175+ @OptIn(UnstableApi ::class )
175176private class SectionPlayer (
176177 private val context : Context ,
177178 private val section : SampleSection ,
178179 private val looping : Boolean ,
179180) {
180- private val mp = MediaPlayer ()
181- private val handler = Handler (Looper .getMainLooper())
182- private var pollRunnable: Runnable ? = null
181+ private val mainHandler = Handler (Looper .getMainLooper())
182+ private val player: ExoPlayer = ExoPlayer .Builder (context)
183+ // Bind to the main looper so callers from any thread can talk to us safely
184+ // through [postToMain] — all actual mutations happen there.
185+ .setLooper(Looper .getMainLooper())
186+ .setAudioAttributes(
187+ AudioAttributes .Builder ()
188+ .setUsage(C .USAGE_MEDIA )
189+ .setContentType(C .AUDIO_CONTENT_TYPE_MUSIC )
190+ .build(),
191+ /* handleAudioFocus = */ false
192+ )
193+ .build()
183194 @Volatile private var released = false
184195
196+ private inline fun postToMain (crossinline block : () -> Unit ) {
197+ if (Looper .myLooper() == Looper .getMainLooper()) block()
198+ else mainHandler.post { block() }
199+ }
200+
185201 fun prepare (): Boolean {
186202 val resId = context.resources.getIdentifier(section.rawAsset, " raw" , context.packageName)
187203 if (resId == 0 ) {
188204 Log .w(" SectionPlayer" , " raw/${section.rawAsset} not found" )
189205 return false
190206 }
191207 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)
208+ postToMain {
209+ if (released) return @postToMain
210+ val uri = RawResourceDataSource .buildRawResourceUri(resId)
211+ val dataSourceFactory = DataSource .Factory { RawResourceDataSource (context) }
212+ val mediaItem = MediaItem .fromUri(uri)
213+ val source = ProgressiveMediaSource .Factory (dataSourceFactory)
214+ .createMediaSource(mediaItem)
215+ // ClippingMediaSource takes microseconds, and REPEAT_MODE_ONE then
216+ // gaplessly stitches the [startMs..endMs] window into a continuous loop.
217+ val clipped = ClippingMediaSource (
218+ source,
219+ section.startMs * 1000L ,
220+ section.endMs * 1000L
221+ )
222+ player.setMediaSource(clipped)
223+ player.repeatMode = if (looping) Player .REPEAT_MODE_ONE else Player .REPEAT_MODE_OFF
224+ player.volume = 0f
225+ player.prepare()
226+ }
203227 true
204228 } catch (e: Throwable ) {
205229 Log .e(" SectionPlayer" , " prepare failed for ${section.rawAsset} " , e)
206230 false
207231 }
208232 }
209233
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- }
234+ fun play () = postToMain { if (! released) player.play() }
233235
234- fun setVolume (v : Float ) {
235- if (released) return
236- try { mp.setVolume(v, v ) } catch (_: Throwable ) {}
236+ fun setVolume (v : Float ) = postToMain {
237+ if (released) return @postToMain
238+ try { player.volume = v.coerceIn( 0f , 1f ) } catch (_: Throwable ) {}
237239 }
238240
239- @SuppressLint(" NewApi" )
240- fun setSpeed (s : Float ) {
241- if (released) return
242- mp.playbackParams = (mp.playbackParams ? : PlaybackParams ()).setSpeed(s)
241+ fun setSpeed (s : Float ) = postToMain {
242+ if (released) return @postToMain
243+ try { player.setPlaybackSpeed(s.coerceIn(0.5f , 2.0f )) } catch (_: Throwable ) {}
243244 }
244245
245246 fun release () {
246247 released = true
247- pollRunnable?. let { handler.removeCallbacks(it) }
248- pollRunnable = null
249- try { mp.stop () } catch (_: Throwable ) {}
250- try { mp.release() } catch (_ : Throwable ) { }
248+ postToMain {
249+ try { player.stop() } catch (_ : Throwable ) {}
250+ try { player.release () } catch (_: Throwable ) {}
251+ }
251252 }
252253}
0 commit comments