feat: detect steps and play audio

This commit is contained in:
2026-03-04 01:31:35 +01:00
parent 804f83340f
commit e22478445d
18 changed files with 435 additions and 17 deletions

View File

@@ -2,6 +2,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -23,6 +27,11 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name="at.lockstep.app.LstForegroundService"
android:exported="false"
android:foregroundServiceType="mediaPlayback"
/>
</application>
</manifest>

View File

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

View File

@@ -37,7 +37,7 @@ public:
std::lock_guard<std::mutex> 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)

View File

@@ -56,3 +56,7 @@ PlaybackEngine::~PlaybackEngine() {
delete mPlayer;
mPlayer = nullptr;
}
void PlaybackEngine::playBeat() {
if(mPlayer) mPlayer->setStartBeat();
}

View File

@@ -5,13 +5,16 @@
#ifndef LOCKSTEP_PLAYBACKENGINE_H
#define LOCKSTEP_PLAYBACKENGINE_H
#include "StepListener.h"
#include "MixingPlayer.h"
#include <string>
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;

View File

@@ -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<float> 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();
}
}

View File

@@ -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 <vector>
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<float> values);
};
#endif //LOCKSTEP_STEPDETECTOR_H

View File

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

View File

@@ -0,0 +1,52 @@
//
// Created by david on 03.03.2026.
//
#include <jni.h>
#include "StepDetector.h"
#include <new>
#include <vector>
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<StepListener *>(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<jlong>(detector);
}
JNIEXPORT void JNICALL
Java_at_lockstep_filter_StepDetector_native_1delete(
JNIEnv *env,
jclass /*unused*/, jlong handle) {
delete reinterpret_cast<StepDetector *>(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<float> vecValues(nativeValues, nativeValues + length);
auto *detector = reinterpret_cast<StepDetector *>(handle);
detector->filter(vecValues);
}
}

View File

@@ -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);
}
}
}
}

View File

@@ -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))
);
}
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:padding="24dp">
<Button
android:id="@+id/btnStart"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Start collection" />
<Button
android:id="@+id/btnStop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Stop collection" />
</LinearLayout>