From a456240a153037f8899c147c723c8b700fc5117d Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Tue, 16 Jun 2026 22:29:26 -0400 Subject: [PATCH 1/3] feat: introduce DelegatingAudioEngine to support switching between Oboe and ExoPlayer engines --- samples/powerplay/build.gradle | 1 + .../oboe/samples/powerplay/MainActivity.kt | 301 +++++++++++------- .../samples/powerplay/engine/AudioEngine.kt | 67 ++++ .../powerplay/engine/AudioEngineType.kt | 22 ++ .../engine/AudioForegroundService.kt | 4 +- .../powerplay/engine/DelegatingAudioEngine.kt | 230 +++++++++++++ .../powerplay/engine/ExoPlayerAudioEngine.kt | 242 ++++++++++++++ .../powerplay/engine/PowerPlayAudioPlayer.kt | 68 ++-- 8 files changed, 797 insertions(+), 138 deletions(-) create mode 100644 samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/AudioEngine.kt create mode 100644 samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/AudioEngineType.kt create mode 100644 samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/DelegatingAudioEngine.kt create mode 100644 samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/ExoPlayerAudioEngine.kt diff --git a/samples/powerplay/build.gradle b/samples/powerplay/build.gradle index fe2a11d85..9151c23e1 100644 --- a/samples/powerplay/build.gradle +++ b/samples/powerplay/build.gradle @@ -78,6 +78,7 @@ dependencies { implementation 'androidx.activity:activity-compose:1.10.1' implementation 'androidx.appcompat:appcompat:1.7.1' implementation 'androidx.compose.runtime:runtime-livedata:1.10.0' + implementation "androidx.media3:media3-exoplayer:1.5.1" testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt index 5d9547553..485e574b3 100644 --- a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt +++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt @@ -132,7 +132,9 @@ import androidx.compose.ui.unit.sp import com.google.oboe.samples.powerplay.engine.AudioForegroundService import com.google.oboe.samples.powerplay.engine.OboePerformanceMode import com.google.oboe.samples.powerplay.engine.PlayerState -import com.google.oboe.samples.powerplay.engine.PowerPlayAudioPlayer +import com.google.oboe.samples.powerplay.engine.AudioEngine +import com.google.oboe.samples.powerplay.engine.AudioEngineType +import com.google.oboe.samples.powerplay.engine.DelegatingAudioEngine import com.google.oboe.samples.powerplay.ui.theme.MusicPlayerTheme import kotlinx.coroutines.flow.distinctUntilChanged import android.os.Handler @@ -146,7 +148,7 @@ import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { - private lateinit var player: PowerPlayAudioPlayer + private lateinit var player: AudioEngine private lateinit var serviceIntent: Intent private var isMMapSupported: Boolean = false private var isOffloadSupported: Boolean = false @@ -159,6 +161,8 @@ class MainActivity : ComponentActivity() { val binder = service as AudioForegroundService.LocalBinder player = binder.getService().player isMMapSupported = player.isMMapSupported() + engineTypeState.value = player.engineType + isOffloadSchedulingEnabledState.value = player.isOffloadSchedulingEnabled() isBound.value = true } @@ -179,6 +183,8 @@ class MainActivity : ComponentActivity() { private val performanceModeState = mutableIntStateOf(0) private val volumeState = mutableFloatStateOf(1.0f) private val isMMapEnabledState = mutableStateOf(false) + private val engineTypeState = mutableStateOf(AudioEngineType.Oboe) + private val isOffloadSchedulingEnabledState = mutableStateOf(false) private val dynamicPlayList = mutableStateListOf() private val isLoadingFile = mutableStateOf(false) @@ -582,18 +588,20 @@ class MainActivity : ComponentActivity() { modifier = Modifier.size(32.dp) ) } - IconButton( - onClick = { showEffectsBottomSheet = true }, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(32.dp) - ) { - Icon( - imageVector = Icons.Default.Menu, - contentDescription = "Effects", - tint = Color.Black, - modifier = Modifier.size(32.dp) - ) + if (engineTypeState.value == AudioEngineType.Oboe) { + IconButton( + onClick = { showEffectsBottomSheet = true }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(32.dp) + ) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = "Effects", + tint = Color.Black, + modifier = Modifier.size(32.dp) + ) + } } IconButton( onClick = { filePickerLauncher.launch(arrayOf("audio/wav", "audio/x-wav")) }, @@ -774,17 +782,20 @@ class MainActivity : ComponentActivity() { } } if (showEffectsBottomSheet) { - ModalBottomSheet( - onDismissRequest = { showEffectsBottomSheet = false }, - sheetState = effectsSheetState, - containerColor = Color.White, - shape = androidx.compose.foundation.shape.RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) - ) { - EffectsBottomSheet( - effectsController = player.effectsController, - isOffloadMode = offload.intValue == 3, - onDismiss = { showEffectsBottomSheet = false } - ) + val controller = player.effectsController + if (controller != null) { + ModalBottomSheet( + onDismissRequest = { showEffectsBottomSheet = false }, + sheetState = effectsSheetState, + containerColor = Color.White, + shape = androidx.compose.foundation.shape.RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) + ) { + EffectsBottomSheet( + effectsController = controller, + isOffloadMode = offload.intValue == 3, + onDismiss = { showEffectsBottomSheet = false } + ) + } } } if (showInfoDialog) { @@ -816,17 +827,24 @@ class MainActivity : ComponentActivity() { Column( verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Row { - Text("Performance Mode: ", fontWeight = FontWeight.Medium) - Text(performanceModeText) - } - Row { - Text("Audio Mode: ", fontWeight = FontWeight.Medium) - Text(mmapModeText) - } - Row { - Text("Buffer Size: ", fontWeight = FontWeight.Medium) - Text(bufferInfo) + if (engineTypeState.value == AudioEngineType.Oboe) { + Row { + Text("Performance Mode: ", fontWeight = FontWeight.Medium) + Text(performanceModeText) + } + Row { + Text("Audio Mode: ", fontWeight = FontWeight.Medium) + Text(mmapModeText) + } + Row { + Text("Buffer Size: ", fontWeight = FontWeight.Medium) + Text(bufferInfo) + } + } else { + Row { + Text("Engine: ", fontWeight = FontWeight.Medium) + Text("ExoPlayer (Media3)") + } } if (wavInfo != null) { Spacer(modifier = Modifier.height(8.dp)) @@ -905,6 +923,8 @@ class MainActivity : ComponentActivity() { LaunchedEffect(playbackSpeed) { localSpeed = playbackSpeed } LaunchedEffect(playbackPitch) { localPitch = playbackPitch } + var currentEngineType by remember { mutableStateOf(player.engineType) } + Column( modifier = Modifier .fillMaxWidth() @@ -913,116 +933,181 @@ class MainActivity : ComponentActivity() { horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "Performance Modes", + text = "Audio Engine", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = Color.Black, - modifier = Modifier.padding(bottom = 16.dp) + modifier = Modifier.padding(bottom = 8.dp) ) - Column( - modifier = Modifier.fillMaxWidth() + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceEvenly ) { - val radioOptions = mutableListOf("None", "Low Latency", "Power Saving") - if (isOffloadSupported) radioOptions.add("PCM Offload") - - val (selectedOption, onOptionSelected) = remember { - mutableStateOf(radioOptions[offload.intValue]) - } - val enabled = !isPlaying - radioOptions.forEachIndexed { index, text -> + AudioEngineType.values().forEach { type -> Row( - Modifier - .fillMaxWidth() - .height(48.dp) - .selectable( - selected = (text == selectedOption), - enabled = enabled, - onClick = { - if (enabled) { - onOptionSelected(text) - player.updatePerformanceMode(OboePerformanceMode.fromInt(index)) - offload.intValue = index - } - }, - role = Role.RadioButton - ) - .padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + if (currentEngineType != type) { + currentEngineType = type + (player as? DelegatingAudioEngine)?.switchEngine(type) + isMMapEnabled.value = player.isMMapEnabled() + isOffloadSchedulingEnabledState.value = player.isOffloadSchedulingEnabled() + } + } ) { RadioButton( - selected = (text == selectedOption), - onClick = null, - enabled = enabled, - colors = RadioButtonDefaults.colors( - selectedColor = MaterialTheme.colorScheme.primary - ) + selected = (currentEngineType == type), + onClick = { + if (currentEngineType != type) { + currentEngineType = type + (player as? DelegatingAudioEngine)?.switchEngine(type) + isMMapEnabled.value = player.isMMapEnabled() + isOffloadSchedulingEnabledState.value = player.isOffloadSchedulingEnabled() + } + } ) Text( - text = text, + text = type.name, style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(start = 12.dp) + modifier = Modifier.padding(start = 8.dp) ) } } } - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = when (offload.intValue) { - 0 -> "Performance Mode: None" - 1 -> "Performance Mode: Low Latency" - 2 -> "Performance Mode: Power Saving" - else -> "Performance Mode: PCM Offload" - }, - color = Color.Gray, - style = MaterialTheme.typography.bodyMedium - ) - - Spacer(modifier = Modifier.height(16.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - if (isMMapSupported) { + if (currentEngineType == AudioEngineType.ExoPlayer) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp) + ) { Checkbox( - checked = !isMMapEnabled.value, + checked = isOffloadSchedulingEnabledState.value, onCheckedChange = { - if (!isPlaying) { - isMMapEnabled.value = !it - player.setMMapEnabled(isMMapEnabled.value) - } - }, - enabled = !isPlaying + isOffloadSchedulingEnabledState.value = it + player.setOffloadSchedulingEnabled(it) + } ) Text( - text = "Disable MMAP", + text = "Enable Offload Scheduling", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(start = 8.dp) ) } + } + + if (currentEngineType == AudioEngineType.Oboe) { Text( - text = when (isMMapEnabled.value) { - true -> "| Current Mode: MMAP" - false -> "| Current Mode: Classic" + text = "Performance Modes", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = Color.Black, + modifier = Modifier.padding(bottom = 16.dp) + ) + Column( + modifier = Modifier.fillMaxWidth() + ) { + val radioOptions = mutableListOf("None", "Low Latency", "Power Saving") + if (isOffloadSupported) radioOptions.add("PCM Offload") + + val (selectedOption, onOptionSelected) = remember { + mutableStateOf(radioOptions[offload.intValue]) + } + val enabled = !isPlaying + radioOptions.forEachIndexed { index, text -> + Row( + Modifier + .fillMaxWidth() + .height(48.dp) + .selectable( + selected = (text == selectedOption), + enabled = enabled, + onClick = { + if (enabled) { + onOptionSelected(text) + player.updatePerformanceMode(OboePerformanceMode.fromInt(index)) + offload.intValue = index + } + }, + role = Role.RadioButton + ) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = (text == selectedOption), + onClick = null, + enabled = enabled, + colors = RadioButtonDefaults.colors( + selectedColor = MaterialTheme.colorScheme.primary + ) + ) + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 12.dp) + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = when (offload.intValue) { + 0 -> "Performance Mode: None" + 1 -> "Performance Mode: Low Latency" + 2 -> "Performance Mode: Power Saving" + else -> "Performance Mode: PCM Offload" }, - style = MaterialTheme.typography.bodyLarge, color = Color.Gray, - modifier = Modifier.padding(start = 8.dp) + style = MaterialTheme.typography.bodyMedium ) + + Spacer(modifier = Modifier.height(16.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + if (isMMapSupported) { + Checkbox( + checked = !isMMapEnabled.value, + onCheckedChange = { + if (!isPlaying) { + isMMapEnabled.value = !it + player.setMMapEnabled(isMMapEnabled.value) + } + }, + enabled = !isPlaying + ) + Text( + text = "Disable MMAP", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 8.dp) + ) + } + Text( + text = when (isMMapEnabled.value) { + true -> "| Current Mode: MMAP" + false -> "| Current Mode: Classic" + }, + style = MaterialTheme.typography.bodyLarge, + color = Color.Gray, + modifier = Modifier.padding(start = 8.dp) + ) + } } val isPlaybackParamsSupported = android.os.Build.VERSION.SDK_INT >= 37 val isOffload = offload.intValue == 3 val isMMap = isMMapEnabled.value - var canUseSpeed = isPlaybackParamsSupported - var canUsePitch = isPlaybackParamsSupported + var canUseSpeed = isPlaybackParamsSupported || currentEngineType == AudioEngineType.ExoPlayer + var canUsePitch = isPlaybackParamsSupported || currentEngineType == AudioEngineType.ExoPlayer // For testing: allow everything except API 37 gate // The previous offload restrictions have been removed to test allowing everything. Spacer(modifier = Modifier.height(16.dp)) - val speedSupportText = if (!isPlaybackParamsSupported) " (Requires API 37)" else "" + val speedSupportText = if (!isPlaybackParamsSupported && currentEngineType == AudioEngineType.Oboe) " (Requires API 37)" else "" Text( text = "Playback Speed: ${"%.2f".format(playbackSpeed)}x$speedSupportText", style = MaterialTheme.typography.bodyMedium, @@ -1050,7 +1135,7 @@ class MainActivity : ComponentActivity() { ) Spacer(modifier = Modifier.height(8.dp)) - val pitchSupportText = if (!isPlaybackParamsSupported) " (Requires API 37)" else "" + val pitchSupportText = if (!isPlaybackParamsSupported && currentEngineType == AudioEngineType.Oboe) " (Requires API 37)" else "" Text( text = "Playback Pitch: ${"%.2f".format(playbackPitch)}x$pitchSupportText", style = MaterialTheme.typography.bodyMedium, diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/AudioEngine.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/AudioEngine.kt new file mode 100644 index 000000000..667ee3fb8 --- /dev/null +++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/AudioEngine.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.oboe.samples.powerplay.engine + +import android.content.ContentResolver +import android.content.res.AssetManager +import android.net.Uri +import androidx.lifecycle.LiveData +import com.google.oboe.samples.powerplay.effects.EffectsController +import kotlinx.coroutines.flow.StateFlow + +interface AudioEngine { + val currentSongIndex: Int + val currentPerformanceMode: OboePerformanceMode + val effectsController: EffectsController? + val engineType: AudioEngineType + + val playerStateFlow: StateFlow + val currentSongIndexFlow: StateFlow + + fun getPlayerStateLive(): LiveData + fun getCurrentSongIndexLive(): LiveData + + fun setupAudioStream(channelCount: Int = 2) + fun startPlaying(index: Int, mode: OboePerformanceMode? = null) + fun stopPlaying(index: Int) + fun setLooping(index: Int, looping: Boolean) + fun setVolume(volume: Float) + fun seekTo(positionMillis: Int) + fun getPlaybackPositionMillis(): Long + fun getDurationMillis(index: Int): Long + fun getCurrentlyPlayingIndex(): Int + fun teardownAudioStream() + + fun loadFile(assetMgr: AssetManager, filename: String, id: Int): WavFileInfo? + fun loadLocalFile(contentResolver: ContentResolver, uri: Uri, index: Int): WavFileInfo? + fun removeSampleSource(index: Int): Boolean + fun setPlaybackParameters(speed: Float, pitch: Float): Boolean + + // Oboe-specific properties/methods + fun setMMapEnabled(enabled: Boolean) + fun isMMapEnabled(): Boolean + fun isMMapSupported(): Boolean + fun setBufferSizeInFrames(bufferSizeInFrames: Int): Int + fun getBufferCapacityInFrames(): Int + fun isOffloaded(): Boolean + fun getSessionId(): Int + fun updatePerformanceMode(mode: OboePerformanceMode) + + // ExoPlayer-specific properties/methods + fun setOffloadSchedulingEnabled(enabled: Boolean) + fun isOffloadSchedulingEnabled(): Boolean +} diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/AudioEngineType.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/AudioEngineType.kt new file mode 100644 index 000000000..91b974174 --- /dev/null +++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/AudioEngineType.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.oboe.samples.powerplay.engine + +enum class AudioEngineType { + Oboe, + ExoPlayer +} diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/AudioForegroundService.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/AudioForegroundService.kt index fd6a36af0..790092054 100644 --- a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/AudioForegroundService.kt +++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/AudioForegroundService.kt @@ -54,7 +54,7 @@ class AudioForegroundService : Service() { private lateinit var mediaSession: MediaSession private var currentAlbumArt: Bitmap? = null - lateinit var player: PowerPlayAudioPlayer + lateinit var player: DelegatingAudioEngine private val binder = LocalBinder() private val serviceScope = CoroutineScope(Dispatchers.Main + Job()) @@ -87,7 +87,7 @@ class AudioForegroundService : Service() { override fun onCreate() { super.onCreate() try { - player = PowerPlayAudioPlayer() + player = DelegatingAudioEngine(this, serviceScope) player.setupAudioStream() audioManager = getSystemService(AUDIO_SERVICE) as AudioManager diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/DelegatingAudioEngine.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/DelegatingAudioEngine.kt new file mode 100644 index 000000000..715e36960 --- /dev/null +++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/DelegatingAudioEngine.kt @@ -0,0 +1,230 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.oboe.samples.powerplay.engine + +import android.content.ContentResolver +import android.content.Context +import android.content.res.AssetManager +import android.net.Uri +import androidx.lifecycle.LiveData +import androidx.lifecycle.asLiveData +import com.google.oboe.samples.powerplay.effects.EffectsController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class DelegatingAudioEngine( + private val context: Context, + private val coroutineScope: CoroutineScope +) : AudioEngine { + private var _activeEngineType = AudioEngineType.Oboe + override val engineType: AudioEngineType get() = _activeEngineType + + private val oboeEngine = PowerPlayAudioPlayer() + private val exoPlayerEngine = ExoPlayerAudioEngine(context) + + private val currentEngine: AudioEngine + get() = when (_activeEngineType) { + AudioEngineType.Oboe -> oboeEngine + AudioEngineType.ExoPlayer -> exoPlayerEngine + } + + private val _playerState = MutableStateFlow(PlayerState.NoResultYet) + override val playerStateFlow: StateFlow get() = _playerState + override fun getPlayerStateLive(): LiveData = _playerState.asLiveData() + + private val _currentSongIndex = MutableStateFlow(0) + override val currentSongIndexFlow: StateFlow get() = _currentSongIndex + override fun getCurrentSongIndexLive(): LiveData = _currentSongIndex.asLiveData() + override val currentSongIndex: Int get() = _currentSongIndex.value + + init { + coroutineScope.launch { + oboeEngine.playerStateFlow.collect { state -> + if (_activeEngineType == AudioEngineType.Oboe) { + _playerState.value = state + } + } + } + coroutineScope.launch { + oboeEngine.currentSongIndexFlow.collect { index -> + if (_activeEngineType == AudioEngineType.Oboe) { + _currentSongIndex.value = index + } + } + } + + coroutineScope.launch { + exoPlayerEngine.playerStateFlow.collect { state -> + if (_activeEngineType == AudioEngineType.ExoPlayer) { + _playerState.value = state + } + } + } + coroutineScope.launch { + exoPlayerEngine.currentSongIndexFlow.collect { index -> + if (_activeEngineType == AudioEngineType.ExoPlayer) { + _currentSongIndex.value = index + } + } + } + } + + fun switchEngine(type: AudioEngineType) { + if (_activeEngineType == type) return + + val wasPlaying = _playerState.value == PlayerState.Playing + val index = currentSongIndex + val position = getPlaybackPositionMillis() + + currentEngine.stopPlaying(index) + currentEngine.teardownAudioStream() + + _activeEngineType = type + + currentEngine.setupAudioStream() + + _playerState.value = currentEngine.playerStateFlow.value + _currentSongIndex.value = currentEngine.currentSongIndexFlow.value + + if (wasPlaying) { + currentEngine.startPlaying(index) + currentEngine.seekTo(position.toInt()) + } + } + + override val currentPerformanceMode: OboePerformanceMode + get() = currentEngine.currentPerformanceMode + + override val effectsController: EffectsController? + get() = currentEngine.effectsController + + override fun setupAudioStream(channelCount: Int) { + oboeEngine.setupAudioStream(channelCount) + exoPlayerEngine.setupAudioStream(channelCount) + _playerState.value = currentEngine.playerStateFlow.value + } + + override fun startPlaying(index: Int, mode: OboePerformanceMode?) { + currentEngine.startPlaying(index, mode) + } + + override fun stopPlaying(index: Int) { + currentEngine.stopPlaying(index) + } + + override fun setLooping(index: Int, looping: Boolean) { + oboeEngine.setLooping(index, looping) + exoPlayerEngine.setLooping(index, looping) + } + + override fun setVolume(volume: Float) { + oboeEngine.setVolume(volume) + exoPlayerEngine.setVolume(volume) + } + + override fun seekTo(positionMillis: Int) { + currentEngine.seekTo(positionMillis) + } + + override fun getPlaybackPositionMillis(): Long { + return currentEngine.getPlaybackPositionMillis() + } + + override fun getDurationMillis(index: Int): Long { + return currentEngine.getDurationMillis(index) + } + + override fun getCurrentlyPlayingIndex(): Int { + return currentEngine.getCurrentlyPlayingIndex() + } + + override fun teardownAudioStream() { + oboeEngine.teardownAudioStream() + exoPlayerEngine.teardownAudioStream() + } + + override fun loadFile(assetMgr: AssetManager, filename: String, id: Int): WavFileInfo? { + val wavInfo = oboeEngine.loadFile(assetMgr, filename, id) + if (wavInfo != null) { + exoPlayerEngine.loadFile(filename, id, wavInfo.durationMs) + } + return wavInfo + } + + override fun loadLocalFile(contentResolver: ContentResolver, uri: Uri, index: Int): WavFileInfo? { + val wavInfo = oboeEngine.loadLocalFile(contentResolver, uri, index) + if (wavInfo != null) { + exoPlayerEngine.loadLocalFile(uri, index, wavInfo.durationMs) + } + return wavInfo + } + + override fun removeSampleSource(index: Int): Boolean { + val r1 = oboeEngine.removeSampleSource(index) + val r2 = exoPlayerEngine.removeSampleSource(index) + return r1 && r2 + } + + override fun setPlaybackParameters(speed: Float, pitch: Float): Boolean { + val r1 = oboeEngine.setPlaybackParameters(speed, pitch) + val r2 = exoPlayerEngine.setPlaybackParameters(speed, pitch) + return r1 && r2 + } + + override fun setMMapEnabled(enabled: Boolean) { + oboeEngine.setMMapEnabled(enabled) + } + + override fun isMMapEnabled(): Boolean { + return oboeEngine.isMMapEnabled() + } + + override fun isMMapSupported(): Boolean { + return oboeEngine.isMMapSupported() + } + + override fun setBufferSizeInFrames(bufferSizeInFrames: Int): Int { + return oboeEngine.setBufferSizeInFrames(bufferSizeInFrames) + } + + override fun getBufferCapacityInFrames(): Int { + return oboeEngine.getBufferCapacityInFrames() + } + + override fun isOffloaded(): Boolean { + return oboeEngine.isOffloaded() + } + + override fun getSessionId(): Int { + return oboeEngine.getSessionId() + } + + override fun updatePerformanceMode(mode: OboePerformanceMode) { + oboeEngine.updatePerformanceMode(mode) + } + + override fun setOffloadSchedulingEnabled(enabled: Boolean) { + currentEngine.setOffloadSchedulingEnabled(enabled) + } + + override fun isOffloadSchedulingEnabled(): Boolean { + return currentEngine.isOffloadSchedulingEnabled() + } +} diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/ExoPlayerAudioEngine.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/ExoPlayerAudioEngine.kt new file mode 100644 index 000000000..06876d0ab --- /dev/null +++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/ExoPlayerAudioEngine.kt @@ -0,0 +1,242 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.oboe.samples.powerplay.engine + +import android.content.ContentResolver +import android.content.Context +import android.content.res.AssetManager +import android.net.Uri +import androidx.lifecycle.LiveData +import androidx.lifecycle.asLiveData +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.common.TrackSelectionParameters +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import com.google.oboe.samples.powerplay.effects.EffectsController +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + +class ExoPlayerAudioEngine(private val context: Context) : AudioEngine { + private var exoPlayer: ExoPlayer? = null + + private val _playerState = MutableStateFlow(PlayerState.NoResultYet) + override val playerStateFlow: StateFlow get() = _playerState + override fun getPlayerStateLive(): LiveData = _playerState.asLiveData() + + private var _currentSongIndex = MutableStateFlow(0) + override val currentSongIndexFlow: StateFlow get() = _currentSongIndex + override fun getCurrentSongIndexLive(): LiveData = _currentSongIndex.asLiveData() + override val currentSongIndex: Int get() = _currentSongIndex.value + + override val currentPerformanceMode = OboePerformanceMode.None + override val effectsController: EffectsController? = null + override val engineType = AudioEngineType.ExoPlayer + + private val playlistItems = mutableMapOf() + private val trackDurations = mutableMapOf() + + private var currentVolume = 1.0f + private var currentPlaybackParameters = PlaybackParameters.DEFAULT + private var offloadSchedulingEnabled = false + + private val playerListener = object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + updatePlayerState() + } + + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + updatePlayerState() + } + } + + private fun updatePlayerState() { + val player = exoPlayer ?: return + val playbackState = player.playbackState + val playWhenReady = player.playWhenReady + + when (playbackState) { + Player.STATE_READY -> { + if (playWhenReady) { + _playerState.update { PlayerState.Playing } + } else { + _playerState.update { PlayerState.Stopped } + } + } + Player.STATE_ENDED -> { + _playerState.update { PlayerState.Stopped } + } + Player.STATE_BUFFERING -> { + if (playWhenReady) { + _playerState.update { PlayerState.Playing } + } else { + _playerState.update { PlayerState.Stopped } + } + } + Player.STATE_IDLE -> { + _playerState.update { PlayerState.Stopped } + } + } + } + + @OptIn(UnstableApi::class) + override fun setupAudioStream(channelCount: Int) { + if (exoPlayer == null) { + val builder = ExoPlayer.Builder(context) + exoPlayer = builder.build().apply { + addListener(playerListener) + volume = currentVolume + playbackParameters = currentPlaybackParameters + + val mode = if (offloadSchedulingEnabled) { + TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED + } else { + TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_DISABLED + } + val offloadPrefs = TrackSelectionParameters.AudioOffloadPreferences.Builder() + .setAudioOffloadMode(mode) + .build() + trackSelectionParameters = trackSelectionParameters.buildUpon() + .setAudioOffloadPreferences(offloadPrefs) + .build() + } + } + _playerState.update { PlayerState.Initialized } + } + + override fun startPlaying(index: Int, mode: OboePerformanceMode?) { + _currentSongIndex.update { index } + val item = playlistItems[index] + if (item != null) { + setupAudioStream() + exoPlayer?.let { player -> + player.setMediaItem(item) + player.prepare() + player.play() + _playerState.update { PlayerState.Playing } + } + } else { + _playerState.update { PlayerState.Error } + } + } + + override fun stopPlaying(index: Int) { + exoPlayer?.stop() + _playerState.update { PlayerState.Stopped } + } + + override fun setVolume(volume: Float) { + currentVolume = volume + exoPlayer?.volume = volume + } + + override fun seekTo(positionMillis: Int) { + exoPlayer?.seekTo(positionMillis.toLong()) + } + + override fun getPlaybackPositionMillis(): Long { + return exoPlayer?.currentPosition ?: 0L + } + + override fun getDurationMillis(index: Int): Long { + return trackDurations[index] ?: 0L + } + + override fun getCurrentlyPlayingIndex(): Int { + val player = exoPlayer + return if (player != null && player.isPlaying) _currentSongIndex.value else -1 + } + + override fun teardownAudioStream() { + exoPlayer?.removeListener(playerListener) + exoPlayer?.release() + exoPlayer = null + } + + override fun loadFile(assetMgr: AssetManager, filename: String, id: Int): WavFileInfo? { + val assetUri = Uri.parse("asset:///$filename") + playlistItems[id] = MediaItem.fromUri(assetUri) + return null + } + + fun loadFile(filename: String, id: Int, durationMs: Long) { + val assetUri = Uri.parse("asset:///$filename") + playlistItems[id] = MediaItem.fromUri(assetUri) + trackDurations[id] = durationMs + } + + override fun loadLocalFile(contentResolver: ContentResolver, uri: Uri, index: Int): WavFileInfo? { + playlistItems[index] = MediaItem.fromUri(uri) + return null + } + + fun loadLocalFile(uri: Uri, index: Int, durationMs: Long) { + playlistItems[index] = MediaItem.fromUri(uri) + trackDurations[index] = durationMs + } + + override fun removeSampleSource(index: Int): Boolean { + playlistItems.remove(index) + trackDurations.remove(index) + return true + } + + override fun setLooping(index: Int, looping: Boolean) { + exoPlayer?.repeatMode = if (looping) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF + } + + override fun setPlaybackParameters(speed: Float, pitch: Float): Boolean { + val params = PlaybackParameters(speed, pitch) + currentPlaybackParameters = params + exoPlayer?.playbackParameters = params + return true + } + + override fun setMMapEnabled(enabled: Boolean) {} + override fun isMMapEnabled() = false + override fun isMMapSupported() = false + override fun setBufferSizeInFrames(bufferSizeInFrames: Int) = bufferSizeInFrames + override fun getBufferCapacityInFrames() = 0 + override fun isOffloaded() = false + override fun getSessionId() = 0 + override fun updatePerformanceMode(mode: OboePerformanceMode) {} + + @OptIn(UnstableApi::class) + override fun setOffloadSchedulingEnabled(enabled: Boolean) { + offloadSchedulingEnabled = enabled + val mode = if (enabled) { + TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED + } else { + TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_DISABLED + } + + val offloadPrefs = TrackSelectionParameters.AudioOffloadPreferences.Builder() + .setAudioOffloadMode(mode) + .build() + + exoPlayer?.let { player -> + player.trackSelectionParameters = player.trackSelectionParameters.buildUpon() + .setAudioOffloadPreferences(offloadPrefs) + .build() + } + } + + override fun isOffloadSchedulingEnabled(): Boolean = offloadSchedulingEnabled +} diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/PowerPlayAudioPlayer.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/PowerPlayAudioPlayer.kt index 346f55d11..5e37f4997 100644 --- a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/PowerPlayAudioPlayer.kt +++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/PowerPlayAudioPlayer.kt @@ -26,25 +26,30 @@ import androidx.lifecycle.asLiveData import com.google.oboe.samples.powerplay.effects.EffectsController import com.google.oboe.samples.powerplay.effects.EqualizerBand import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -class PowerPlayAudioPlayer() : DefaultLifecycleObserver { +class PowerPlayAudioPlayer() : AudioEngine, DefaultLifecycleObserver { + override val engineType = AudioEngineType.Oboe + private var _playerState = MutableStateFlow(PlayerState.NoResultYet) - fun getPlayerStateLive() = _playerState.asLiveData() + override val playerStateFlow: StateFlow get() = _playerState + override fun getPlayerStateLive() = _playerState.asLiveData() private var _currentSongIndex = MutableStateFlow(0) - fun getCurrentSongIndexLive() = _currentSongIndex.asLiveData() - val currentSongIndex: Int get() = _currentSongIndex.value + override val currentSongIndexFlow: StateFlow get() = _currentSongIndex + override fun getCurrentSongIndexLive() = _currentSongIndex.asLiveData() + override val currentSongIndex: Int get() = _currentSongIndex.value private var _currentPerformanceMode = OboePerformanceMode.None - val currentPerformanceMode: OboePerformanceMode get() = _currentPerformanceMode + override val currentPerformanceMode: OboePerformanceMode get() = _currentPerformanceMode /** * Native passthrough functions */ - val effectsController = EffectsController() + override val effectsController = EffectsController() - fun setupAudioStream(channelCount: Int = NUM_PLAY_CHANNELS) { + override fun setupAudioStream(channelCount: Int) { setupAudioStreamNative(channelCount) _playerState.update { PlayerState.Initialized } val sessionId = getSessionId() @@ -53,7 +58,7 @@ class PowerPlayAudioPlayer() : DefaultLifecycleObserver { } } - fun startPlaying(index: Int, mode: OboePerformanceMode?) { + override fun startPlaying(index: Int, mode: OboePerformanceMode?) { val actualMode = mode ?: _currentPerformanceMode _currentPerformanceMode = actualMode _currentSongIndex.update { index } @@ -62,12 +67,12 @@ class PowerPlayAudioPlayer() : DefaultLifecycleObserver { _playerState.update { PlayerState.Playing } } - fun stopPlaying(index: Int) { + override fun stopPlaying(index: Int) { stopPlayingNative(index) _playerState.update { PlayerState.Stopped } } - fun updatePerformanceMode(mode: OboePerformanceMode) { + override fun updatePerformanceMode(mode: OboePerformanceMode) { _currentPerformanceMode = mode updatePerformanceModeNative(mode) effectsController.release() @@ -77,19 +82,21 @@ class PowerPlayAudioPlayer() : DefaultLifecycleObserver { } } - fun setLooping(index: Int, looping: Boolean) = setLoopingNative(index, looping) - fun teardownAudioStream() { + override fun setLooping(index: Int, looping: Boolean) { + setLoopingNative(index, looping) + } + override fun teardownAudioStream() { effectsController.release() teardownAudioStreamNative() } fun unloadAssets() = unloadAssetsNative() - fun setPlaybackParameters(speed: Float, pitch: Float): Boolean = setPlaybackParametersNative(speed, pitch) + override fun setPlaybackParameters(speed: Float, pitch: Float): Boolean = setPlaybackParametersNative(speed, pitch) /** * Loads a file from assets into memory and returns its WAV properties. * Reads the file bytes once, probes the header, then loads the sample data. */ - fun loadFile(assetMgr: AssetManager, filename: String, id: Int): WavFileInfo? { + override fun loadFile(assetMgr: AssetManager, filename: String, id: Int): WavFileInfo? { val assetFD = assetMgr.openFd(filename) val stream = assetFD.createInputStream() val len = assetFD.getLength().toInt() @@ -141,7 +148,7 @@ class PowerPlayAudioPlayer() : DefaultLifecycleObserver { * @param index The sample source index to load into * @return WavFileInfo if successful, null on failure */ - fun loadLocalFile(contentResolver: ContentResolver, uri: Uri, index: Int): WavFileInfo? { + override fun loadLocalFile(contentResolver: ContentResolver, uri: Uri, index: Int): WavFileInfo? { return try { val inputStream = contentResolver.openInputStream(uri) ?: throw IllegalStateException("Cannot open input stream for URI: $uri") @@ -175,25 +182,27 @@ class PowerPlayAudioPlayer() : DefaultLifecycleObserver { * @param index The index of the sample source to remove * @return true if successfully removed */ - fun removeSampleSource(index: Int): Boolean = removeSampleSourceNative(index) + override fun removeSampleSource(index: Int): Boolean = removeSampleSourceNative(index) /** * Sets whether the audio stream should use MMap audio. * @param enabled True to enable MMap, false to disable. */ - fun setMMapEnabled(enabled: Boolean) = setMMapEnabledNative(enabled) + override fun setMMapEnabled(enabled: Boolean) { + setMMapEnabledNative(enabled) + } /** * Checks if MMap is currently enabled. * @return True if MMap is enabled, false otherwise. */ - fun isMMapEnabled(): Boolean = isMMapEnabledNative() + override fun isMMapEnabled(): Boolean = isMMapEnabledNative() /** * Checks if MMap is supported by the current device. * @return True if MMap is supported, false otherwise. */ - fun isMMapSupported(): Boolean = isMMapSupportedNative() + override fun isMMapSupported(): Boolean = isMMapSupportedNative() /** * Sets the buffer size in frames for the audio stream. @@ -204,46 +213,49 @@ class PowerPlayAudioPlayer() : DefaultLifecycleObserver { * @param bufferSizeInFrames The requested buffer size in frames. * @return The actual buffer size set by the native audio engine. */ - fun setBufferSizeInFrames(bufferSizeInFrames: Int): Int = setBufferSizeInFramesNative(bufferSizeInFrames) + override fun setBufferSizeInFrames(bufferSizeInFrames: Int): Int = setBufferSizeInFramesNative(bufferSizeInFrames) - fun getBufferCapacityInFrames(): Int = getBufferCapacityInFramesNative() + override fun getBufferCapacityInFrames(): Int = getBufferCapacityInFramesNative() /** * Sets the playback volume (gain) for the audio stream. * * @param volume Volume level from 0.0 (mute) to 1.0 (full volume) */ - fun setVolume(volume: Float) = setVolumeNative(volume) + override fun setVolume(volume: Float) = setVolumeNative(volume) /** * Checks if the current audio stream is using PCM Offload. * * @return true if offload is active, false otherwise */ - fun isOffloaded(): Boolean = isOffloadedNative() - fun getSessionId(): Int = getSessionIdNative() + override fun isOffloaded(): Boolean = isOffloadedNative() + override fun getSessionId(): Int = getSessionIdNative() /** * Gets the index of the currently playing track. * * @return Track index (0-based) or -1 if nothing is playing */ - fun getCurrentlyPlayingIndex(): Int = getCurrentlyPlayingIndexNative() + override fun getCurrentlyPlayingIndex(): Int = getCurrentlyPlayingIndexNative() /** * Gets the current playback position in milliseconds. */ - fun getPlaybackPositionMillis(): Long = getPlaybackPositionMillisNative() + override fun getPlaybackPositionMillis(): Long = getPlaybackPositionMillisNative() /** * Seeks to a specific position in milliseconds. */ - fun seekTo(positionMillis: Int) = seekToNative(positionMillis) + override fun seekTo(positionMillis: Int) = seekToNative(positionMillis) /** * Gets the duration of the track at the specified index in milliseconds. */ - fun getDurationMillis(index: Int): Long = getDurationMillisNative(index) + override fun getDurationMillis(index: Int): Long = getDurationMillisNative(index) + + override fun setOffloadSchedulingEnabled(enabled: Boolean) {} + override fun isOffloadSchedulingEnabled(): Boolean = false /** * Native functions. From dec933de645d7ba24f5d116f578d26f473275d70 Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Tue, 16 Jun 2026 23:01:46 -0400 Subject: [PATCH 2/3] Code Review, Fixing Bugs --- .../oboe/samples/powerplay/MainActivity.kt | 54 +++++---- .../engine/AudioForegroundService.kt | 5 + .../powerplay/engine/DelegatingAudioEngine.kt | 35 +++++- .../powerplay/engine/ExoPlayerAudioEngine.kt | 106 ++++++++---------- 4 files changed, 105 insertions(+), 95 deletions(-) diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt index 485e574b3..c7a28e66d 100644 --- a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt +++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/MainActivity.kt @@ -500,9 +500,12 @@ class MainActivity : ComponentActivity() { var assetsReady by remember { mutableStateOf(false) } var playbackPosition by remember { mutableLongStateOf(0L) } var isSeeking by remember { mutableStateOf(false) } - val duration = remember(playingSongIndex.intValue, assetsReady, playList.size) { player.getDurationMillis(playingSongIndex.intValue) } - LaunchedEffect(isPlaying, offload.intValue) { - if (isPlaying && offload.intValue != 3) { + val duration = remember(playingSongIndex.intValue, assetsReady, playList.size, engineTypeState.value) { player.getDurationMillis(playingSongIndex.intValue) } + // ExoPlayer reports position in every mode; only Oboe's PCM Offload mode (3) doesn't, so + // the seek bar / position polling are suppressed solely for that case. + val isPositionTrackable = engineTypeState.value == AudioEngineType.ExoPlayer || offload.intValue != 3 + LaunchedEffect(isPlaying, offload.intValue, engineTypeState.value) { + if (isPlaying && isPositionTrackable) { while (true) { if (!isSeeking) { playbackPosition = player.getPlaybackPositionMillis() @@ -684,7 +687,7 @@ class MainActivity : ComponentActivity() { Spacer(modifier = Modifier.height(24.dp)) - AnimatedVisibility(visible = offload.intValue != 3) { + AnimatedVisibility(visible = isPositionTrackable) { Column( modifier = Modifier .fillMaxWidth() @@ -923,7 +926,16 @@ class MainActivity : ComponentActivity() { LaunchedEffect(playbackSpeed) { localSpeed = playbackSpeed } LaunchedEffect(playbackPitch) { localPitch = playbackPitch } - var currentEngineType by remember { mutableStateOf(player.engineType) } + // engineTypeState is the single source of truth for the active engine, shared with the + // Effects button and the Info dialog. Updating it here keeps the whole screen in sync. + val onSelectEngine: (AudioEngineType) -> Unit = { type -> + if (engineTypeState.value != type) { + engineTypeState.value = type + (player as? DelegatingAudioEngine)?.switchEngine(type) + isMMapEnabled.value = player.isMMapEnabled() + isOffloadSchedulingEnabledState.value = player.isOffloadSchedulingEnabled() + } + } Column( modifier = Modifier @@ -946,25 +958,11 @@ class MainActivity : ComponentActivity() { AudioEngineType.values().forEach { type -> Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { - if (currentEngineType != type) { - currentEngineType = type - (player as? DelegatingAudioEngine)?.switchEngine(type) - isMMapEnabled.value = player.isMMapEnabled() - isOffloadSchedulingEnabledState.value = player.isOffloadSchedulingEnabled() - } - } + modifier = Modifier.clickable { onSelectEngine(type) } ) { RadioButton( - selected = (currentEngineType == type), - onClick = { - if (currentEngineType != type) { - currentEngineType = type - (player as? DelegatingAudioEngine)?.switchEngine(type) - isMMapEnabled.value = player.isMMapEnabled() - isOffloadSchedulingEnabledState.value = player.isOffloadSchedulingEnabled() - } - } + selected = (engineTypeState.value == type), + onClick = { onSelectEngine(type) } ) Text( text = type.name, @@ -975,7 +973,7 @@ class MainActivity : ComponentActivity() { } } - if (currentEngineType == AudioEngineType.ExoPlayer) { + if (engineTypeState.value == AudioEngineType.ExoPlayer) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp) @@ -995,7 +993,7 @@ class MainActivity : ComponentActivity() { } } - if (currentEngineType == AudioEngineType.Oboe) { + if (engineTypeState.value == AudioEngineType.Oboe) { Text( text = "Performance Modes", fontSize = 20.sp, @@ -1100,14 +1098,14 @@ class MainActivity : ComponentActivity() { val isOffload = offload.intValue == 3 val isMMap = isMMapEnabled.value - var canUseSpeed = isPlaybackParamsSupported || currentEngineType == AudioEngineType.ExoPlayer - var canUsePitch = isPlaybackParamsSupported || currentEngineType == AudioEngineType.ExoPlayer + val canUseSpeed = isPlaybackParamsSupported || engineTypeState.value == AudioEngineType.ExoPlayer + val canUsePitch = isPlaybackParamsSupported || engineTypeState.value == AudioEngineType.ExoPlayer // For testing: allow everything except API 37 gate // The previous offload restrictions have been removed to test allowing everything. Spacer(modifier = Modifier.height(16.dp)) - val speedSupportText = if (!isPlaybackParamsSupported && currentEngineType == AudioEngineType.Oboe) " (Requires API 37)" else "" + val speedSupportText = if (!isPlaybackParamsSupported && engineTypeState.value == AudioEngineType.Oboe) " (Requires API 37)" else "" Text( text = "Playback Speed: ${"%.2f".format(playbackSpeed)}x$speedSupportText", style = MaterialTheme.typography.bodyMedium, @@ -1135,7 +1133,7 @@ class MainActivity : ComponentActivity() { ) Spacer(modifier = Modifier.height(8.dp)) - val pitchSupportText = if (!isPlaybackParamsSupported && currentEngineType == AudioEngineType.Oboe) " (Requires API 37)" else "" + val pitchSupportText = if (!isPlaybackParamsSupported && engineTypeState.value == AudioEngineType.Oboe) " (Requires API 37)" else "" Text( text = "Playback Pitch: ${"%.2f".format(playbackPitch)}x$pitchSupportText", style = MaterialTheme.typography.bodyMedium, diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/AudioForegroundService.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/AudioForegroundService.kt index 790092054..0f799bf53 100644 --- a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/AudioForegroundService.kt +++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/AudioForegroundService.kt @@ -42,6 +42,7 @@ import com.google.oboe.samples.powerplay.R import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -167,6 +168,10 @@ class AudioForegroundService : Service() { if (::mediaSession.isInitialized) { mediaSession.release() } + + // Cancel the scope so the engine's state-forwarding collectors stop and don't + // retain the (now destroyed) Service context, the ExoPlayer, or the engines. + serviceScope.cancel() } private fun loadAlbumArt(index: Int) { diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/DelegatingAudioEngine.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/DelegatingAudioEngine.kt index 715e36960..f24843207 100644 --- a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/DelegatingAudioEngine.kt +++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/DelegatingAudioEngine.kt @@ -26,7 +26,6 @@ import com.google.oboe.samples.powerplay.effects.EffectsController import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class DelegatingAudioEngine( @@ -39,6 +38,14 @@ class DelegatingAudioEngine( private val oboeEngine = PowerPlayAudioPlayer() private val exoPlayerEngine = ExoPlayerAudioEngine(context) + // Desired playback settings, mirrored to both engines. Tearing down and re-creating an + // engine's native stream on switch resets these to defaults, so they are re-applied to the + // newly active engine in switchEngine(). + private var desiredVolume = 1.0f + private var desiredSpeed = 1.0f + private var desiredPitch = 1.0f + private var desiredLooping = false + private val currentEngine: AudioEngine get() = when (_activeEngineType) { AudioEngineType.Oboe -> oboeEngine @@ -100,6 +107,12 @@ class DelegatingAudioEngine( currentEngine.setupAudioStream() + // setupAudioStream creates a fresh native stream/player at default settings; restore the + // user's mirrored playback settings on the now-active engine. + currentEngine.setVolume(desiredVolume) + currentEngine.setPlaybackParameters(desiredSpeed, desiredPitch) + currentEngine.setLooping(index, desiredLooping) + _playerState.value = currentEngine.playerStateFlow.value _currentSongIndex.value = currentEngine.currentSongIndexFlow.value @@ -130,11 +143,13 @@ class DelegatingAudioEngine( } override fun setLooping(index: Int, looping: Boolean) { + desiredLooping = looping oboeEngine.setLooping(index, looping) exoPlayerEngine.setLooping(index, looping) } override fun setVolume(volume: Float) { + desiredVolume = volume oboeEngine.setVolume(volume) exoPlayerEngine.setVolume(volume) } @@ -161,9 +176,12 @@ class DelegatingAudioEngine( } override fun loadFile(assetMgr: AssetManager, filename: String, id: Int): WavFileInfo? { + // Oboe probes the WAV header and returns the metadata; mirror the source into ExoPlayer + // and hand it the duration Oboe computed. val wavInfo = oboeEngine.loadFile(assetMgr, filename, id) if (wavInfo != null) { - exoPlayerEngine.loadFile(filename, id, wavInfo.durationMs) + exoPlayerEngine.loadFile(assetMgr, filename, id) + exoPlayerEngine.setTrackDuration(id, wavInfo.durationMs) } return wavInfo } @@ -171,7 +189,8 @@ class DelegatingAudioEngine( override fun loadLocalFile(contentResolver: ContentResolver, uri: Uri, index: Int): WavFileInfo? { val wavInfo = oboeEngine.loadLocalFile(contentResolver, uri, index) if (wavInfo != null) { - exoPlayerEngine.loadLocalFile(uri, index, wavInfo.durationMs) + exoPlayerEngine.loadLocalFile(contentResolver, uri, index) + exoPlayerEngine.setTrackDuration(index, wavInfo.durationMs) } return wavInfo } @@ -183,9 +202,13 @@ class DelegatingAudioEngine( } override fun setPlaybackParameters(speed: Float, pitch: Float): Boolean { - val r1 = oboeEngine.setPlaybackParameters(speed, pitch) - val r2 = exoPlayerEngine.setPlaybackParameters(speed, pitch) - return r1 && r2 + desiredSpeed = speed + desiredPitch = pitch + val oboeResult = oboeEngine.setPlaybackParameters(speed, pitch) + val exoResult = exoPlayerEngine.setPlaybackParameters(speed, pitch) + // Report the result of the engine that is actually playing, so the inactive engine + // (e.g. Oboe failing below API 37) doesn't veto a change the active engine applied. + return if (_activeEngineType == AudioEngineType.Oboe) oboeResult else exoResult } override fun setMMapEnabled(enabled: Boolean) { diff --git a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/ExoPlayerAudioEngine.kt b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/ExoPlayerAudioEngine.kt index 06876d0ab..8c0c597b0 100644 --- a/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/ExoPlayerAudioEngine.kt +++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/ExoPlayerAudioEngine.kt @@ -33,6 +33,7 @@ import com.google.oboe.samples.powerplay.effects.EffectsController import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import java.util.concurrent.ConcurrentHashMap class ExoPlayerAudioEngine(private val context: Context) : AudioEngine { private var exoPlayer: ExoPlayer? = null @@ -41,7 +42,7 @@ class ExoPlayerAudioEngine(private val context: Context) : AudioEngine { override val playerStateFlow: StateFlow get() = _playerState override fun getPlayerStateLive(): LiveData = _playerState.asLiveData() - private var _currentSongIndex = MutableStateFlow(0) + private val _currentSongIndex = MutableStateFlow(0) override val currentSongIndexFlow: StateFlow get() = _currentSongIndex override fun getCurrentSongIndexLive(): LiveData = _currentSongIndex.asLiveData() override val currentSongIndex: Int get() = _currentSongIndex.value @@ -50,8 +51,10 @@ class ExoPlayerAudioEngine(private val context: Context) : AudioEngine { override val effectsController: EffectsController? = null override val engineType = AudioEngineType.ExoPlayer - private val playlistItems = mutableMapOf() - private val trackDurations = mutableMapOf() + // Written from the background file-loading thread and read on the main thread, so use a + // concurrent map to avoid corrupting the structure under concurrent access. + private val playlistItems = ConcurrentHashMap() + private val trackDurations = ConcurrentHashMap() private var currentVolume = 1.0f private var currentPlaybackParameters = PlaybackParameters.DEFAULT @@ -73,24 +76,10 @@ class ExoPlayerAudioEngine(private val context: Context) : AudioEngine { val playWhenReady = player.playWhenReady when (playbackState) { - Player.STATE_READY -> { - if (playWhenReady) { - _playerState.update { PlayerState.Playing } - } else { - _playerState.update { PlayerState.Stopped } - } + Player.STATE_READY, Player.STATE_BUFFERING -> { + _playerState.update { if (playWhenReady) PlayerState.Playing else PlayerState.Stopped } } - Player.STATE_ENDED -> { - _playerState.update { PlayerState.Stopped } - } - Player.STATE_BUFFERING -> { - if (playWhenReady) { - _playerState.update { PlayerState.Playing } - } else { - _playerState.update { PlayerState.Stopped } - } - } - Player.STATE_IDLE -> { + Player.STATE_ENDED, Player.STATE_IDLE -> { _playerState.update { PlayerState.Stopped } } } @@ -99,28 +88,32 @@ class ExoPlayerAudioEngine(private val context: Context) : AudioEngine { @OptIn(UnstableApi::class) override fun setupAudioStream(channelCount: Int) { if (exoPlayer == null) { - val builder = ExoPlayer.Builder(context) - exoPlayer = builder.build().apply { + exoPlayer = ExoPlayer.Builder(context).build().apply { addListener(playerListener) volume = currentVolume playbackParameters = currentPlaybackParameters - - val mode = if (offloadSchedulingEnabled) { - TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED - } else { - TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_DISABLED - } - val offloadPrefs = TrackSelectionParameters.AudioOffloadPreferences.Builder() - .setAudioOffloadMode(mode) - .build() - trackSelectionParameters = trackSelectionParameters.buildUpon() - .setAudioOffloadPreferences(offloadPrefs) - .build() + applyOffloadPreferences(this) } } _playerState.update { PlayerState.Initialized } } + /** Applies the current offload-scheduling preference to [player]. */ + @OptIn(UnstableApi::class) + private fun applyOffloadPreferences(player: ExoPlayer) { + val mode = if (offloadSchedulingEnabled) { + TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED + } else { + TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_DISABLED + } + val offloadPrefs = TrackSelectionParameters.AudioOffloadPreferences.Builder() + .setAudioOffloadMode(mode) + .build() + player.trackSelectionParameters = player.trackSelectionParameters.buildUpon() + .setAudioOffloadPreferences(offloadPrefs) + .build() + } + override fun startPlaying(index: Int, mode: OboePerformanceMode?) { _currentSongIndex.update { index } val item = playlistItems[index] @@ -170,32 +163,37 @@ class ExoPlayerAudioEngine(private val context: Context) : AudioEngine { exoPlayer = null } + // ExoPlayer probes audio properties only after preparing an item, so loadFile/loadLocalFile + // just register the source URI here and return null; the WAV duration is supplied separately + // via setTrackDuration() from the duration the Oboe engine computed. override fun loadFile(assetMgr: AssetManager, filename: String, id: Int): WavFileInfo? { - val assetUri = Uri.parse("asset:///$filename") - playlistItems[id] = MediaItem.fromUri(assetUri) + playlistItems[id] = MediaItem.fromUri(Uri.parse("asset:///$filename")) return null } - fun loadFile(filename: String, id: Int, durationMs: Long) { - val assetUri = Uri.parse("asset:///$filename") - playlistItems[id] = MediaItem.fromUri(assetUri) - trackDurations[id] = durationMs - } - override fun loadLocalFile(contentResolver: ContentResolver, uri: Uri, index: Int): WavFileInfo? { playlistItems[index] = MediaItem.fromUri(uri) return null } - fun loadLocalFile(uri: Uri, index: Int, durationMs: Long) { - playlistItems[index] = MediaItem.fromUri(uri) + /** Records the duration (probed by the Oboe engine) for the track at [index]. */ + fun setTrackDuration(index: Int, durationMs: Long) { trackDurations[index] = durationMs } override fun removeSampleSource(index: Int): Boolean { - playlistItems.remove(index) + val existed = playlistItems.remove(index) != null trackDurations.remove(index) - return true + // The native Oboe player erases from a vector and the UI playlist uses removeAt(index), + // both of which shift higher indices down by one. Mirror that here so the index-keyed + // maps stay aligned with track positions. + playlistItems.keys.filter { it > index }.sorted().forEach { key -> + playlistItems[key - 1] = playlistItems.remove(key)!! + } + trackDurations.keys.filter { it > index }.sorted().forEach { key -> + trackDurations[key - 1] = trackDurations.remove(key)!! + } + return existed } override fun setLooping(index: Int, looping: Boolean) { @@ -221,21 +219,7 @@ class ExoPlayerAudioEngine(private val context: Context) : AudioEngine { @OptIn(UnstableApi::class) override fun setOffloadSchedulingEnabled(enabled: Boolean) { offloadSchedulingEnabled = enabled - val mode = if (enabled) { - TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED - } else { - TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_DISABLED - } - - val offloadPrefs = TrackSelectionParameters.AudioOffloadPreferences.Builder() - .setAudioOffloadMode(mode) - .build() - - exoPlayer?.let { player -> - player.trackSelectionParameters = player.trackSelectionParameters.buildUpon() - .setAudioOffloadPreferences(offloadPrefs) - .build() - } + exoPlayer?.let { applyOffloadPreferences(it) } } override fun isOffloadSchedulingEnabled(): Boolean = offloadSchedulingEnabled From 2c3e1bac64440debaeb715acb40cbac0d3f3b60e Mon Sep 17 00:00:00 2001 From: Mozart Louis Date: Wed, 17 Jun 2026 00:03:46 -0400 Subject: [PATCH 3/3] build: upgrade androidx.media3:media3-exoplayer to 1.10.1 --- samples/powerplay/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/powerplay/build.gradle b/samples/powerplay/build.gradle index 9151c23e1..f0a8204c7 100644 --- a/samples/powerplay/build.gradle +++ b/samples/powerplay/build.gradle @@ -78,7 +78,7 @@ dependencies { implementation 'androidx.activity:activity-compose:1.10.1' implementation 'androidx.appcompat:appcompat:1.7.1' implementation 'androidx.compose.runtime:runtime-livedata:1.10.0' - implementation "androidx.media3:media3-exoplayer:1.5.1" + implementation "androidx.media3:media3-exoplayer:1.10.1" testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'