diff --git a/CircleOfFifths/AndroidManifest.xml b/CircleOfFifths/AndroidManifest.xml
index 203ba742..41838ba5 100644
--- a/CircleOfFifths/AndroidManifest.xml
+++ b/CircleOfFifths/AndroidManifest.xml
@@ -1,5 +1,6 @@
+
mEntries = new ArrayList<>();
+ private List mValues = new ArrayList<>();
+
+ private CharSequence[] listToArray(List l) {
+ String[] array = new String[l.size()];
+ l.toArray(array);
+ return array;
+ }
+ AudioDeviceList(PreferenceFragment prefFragment, String key) {
+ mPref = (ListPreference) prefFragment.findPreference(key);
+ mEntries.add("Default");
+ mValues.add("-1");
+ }
+ public void add(AudioDeviceInfo device) {
+ String name = device.getProductName().toString() + " " + typeToString(device.getType());
+ mEntries.add(name);
+ mValues.add(Integer.toString(device.getId()));
+ mPref.setEntries(listToArray(mEntries));
+ mPref.setEntryValues(listToArray(mValues));
+ }
+ }
+
+ private AudioDeviceList mInputDevices = null;
+ private AudioDeviceList mOutputDevices = null;
+
+ /**
+ * @param context activity or service that calls this method
+ */
+ AudioDevices(Activity context) {
+ mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
+ PreferenceFragment prefFragment = (PreferenceFragment) context.getFragmentManager().findFragmentByTag("prefFragment");
+ Resources res = context.getResources();
+ mInputDevices = new AudioDeviceList(prefFragment, res.getString(R.string.pref_key_indevice));
+ mOutputDevices = new AudioDeviceList(prefFragment, res.getString(R.string.pref_key_outdevice));
+ setupAudioDeviceCallback();
+ }
+
+ /**
+ * Converts the value from {@link AudioDeviceInfo#getType()} into a human
+ * readable string
+ * @param type One of the {@link AudioDeviceInfo}.TYPE_* values
+ * e.g. AudioDeviceInfo.TYPE_BUILT_IN_SPEAKER
+ * @return string which describes the type of audio device
+ */
+ static String typeToString(int type){
+ switch (type) {
+ case AudioDeviceInfo.TYPE_AUX_LINE:
+ return "auxiliary line-level connectors";
+ case AudioDeviceInfo.TYPE_BLUETOOTH_A2DP:
+ return "Bluetooth device supporting the A2DP profile";
+ case AudioDeviceInfo.TYPE_BLUETOOTH_SCO:
+ return "Bluetooth device typically used for telephony";
+ case AudioDeviceInfo.TYPE_BUILTIN_EARPIECE:
+ return "built-in earphone speaker";
+ case AudioDeviceInfo.TYPE_BUILTIN_MIC:
+ return "built-in microphone";
+ case AudioDeviceInfo.TYPE_BUILTIN_SPEAKER:
+ return "built-in speaker";
+ case AudioDeviceInfo.TYPE_BUS:
+ return "BUS";
+ case AudioDeviceInfo.TYPE_DOCK:
+ return "DOCK";
+ case AudioDeviceInfo.TYPE_FM:
+ return "FM";
+ case AudioDeviceInfo.TYPE_FM_TUNER:
+ return "FM tuner";
+ case AudioDeviceInfo.TYPE_HDMI:
+ return "HDMI";
+ case AudioDeviceInfo.TYPE_HDMI_ARC:
+ return "HDMI audio return channel";
+ case AudioDeviceInfo.TYPE_IP:
+ return "IP";
+ case AudioDeviceInfo.TYPE_LINE_ANALOG:
+ return "line analog";
+ case AudioDeviceInfo.TYPE_LINE_DIGITAL:
+ return "line digital";
+ case AudioDeviceInfo.TYPE_TELEPHONY:
+ return "telephony";
+ case AudioDeviceInfo.TYPE_TV_TUNER:
+ return "TV tuner";
+ case AudioDeviceInfo.TYPE_USB_ACCESSORY:
+ return "USB accessory";
+ case AudioDeviceInfo.TYPE_USB_DEVICE:
+ return "USB device";
+ case AudioDeviceInfo.TYPE_WIRED_HEADPHONES:
+ return "wired headphones";
+ case AudioDeviceInfo.TYPE_WIRED_HEADSET:
+ return "wired headset";
+ default:
+ case AudioDeviceInfo.TYPE_UNKNOWN:
+ return "unknown";
+ }
+ }
+
+ private void setupAudioDeviceCallback(){
+ // Note that we will immediately receive a call to onDevicesAdded with the list of
+ // devices which are currently connected.
+ mAudioManager.registerAudioDeviceCallback(new AudioDeviceCallback() {
+ @Override
+ public void onAudioDevicesAdded(AudioDeviceInfo[] addedDevices) {
+ for (AudioDeviceInfo device : addedDevices){
+ if(device.isSource()) mInputDevices.add(device);
+ else if(device.isSink()) mOutputDevices.add(device);
+ }
+ }
+
+ public void onAudioDevicesRemoved(AudioDeviceInfo[] removedDevices) {
+ }
+ }, null);
+ }
+}
diff --git a/PdCore/pd-core/src/main/java/org/puredata/android/service/PdPreferences.java b/PdCore/pd-core/src/main/java/org/puredata/android/service/PdPreferences.java
index 61991f44..25250649 100644
--- a/PdCore/pd-core/src/main/java/org/puredata/android/service/PdPreferences.java
+++ b/PdCore/pd-core/src/main/java/org/puredata/android/service/PdPreferences.java
@@ -9,6 +9,7 @@
import org.puredata.android.io.AudioParameters;
import org.puredata.core.PdBase;
+import org.puredata.android.io.PdAudio;
import android.content.Context;
import android.content.SharedPreferences;
@@ -16,6 +17,7 @@
import android.os.Bundle;
import android.preference.PreferenceActivity;
import android.preference.PreferenceManager;
+import android.preference.PreferenceFragment;
/**
*
@@ -27,13 +29,14 @@
*/
public class PdPreferences extends PreferenceActivity {
- @SuppressWarnings("deprecation")
+ public AudioDevices audioDevices = null;
+
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
AudioParameters.init(this);
initPreferences(getApplicationContext());
- addPreferencesFromResource(R.xml.preferences);
+ getFragmentManager().beginTransaction().replace(android.R.id.content, new MyPreferenceFragment(), "prefFragment").commit();
}
@Override
@@ -41,20 +44,36 @@ protected void onDestroy() {
super.onDestroy();
}
+ public static class MyPreferenceFragment extends PreferenceFragment
+ {
+ @Override
+ public void onCreate(final Bundle savedInstanceState)
+ {
+ super.onCreate(savedInstanceState);
+ Resources res = getResources();
+ addPreferencesFromResource(R.xml.preferences);
+ ((PdPreferences)getActivity()).audioDevices = new AudioDevices(getActivity());
+ }
+ }
+
/**
* If no preferences are available, initialize preferences with defaults suggested by {@link PdBase} or {@link AudioParameters}, in that order.
*
* @param context current application context
*/
public static void initPreferences(Context context) {
+ // make sure native handler is setup, because we need PdBase here:
+ PdAudio.setupNativeLoader();
Resources res = context.getResources();
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
if (!prefs.contains(res.getString(R.string.pref_key_srate))) {
SharedPreferences.Editor editor = prefs.edit();
int srate = PdBase.suggestSampleRate();
editor.putString(res.getString(R.string.pref_key_srate), "" + ((srate > 0) ? srate : AudioParameters.suggestSampleRate()));
+ editor.putString(res.getString(R.string.pref_key_indevice), res.getStringArray(R.array.indevice_values)[0]);
int nic = PdBase.suggestInputChannels();
editor.putString(res.getString(R.string.pref_key_inchannels), "" + ((nic > 0) ? nic : AudioParameters.suggestInputChannels()));
+ editor.putString(res.getString(R.string.pref_key_outdevice), res.getStringArray(R.array.outdevice_values)[0]);
int noc = PdBase.suggestOutputChannels();
editor.putString(res.getString(R.string.pref_key_outchannels), "" + ((noc > 0) ? noc : AudioParameters.suggestOutputChannels()));
editor.commit();
diff --git a/PdCore/pd-core/src/main/java/org/puredata/android/service/PdService.java b/PdCore/pd-core/src/main/java/org/puredata/android/service/PdService.java
index 18dc6c42..7d5723d9 100644
--- a/PdCore/pd-core/src/main/java/org/puredata/android/service/PdService.java
+++ b/PdCore/pd-core/src/main/java/org/puredata/android/service/PdService.java
@@ -100,6 +100,18 @@ public synchronized void initAudio(int srate, int nic, int noc, float millis) th
stopForeground();
Resources res = getResources();
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getApplicationContext());
+ int in_id = -1;
+ int out_id = -1;
+ {
+ String s = prefs.getString(res.getString(R.string.pref_key_indevice), null);
+ if(s != null) {
+ in_id = Integer.parseInt(s);
+ }
+ s = prefs.getString(res.getString(R.string.pref_key_outdevice), null);
+ if(s != null) {
+ out_id = Integer.parseInt(s);
+ }
+ }
if (srate < 0) {
String s = prefs.getString(res.getString(R.string.pref_key_srate), null);
if (s != null) {
@@ -137,7 +149,7 @@ public synchronized void initAudio(int srate, int nic, int noc, float millis) th
millis = 50.0f; // conservative choice
}
int tpb = (int) (0.001f * millis * srate / PdBase.blockSize()) + 1;
- PdAudio.initAudio(srate, nic, noc, tpb, true);
+ PdAudio.initAudio(srate, in_id, nic, out_id, noc, tpb, true);
sampleRate = srate;
inputChannels = nic;
outputChannels = noc;
@@ -213,6 +225,9 @@ public boolean onUnbind(Intent intent) {
@Override
public void onCreate() {
super.onCreate();
+ // make sure native handler is setup, because we need PdBase here:
+ PdAudio.setupNativeLoader();
+
AudioParameters.init(this);
if (!abstractionsInstalled) {
try {
diff --git a/PdCore/pd-core/src/main/jni/Android.mk b/PdCore/pd-core/src/main/jni/Android.mk
index 5053e7d6..83384322 100644
--- a/PdCore/pd-core/src/main/jni/Android.mk
+++ b/PdCore/pd-core/src/main/jni/Android.mk
@@ -1 +1,2 @@
include $(call all-subdir-makefiles)
+
diff --git a/PdCore/pd-core/src/main/jni/Application.mk b/PdCore/pd-core/src/main/jni/Application.mk
index 1d0b95ea..fb60ca30 100644
--- a/PdCore/pd-core/src/main/jni/Application.mk
+++ b/PdCore/pd-core/src/main/jni/Application.mk
@@ -1,3 +1,4 @@
APP_PLATFORM := android-28
APP_OPTIM := release
APP_ABI := armeabi-v7a arm64-v8a x86 x86_64
+APP_STL := c++_shared
diff --git a/PdCore/pd-core/src/main/jni/oboe/Android.mk b/PdCore/pd-core/src/main/jni/oboe/Android.mk
new file mode 100644
index 00000000..cef00f3c
--- /dev/null
+++ b/PdCore/pd-core/src/main/jni/oboe/Android.mk
@@ -0,0 +1,13 @@
+# Build Oboe JNI binary
+
+include $(CLEAR_VARS)
+
+LOCAL_MODULE := pdnativeoboe
+LOCAL_C_INCLUDES := $(PD_C_INCLUDES) $(LOCAL_PATH)/jni
+LOCAL_SRC_FILES := ../oboe/z_jni_oboe_shared.c ../oboe/z_jni_oboe.cpp ../oboe/OboeEngine.cpp
+LOCAL_SHARED_LIBRARIES := pd oboe
+LOCAL_LDLIBS := -llog
+include $(BUILD_SHARED_LIBRARY)
+
+$(call import-module,prefab/oboe)
+
diff --git a/PdCore/pd-core/src/main/jni/oboe/OboeEngine.cpp b/PdCore/pd-core/src/main/jni/oboe/OboeEngine.cpp
new file mode 100644
index 00000000..27390cff
--- /dev/null
+++ b/PdCore/pd-core/src/main/jni/oboe/OboeEngine.cpp
@@ -0,0 +1,277 @@
+/*
+ * Copyright (c) 2025 Antoine Rousseau
+ * (derived from 'LiveEffect' oboe sample)
+ *
+ * For information on usage and redistribution, and for a DISCLAIMER OF ALL
+ * WARRANTIES, see the file, "LICENSE.txt," in this distribution.
+ */
+
+#include
+#include "logging_macros.h"
+
+#include "OboeEngine.h"
+
+OboeEngine::OboeEngine() {
+}
+
+void OboeEngine::setRecordingDeviceId(int32_t deviceId) {
+ mRecordingDeviceId = deviceId;
+}
+
+void OboeEngine::setPlaybackDeviceId(int32_t deviceId) {
+ mPlaybackDeviceId = deviceId;
+}
+
+bool OboeEngine::isAAudioRecommended() {
+ return oboe::AudioStreamBuilder::isAAudioRecommended();
+}
+
+bool OboeEngine::setAudioApi(oboe::AudioApi api) {
+ if (mIsEffectOn) return false;
+ mAudioApi = api;
+ return true;
+}
+
+void OboeEngine::setChannelCounts(int numInputs, int numOutputs) {
+ mInputChannelCount = numInputs;
+ mOutputChannelCount = numOutputs;
+}
+
+void OboeEngine::getAudioParams(int &numInputs, int &numOutputs, int &sampleRate) {
+ if(!mPlayStream) return;
+ numInputs = mRecordingStream ? mRecordingStream->getChannelCount() : 0;
+ numOutputs = mPlayStream->getChannelCount();
+ sampleRate = mPlayStream->getSampleRate();
+}
+
+void OboeEngine::setAudioCallback(void (*callback)(void*), void *userData) {
+ mAudioCallback = callback;
+ mAudioCallbackUserData = userData;
+}
+
+void OboeEngine::setBufferSizeInFrames(int frames)
+{
+ if(mPlayStream) mPlayStream->setBufferSizeInFrames(frames);
+}
+
+bool OboeEngine::setEffectOn(bool isOn) {
+ bool success = true;
+ if (isOn != mIsEffectOn) {
+ if (isOn) {
+ success = openStreams() == oboe::Result::OK;
+ if (success) {
+ mIsEffectOn = isOn;
+ }
+ } else {
+ closeStreams();
+ mIsEffectOn = isOn;
+ }
+ }
+ return success;
+}
+
+void OboeEngine::closeStreams() {
+ /*
+ * Note: The order of events is important here.
+ * The playback stream must be closed before the recording stream. If the
+ * recording stream were to be closed first the playback stream's
+ * callback may attempt to read from the recording stream
+ * which would cause the app to crash since the recording stream would be
+ * null.
+ */
+ if (mDuplexStream) {
+ mDuplexStream->stop();
+ closeStream(mPlayStream);
+ closeStream(mRecordingStream);
+ mDuplexStream.reset();
+ } else {
+ closeStream(mPlayStream);
+ mSimplexStream.reset();
+ }
+}
+
+oboe::Result OboeEngine::openStreams() {
+ // Note: The order of stream creation is important. We create the playback
+ // stream first, then use properties from the playback stream
+ // (e.g. sample rate) to create the recording stream. By matching the
+ // properties we should get the lowest latency path
+ oboe::AudioStreamBuilder inBuilder, outBuilder;
+ setupPlaybackStreamParameters(&outBuilder);
+ oboe::Result result = outBuilder.openStream(mPlayStream);
+ if (result != oboe::Result::OK) {
+ LOGE("Failed to open output stream. Error %s", oboe::convertToText(result));
+ mSampleRate = oboe::kUnspecified;
+ return result;
+ } else {
+ // The input stream needs to run at the same sample rate as the output.
+ mSampleRate = mPlayStream->getSampleRate();
+ }
+ warnIfNotLowLatency(mPlayStream);
+
+ if (mInputChannelCount > 0) {
+ setupRecordingStreamParameters(&inBuilder, mSampleRate);
+ result = inBuilder.openStream(mRecordingStream);
+ if (result != oboe::Result::OK) {
+ LOGE("Failed to open input stream. Error %s", oboe::convertToText(result));
+ } else {
+ warnIfNotLowLatency(mRecordingStream);
+ }
+ }
+
+ if (mRecordingStream) {
+ mDuplexStream = std::make_unique();
+ mDuplexStream->setSharedInputStream(mRecordingStream);
+ mDuplexStream->setSharedOutputStream(mPlayStream);
+ mDuplexStream->start();
+ } else {
+ mSimplexStream = std::make_unique();
+ mPlayStream->start();
+ }
+ return oboe::Result::OK;
+}
+
+/**
+ * Sets the stream parameters which are specific to recording,
+ * including the sample rate which is determined from the
+ * playback stream.
+ *
+ * @param builder The recording stream builder
+ * @param sampleRate The desired sample rate of the recording stream
+ */
+oboe::AudioStreamBuilder *OboeEngine::setupRecordingStreamParameters(
+ oboe::AudioStreamBuilder *builder, int32_t sampleRate) {
+ // This sample uses blocking read() because we don't specify a callback
+ builder->setDeviceId(mRecordingDeviceId)
+ ->setDirection(oboe::Direction::Input)
+ ->setSampleRate(sampleRate)
+ ->setChannelCount(mInputChannelCount);
+ return setupCommonStreamParameters(builder);
+}
+
+/**
+ * Sets the stream parameters which are specific to playback, including device
+ * id and the dataCallback function, which must be set for low latency
+ * playback.
+ * @param builder The playback stream builder
+ */
+oboe::AudioStreamBuilder *OboeEngine::setupPlaybackStreamParameters(
+ oboe::AudioStreamBuilder *builder) {
+ builder->setDataCallback(this)
+ ->setErrorCallback(this)
+ ->setDeviceId(mPlaybackDeviceId)
+ ->setDirection(oboe::Direction::Output)
+ ->setChannelCount(mOutputChannelCount);
+
+ return setupCommonStreamParameters(builder);
+}
+
+/**
+ * Set the stream parameters which are common to both recording and playback
+ * streams.
+ * @param builder The playback or recording stream builder
+ */
+oboe::AudioStreamBuilder *OboeEngine::setupCommonStreamParameters(
+ oboe::AudioStreamBuilder *builder) {
+ // We request EXCLUSIVE mode since this will give us the lowest possible
+ // latency.
+ // If EXCLUSIVE mode isn't available the builder will fall back to SHARED
+ // mode.
+ builder->setAudioApi(mAudioApi)
+ ->setFormat(mFormat)
+ ->setFormatConversionAllowed(true)
+ ->setSharingMode(oboe::SharingMode::Exclusive)
+ ->setPerformanceMode(oboe::PerformanceMode::LowLatency);
+ return builder;
+}
+
+/**
+ * Close the stream. AudioStream::close() is a blocking call so
+ * the application does not need to add synchronization between
+ * onAudioReady() function and the thread calling close().
+ * [the closing thread is the UI thread in this sample].
+ * @param stream the stream to close
+ */
+void OboeEngine::closeStream(std::shared_ptr &stream) {
+ if (stream) {
+ oboe::Result result = stream->stop();
+ if (result != oboe::Result::OK) {
+ LOGW("Error stopping stream: %s", oboe::convertToText(result));
+ }
+ result = stream->close();
+ if (result != oboe::Result::OK) {
+ LOGE("Error closing stream: %s", oboe::convertToText(result));
+ } else {
+ LOGW("Successfully closed streams");
+ }
+ stream.reset();
+ }
+}
+
+/**
+ * Warn in logcat if non-low latency stream is created
+ * @param stream: newly created stream
+ *
+ */
+void OboeEngine::warnIfNotLowLatency(std::shared_ptr &stream) {
+ if (stream->getPerformanceMode() != oboe::PerformanceMode::LowLatency) {
+ LOGW(
+ "Stream is NOT low latency."
+ "Check your requested format, sample rate and channel count");
+ }
+}
+
+/**
+ * Handles playback stream's audio request. In this sample, we simply block-read
+ * from the record stream for the required samples.
+ *
+ * @param oboeStream: the playback stream that requesting additional samples
+ * @param audioData: the buffer to load audio samples for playback stream
+ * @param numFrames: number of frames to load to audioData buffer
+ * @return: DataCallbackResult::Continue.
+ */
+oboe::DataCallbackResult OboeEngine::onAudioReady(
+ oboe::AudioStream *oboeStream, void *audioData, int32_t numFrames) {
+ if (mAudioCallback) {
+ mAudioCallback(mAudioCallbackUserData);
+ }
+ if (mDuplexStream) {
+ return mDuplexStream->onAudioReady(oboeStream, audioData, numFrames);
+ } else if (mSimplexStream) {
+ return mSimplexStream->onAudioReady(oboeStream, audioData, numFrames);
+ }
+ return oboe::DataCallbackResult::Stop;
+}
+
+/**
+ * Oboe notifies the application for "about to close the stream".
+ *
+ * @param oboeStream: the stream to close
+ * @param error: oboe's reason for closing the stream
+ */
+void OboeEngine::onErrorBeforeClose(oboe::AudioStream *oboeStream,
+ oboe::Result error) {
+ LOGE("%s stream Error before close: %s",
+ oboe::convertToText(oboeStream->getDirection()),
+ oboe::convertToText(error));
+}
+
+/**
+ * Oboe notifies application that "the stream is closed"
+ *
+ * @param oboeStream
+ * @param error
+ */
+void OboeEngine::onErrorAfterClose(oboe::AudioStream *oboeStream,
+ oboe::Result error) {
+ LOGE("%s stream Error after close: %s",
+ oboe::convertToText(oboeStream->getDirection()),
+ oboe::convertToText(error));
+
+ closeStreams();
+
+ // Restart the stream if the error is a disconnect.
+ if (error == oboe::Result::ErrorDisconnected) {
+ LOGI("Restarting AudioStream");
+ openStreams();
+ }
+}
diff --git a/PdCore/pd-core/src/main/jni/oboe/OboeEngine.h b/PdCore/pd-core/src/main/jni/oboe/OboeEngine.h
new file mode 100644
index 00000000..f425f03d
--- /dev/null
+++ b/PdCore/pd-core/src/main/jni/oboe/OboeEngine.h
@@ -0,0 +1,84 @@
+/*
+ * Copyright (c) 2025 Antoine Rousseau
+ * (derived from 'LiveEffect' oboe sample)
+ *
+ * For information on usage and redistribution, and for a DISCLAIMER OF ALL
+ * WARRANTIES, see the file, "LICENSE.txt," in this distribution.
+ */
+
+#ifndef OBOE_ENGINE_H
+#define OBOE_ENGINE_H
+
+#include
+#include
+#include
+#include
+#include "PdStreams.h"
+
+class OboeEngine : public oboe::AudioStreamCallback {
+public:
+ OboeEngine();
+
+ void setRecordingDeviceId(int32_t deviceId);
+ void setPlaybackDeviceId(int32_t deviceId);
+ bool setAudioApi(oboe::AudioApi);
+ bool isAAudioRecommended(void);
+ void setChannelCounts(int numInputs, int numOutputs);
+ void getAudioParams(int &numInputs, int &numOutputs, int &sampleRate);
+ void setBufferSizeInFrames(int frames);
+
+ /* optional user audio callback */
+ void setAudioCallback(void (*callback)(void*), void *userData = nullptr);
+
+ /**
+ * @param isOn
+ * @return true if it succeeds
+ */
+ bool setEffectOn(bool isOn);
+
+ /*
+ * oboe::AudioStreamDataCallback interface implementation
+ */
+ oboe::DataCallbackResult onAudioReady(oboe::AudioStream *oboeStream,
+ void *audioData, int32_t numFrames) override;
+
+ /*
+ * oboe::AudioStreamErrorCallback interface implementation
+ */
+ void onErrorBeforeClose(oboe::AudioStream *oboeStream, oboe::Result error) override;
+ void onErrorAfterClose(oboe::AudioStream *oboeStream, oboe::Result error) override;
+
+private:
+ bool mIsEffectOn = false;
+ int32_t mRecordingDeviceId = oboe::kUnspecified;
+ int32_t mPlaybackDeviceId = oboe::kUnspecified;
+ const oboe::AudioFormat mFormat = oboe::AudioFormat::Float; // for easier processing
+ oboe::AudioApi mAudioApi = oboe::AudioApi::AAudio;
+ int32_t mSampleRate = oboe::kUnspecified;
+ int32_t mInputChannelCount = oboe::ChannelCount::Stereo;
+ int32_t mOutputChannelCount = oboe::ChannelCount::Stereo;
+
+ std::unique_ptr mDuplexStream;
+ std::unique_ptr mSimplexStream;
+ std::shared_ptr mRecordingStream;
+ std::shared_ptr mPlayStream;
+
+ void (*mAudioCallback)(void *data) = nullptr;
+ void *mAudioCallbackUserData = nullptr;
+
+ oboe::Result openStreams();
+
+ void closeStreams();
+
+ void closeStream(std::shared_ptr &stream);
+
+ oboe::AudioStreamBuilder *setupCommonStreamParameters(
+ oboe::AudioStreamBuilder *builder);
+ oboe::AudioStreamBuilder *setupRecordingStreamParameters(
+ oboe::AudioStreamBuilder *builder, int32_t sampleRate);
+ oboe::AudioStreamBuilder *setupPlaybackStreamParameters(
+ oboe::AudioStreamBuilder *builder);
+ void warnIfNotLowLatency(std::shared_ptr &stream);
+};
+
+#endif // OBOE_ENGINE_H
diff --git a/PdCore/pd-core/src/main/jni/oboe/PdStreams.h b/PdCore/pd-core/src/main/jni/oboe/PdStreams.h
new file mode 100644
index 00000000..af947d13
--- /dev/null
+++ b/PdCore/pd-core/src/main/jni/oboe/PdStreams.h
@@ -0,0 +1,175 @@
+/*
+ * Copyright (c) 2025 Antoine Rousseau
+ *
+ * For information on usage and redistribution, and for a DISCLAIMER OF ALL
+ * WARRANTIES, see the file, "LICENSE.txt," in this distribution.
+ */
+
+#pragma once
+
+#include "z_libpd.h"
+
+extern "C" {
+ pthread_mutex_t *jni_oboe_get_libpd_mutex();
+}
+
+class PdStreamDuplex : public oboe::FullDuplexStream {
+private:
+ static const int MAX_NFRAMES = 8192;
+ static const int MAX_IN_CHANS = 8;
+ static const int MAX_OUT_CHANS = 8;
+ static const int PD_BLOCKSIZE = 64;
+ pthread_mutex_t &mutex = *jni_oboe_get_libpd_mutex();
+
+ class InputBuffer {
+ private:
+ static const int BUFSIZE = MAX_NFRAMES * MAX_IN_CHANS;
+ float ringbuffer[BUFSIZE];
+ float blockbuffer[PD_BLOCKSIZE * MAX_IN_CHANS];
+ int queue = 0;
+ int tail = 0;
+ public:
+ bool is_empty() {
+ return queue == tail;
+ }
+ bool is_full() {
+ return ((tail + 1) % BUFSIZE) == queue;
+ }
+ void push(const float *samples, int nsamples) {
+ for(int c = 0; c < nsamples; c++) {
+ if(is_full()) break;
+ ringbuffer[tail++] = samples[c];
+ if(tail == BUFSIZE)
+ tail = 0;
+ }
+ }
+ float *pop_block(int nchannels) {
+ int nsamples = PD_BLOCKSIZE * nchannels;
+ for(int i = 0 ; i < nsamples; i++) {
+ float s = 0.0;
+ if(!is_empty()) {
+ s = ringbuffer[queue++];
+ if(queue == BUFSIZE)
+ queue = 0;
+ }
+ blockbuffer[i] = s;
+ }
+ return blockbuffer;
+ }
+ } inputBuffer;
+
+ class OutputBuffer {
+ private:
+ float buffer[PD_BLOCKSIZE * MAX_OUT_CHANS];
+ int len = 0;
+ int index = 0;
+ public:
+ bool is_empty() {
+ return index == len;
+ }
+ float *init(int nsamples) {
+ len = nsamples;
+ index = 0;
+ return buffer;
+ }
+ float pop() {
+ return buffer[index++];
+ }
+ } outputBuffer;
+
+public:
+ virtual oboe::DataCallbackResult onBothStreamsReady(
+ const void *inputData,
+ int numInputFrames,
+ void *outputData,
+ int numOutputFrames) {
+
+ // This code assumes the data format for both streams is Float.
+ const float *inputFloats = static_cast(inputData);
+ float *outputFloats = static_cast(outputData);
+
+ int32_t inChannels = getInputStream()->getChannelCount();
+ int32_t outChannels = getOutputStream()->getChannelCount();
+ int32_t numInputSamples = numInputFrames * inChannels;
+ int32_t numOutputSamples = numOutputFrames * outChannels;
+
+ // Store input samples in inputBuffer ring buffer
+ if(numInputSamples < MAX_NFRAMES) // ignore too big buffers
+ inputBuffer.push(inputFloats, numInputSamples);
+
+ int processedSamples = 0;
+ while(processedSamples < numOutputSamples) {
+ // If outputBuffer is empty, get a new one from Pd in exchange for a block from inputBuffer.
+ // When inputBuffer doesn't have enough data, it fills the block with zeroes.
+ if(outputBuffer.is_empty()) {
+ pthread_mutex_lock(&mutex);
+ libpd_process_float(1,
+ inputBuffer.pop_block(inChannels),
+ outputBuffer.init(PD_BLOCKSIZE * outChannels)
+ );
+ pthread_mutex_unlock(&mutex);
+ }
+ // Send next outputBuffer sample to audio output
+ outputFloats[processedSamples++] = outputBuffer.pop();
+ }
+
+ return oboe::DataCallbackResult::Continue;
+ }
+};
+
+class PdStreamSimplex {
+private:
+ static const int MAX_NFRAMES = 8192;
+ static const int MAX_OUT_CHANS = 8;
+ static const int PD_BLOCKSIZE = 64;
+ pthread_mutex_t &mutex = *jni_oboe_get_libpd_mutex();
+
+ class OutputBuffer {
+ private:
+ float buffer[PD_BLOCKSIZE * MAX_OUT_CHANS];
+ int len = 0;
+ int index = 0;
+ public:
+ bool is_empty() {
+ return index == len;
+ }
+ float *init(int nsamples) {
+ len = nsamples;
+ index = 0;
+ return buffer;
+ }
+ float pop() {
+ return buffer[index++];
+ }
+ } outputBuffer;
+
+public:
+ oboe::DataCallbackResult onAudioReady(
+ oboe::AudioStream *oboeStream,
+ void *outputData,
+ int numOutputFrames) {
+
+ float *outputFloats = static_cast(outputData);
+
+ int32_t outChannels = oboeStream->getChannelCount();
+ int32_t numOutputSamples = numOutputFrames * outChannels;
+ int processedSamples = 0;
+ while(processedSamples < numOutputSamples) {
+ // If outputBuffer is empty, get a new one from Pd.
+ if(outputBuffer.is_empty()) {
+ float dummy;
+ pthread_mutex_lock(&mutex);
+ libpd_process_float(1,
+ &dummy,
+ outputBuffer.init(PD_BLOCKSIZE * outChannels)
+ );
+ pthread_mutex_unlock(&mutex);
+ }
+ // Send next outputBuffer sample to audio output
+ outputFloats[processedSamples++] = outputBuffer.pop();
+ }
+
+ return oboe::DataCallbackResult::Continue;
+ }
+};
+
diff --git a/PdCore/pd-core/src/main/jni/oboe/logging_macros.h b/PdCore/pd-core/src/main/jni/oboe/logging_macros.h
new file mode 100644
index 00000000..d2f43282
--- /dev/null
+++ b/PdCore/pd-core/src/main/jni/oboe/logging_macros.h
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2017 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 __SAMPLE_ANDROID_DEBUG_H__
+#define __SAMPLE_ANDROID_DEBUG_H__
+#include
+
+#if 1
+#ifndef MODULE_NAME
+#define MODULE_NAME "AUDIO-APP"
+#endif
+
+#define LOGV(...) __android_log_print(ANDROID_LOG_VERBOSE, MODULE_NAME, __VA_ARGS__)
+#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, MODULE_NAME, __VA_ARGS__)
+#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, MODULE_NAME, __VA_ARGS__)
+#define LOGW(...) __android_log_print(ANDROID_LOG_WARN,MODULE_NAME, __VA_ARGS__)
+#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR,MODULE_NAME, __VA_ARGS__)
+#define LOGF(...) __android_log_print(ANDROID_LOG_FATAL,MODULE_NAME, __VA_ARGS__)
+
+#define ASSERT(cond, ...) if (!(cond)) {__android_log_assert(#cond, MODULE_NAME, __VA_ARGS__);}
+#else
+
+#define LOGV(...)
+#define LOGD(...)
+#define LOGI(...)
+#define LOGW(...)
+#define LOGE(...)
+#define LOGF(...)
+#define ASSERT(cond, ...)
+
+#endif
+
+#endif // __SAMPLE_ANDROID_DEBUG_H__
diff --git a/PdCore/pd-core/src/main/jni/oboe/z_jni_oboe.cpp b/PdCore/pd-core/src/main/jni/oboe/z_jni_oboe.cpp
new file mode 100644
index 00000000..4d42942b
--- /dev/null
+++ b/PdCore/pd-core/src/main/jni/oboe/z_jni_oboe.cpp
@@ -0,0 +1,152 @@
+/*
+ * Copyright (c) 2025 Antoine Rousseau
+ *
+ * For information on usage and redistribution, and for a DISCLAIMER OF ALL
+ * WARRANTIES, see the file, "LICENSE.txt," in this distribution.
+ */
+
+#include "logging_macros.h"
+#include "z_jni_oboe.h"
+
+static OboeEngine *engine = nullptr;
+static bool isRunning = false;
+
+pthread_mutex_t &mutex = *jni_oboe_get_libpd_mutex();
+
+OboeEngine *jni_oboe_get_engine() {
+ return engine;
+}
+
+extern "C" {
+
+JNIEXPORT jstring JNICALL Java_org_puredata_core_PdBase_audioImplementation
+(JNIEnv *env , jclass cls) {
+ return env->NewStringUTF("Oboe");
+}
+
+JNIEXPORT jboolean JNICALL Java_org_puredata_core_PdBase_implementsAudio
+(JNIEnv *env, jclass cls) {
+ return JNI_TRUE;
+}
+
+JNIEXPORT void JNICALL Java_org_puredata_core_PdBase_closeAudio
+(JNIEnv *env, jclass cls) {
+ if (engine) {
+ engine->setEffectOn(false);
+ isRunning = false;
+ delete engine;
+ engine = nullptr;
+ }
+}
+
+JNIEXPORT jint JNICALL Java_org_puredata_core_PdBase_openAudio
+(JNIEnv *env, jclass cls, jint inChans, jint outChans, jint sRate,
+jobject options) {
+ Java_org_puredata_core_PdBase_closeAudio(env, cls);
+ pthread_mutex_lock(&mutex);
+ // Pd audio parameters will be updated later, in startAudio
+ jint err = libpd_init_audio(2, 2, 48000);
+ pthread_mutex_unlock(&mutex);
+ if (err) return err;
+ if (engine == nullptr) {
+ engine = new OboeEngine();
+ }
+ if (engine != nullptr) {
+ engine->setAudioApi(engine->isAAudioRecommended() ? oboe::AudioApi::AAudio : oboe::AudioApi::OpenSLES);
+ engine->setChannelCounts(inChans < 0 ? oboe::kUnspecified : inChans,
+ outChans < 0 ? oboe::kUnspecified : outChans);
+ }
+ return (engine == nullptr) ? -1 : 0;
+}
+
+JNIEXPORT jint JNICALL Java_org_puredata_core_PdBase_startAudio
+(JNIEnv *env, jclass cls) {
+ if (engine == nullptr) {
+ LOGE(
+ "Engine is null, you must call createEngine before calling startAudio "
+ "method");
+ return -1;
+ }
+ isRunning = engine->setEffectOn(true);
+ // update Pd audio parameters for the actual devices
+ if (isRunning) {
+ int inChans, outChans, sRate;
+ engine->getAudioParams(inChans, outChans, sRate);
+ libpd_init_audio(inChans, outChans, sRate);
+ }
+ return isRunning ? 0 : -1;
+}
+
+JNIEXPORT jint JNICALL Java_org_puredata_core_PdBase_pauseAudio
+(JNIEnv *env, jclass cls) {
+ if (engine == nullptr) {
+ LOGE(
+ "Engine is null, you must call createEngine before calling pauseAudio "
+ "method");
+ return -1;
+ }
+ isRunning = false;
+ engine->setEffectOn(false);
+ return 0;
+}
+
+JNIEXPORT jboolean JNICALL Java_org_puredata_core_PdBase_isRunning
+(JNIEnv *env, jclass cls) {
+ return engine && isRunning;
+}
+
+JNIEXPORT jint JNICALL Java_org_puredata_core_PdBase_suggestSampleRate
+(JNIEnv *env, jclass cls) {
+ return -1;
+}
+
+JNIEXPORT jint JNICALL Java_org_puredata_core_PdBase_suggestInputChannels
+(JNIEnv *env, jclass cls) {
+ return -1;
+}
+
+JNIEXPORT jint JNICALL Java_org_puredata_core_PdBase_suggestOutputChannels
+(JNIEnv *env, jclass cls) {
+ return -1;
+}
+
+
+JNIEXPORT void JNICALL
+Java_org_puredata_android_io_PdAudio_setRecordingDeviceId(
+ JNIEnv *env, jclass, jint deviceId) {
+ if (engine == nullptr) {
+ LOGE(
+ "Engine is null, you must call createEngine before calling setRecordingDeviceId "
+ "method");
+ return;
+ }
+
+ engine->setRecordingDeviceId(deviceId < 0 ? oboe::kUnspecified : deviceId);
+}
+
+JNIEXPORT void JNICALL
+Java_org_puredata_android_io_PdAudio_setPlaybackDeviceId(
+ JNIEnv *env, jclass, jint deviceId) {
+ if (engine == nullptr) {
+ LOGE(
+ "Engine is null, you must call createEngine before calling setPlaybackDeviceId "
+ "method");
+ return;
+ }
+ engine->setPlaybackDeviceId(deviceId < 0 ? oboe::kUnspecified : deviceId);
+}
+
+JNIEXPORT void JNICALL
+Java_org_puredata_android_io_PdAudio_setBufferSizeInFrames(
+ JNIEnv *env, jclass, jint frames) {
+ if (engine == nullptr) {
+ LOGE(
+ "Engine is null, you must call createEngine before calling setBufferSizeInFrames "
+ "method");
+ return;
+ }
+ engine->setBufferSizeInFrames(frames);
+}
+
+} // extern "C"
+
diff --git a/PdCore/pd-core/src/main/jni/oboe/z_jni_oboe.h b/PdCore/pd-core/src/main/jni/oboe/z_jni_oboe.h
new file mode 100644
index 00000000..7f671611
--- /dev/null
+++ b/PdCore/pd-core/src/main/jni/oboe/z_jni_oboe.h
@@ -0,0 +1,14 @@
+/*
+ * Copyright (c) 2025 Antoine Rousseau
+ *
+ * For information on usage and redistribution, and for a DISCLAIMER OF ALL
+ * WARRANTIES, see the file, "LICENSE.txt," in this distribution.
+ */
+
+#pragma once
+
+#include "OboeEngine.h"
+
+OboeEngine *jni_oboe_get_engine();
+
+
diff --git a/PdCore/pd-core/src/main/jni/oboe/z_jni_oboe_shared.c b/PdCore/pd-core/src/main/jni/oboe/z_jni_oboe_shared.c
new file mode 100644
index 00000000..730de4ed
--- /dev/null
+++ b/PdCore/pd-core/src/main/jni/oboe/z_jni_oboe_shared.c
@@ -0,0 +1,7 @@
+
+#include "z_jni_shared.c"
+
+// export mutex to C++
+pthread_mutex_t *jni_oboe_get_libpd_mutex() {
+ return &mutex;
+}
diff --git a/PdCore/pd-core/src/main/res/values/audio.xml b/PdCore/pd-core/src/main/res/values/audio.xml
index 80aef857..7be836c8 100644
--- a/PdCore/pd-core/src/main/res/values/audio.xml
+++ b/PdCore/pd-core/src/main/res/values/audio.xml
@@ -18,6 +18,12 @@
- 44100
- 48000
+
+ - Default
+
+
+ - -1
+
- None
- Mono
@@ -28,6 +34,12 @@
- 1
- 2
+
+ - Default
+
+
+ - -1
+
- None
- Mono
diff --git a/PdCore/pd-core/src/main/res/values/strings.xml b/PdCore/pd-core/src/main/res/values/strings.xml
index 26d9d507..a808125b 100644
--- a/PdCore/pd-core/src/main/res/values/strings.xml
+++ b/PdCore/pd-core/src/main/res/values/strings.xml
@@ -5,9 +5,15 @@
SAMPLE_RATE
Sample rate
Sample rate for Pure Data
+ INPUT_DEVICE
+ Input device
+ Name of the input device
INPUT_CHANNELS
Input channels
Number of input channels
+ OUTPUT_DEVICE
+ Output device
+ Name of the output device
OUTPUT_CHANNELS
Output channels
Number of output channels
diff --git a/PdCore/pd-core/src/main/res/xml/preferences.xml b/PdCore/pd-core/src/main/res/xml/preferences.xml
index c33c0714..611484e9 100644
--- a/PdCore/pd-core/src/main/res/xml/preferences.xml
+++ b/PdCore/pd-core/src/main/res/xml/preferences.xml
@@ -5,9 +5,15 @@
android:entries="@array/srate_labels" android:key="@string/pref_key_srate"
android:title="@string/pref_title_srate" android:summary="@string/pref_sum_srate">
+
+
diff --git a/PdTest/AndroidManifest.xml b/PdTest/AndroidManifest.xml
index 5961975c..4140ee62 100644
--- a/PdTest/AndroidManifest.xml
+++ b/PdTest/AndroidManifest.xml
@@ -20,5 +20,6 @@
-
+
+
diff --git a/PdTest/jni/Application.mk b/PdTest/jni/Application.mk
index 1dfcd802..234df209 100644
--- a/PdTest/jni/Application.mk
+++ b/PdTest/jni/Application.mk
@@ -1,4 +1,4 @@
APP_OPTIM := release
APP_ABI := armeabi-v7a arm64-v8a x86 x86_64
APP_ALLOW_MISSING_DEPS=true
-
+APP_STL := c++_shared
diff --git a/ScenePlayer/AndroidManifest.xml b/ScenePlayer/AndroidManifest.xml
index cf6c4342..565d703c 100644
--- a/ScenePlayer/AndroidManifest.xml
+++ b/ScenePlayer/AndroidManifest.xml
@@ -10,6 +10,7 @@
+
@@ -64,4 +65,4 @@
-
\ No newline at end of file
+
diff --git a/ScenePlayer/jni/Application.mk b/ScenePlayer/jni/Application.mk
index 1dfcd802..234df209 100644
--- a/ScenePlayer/jni/Application.mk
+++ b/ScenePlayer/jni/Application.mk
@@ -1,4 +1,4 @@
APP_OPTIM := release
APP_ABI := armeabi-v7a arm64-v8a x86 x86_64
APP_ALLOW_MISSING_DEPS=true
-
+APP_STL := c++_shared
diff --git a/Voice-O-Rama/AndroidManifest.xml b/Voice-O-Rama/AndroidManifest.xml
index 75dab709..b4340e15 100644
--- a/Voice-O-Rama/AndroidManifest.xml
+++ b/Voice-O-Rama/AndroidManifest.xml
@@ -5,6 +5,7 @@
+