Skip to content

Commit dd0163e

Browse files
committed
Merge motor-sound into main: v0.6.0 release
2 parents 6bd9019 + f6b95fc commit dd0163e

84 files changed

Lines changed: 4966 additions & 466 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

app/build.gradle.kts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ android {
2727
applicationId = "com.eried.eucplanet"
2828
minSdk = 29
2929
targetSdk = 35
30-
versionCode = 37
31-
versionName = "0.4.5"
30+
versionCode = 39
31+
versionName = "0.6.0"
3232

3333
val buildStamp = SimpleDateFormat("yyMMdd.HHmm")
3434
.apply { timeZone = TimeZone.getTimeZone("UTC") }
@@ -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: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
package com.eried.eucplanet.audio
2+
3+
import android.content.Context
4+
import android.net.Uri
5+
import android.os.Handler
6+
import android.os.Looper
7+
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
19+
import kotlin.math.abs
20+
21+
/**
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).
25+
*
26+
* Section types:
27+
* - "idle_loop" — sustained low-RPM loop (required)
28+
* - "rev_loop" — sustained high-RPM loop (optional — without it, idle pitch-shifts up)
29+
* - "startup" — one-shot transient played on engine start (optional)
30+
* - "decel" — one-shot transient played when throttle closes sharply (optional)
31+
* - "shutdown" — one-shot transient played on engine stop (optional)
32+
*
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.
37+
*/
38+
@OptIn(UnstableApi::class)
39+
class CompositionEnginePlayer(private val context: Context) {
40+
41+
private val mainHandler = Handler(Looper.getMainLooper())
42+
43+
@Volatile private var profile: EngineProfile? = null
44+
@Volatile private var idle: SectionPlayer? = null
45+
@Volatile private var rev: SectionPlayer? = null
46+
private val oneShots = mutableListOf<SectionPlayer>()
47+
48+
@Volatile private var lastVolume: Float = 0f
49+
@Volatile private var lastRpmNorm: Float = 0f
50+
@Volatile private var lastIdleVol: Float = -1f
51+
@Volatile private var lastRevVol: Float = -1f
52+
@Volatile private var lastSpeed: Float = -1f
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.
55+
@Volatile private var smoothedIdleVol: Float = 0f
56+
@Volatile private var smoothedRevVol: Float = 0f
57+
private val volSmoothingAlpha = 0.15f
58+
59+
fun isPlaying(): Boolean = idle != null
60+
61+
fun start(profile: EngineProfile) {
62+
if (this.profile?.key == profile.key && idle != null) return
63+
stop()
64+
val sections = profile.sampleSections ?: return
65+
this.profile = profile
66+
67+
sections["idle_loop"]?.let { sec ->
68+
idle = SectionPlayer(context, sec, looping = true).also {
69+
if (!it.prepare()) { it.release(); idle = null; Log.w(TAG, "idle_loop failed") }
70+
else { it.setVolume(0f); it.play() }
71+
}
72+
}
73+
sections["rev_loop"]?.let { sec ->
74+
rev = SectionPlayer(context, sec, looping = true).also {
75+
if (!it.prepare()) { it.release(); rev = null; Log.w(TAG, "rev_loop failed") }
76+
else { it.setVolume(0f); it.play() }
77+
}
78+
}
79+
sections["startup"]?.let { fireOneShot(it, gain = 1f) }
80+
}
81+
82+
fun stop() {
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
91+
idle = null
92+
rev = null
93+
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+
112+
lastIdleVol = -1f
113+
lastRevVol = -1f
114+
lastSpeed = -1f
115+
smoothedIdleVol = 0f
116+
smoothedRevVol = 0f
117+
}
118+
119+
fun update(rpmNorm: Float, volume: Float) {
120+
lastRpmNorm = rpmNorm.coerceIn(0f, 1f)
121+
lastVolume = volume.coerceIn(0f, 1f)
122+
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+
128+
smoothedIdleVol += (targetIdle - smoothedIdleVol) * volSmoothingAlpha
129+
smoothedRevVol += (targetRev - smoothedRevVol) * volSmoothingAlpha
130+
131+
if (abs(smoothedIdleVol - lastIdleVol) > 0.005f) {
132+
idle?.setVolume(smoothedIdleVol); lastIdleVol = smoothedIdleVol
133+
}
134+
if (abs(smoothedRevVol - lastRevVol) > 0.005f) {
135+
rev?.setVolume(smoothedRevVol); lastRevVol = smoothedRevVol
136+
}
137+
138+
// Playback speed stays at 1.0× — ExoPlayer's Sonic time-stretch was painting
139+
// audible artifacts over every loop iteration. The natural idle ↔ rev crossfade
140+
// (above) handles the perceived "going faster" without touching pitch.
141+
// Variable speed left for a future profile-level opt-in only if a clip really
142+
// demands it.
143+
}
144+
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+
mainHandler.postDelayed({
156+
synchronized(oneShots) { oneShots.remove(sp) }
157+
sp.release()
158+
}, section.durationMs + 200L) // grace beyond the natural duration
159+
}
160+
161+
companion object {
162+
private const val TAG = "CompositionEnginePlayer"
163+
}
164+
}
165+
166+
/**
167+
* Plays a [SampleSection] from a res/raw resource via [ExoPlayer]. When
168+
* [looping] is true, ExoPlayer's [ClippingMediaSource] reports a duration
169+
* matching the section window and REPEAT_MODE_ONE handles a gapless restart —
170+
* no perceptible seam.
171+
*/
172+
@OptIn(UnstableApi::class)
173+
private class SectionPlayer(
174+
private val context: Context,
175+
private val section: SampleSection,
176+
private val looping: Boolean,
177+
) {
178+
private val mainHandler = Handler(Looper.getMainLooper())
179+
private val player: ExoPlayer = ExoPlayer.Builder(context)
180+
// Bind to the main looper so callers from any thread can talk to us safely
181+
// through [postToMain] — all actual mutations happen there.
182+
.setLooper(Looper.getMainLooper())
183+
.setAudioAttributes(
184+
AudioAttributes.Builder()
185+
.setUsage(C.USAGE_MEDIA)
186+
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
187+
.build(),
188+
/* handleAudioFocus = */ false
189+
)
190+
.build()
191+
@Volatile private var released = false
192+
193+
private inline fun postToMain(crossinline block: () -> Unit) {
194+
if (Looper.myLooper() == Looper.getMainLooper()) block()
195+
else mainHandler.post { block() }
196+
}
197+
198+
fun prepare(): Boolean {
199+
val resId = context.resources.getIdentifier(section.rawAsset, "raw", context.packageName)
200+
if (resId == 0) {
201+
Log.w("SectionPlayer", "raw/${section.rawAsset} not found")
202+
return false
203+
}
204+
return try {
205+
postToMain {
206+
if (released) return@postToMain
207+
val uri = RawResourceDataSource.buildRawResourceUri(resId)
208+
val dataSourceFactory = DataSource.Factory { RawResourceDataSource(context) }
209+
val mediaItem = MediaItem.fromUri(uri)
210+
val source = ProgressiveMediaSource.Factory(dataSourceFactory)
211+
.createMediaSource(mediaItem)
212+
// ClippingMediaSource takes microseconds, and REPEAT_MODE_ONE then
213+
// gaplessly stitches the [startMs..endMs] window into a continuous loop.
214+
val clipped = ClippingMediaSource(
215+
source,
216+
section.startMs * 1000L,
217+
section.endMs * 1000L
218+
)
219+
player.setMediaSource(clipped)
220+
player.repeatMode = if (looping) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
221+
player.volume = 0f
222+
player.prepare()
223+
}
224+
true
225+
} catch (e: Throwable) {
226+
Log.e("SectionPlayer", "prepare failed for ${section.rawAsset}", e)
227+
false
228+
}
229+
}
230+
231+
fun play() = postToMain { if (!released) player.play() }
232+
233+
fun setVolume(v: Float) = postToMain {
234+
if (released) return@postToMain
235+
try { player.volume = v.coerceIn(0f, 1f) } catch (_: Throwable) {}
236+
}
237+
238+
fun setSpeed(s: Float) = postToMain {
239+
if (released) return@postToMain
240+
try { player.setPlaybackSpeed(s.coerceIn(0.5f, 2.0f)) } catch (_: Throwable) {}
241+
}
242+
243+
fun release() {
244+
released = true
245+
postToMain {
246+
try { player.stop() } catch (_: Throwable) {}
247+
try { player.release() } catch (_: Throwable) {}
248+
}
249+
}
250+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.eried.eucplanet.audio
2+
3+
/**
4+
* Snapshot of live engine state, produced by [EngineSoundEngine] and consumed by
5+
* the audio thread on every buffer fill. All fields are smoothed/derived — the
6+
* raw (speed, pwm) inputs never reach the synth directly so jittery telemetry
7+
* doesn't make the engine warble.
8+
*
9+
* Immutable / value-type by intent; copy a new one on each update.
10+
*/
11+
data class EngineParams(
12+
/** Smoothed engine RPM (post low-pass). */
13+
val rpm: Float = 0f,
14+
/** Smoothed load 0..1 (PWM normalized, with floor for engine compression sound at idle). */
15+
val load: Float = 0f,
16+
/** Decel intensity 0..1 — rises when PWM falls sharply; drives backfire pops. */
17+
val decelAmount: Float = 0f,
18+
/** Idle envelope 0..1 — fades the whole signal out after parked timeout. */
19+
val idleAmount: Float = 1f,
20+
/** Master output gain 0..1 (master volume × duck factor). */
21+
val masterGain: Float = 1f,
22+
/** Number of decel pop events queued for this buffer (0+ — typically 0 or 1). */
23+
val pendingPops: Int = 0,
24+
/** Transient RPM bump from rev-up detection — added on top of [rpm] in the synth. */
25+
val revBump: Float = 0f,
26+
/**
27+
* Engine brake intensity 0..1 — rises during sustained regen/decel, decays
28+
* back to 0 under accel/cruise. Drives the high-frequency overrun whine.
29+
*/
30+
val engineBrakeAmount: Float = 0f,
31+
/** Active profile. */
32+
val profile: EngineProfile = EngineProfile.byKey("FOUR_STROKE_SINGLE"),
33+
/** Muffler cutoff scale 0..1 (1 = open pipes, 0 = closed muffler). */
34+
val mufflerOpenness: Float = 0.6f
35+
) {
36+
companion object {
37+
/** Silent / off state. */
38+
val SILENT = EngineParams(rpm = 0f, masterGain = 0f, idleAmount = 0f)
39+
}
40+
}

0 commit comments

Comments
 (0)