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 +----------- +![oboedj_image](oboedj_image.png) \ 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