diff --git a/TODO.md b/TODO.md index 0fa7706..e1e6ea1 100644 --- a/TODO.md +++ b/TODO.md @@ -1,3 +1,12 @@ +## TODO + +O> 16 KB paging for NDK libs +* target SDK level 35 + +* minimum amplitude for accelero +* filter away bad SQI areas of the signal (do not detect steps if we have bad SQI) + +## Nice-To * myband PlaybackEngine.cpp has latency management and other audio performance related features. Check if the app can be improved (audio wise) by using that code instead. diff --git a/app/build.gradle b/app/build.gradle index 745bbb2..52e0514 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -6,6 +6,7 @@ plugins { android { namespace 'at.lockstep' compileSdk 34 + ndkVersion '29.0.14206865' defaultConfig { applicationId "at.lockstep" @@ -21,15 +22,19 @@ android { externalNativeBuild { cmake { //path 'src/main/cpp/CMakeLists.txt' - cppFlags '' + //cppFlags '' arguments "-DANDROID_STL=c++_shared" //cppFlags "-std=c++14" //arguments '-DANDROID_STL=c++_static' + //cppFlags "-Wl,-z,max-page-size=16384 -Wl,-z,common-page-size=4096" + // should be provided by default by newer NDK (NDK r29) + // armeabi and mips are deprecated in NDK r16 so we don't want to build for them // TODO: android manifest filters to include only these hardware archs - abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64' //, 'x86', 'x86_64' + //abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64' //, 'x86', 'x86_64' + abiFilters 'armeabi-v7a', 'arm64-v8a' //, 'aarch64' // 'arm64-v8a' ??? } } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0855b20..1932e55 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index 75c2999..0571075 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -12,6 +12,8 @@ cmake_minimum_required(VERSION 3.22.1) # build script scope). project("lockstep-native") +add_subdirectory(libpasada/pasada-lib) + # Creates and names a library, sets it as either STATIC # or SHARED, and provides the relative paths to its source code. # You can define multiple libraries, and CMake builds them for you. @@ -31,6 +33,8 @@ add_library(${CMAKE_PROJECT_NAME} SHARED PlaybackEngine.cpp mp3file.cpp jni_bridge.cpp + jni_stepdetector.cpp + StepDetector.cpp ) find_package (oboe REQUIRED CONFIG) @@ -45,11 +49,14 @@ set_target_properties(mpg123 PROPERTIES IMPORTED_LOCATION ${mpg123_DIR}/lib/${ANDROID_ABI}/libmpg123.so) include_directories(${mpg123_DIR}/lib/${ANDROID_ABI}/include) +target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE libpasada/pasada-lib/include) + # Specifies libraries CMake should link to your target library. You # can link libraries from various origins, such as libraries defined in this # build script, prebuilt third-party libraries, or Android system libraries. target_link_libraries(${CMAKE_PROJECT_NAME} # List libraries link to the target library + pasada oboe::oboe mpg123 android diff --git a/app/src/main/cpp/MixingPlayer.h b/app/src/main/cpp/MixingPlayer.h index a1893ef..4001295 100644 --- a/app/src/main/cpp/MixingPlayer.h +++ b/app/src/main/cpp/MixingPlayer.h @@ -37,7 +37,7 @@ public: std::lock_guard lock(mLock); oboe::AudioStreamBuilder builder; // The builder set methods can be chained for convenience. - Result result = builder.setSharingMode(oboe::SharingMode::Exclusive) + Result result = builder.setSharingMode(oboe::SharingMode::Shared) ->setPerformanceMode(oboe::PerformanceMode::LowLatency) ->setChannelCount(kChannelCount) ->setSampleRate(kSampleRate) diff --git a/app/src/main/cpp/PlaybackEngine.cpp b/app/src/main/cpp/PlaybackEngine.cpp index 13470a0..53477d8 100644 --- a/app/src/main/cpp/PlaybackEngine.cpp +++ b/app/src/main/cpp/PlaybackEngine.cpp @@ -56,3 +56,7 @@ PlaybackEngine::~PlaybackEngine() { delete mPlayer; mPlayer = nullptr; } + +void PlaybackEngine::playBeat() { + if(mPlayer) mPlayer->setStartBeat(); +} diff --git a/app/src/main/cpp/PlaybackEngine.h b/app/src/main/cpp/PlaybackEngine.h index b6bfa3e..b6ad43b 100644 --- a/app/src/main/cpp/PlaybackEngine.h +++ b/app/src/main/cpp/PlaybackEngine.h @@ -5,13 +5,16 @@ #ifndef LOCKSTEP_PLAYBACKENGINE_H #define LOCKSTEP_PLAYBACKENGINE_H +#include "StepListener.h" #include "MixingPlayer.h" #include -class PlaybackEngine { +class PlaybackEngine : public StepListener { public: PlaybackEngine(std::string filesDir, int resid); virtual ~PlaybackEngine(); + /** Play a beat sound. */ + virtual void playBeat(); private: MixingPlayer *mPlayer; std::string mFilesDir; diff --git a/app/src/main/cpp/StepDetector.cpp b/app/src/main/cpp/StepDetector.cpp new file mode 100644 index 0000000..3765520 --- /dev/null +++ b/app/src/main/cpp/StepDetector.cpp @@ -0,0 +1,44 @@ +// +// Created by david on 03.03.2026. +// + +#include "StepDetector.h" + +// TODO: we are hardcoding filter coefficients for 60 Hz +// TODO: this is tolerable for 50 Hz + +// TODO: check if we can do with floats instead of doubles +// (check how much the [already bad] accuracy of filtering suffers) + +// TODO: in Java, check if delta timestamps effectively match FPS +// TODO: FPS constant should be passed as argument to C++ (but we keep an FPS define to validate the coefficients) + +// Butterworth filter: order=5, fc=0.5, fs=60, btype='highpass' +static std::vector hpf_taps_b {0.91875845, -4.59379227, 9.18758454, -9.18758454, 4.59379227, -0.91875845}; +static std::vector hpf_taps_a {1. , -4.83056552, 9.33652742, -9.02545247, 4.36360803, -0.8441171}; +static size_t upslope_width = 4; +const size_t len_refr = (size_t) (FPS / (MAX_BPM / 60)); + +StepDetector::StepDetector(StepListener *listener) : + listener(listener), + f_highpass(hpf_taps_b, hpf_taps_a), + f_neg(1, 0, 0, std::vector {-1.0}), + f_ssf(upslope_width), + f_ssd(len_refr) +{} + +#if (FPS != 60) +#error "FPS must currently be 60, as highpass taps are pre-computed for that value" +#endif + +void StepDetector::filter(std::vector values) { + // TODO: later on, we should use a vector projection towards gravity + auto s0 = (double) values[1]; // take y-axis value for now + auto s1 = f_highpass.filter(s0); + auto s2 = f_neg.filter(s1); + auto s3 = f_ssf.filter(s2); + auto s4 = f_ssd.filter(s3); + if(s4 > 0.0 && listener != nullptr) { + listener->playBeat(); + } +} diff --git a/app/src/main/cpp/StepDetector.h b/app/src/main/cpp/StepDetector.h new file mode 100644 index 0000000..68a424e --- /dev/null +++ b/app/src/main/cpp/StepDetector.h @@ -0,0 +1,26 @@ +// +// Created by david on 03.03.2026. +// + +#ifndef LOCKSTEP_STEPDETECTOR_H +#define LOCKSTEP_STEPDETECTOR_H + +#include "StepListener.h" +#include "iir_filter.h" +#include "ssf_filter.h" +#include + +class StepDetector { +protected: + StepListener *listener; + IirFilter f_highpass; + Filt f_neg; + SsfFilter f_ssf; + SsfStepDetector f_ssd; + +public: + StepDetector(StepListener *listener); + void filter(std::vector values); +}; + +#endif //LOCKSTEP_STEPDETECTOR_H diff --git a/app/src/main/cpp/StepListener.h b/app/src/main/cpp/StepListener.h new file mode 100644 index 0000000..708f923 --- /dev/null +++ b/app/src/main/cpp/StepListener.h @@ -0,0 +1,14 @@ +// +// Created by david on 03.03.2026. +// + +#ifndef LOCKSTEP_STEPLISTENER_H +#define LOCKSTEP_STEPLISTENER_H + +class StepListener { +public: + virtual ~StepListener() {} + virtual void playBeat() = 0; +}; + +#endif //LOCKSTEP_STEPLISTENER_H diff --git a/app/src/main/cpp/jni_stepdetector.cpp b/app/src/main/cpp/jni_stepdetector.cpp new file mode 100644 index 0000000..7f6a98d --- /dev/null +++ b/app/src/main/cpp/jni_stepdetector.cpp @@ -0,0 +1,52 @@ +// +// Created by david on 03.03.2026. +// + +#include +#include "StepDetector.h" +#include +#include + +extern "C" { + +jint throwIllegalArgumentException(JNIEnv *env, const char *message) +{ + jclass exClass; + const char *className = "java/lang/IllegalArgumentException"; + exClass = env->FindClass(className); + if (exClass == nullptr) { + return -1; + } + return env->ThrowNew(exClass, message); +} + +JNIEXPORT jlong JNICALL +Java_at_lockstep_filter_StepDetector_native_1create( + JNIEnv *env, + jclass /*unused*/, jlong engineHandle) { + auto *listener = reinterpret_cast(engineHandle); + // We use std::nothrow so `new` returns a nullptr if the engine creation fails + auto *detector = new(std::nothrow) StepDetector(listener); + return reinterpret_cast(detector); +} + +JNIEXPORT void JNICALL +Java_at_lockstep_filter_StepDetector_native_1delete( + JNIEnv *env, + jclass /*unused*/, jlong handle) { + delete reinterpret_cast(handle); +} + +JNIEXPORT void JNICALL +Java_at_lockstep_filter_StepDetector_native_1filter( + JNIEnv *env, + jclass /*unused*/, jlong handle, jlong timestamp, jfloatArray values) { + if(values == nullptr) throwIllegalArgumentException(env, "values == null"); + float* nativeValues = (float *)env->GetFloatArrayElements(values, 0); + jsize length = env->GetArrayLength(values); + std::vector vecValues(nativeValues, nativeValues + length); + auto *detector = reinterpret_cast(handle); + detector->filter(vecValues); +} + +} \ No newline at end of file diff --git a/app/src/main/java/at/lockstep/app/LstForegroundService.java b/app/src/main/java/at/lockstep/app/LstForegroundService.java new file mode 100644 index 0000000..aed4809 --- /dev/null +++ b/app/src/main/java/at/lockstep/app/LstForegroundService.java @@ -0,0 +1,182 @@ +package at.lockstep.app; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.os.Build; +import android.os.IBinder; +import android.os.PowerManager; + +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; + +import at.lockstep.filter.StepDetector; +import at.lockstep.pb.PlaybackEngine; +import at.lockstep.R; + +public class LstForegroundService extends Service implements SensorEventListener { + + private static final String CHANNEL_ID = "sensor_collection_channel"; + private static final int NOTIFICATION_ID = 1001; + + public static final String ACTION_START = "at.lockstep.action.START"; + public static final String ACTION_STOP = "at.lockstep.action.STOP"; + + private SensorManager sensorManager; + private Sensor accelerometer; + private PowerManager.WakeLock wakeLock; + private boolean isCollecting = false; + + private StepDetector stepDetector; + + public static Intent startIntent(Context context) { + Intent intent = new Intent(context, LstForegroundService.class); + intent.setAction(ACTION_START); + return intent; + } + + public static Intent stopIntent(Context context) { + Intent intent = new Intent(context, LstForegroundService.class); + intent.setAction(ACTION_STOP); + return intent; + } + + @Override + public void onCreate() { + super.onCreate(); + + int resid = R.raw.track_beat; + PlaybackEngine.create(this, resid); + stepDetector = new StepDetector(PlaybackEngine.getEngineHandle()); + + sensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE); + if (sensorManager != null) { + accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); + } + + createNotificationChannel(); + + PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE); + if (powerManager != null) { + wakeLock = powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + getPackageName() + ":SensorCollectionWakeLock" + ); + wakeLock.setReferenceCounted(false); + } + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent != null) { + String action = intent.getAction(); + if (ACTION_START.equals(action)) { + startCollection(); + } else if (ACTION_STOP.equals(action)) { + stopCollectionAndSelf(); + } + } + return START_STICKY; + } + + private void startCollection() { + if (isCollecting) { + return; + } + + startForeground(NOTIFICATION_ID, buildNotification("Collecting sensor data")); + + if (wakeLock != null && !wakeLock.isHeld()) { + // TODO: provide a timeout reasonable for a run + wakeLock.acquire(2*60*60*1000L /*2 hours*/); + } + + if (accelerometer != null && sensorManager != null) { + sensorManager.registerListener( + this, + accelerometer, + SensorManager.SENSOR_DELAY_GAME + ); + isCollecting = true; + } else { + stopCollectionAndSelf(); + } + } + + private void stopCollectionAndSelf() { + if (isCollecting && sensorManager != null) { + sensorManager.unregisterListener(this); + isCollecting = false; + } + + if (wakeLock != null && wakeLock.isHeld()) { + wakeLock.release(); + } + + stopForeground(STOP_FOREGROUND_REMOVE); + stopSelf(); + } + + @Override + public void onDestroy() { + if (sensorManager != null) { + sensorManager.unregisterListener(this); + } + + if (wakeLock != null && wakeLock.isHeld()) { + wakeLock.release(); + } + + // TODO: check threading to see if these run in separate threads - if so, deleting PlaybackEngine will leave a dangling pointer in StepDetector. + // 2026-03-04 01:26:11.741 12507-12507 libc at.lockstep A Fatal signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0xb4000071d3a79000 in tid 12507 (at.lockstep), pid 12507 (at.lockstep) + stepDetector.close(); + PlaybackEngine.delete(); + + super.onDestroy(); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public void onSensorChanged(SensorEvent event) { + stepDetector.filter(event.timestamp, event.values); + } + + @Override + public void onAccuracyChanged(Sensor sensor, int accuracy) {} + + private Notification buildNotification(String contentText) { + return new NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Lockstep is reading your pace.") + .setContentText(contentText) + .setSmallIcon(android.R.drawable.ic_menu_compass) + .setOngoing(true) + .build(); + } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + "Lockstep", + NotificationManager.IMPORTANCE_LOW + ); + + NotificationManager nm = getSystemService(NotificationManager.class); + if (nm != null) { + nm.createNotificationChannel(channel); + } + } + } +} diff --git a/app/src/main/java/at/lockstep/app/MainActivity.java b/app/src/main/java/at/lockstep/app/MainActivity.java index 2a04655..d32b9d9 100644 --- a/app/src/main/java/at/lockstep/app/MainActivity.java +++ b/app/src/main/java/at/lockstep/app/MainActivity.java @@ -1,24 +1,42 @@ package at.lockstep.app; import android.app.Activity; +import android.os.Bundle; +import android.widget.Button; +import androidx.core.content.ContextCompat; + +import at.lockstep.R; import at.lockstep.pb.PlaybackEngine; public class MainActivity extends Activity { - /* - * Creating engine in onResume() and destroying in onPause() so the stream retains exclusive - * mode only while in focus. This allows other apps to reclaim exclusive stream mode. - */ - @Override - protected void onResume() { - super.onResume(); - int resid = at.lockstep.R.raw.track_beat; - PlaybackEngine.create(this, resid); // note: called twice (is permission request causing Activity to go out of focus?) - } + private Button btnStart; + private Button btnStop; @Override - protected void onPause() { - PlaybackEngine.delete(); - super.onPause(); + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + btnStart = findViewById(R.id.btnStart); + btnStop = findViewById(R.id.btnStop); + + btnStart.setOnClickListener(v -> + ContextCompat.startForegroundService( + MainActivity.this, + LstForegroundService.startIntent(MainActivity.this) + ) + ); + + //if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + // startForeground(SERVICE_ID, notification) + //} else { + // startForeground(SERVICE_ID, notification, + //FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) + //} + + btnStop.setOnClickListener(v -> + startService(LstForegroundService.stopIntent(MainActivity.this)) + ); } } diff --git a/app/src/main/java/at/lockstep/filter/StepDetector.java b/app/src/main/java/at/lockstep/filter/StepDetector.java new file mode 100644 index 0000000..3228f93 --- /dev/null +++ b/app/src/main/java/at/lockstep/filter/StepDetector.java @@ -0,0 +1,21 @@ +package at.lockstep.filter; + +import at.lockstep.pb.PlaybackEngine; + +public class StepDetector { + long handle; + + public StepDetector(long engineHandle) { + handle = native_create(engineHandle); + } + public void close() { + native_delete(handle); + } + public void filter(long timestamp, float[] values) { + native_filter(handle, timestamp, values); + } + + private static native long native_create(long engineHandle); + private static native void native_delete(long sdHandle); + private static native void native_filter(long sdHandle, long timestamp, float[] values); +} diff --git a/app/src/main/java/at/lockstep/pb/PlaybackEngine.java b/app/src/main/java/at/lockstep/pb/PlaybackEngine.java index 70f5a2e..818e914 100644 --- a/app/src/main/java/at/lockstep/pb/PlaybackEngine.java +++ b/app/src/main/java/at/lockstep/pb/PlaybackEngine.java @@ -67,6 +67,9 @@ public class PlaybackEngine { } mEngineHandle = 0; } + public static long getEngineHandle() { + return mEngineHandle; + } private static native long native_createEngine(String filesDir, int resid); private static native void native_deleteEngine(long engineHandle); diff --git a/app/src/main/lib/mpg123/lib/arm64-v8a/libmpg123.so b/app/src/main/lib/mpg123/lib/arm64-v8a/libmpg123.so index 6eab311..3807195 100644 Binary files a/app/src/main/lib/mpg123/lib/arm64-v8a/libmpg123.so and b/app/src/main/lib/mpg123/lib/arm64-v8a/libmpg123.so differ diff --git a/app/src/main/lib/mpg123/lib/armeabi-v7a/libmpg123.so b/app/src/main/lib/mpg123/lib/armeabi-v7a/libmpg123.so index e7d400a..4381600 100644 Binary files a/app/src/main/lib/mpg123/lib/armeabi-v7a/libmpg123.so and b/app/src/main/lib/mpg123/lib/armeabi-v7a/libmpg123.so differ diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..e946fd5 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,21 @@ + + + +