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