Skip to content

Commit 2ee33b9

Browse files
eriedclaude
andcommitted
Audio: ExoPlayer for gapless loops + WAV→OGG re-encode + speed-based auto-volume + APK trim
CompositionEnginePlayer rewritten on top of ExoPlayer with ClippingMediaSource + REPEAT_MODE_ONE so the [startMs..endMs] window stitches gaplessly back to its start — the seam click between loop iterations is gone. SectionPlayer posts to the main looper so callers from any thread are safe. The four uncompressed WAV source clips (helicopter, motorcycle start, Bulleid steam, tractor start) are re-encoded to Ogg Vorbis q=4 — 50 MB → 5 MB with no audible loss on engine recordings. 64 unused src_ scouting clips moved out of res/raw into dev_audio/ at the repo root (kept reachable by deletelater.html via the local HTTP server, but no longer packaged into the APK). Combined with the WAV→OGG swap the APK drops from 434 MB → 98 MB. Speed-based engine auto-volume wired end-to-end: AppSettings holds the 4-point curve at 0/25/50/75 km/h (default decreasing 1.0/0.7/0.4/0.2 — opposite of the voice auto-volume's increasing curve, since here we want the engine loud at pedestrian-awareness speeds). EngineSoundEngine.pushTelemetry evaluates the curve via pchipInterpolate, smooths the multiplier across ticks, and applies it to the master gain. Settings UI gets a "Adjust by speed" switch and a 4-point pchip curve editor under the Volume slider — no monotonic constraint and full 0..1 range so the user can shape the curve freely. Plus: ScanScreen no longer leaks the virtual-wheel picker in debug builds, only when Service Mode is active. PlayButton stays visible but dims when the wheel is moving instead of disappearing. Language picker label switched to stringResource so it recomposes on locale change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2b8cbb3 commit 2ee33b9

80 files changed

Lines changed: 347 additions & 130 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.

app/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,10 @@ dependencies {
133133
// Drag-to-reorder for settings lists
134134
implementation(libs.reorderable)
135135

136+
// ExoPlayer — gapless looping for the multi-section engine sound composition
137+
implementation(libs.media3.exoplayer)
138+
implementation(libs.media3.common)
139+
136140
// Wear OS Data Layer (talks to the wear/ companion module on paired watches)
137141
implementation(libs.play.services.wearable)
138142
}
Lines changed: 117 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,41 @@
11
package com.eried.eucplanet.audio
22

3-
import android.annotation.SuppressLint
43
import android.content.Context
5-
import android.media.AudioAttributes
6-
import android.media.MediaPlayer
7-
import android.media.PlaybackParams
4+
import android.net.Uri
85
import android.os.Handler
96
import android.os.Looper
107
import 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
1119
import 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)
3339
class 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)
175176
private 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

Comments
 (0)