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 @@ +