diff --git a/samples/OboeDJ/README.md b/samples/OboeDJ/README.md
new file mode 100644
index 000000000..6b648be37
--- /dev/null
+++ b/samples/OboeDJ/README.md
@@ -0,0 +1,30 @@
+# OboeDJ Sample
+
+A high-performance, low-latency DJ sample application using the Oboe library. It features two independent playback decks, real-time audio scratching, and a Jetpack Compose UI.
+
+## Features
+
+- **Dual Playback Decks**: Play two tracks simultaneously.
+- **Audio Scratching**: Interactive vinyl wheels allow you to scratch audio in real-time.
+- **BPM Synchronization**: Automatically sync the tempo of one deck to the other.
+- **Track Queueing**: Load a third track into either deck dynamically.
+- **Crossfader**: Smoothly mix between the left and right decks.
+
+## UI Overview
+
+- **Vinyl Wheels**: Tap and drag to scratch. Release to resume normal playback.
+- **Play/Pause Buttons**: Control playback for each deck.
+- **Speed Sliders**: Adjust playback speed multiplier (0.5x to 2.0x).
+- **Sync BPM Button**: Automatically calculates and sets the speed multiplier to match the other deck's tempo.
+- **Crossfader**: Located at the bottom to mix between decks.
+- **Queue Console**: Load the next track ("Window Seat") into Deck 1 or Deck 2.
+
+## Technical Details
+
+- **Engine**: C++ `DJEngine` managing Oboe streams and mixing.
+- **Interpolation**: `SoundPlayer` uses linear interpolation for smooth variable speed and reverse playback.
+- **State Management**: Jetpack Compose state hoisting for synchronized UI/Audio engine states.
+
+Images
+-----------
+
\ No newline at end of file
diff --git a/samples/OboeDJ/build.gradle b/samples/OboeDJ/build.gradle
new file mode 100644
index 000000000..ccb82e834
--- /dev/null
+++ b/samples/OboeDJ/build.gradle
@@ -0,0 +1,65 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'org.jetbrains.kotlin.plugin.compose'
+
+android {
+ compileSdkVersion 35
+ defaultConfig {
+ applicationId 'com.google.oboe.samples.oboedj'
+ minSdkVersion 23
+ targetSdkVersion 36
+ compileSdkVersion 36
+ versionCode 1
+ versionName '1.0'
+ externalNativeBuild {
+ cmake {
+ cppFlags "-std=c++17"
+ arguments '-DANDROID_STL=c++_static'
+ abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
+ }
+ }
+ }
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'),
+ 'proguard-rules.pro'
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_18
+ targetCompatibility JavaVersion.VERSION_18
+ }
+ kotlinOptions {
+ jvmTarget = "18"
+ }
+ externalNativeBuild {
+ cmake {
+ path 'src/main/cpp/CMakeLists.txt'
+ }
+ }
+ buildFeatures {
+ compose true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion compose_version
+ }
+ namespace 'com.google.oboe.samples.oboedj'
+}
+
+dependencies {
+ implementation fileTree(include: ['*.jar'], dir: 'libs')
+ implementation project(':audio-device')
+ implementation project(':parselib') // We need parselib for WAV reading
+ implementation 'androidx.appcompat:appcompat:1.7.0'
+ implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
+
+ // Compose
+ implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
+ implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
+ implementation "androidx.compose.ui:ui:$compose_version"
+ implementation "androidx.compose.material3:material3:1.3.2"
+ implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
+ implementation 'androidx.activity:activity-compose:1.10.1'
+ implementation 'androidx.compose.runtime:runtime-livedata:1.10.0'
+}
diff --git a/samples/OboeDJ/oboedj_image.png b/samples/OboeDJ/oboedj_image.png
new file mode 100644
index 000000000..391a00491
Binary files /dev/null and b/samples/OboeDJ/oboedj_image.png differ
diff --git a/samples/OboeDJ/src/main/AndroidManifest.xml b/samples/OboeDJ/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..65d002556
--- /dev/null
+++ b/samples/OboeDJ/src/main/AndroidManifest.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/OboeDJ/src/main/assets/song1.wav b/samples/OboeDJ/src/main/assets/song1.wav
new file mode 100644
index 000000000..2c773f1d5
Binary files /dev/null and b/samples/OboeDJ/src/main/assets/song1.wav differ
diff --git a/samples/OboeDJ/src/main/assets/song2.wav b/samples/OboeDJ/src/main/assets/song2.wav
new file mode 100644
index 000000000..88b00a023
Binary files /dev/null and b/samples/OboeDJ/src/main/assets/song2.wav differ
diff --git a/samples/OboeDJ/src/main/assets/song3.wav b/samples/OboeDJ/src/main/assets/song3.wav
new file mode 100644
index 000000000..8338b288d
Binary files /dev/null and b/samples/OboeDJ/src/main/assets/song3.wav differ
diff --git a/samples/OboeDJ/src/main/cpp/CMakeLists.txt b/samples/OboeDJ/src/main/cpp/CMakeLists.txt
new file mode 100644
index 000000000..5e8dcb505
--- /dev/null
+++ b/samples/OboeDJ/src/main/cpp/CMakeLists.txt
@@ -0,0 +1,49 @@
+cmake_minimum_required(VERSION 3.22.1)
+
+project(OboeDJ)
+
+# Pull in required libs
+set(PARSELIB_DIR ../../../../parselib)
+set(IOLIB_DIR ../../../../iolib)
+set(OBOE_DIR ../../../../../)
+
+add_subdirectory(${OBOE_DIR} ./oboe-bin)
+
+# include folders
+include_directories(
+ ${OBOE_DIR}/include
+ ${OBOE_DIR}/samples/shared
+ ${PARSELIB_DIR}/src/main/cpp
+ ${IOLIB_DIR}/src/main/cpp
+ ${CMAKE_CURRENT_LIST_DIR}
+)
+
+# Include parselib & iolib CMake to build the static libraries
+include(${PARSELIB_DIR}/src/main/cpp/CMakeLists.txt)
+include(${IOLIB_DIR}/src/main/cpp/CMakeLists.txt)
+
+# App specific sources
+set(APP_SOURCES
+ native-lib.cpp
+ DJEngine.cpp
+)
+
+# Build the OboeDJ (native) library
+add_library(
+ oboedj SHARED ${APP_SOURCES}
+)
+
+# Enable optimization flags
+target_compile_options(oboedj PRIVATE -Wall -Werror "$<$:-O3 -ffast-math>")
+
+target_link_libraries(
+ oboedj
+ -Wl,--whole-archive
+ parselib
+ iolib
+ -Wl,--no-whole-archive
+ oboe
+ log
+)
+
+target_link_options(oboedj PRIVATE "-Wl,-z,max-page-size=16384")
diff --git a/samples/OboeDJ/src/main/cpp/DJEngine.cpp b/samples/OboeDJ/src/main/cpp/DJEngine.cpp
new file mode 100644
index 000000000..7d32adb7e
--- /dev/null
+++ b/samples/OboeDJ/src/main/cpp/DJEngine.cpp
@@ -0,0 +1,152 @@
+/*
+ * 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.
+ */
+
+#include "DJEngine.h"
+#include
+
+static const char* TAG = "DJEngine";
+
+namespace oboedj {
+
+DJEngine::DJEngine()
+ : mCrossfader(0.5f) // Center
+ , mChannelCount(2)
+ , mSampleRate(48000) { // Default, will be updated
+
+ // Initialize two empty decks
+ mDecks.push_back(std::make_shared(nullptr));
+ mDecks.push_back(std::make_shared(nullptr));
+}
+
+DJEngine::~DJEngine() {
+ stopStream();
+}
+
+bool DJEngine::openStream() {
+ std::lock_guard lock(mLock);
+ if (mStream) return true; // Already open
+
+ oboe::AudioStreamBuilder builder;
+ builder.setDirection(oboe::Direction::Output)
+ ->setPerformanceMode(oboe::PerformanceMode::LowLatency)
+ ->setSharingMode(oboe::SharingMode::Exclusive)
+ ->setFormat(oboe::AudioFormat::Float)
+ ->setChannelCount(mChannelCount)
+ ->setDataCallback(this);
+
+ oboe::Result result = builder.openStream(mStream);
+ if (result != oboe::Result::OK) {
+ __android_log_print(ANDROID_LOG_ERROR, TAG, "Failed to open stream: %s", oboe::convertToText(result));
+ return false;
+ }
+
+ mSampleRate = mStream->getSampleRate();
+ __android_log_print(ANDROID_LOG_INFO, TAG, "Stream opened with sample rate: %d", mSampleRate);
+
+ return true;
+}
+
+bool DJEngine::startStream() {
+ std::lock_guard lock(mLock);
+ if (!mStream) return false;
+
+ if (mStream->getState() == oboe::StreamState::Started) return true;
+
+ oboe::Result result = mStream->requestStart();
+ if (result != oboe::Result::OK) {
+ __android_log_print(ANDROID_LOG_ERROR, TAG, "Failed to start stream: %s", oboe::convertToText(result));
+ return false;
+ }
+ return true;
+}
+
+void DJEngine::stopStream() {
+ std::lock_guard lock(mLock);
+ if (mStream) {
+ mStream->stop();
+ mStream->close();
+ mStream.reset();
+ }
+}
+
+void DJEngine::loadTrack(uint8_t* buffer, int32_t length, int32_t deckIndex) {
+ if (deckIndex < 0 || deckIndex >= mDecks.size()) return;
+
+ parselib::MemInputStream stream(buffer, length);
+ parselib::WavStreamReader reader(&stream);
+ reader.parse();
+
+ auto sampleBuffer = std::make_shared();
+ sampleBuffer->loadSampleData(&reader);
+
+ // Statically resample to device rate if needed
+ sampleBuffer->resampleData(mSampleRate);
+
+ // Update the deck
+ mDecks[deckIndex] = std::make_shared(sampleBuffer);
+ __android_log_print(ANDROID_LOG_INFO, TAG, "Loaded track for deck %d", deckIndex);
+}
+
+void DJEngine::setDeckSpeed(int32_t deckIndex, float speed) {
+ if (deckIndex >= 0 && deckIndex < mDecks.size()) {
+ mDecks[deckIndex]->setSpeed(speed);
+ }
+}
+
+void DJEngine::setDeckPlaying(int32_t deckIndex, bool isPlaying) {
+ if (deckIndex >= 0 && deckIndex < mDecks.size()) {
+ mDecks[deckIndex]->setPlaying(isPlaying);
+ }
+}
+
+void DJEngine::setCrossfader(float position) {
+ mCrossfader.store(position);
+}
+
+oboe::DataCallbackResult DJEngine::onAudioReady(oboe::AudioStream *oboeStream,
+ void *audioData,
+ int32_t numFrames) {
+
+ float* floatData = static_cast(audioData);
+ int32_t numChannels = oboeStream->getChannelCount();
+
+ // Clear buffer first
+ memset(audioData, 0, numFrames * numChannels * sizeof(float));
+
+ float crossfade = mCrossfader.load();
+ float leftVolume = 1.0f - crossfade;
+ float rightVolume = crossfade;
+
+ // We need a temp buffer for each deck to scale volume
+ std::vector tempBuffer(numFrames * numChannels, 0.0f);
+
+ for (size_t i = 0; i < mDecks.size(); ++i) {
+ if (mDecks[i]->isPlaying()) {
+ std::fill(tempBuffer.begin(), tempBuffer.end(), 0.0f);
+ mDecks[i]->renderAudio(tempBuffer.data(), numChannels, numFrames);
+
+ float volume = (i == 0) ? leftVolume : rightVolume;
+
+ for (int32_t j = 0; j < numFrames * numChannels; ++j) {
+ floatData[j] += tempBuffer[j] * volume;
+ }
+ }
+ }
+
+ return oboe::DataCallbackResult::Continue;
+}
+
+} // namespace oboedj
diff --git a/samples/OboeDJ/src/main/cpp/DJEngine.h b/samples/OboeDJ/src/main/cpp/DJEngine.h
new file mode 100644
index 000000000..ec2d1132a
--- /dev/null
+++ b/samples/OboeDJ/src/main/cpp/DJEngine.h
@@ -0,0 +1,68 @@
+/*
+ * 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.
+ */
+
+#ifndef OBOEDJ_DJENGINE_H
+#define OBOEDJ_DJENGINE_H
+
+#include
+#include
+#include
+#include
+#include
+
+// iolib/parselib includes
+#include
+#include
+#include
+
+#include "Deck.h"
+
+namespace oboedj {
+
+class DJEngine : public oboe::AudioStreamDataCallback {
+public:
+ DJEngine();
+ virtual ~DJEngine();
+
+ bool openStream();
+ bool startStream();
+ void stopStream();
+
+ void loadTrack(uint8_t* buffer, int32_t length, int32_t deckIndex);
+
+ void setDeckSpeed(int32_t deckIndex, float speed);
+ void setDeckPlaying(int32_t deckIndex, bool isPlaying);
+ void setCrossfader(float position); // 0.0 (Left) to 1.0 (Right)
+
+ // From AudioStreamDataCallback
+ oboe::DataCallbackResult onAudioReady(oboe::AudioStream *oboeStream,
+ void *audioData,
+ int32_t numFrames) override;
+
+private:
+ std::shared_ptr mStream;
+ std::vector> mDecks;
+
+ std::atomic mCrossfader; // 0.0 to 1.0
+ int32_t mChannelCount;
+ int32_t mSampleRate;
+
+ std::mutex mLock; // Protect stream operations
+};
+
+} // namespace oboedj
+
+#endif // OBOEDJ_DJENGINE_H
diff --git a/samples/OboeDJ/src/main/cpp/Deck.h b/samples/OboeDJ/src/main/cpp/Deck.h
new file mode 100644
index 000000000..bbf8809d1
--- /dev/null
+++ b/samples/OboeDJ/src/main/cpp/Deck.h
@@ -0,0 +1,57 @@
+/*
+ * 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.
+ */
+
+#ifndef OBOEDJ_DECK_H
+#define OBOEDJ_DECK_H
+
+#include
+#include "SoundPlayer.h"
+
+namespace oboedj {
+
+class Deck {
+public:
+ Deck(std::shared_ptr buffer) {
+ mPlayer = std::make_shared(buffer);
+ }
+
+ void setSpeed(float speed) {
+ mPlayer->setSpeed(speed);
+ }
+
+ void setPlaying(bool isPlaying) {
+ mPlayer->setPlaying(isPlaying);
+ }
+
+ bool isPlaying() const {
+ return mPlayer->isPlaying();
+ }
+
+ void reset() {
+ mPlayer->reset();
+ }
+
+ void renderAudio(float* outBuffer, int32_t numChannels, int32_t numFrames) {
+ mPlayer->renderAudio(outBuffer, numChannels, numFrames);
+ }
+
+private:
+ std::shared_ptr mPlayer;
+};
+
+} // namespace oboedj
+
+#endif // OBOEDJ_DECK_H
diff --git a/samples/OboeDJ/src/main/cpp/SoundPlayer.h b/samples/OboeDJ/src/main/cpp/SoundPlayer.h
new file mode 100644
index 000000000..d00dde5fc
--- /dev/null
+++ b/samples/OboeDJ/src/main/cpp/SoundPlayer.h
@@ -0,0 +1,114 @@
+/*
+ * 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.
+ */
+
+#ifndef OBOEDJ_SOUNDPLAYER_H
+#define OBOEDJ_SOUNDPLAYER_H
+
+#include
+#include
+#include
+#include
+#include
+
+namespace oboedj {
+
+/**
+ * A player that reads from a pre-loaded PCM buffer with variable speed and linear interpolation.
+ */
+class SoundPlayer {
+public:
+ SoundPlayer(std::shared_ptr buffer)
+ : mBuffer(buffer), mPosition(0.0f), mSpeed(1.0f), mIsPlaying(false) {}
+
+ void setSpeed(float speed) {
+ mSpeed = speed;
+ }
+
+ void setPlaying(bool isPlaying) {
+ mIsPlaying = isPlaying;
+ }
+
+ bool isPlaying() const {
+ return mIsPlaying;
+ }
+
+ void reset() {
+ mPosition = 0.0f;
+ }
+
+ /**
+ * Render audio into the output buffer with linear interpolation for variable speed.
+ */
+ void renderAudio(float* outBuffer, int32_t numChannels, int32_t numFrames) {
+ if (!mIsPlaying || !mBuffer) return;
+
+ float* data = mBuffer->getSampleData();
+ int32_t totalSamples = mBuffer->getNumSamples();
+ int32_t bufferChannels = mBuffer->getProperties().channelCount;
+
+ if (totalSamples == 0 || data == nullptr) return;
+
+ int32_t framesAvailable = (totalSamples / bufferChannels);
+
+ for (int32_t i = 0; i < numFrames; ++i) {
+ float floatIndex = mPosition;
+ int32_t index0 = static_cast(floatIndex);
+ int32_t index1 = index0 + 1;
+
+ float frac = floatIndex - index0;
+
+ // Loop or clamp
+ if (index0 >= framesAvailable) {
+ mPosition = 0.0f; // Reset if looped
+ break; // Or handle loop properly
+ }
+ if (index1 >= framesAvailable) {
+ index1 = index0; // Clamp at edge
+ }
+
+ for (int32_t c = 0; c < numChannels; ++c) {
+ // If buffer is mono and output is stereo, duplicate. If same, map.
+ int32_t bufChannel = (c < bufferChannels) ? c : 0;
+
+ float sample0 = data[index0 * bufferChannels + bufChannel];
+ float sample1 = data[index1 * bufferChannels + bufChannel];
+
+ // Linear interpolation
+ float interpolated = sample0 * (1.0f - frac) + sample1 * frac;
+
+ outBuffer[i * numChannels + c] += interpolated;
+ }
+
+ mPosition += mSpeed;
+ while (mPosition >= framesAvailable) {
+ mPosition -= framesAvailable;
+ }
+ while (mPosition < 0) {
+ mPosition += framesAvailable;
+ }
+ }
+ }
+
+private:
+ std::shared_ptr mBuffer;
+ float mPosition; // Fractional position in frames
+ float mSpeed; // 1.0 = normal, 2.0 = double speed, -1.0 = reverse
+ bool mIsPlaying;
+};
+
+} // namespace oboedj
+
+#endif // OBOEDJ_SOUNDPLAYER_H
diff --git a/samples/OboeDJ/src/main/cpp/native-lib.cpp b/samples/OboeDJ/src/main/cpp/native-lib.cpp
new file mode 100644
index 000000000..8d876621b
--- /dev/null
+++ b/samples/OboeDJ/src/main/cpp/native-lib.cpp
@@ -0,0 +1,88 @@
+/*
+ * 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.
+ */
+
+#include
+#include
+#include "DJEngine.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+using namespace oboedj;
+
+// Global instance of the engine
+static std::unique_ptr gEngine;
+
+JNIEXPORT void JNICALL
+Java_com_google_oboe_samples_oboedj_DJEngine_initNative(JNIEnv *env, jobject thiz) {
+ if (!gEngine) {
+ gEngine = std::make_unique();
+ }
+ gEngine->openStream();
+}
+
+JNIEXPORT void JNICALL
+Java_com_google_oboe_samples_oboedj_DJEngine_startNative(JNIEnv *env, jobject thiz) {
+ if (gEngine) {
+ gEngine->startStream();
+ }
+}
+
+JNIEXPORT void JNICALL
+Java_com_google_oboe_samples_oboedj_DJEngine_stopNative(JNIEnv *env, jobject thiz) {
+ if (gEngine) {
+ gEngine->stopStream();
+ }
+}
+
+JNIEXPORT void JNICALL
+Java_com_google_oboe_samples_oboedj_DJEngine_loadTrackNative(JNIEnv *env, jobject thiz, jbyteArray track_bytes, jint deck_index) {
+ if (!gEngine) return;
+
+ jsize len = env->GetArrayLength(track_bytes);
+ jbyte* buffer = env->GetByteArrayElements(track_bytes, nullptr);
+
+ if (buffer != nullptr) {
+ gEngine->loadTrack(reinterpret_cast(buffer), len, deck_index);
+ env->ReleaseByteArrayElements(track_bytes, buffer, JNI_ABORT); // JNI_ABORT means we don't copy back
+ }
+}
+
+JNIEXPORT void JNICALL
+Java_com_google_oboe_samples_oboedj_DJEngine_setSpeedNative(JNIEnv *env, jobject thiz, jint deck_index, jfloat speed) {
+ if (gEngine) {
+ gEngine->setDeckSpeed(deck_index, speed);
+ }
+}
+
+JNIEXPORT void JNICALL
+Java_com_google_oboe_samples_oboedj_DJEngine_setPlayingNative(JNIEnv *env, jobject thiz, jint deck_index, jboolean is_playing) {
+ if (gEngine) {
+ gEngine->setDeckPlaying(deck_index, is_playing);
+ }
+}
+
+JNIEXPORT void JNICALL
+Java_com_google_oboe_samples_oboedj_DJEngine_setCrossfaderNative(JNIEnv *env, jobject thiz, jfloat position) {
+ if (gEngine) {
+ gEngine->setCrossfader(position);
+ }
+}
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/samples/OboeDJ/src/main/kotlin/com/google/oboe/samples/oboedj/DJEngine.kt b/samples/OboeDJ/src/main/kotlin/com/google/oboe/samples/oboedj/DJEngine.kt
new file mode 100644
index 000000000..41c3f4574
--- /dev/null
+++ b/samples/OboeDJ/src/main/kotlin/com/google/oboe/samples/oboedj/DJEngine.kt
@@ -0,0 +1,56 @@
+/*
+ * 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.oboedj
+
+import android.content.res.AssetManager
+import java.io.IOException
+
+class DJEngine {
+ companion object {
+ init {
+ System.loadLibrary("oboedj")
+ }
+ }
+
+ fun init() = initNative()
+ fun start() = startNative()
+ fun stop() = stopNative()
+
+ fun loadTrack(assetManager: AssetManager, filename: String, deckIndex: Int) {
+ try {
+ assetManager.open(filename).use { inputStream ->
+ val bytes = inputStream.readBytes()
+ loadTrackNative(bytes, deckIndex)
+ }
+ } catch (e: IOException) {
+ e.printStackTrace()
+ }
+ }
+
+ fun setSpeed(deckIndex: Int, speed: Float) = setSpeedNative(deckIndex, speed)
+ fun setPlaying(deckIndex: Int, isPlaying: Boolean) = setPlayingNative(deckIndex, isPlaying)
+ fun setCrossfader(position: Float) = setCrossfaderNative(position)
+
+ // JNI Methods
+ private external fun initNative()
+ private external fun startNative()
+ private external fun stopNative()
+ private external fun loadTrackNative(trackBytes: ByteArray, deckIndex: Int)
+ private external fun setSpeedNative(deckIndex: Int, speed: Float)
+ private external fun setPlayingNative(deckIndex: Int, isPlaying: Boolean)
+ private external fun setCrossfaderNative(position: Float)
+}
diff --git a/samples/OboeDJ/src/main/kotlin/com/google/oboe/samples/oboedj/MainActivity.kt b/samples/OboeDJ/src/main/kotlin/com/google/oboe/samples/oboedj/MainActivity.kt
new file mode 100644
index 000000000..88f2a6d21
--- /dev/null
+++ b/samples/OboeDJ/src/main/kotlin/com/google/oboe/samples/oboedj/MainActivity.kt
@@ -0,0 +1,383 @@
+/*
+ * 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.oboedj
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.animation.core.*
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.nativeCanvas
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlin.math.atan2
+import androidx.compose.ui.platform.LocalContext
+
+class MainActivity : ComponentActivity() {
+ private val engine = DJEngine()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ engine.init()
+
+ // Load tracks from assets
+ engine.loadTrack(assets, "song1.wav", 0)
+ engine.loadTrack(assets, "song2.wav", 1)
+
+ engine.start()
+
+ setContent {
+ MaterialTheme(
+ colorScheme = darkColorScheme(
+ background = Color(0xFF121212),
+ surface = Color(0xFF1E1E1E),
+ primary = Color.Red, // Match standard Red from vinyl disk center
+ secondary = Color(0xFFFF4081), // Neon Pink
+ onPrimary = Color.White // White text on red buttons
+ )
+ ) {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ DJApp(engine)
+ }
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ engine.stop()
+ }
+}
+
+@Composable
+fun DJApp(engine: DJEngine) {
+ var crossfader by remember { mutableFloatStateOf(0.5f) }
+ var deck1Track by remember { mutableStateOf("Chemical Reaction") }
+ var deck2Track by remember { mutableStateOf("Digital Noca") }
+ var queuedTrack by remember { mutableStateOf("Window Seat") }
+
+ // State to share speeds between decks for syncing
+ var deck1Speed by remember { mutableFloatStateOf(128f / 170f) }
+ var deck2Speed by remember { mutableFloatStateOf(1.0f) }
+
+ var deck1Playing by remember { mutableStateOf(false) }
+ var deck2Playing by remember { mutableStateOf(false) }
+
+ val trackBpms = mapOf("Chemical Reaction" to 170f, "Digital Noca" to 128f, "Window Seat" to 100f)
+ val context = LocalContext.current
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(top = 32.dp, bottom = 16.dp, start = 16.dp, end = 16.dp), // Added top padding for EdgeToEdge
+ verticalArrangement = Arrangement.SpaceEvenly,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ DeckUI(
+ deckIndex = 0,
+ trackName = deck1Track,
+ currentSpeed = deck1Speed,
+ onSpeedChange = { deck1Speed = it },
+ otherDeckBpm = trackBpms[deck2Track] ?: 128f,
+ thisDeckBpm = trackBpms[deck1Track] ?: 170f,
+ otherDeckSpeed = deck2Speed,
+ isPlaying = deck1Playing,
+ onPlayingChange = { deck1Playing = it },
+ engine = engine
+ )
+ DeckUI(
+ deckIndex = 1,
+ trackName = deck2Track,
+ currentSpeed = deck2Speed,
+ onSpeedChange = { deck2Speed = it },
+ otherDeckBpm = trackBpms[deck1Track] ?: 170f,
+ thisDeckBpm = trackBpms[deck2Track] ?: 128f,
+ otherDeckSpeed = deck1Speed,
+ isPlaying = deck2Playing,
+ onPlayingChange = { deck2Playing = it },
+ engine = engine
+ )
+ }
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text("Crossfader", color = Color.White)
+ Slider(
+ value = crossfader,
+ onValueChange = {
+ crossfader = it
+ engine.setCrossfader(it)
+ },
+ modifier = Modifier.width(300.dp)
+ )
+ }
+
+ // Queue Feature
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text("Next in Queue: $queuedTrack", color = Color.White, fontSize = 18.sp) // Color changed to White
+ Spacer(modifier = Modifier.height(8.dp))
+ Row {
+ Button(onClick = {
+ val temp = deck1Track
+ deck1Track = queuedTrack
+ queuedTrack = temp
+ val filename = when (deck1Track) {
+ "Chemical Reaction" -> "song1.wav"
+ "Digital Noca" -> "song2.wav"
+ else -> "song3.wav"
+ }
+ engine.loadTrack(context.assets, filename, 0)
+ deck1Playing = true
+ engine.setPlaying(0, true)
+ }) {
+ Text("Load into Deck 1")
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ Button(onClick = {
+ val temp = deck2Track
+ deck2Track = queuedTrack
+ queuedTrack = temp
+ val filename = when (deck2Track) {
+ "Chemical Reaction" -> "song1.wav"
+ "Digital Noca" -> "song2.wav"
+ else -> "song3.wav"
+ }
+ engine.loadTrack(context.assets, filename, 1)
+ deck2Playing = true
+ engine.setPlaying(1, true)
+ }) {
+ Text("Load into Deck 2")
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun DeckUI(
+ deckIndex: Int,
+ trackName: String,
+ currentSpeed: Float,
+ onSpeedChange: (Float) -> Unit,
+ otherDeckBpm: Float,
+ thisDeckBpm: Float,
+ otherDeckSpeed: Float,
+ isPlaying: Boolean,
+ onPlayingChange: (Boolean) -> Unit,
+ engine: DJEngine
+) {
+ var speed by remember { mutableFloatStateOf(currentSpeed) }
+ var rotationAngle by remember { mutableFloatStateOf(0.0f) }
+
+ // Automatic rotation when playing
+ LaunchedEffect(isPlaying) {
+ if (isPlaying) {
+ while (isPlaying) {
+ rotationAngle = (rotationAngle + speed * 2f) % 360f // Visual rotation scale
+ delay(16) // ~60 FPS
+ }
+ }
+ }
+
+ // Keep speed State in sync with prop if changed from outside (Sync button)
+ LaunchedEffect(currentSpeed) {
+ speed = currentSpeed
+ }
+
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .background(MaterialTheme.colorScheme.surface)
+ .padding(8.dp) // Reduced padding to prevent overlap
+ ) {
+ // Spacing fix: Text first, then Wheel
+ Text(trackName, color = Color.White, fontSize = 14.sp)
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Turnable Vinyl Wheel
+ VinylWheel(
+ rotationAngle = rotationAngle,
+ onAngleChanged = { deltaAngle ->
+ rotationAngle = (rotationAngle + deltaAngle) % 360f
+ // Map deltaAngle to scratch speed
+ // 1 degree per 16ms is ~ 60 deg/sec $\approx$ 1 rad/sec.
+ // Let's make it feel responsive.
+ val scratchSpeed = deltaAngle / 5f // Scale factor for touch
+ engine.setSpeed(deckIndex, scratchSpeed)
+ },
+ onRelease = {
+ // Return to normal speed or stop depending on state
+ engine.setSpeed(deckIndex, if (isPlaying) speed else 0.0f)
+ }
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Row {
+ Button(onClick = {
+ onPlayingChange(!isPlaying)
+ engine.setPlaying(deckIndex, !isPlaying)
+ engine.setSpeed(deckIndex, if (!isPlaying) speed else 0.0f)
+ }) {
+ Text(if (isPlaying) "Pause" else "Play")
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text("Speed: %.2f".format(speed), color = Color.White)
+ Slider(
+ value = speed,
+ onValueChange = {
+ speed = it
+ onSpeedChange(it)
+ if (isPlaying) {
+ engine.setSpeed(deckIndex, it)
+ }
+ },
+ valueRange = 0.5f..2.0f,
+ modifier = Modifier.width(130.dp) // Slightly narrower
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Button(onClick = {
+ // Sync Logic: Target Speed = (Other Deck BOM / This Deck BPM) * Other Speed
+ val targetSpeed = (otherDeckBpm / thisDeckBpm) * otherDeckSpeed
+ speed = targetSpeed
+ onSpeedChange(targetSpeed)
+ if (isPlaying) {
+ engine.setSpeed(deckIndex, targetSpeed)
+ }
+ }) {
+ Text("Sync BPМ", fontSize = 12.sp)
+ }
+ }
+}
+
+@Composable
+fun VinylWheel(
+ rotationAngle: Float,
+ onAngleChanged: (Float) -> Unit,
+ onRelease: () -> Unit
+) {
+ var lastAngle by remember { mutableFloatStateOf(0f) }
+
+ Box(
+ modifier = Modifier
+ .size(160.dp)
+ .clip(CircleShape)
+ .background(Color.Black)
+ .pointerInput(Unit) {
+ detectDragGestures(
+ onDragStart = { offset ->
+ lastAngle = atan2(offset.y - size.height / 2f, offset.x - size.width / 2f)
+ },
+ onDrag = { change, _ ->
+ val currentOffset = change.position
+ val currentAngle = atan2(
+ currentOffset.y - size.height / 2f,
+ currentOffset.x - size.width / 2f
+ )
+ var delta = Math.toDegrees((currentAngle - lastAngle).toDouble()).toFloat()
+
+ // Handle wraparound
+ if (delta > 180f) delta -= 360f
+ if (delta < -180f) delta += 360f
+
+ onAngleChanged(delta)
+ lastAngle = currentAngle
+ },
+ onDragEnd = {
+ onRelease()
+ },
+ onDragCancel = {
+ onRelease()
+ }
+ )
+ },
+ contentAlignment = Alignment.Center
+ ) {
+ Canvas(modifier = Modifier.fillMaxSize()) {
+ val center = Offset(size.width / 2, size.height / 2)
+ val radius = size.minDimension / 2
+
+ // Draw vinyl base
+ drawCircle(
+ color = Color(0xFF1A1A1A),
+ radius = radius,
+ center = center
+ )
+
+ // Draw grooves (concentric circles)
+ for (i in 1..10) {
+ drawCircle(
+ color = Color.DarkGray.copy(alpha = 0.5f),
+ radius = radius * (i / 10f),
+ center = center,
+ style = androidx.compose.ui.graphics.drawscope.Stroke(width = 1f)
+ )
+ }
+
+ // Draw center label
+ drawCircle(
+ color = Color.Red,
+ radius = radius * 0.3f,
+ center = center
+ )
+
+ // Draw indicator line (so you can see it spin)
+ val angleRad = Math.toRadians(rotationAngle.toDouble()).toFloat()
+ val endX = center.x + radius * Math.cos(angleRad.toDouble()).toFloat()
+ val endY = center.y + radius * Math.sin(angleRad.toDouble()).toFloat()
+
+ drawLine(
+ color = Color.White,
+ start = center,
+ end = Offset(endX, endY),
+ strokeWidth = 4f
+ )
+ }
+ }
+}
diff --git a/samples/OboeDJ/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/samples/OboeDJ/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..0c02e8bf1
Binary files /dev/null and b/samples/OboeDJ/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/samples/iolib/src/main/cpp/player/SampleBuffer.h b/samples/iolib/src/main/cpp/player/SampleBuffer.h
index 7cc25c1f5..faf867e4b 100644
--- a/samples/iolib/src/main/cpp/player/SampleBuffer.h
+++ b/samples/iolib/src/main/cpp/player/SampleBuffer.h
@@ -32,7 +32,7 @@ struct AudioProperties {
class SampleBuffer {
public:
SampleBuffer() : mNumSamples(0) {};
- ~SampleBuffer() { unloadSampleData(); }
+ virtual ~SampleBuffer() { unloadSampleData(); }
// Data load/unload
void loadSampleData(parselib::WavStreamReader* reader);
diff --git a/samples/settings.gradle b/samples/settings.gradle
index 373850564..c95677903 100644
--- a/samples/settings.gradle
+++ b/samples/settings.gradle
@@ -25,4 +25,5 @@ include ':drumthumper'
include ':parselib'
include ':iolib'
include ':minimaloboe'
-include ':powerplay'
\ No newline at end of file
+include ':powerplay'
+include ':OboeDJ'
\ No newline at end of file