diff --git a/samples/powerplay/build.gradle b/samples/powerplay/build.gradle index fe2a11d85..f0a8204c7 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.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' 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..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 @@ -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) @@ -494,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() @@ -582,18 +591,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")) }, @@ -676,7 +687,7 @@ class MainActivity : ComponentActivity() { Spacer(modifier = Modifier.height(24.dp)) - AnimatedVisibility(visible = offload.intValue != 3) { + AnimatedVisibility(visible = isPositionTrackable) { Column( modifier = Modifier .fillMaxWidth() @@ -774,17 +785,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 +830,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 +926,17 @@ class MainActivity : ComponentActivity() { LaunchedEffect(playbackSpeed) { localSpeed = playbackSpeed } LaunchedEffect(playbackPitch) { localPitch = playbackPitch } + // 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 .fillMaxWidth() @@ -913,116 +945,167 @@ 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 { onSelectEngine(type) } ) { RadioButton( - selected = (text == selectedOption), - onClick = null, - enabled = enabled, - colors = RadioButtonDefaults.colors( - selectedColor = MaterialTheme.colorScheme.primary - ) + selected = (engineTypeState.value == type), + onClick = { onSelectEngine(type) } ) 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 (engineTypeState.value == 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 (engineTypeState.value == AudioEngineType.Oboe) { + Text( + 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 (isMMapEnabled.value) { - true -> "| Current Mode: MMAP" - false -> "| Current Mode: Classic" + 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 + 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) " (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, @@ -1050,7 +1133,7 @@ class MainActivity : ComponentActivity() { ) Spacer(modifier = Modifier.height(8.dp)) - val pitchSupportText = if (!isPlaybackParamsSupported) " (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/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..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 @@ -54,7 +55,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 +88,7 @@ class AudioForegroundService : Service() { override fun onCreate() { super.onCreate() try { - player = PowerPlayAudioPlayer() + player = DelegatingAudioEngine(this, serviceScope) player.setupAudioStream() audioManager = getSystemService(AUDIO_SERVICE) as AudioManager @@ -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 new file mode 100644 index 000000000..f24843207 --- /dev/null +++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/DelegatingAudioEngine.kt @@ -0,0 +1,253 @@ +/* + * 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.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) + + // 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 + 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() + + // 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 + + 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) { + desiredLooping = looping + oboeEngine.setLooping(index, looping) + exoPlayerEngine.setLooping(index, looping) + } + + override fun setVolume(volume: Float) { + desiredVolume = volume + 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? { + // 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(assetMgr, filename, id) + exoPlayerEngine.setTrackDuration(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(contentResolver, uri, index) + exoPlayerEngine.setTrackDuration(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 { + 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) { + 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..8c0c597b0 --- /dev/null +++ b/samples/powerplay/src/main/kotlin/com/google/oboe/samples/powerplay/engine/ExoPlayerAudioEngine.kt @@ -0,0 +1,226 @@ +/* + * 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 +import java.util.concurrent.ConcurrentHashMap + +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 val _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 + + // 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 + 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, Player.STATE_BUFFERING -> { + _playerState.update { if (playWhenReady) PlayerState.Playing else PlayerState.Stopped } + } + Player.STATE_ENDED, Player.STATE_IDLE -> { + _playerState.update { PlayerState.Stopped } + } + } + } + + @OptIn(UnstableApi::class) + override fun setupAudioStream(channelCount: Int) { + if (exoPlayer == null) { + exoPlayer = ExoPlayer.Builder(context).build().apply { + addListener(playerListener) + volume = currentVolume + playbackParameters = currentPlaybackParameters + 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] + 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 + } + + // 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? { + playlistItems[id] = MediaItem.fromUri(Uri.parse("asset:///$filename")) + return null + } + + override fun loadLocalFile(contentResolver: ContentResolver, uri: Uri, index: Int): WavFileInfo? { + playlistItems[index] = MediaItem.fromUri(uri) + return null + } + + /** 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 { + val existed = playlistItems.remove(index) != null + trackDurations.remove(index) + // 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) { + 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 + exoPlayer?.let { applyOffloadPreferences(it) } + } + + 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.