PowerPlay: Add playback speed and pitch control (#2372)
* PowerPlay: Add playback speed and pitch control
* PowerPlay: Refine playback parameter support and add reset button
* PowerPlay: Allow speed control for 44100 Hz when using MMAP.
* refactor: remove explicit buffer capacity configuration in PowerPlayMultiPlayer audio stream builder
* refactor: update playback parameters API to return success status and handle state synchronization in UI
diff --git a/samples/.gitignore b/samples/.gitignore
index b0b2397..3d9979c 100644
--- a/samples/.gitignore
+++ b/samples/.gitignore
@@ -8,3 +8,4 @@
/captures
.externalNativeBuild
test/build
+*.salive
diff --git a/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp b/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp
index b916a41..f3e1727 100644
--- a/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp
+++ b/samples/powerplay/src/main/cpp/PowerPlayJNI.cpp
@@ -420,6 +420,18 @@
return player.removeSampleSource(index);
}
+/**
+ * Native (JNI) implementation of PowerPlayAudioPlayer.setPlaybackParametersNative()
+ */
+JNIEXPORT jboolean JNICALL
+Java_com_google_oboe_samples_powerplay_engine_PowerPlayAudioPlayer_setPlaybackParametersNative(
+ JNIEnv *env,
+ jobject,
+ jfloat speed,
+ jfloat pitch) {
+ return player.setPlaybackParameters(speed, pitch);
+}
+
#ifdef __cplusplus
}
#endif
diff --git a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp
index 9b97592..f7f3715 100644
--- a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp
+++ b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.cpp
@@ -71,9 +71,7 @@
if (mAudioStream->getPerformanceMode() != oboe::PerformanceMode::PowerSavingOffloaded ||
!OboeExtensions::isMMapUsed(mAudioStream.get())) {
- constexpr int32_t kBufferSizeInBursts = 2; // Use 2 bursts as the buffer size (double buffer)
- result = mAudioStream->setBufferSizeInFrames(
- mAudioStream->getFramesPerBurst() * kBufferSizeInBursts);
+ result = mAudioStream->setBufferSizeInFrames(mAudioStream->getBufferCapacityInFrames());
if (result != Result::OK) {
__android_log_print(
ANDROID_LOG_WARN,
@@ -84,6 +82,10 @@
}
mSampleRate = mAudioStream->getSampleRate();
+
+ // Apply stored playback parameters
+ setPlaybackParameters(mPlaybackSpeed, mPlaybackPitch);
+
return true;
}
@@ -350,3 +352,23 @@
index, mNumSampleBuffers);
return true;
}
+
+bool PowerPlayMultiPlayer::setPlaybackParameters(float speed, float pitch) {
+ if (mAudioStream) {
+ oboe::PlaybackParameters params = {
+ oboe::FallbackMode::Default,
+ oboe::StretchMode::Default,
+ pitch,
+ speed
+ };
+ auto result = mAudioStream->setPlaybackParameters(params);
+ if (result != oboe::Result::OK) {
+ __android_log_print(ANDROID_LOG_ERROR, "PowerPlayMultiPlayer",
+ "setPlaybackParameters failed: %s", oboe::convertToText(result));
+ return false;
+ }
+ }
+ mPlaybackSpeed = speed;
+ mPlaybackPitch = pitch;
+ return true;
+}
diff --git a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h
index 373449a..5680b10 100644
--- a/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h
+++ b/samples/powerplay/src/main/cpp/PowerPlayMultiPlayer.h
@@ -60,6 +60,8 @@
bool removeSampleSource(int32_t index);
+ bool setPlaybackParameters(float speed, float pitch);
+
private:
class MyPresentationCallback : public oboe::AudioStreamPresentationCallback {
public:
@@ -79,6 +81,8 @@
oboe::PerformanceMode mLastPerformanceMode;
bool mLastMMapEnabled;
+ float mPlaybackSpeed = 1.0f;
+ float mPlaybackPitch = 1.0f;
};
#endif //SAMPLES_POWERPLAYMULTIPLAYER_H
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 fb97ae4..4a6c74b 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
@@ -477,6 +477,8 @@
val playerStateWrapper = player.getPlayerStateLive().observeAsState(PlayerState.NoResultYet)
val isPlaying = playerStateWrapper.value == PlayerState.Playing
var sliderPosition by remember { mutableFloatStateOf(0f) }
+ var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
+ var playbackPitch by remember { mutableFloatStateOf(1.0f) }
var showBottomSheet by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@@ -731,12 +733,30 @@
containerColor = Color.White,
shape = androidx.compose.foundation.shape.RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
) {
+ val currentTrack = playList.getOrNull(playingSongIndex.intValue)
PerformanceBottomSheetContent(
offload = offload,
isMMapEnabled = isMMapEnabled,
isPlaying = isPlaying,
sliderPosition = sliderPosition,
onSliderPositionChange = { sliderPosition = it },
+ playbackSpeed = playbackSpeed,
+ playbackPitch = playbackPitch,
+ onSpeedChange = {
+ val success = player.setPlaybackParameters(it, playbackPitch)
+ if (success) {
+ playbackSpeed = it
+ }
+ success
+ },
+ onPitchChange = {
+ val success = player.setPlaybackParameters(playbackSpeed, it)
+ if (success) {
+ playbackPitch = it
+ }
+ success
+ },
+ fileSampleRate = currentTrack?.wavInfo?.sampleRate ?: 48000,
onDismiss = { showBottomSheet = false }
)
}
@@ -843,11 +863,23 @@
isPlaying: Boolean,
sliderPosition: Float,
onSliderPositionChange: (Float) -> Unit,
+ playbackSpeed: Float,
+ playbackPitch: Float,
+ onSpeedChange: (Float) -> Boolean,
+ onPitchChange: (Float) -> Boolean,
+ fileSampleRate: Int,
onDismiss: () -> Unit
) {
var localSliderPosition by remember { mutableFloatStateOf(sliderPosition) }
val requestedFrames = remember { mutableIntStateOf(0) }
val actualFrames = remember { mutableIntStateOf(0) }
+ var isModified by remember { mutableStateOf(playbackSpeed != 1.0f || playbackPitch != 1.0f) }
+
+ var localSpeed by remember { mutableFloatStateOf(playbackSpeed) }
+ var localPitch by remember { mutableFloatStateOf(playbackPitch) }
+
+ LaunchedEffect(playbackSpeed) { localSpeed = playbackSpeed }
+ LaunchedEffect(playbackPitch) { localPitch = playbackPitch }
Column(
modifier = Modifier
@@ -955,6 +987,92 @@
)
}
+ val isPlaybackParamsSupported = android.os.Build.VERSION.SDK_INT >= 37
+ val isOffload = offload.intValue == 3
+ val isMMap = isMMapEnabled.value
+
+ var canUseSpeed = isPlaybackParamsSupported
+ var canUsePitch = isPlaybackParamsSupported
+
+ // 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 ""
+ Text(
+ text = "Playback Speed: ${"%.2f".format(playbackSpeed)}x$speedSupportText",
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (canUseSpeed) Color.Unspecified else Color.Gray
+ )
+ Slider(
+ value = localSpeed,
+ onValueChange = {
+ localSpeed = it
+ onSpeedChange(it)
+ },
+ onValueChangeFinished = {
+ if (localSpeed != playbackSpeed) {
+ localSpeed = playbackSpeed
+ }
+ isModified = (playbackSpeed != 1.0f || playbackPitch != 1.0f)
+ },
+ valueRange = 0.5f..2.0f,
+ enabled = canUseSpeed,
+ colors = SliderDefaults.colors(
+ thumbColor = MaterialTheme.colorScheme.primary,
+ activeTrackColor = MaterialTheme.colorScheme.primary
+ ),
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+ val pitchSupportText = if (!isPlaybackParamsSupported) " (Requires API 37)" else ""
+ Text(
+ text = "Playback Pitch: ${"%.2f".format(playbackPitch)}x$pitchSupportText",
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (canUsePitch) Color.Unspecified else Color.Gray
+ )
+ Slider(
+ value = localPitch,
+ onValueChange = {
+ localPitch = it
+ onPitchChange(it)
+ },
+ onValueChangeFinished = {
+ if (localPitch != playbackPitch) {
+ localPitch = playbackPitch
+ }
+ isModified = (playbackSpeed != 1.0f || playbackPitch != 1.0f)
+ },
+ valueRange = 0.5f..2.0f,
+ enabled = canUsePitch,
+ colors = SliderDefaults.colors(
+ thumbColor = MaterialTheme.colorScheme.primary,
+ activeTrackColor = MaterialTheme.colorScheme.primary
+ ),
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ AnimatedVisibility(
+ visible = isModified,
+ enter = androidx.compose.animation.fadeIn(animationSpec = androidx.compose.animation.core.tween(durationMillis = 500)) + androidx.compose.animation.expandVertically(),
+ exit = androidx.compose.animation.fadeOut(animationSpec = androidx.compose.animation.core.tween(durationMillis = 500)) + androidx.compose.animation.shrinkVertically()
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Spacer(modifier = Modifier.height(16.dp))
+ TextButton(
+ onClick = {
+ onSpeedChange(1.0f)
+ onPitchChange(1.0f)
+ isModified = false
+ },
+ enabled = canUseSpeed || canUsePitch
+ ) {
+ Text("Reset to Defaults")
+ }
+ }
+ }
+
AnimatedVisibility(
visible = offload.intValue == 3,
enter = androidx.compose.animation.expandVertically() + fadeIn(),
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 747fa6c..e7500f2 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
@@ -66,6 +66,7 @@
fun setLooping(index: Int, looping: Boolean) = setLoopingNative(index, looping)
fun teardownAudioStream() = teardownAudioStreamNative()
fun unloadAssets() = unloadAssetsNative()
+ fun setPlaybackParameters(speed: Float, pitch: Float): Boolean = setPlaybackParametersNative(speed, pitch)
/**
* Loads a file from assets into memory and returns its WAV properties.
@@ -258,6 +259,7 @@
private external fun getDurationMillisNative(index: Int): Long
private external fun getWavFileInfoNative(wavBytes: ByteArray): IntArray
private external fun removeSampleSourceNative(index: Int): Boolean
+ private external fun setPlaybackParametersNative(speed: Float, pitch: Float): Boolean
/**
* Companion