From a924331ede6e1fa286557f3d6b329114db80ca06 Mon Sep 17 00:00:00 2001 From: David Madl Date: Sun, 24 May 2026 16:02:06 +0200 Subject: [PATCH] feat: API for LibPasada music player --- app/src/main/cpp/CMakeLists.txt | 2 + app/src/main/cpp/LibPasada.cpp | 95 ++++++++ app/src/main/cpp/LibPasada.h | 98 +++++++++ app/src/main/cpp/jni_libpasada.cpp | 205 ++++++++++++++++++ .../at/lockstep/player/pasada/LibPasada.java | 72 ++++++ .../player/pasada/PasadaPlaybackListener.java | 14 ++ .../lockstep/player/pasada/PasadaState.java | 29 +++ 7 files changed, 515 insertions(+) create mode 100644 app/src/main/cpp/LibPasada.cpp create mode 100644 app/src/main/cpp/LibPasada.h create mode 100644 app/src/main/cpp/jni_libpasada.cpp create mode 100644 app/src/main/java/at/lockstep/player/pasada/LibPasada.java create mode 100644 app/src/main/java/at/lockstep/player/pasada/PasadaPlaybackListener.java create mode 100644 app/src/main/java/at/lockstep/player/pasada/PasadaState.java diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index cd93dfd..eae9b5d 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -35,6 +35,8 @@ add_library(${CMAKE_PROJECT_NAME} SHARED jni_mpg123.cpp jni_lockstep.cpp jni_stepdetector.cpp + jni_libpasada.cpp + LibPasada.cpp ) find_package (oboe REQUIRED CONFIG) diff --git a/app/src/main/cpp/LibPasada.cpp b/app/src/main/cpp/LibPasada.cpp new file mode 100644 index 0000000..f4b8de2 --- /dev/null +++ b/app/src/main/cpp/LibPasada.cpp @@ -0,0 +1,95 @@ +#include "LibPasada.h" +#include + +/** Current track reached end; service should advance queue and call {@link LibPasada#play}. */ +void PasadaPlaybackListener::onTrackFinished() { + emitTrackFinished(); +} + +/** Decode / Oboe / pipeline failure; service should stop safely and surface error. */ +void PasadaPlaybackListener::onError(int errorCode, std::string message) { + emitError(errorCode, message); +} + +/** + * Mirrors the libpasada state machine documented in DESIGN.md. + * Keep values in sync with PasadaState.java + */ +/*enum PasadaState { + LOADED = 0, + INITIALIZED = 1, + PLAYING = 2, + PAUSED = 3, + FINISHED = 4, + STOPPED = 5 +};*/ + +/** + * JNI entry point for libpasada. + *

Native state machine: LOADED → INITIALIZED → PLAYING ↔ PAUSED → FINISHED → STOPPED + */ + +LibPasada::LibPasada() : state(LOADED) +{} + +LibPasada::~LibPasada() {} + +/** Called each time a run/playlist session starts (Oboe silent, buffers ready). */ +void LibPasada::init() { + if(state == LOADED || state == STOPPED) { + // perform init + state = INITIALIZED; + } +} + +/** Submit one accelerometer sample (m/s²); may be called from a sensor thread. */ +void LibPasada::feedAccel(float x, float y, float z, long long timestamp_nanos) {} + +/** + * Open MP3 from an already-open file descriptor and begin adaptive playback. + * + * @param fd open read FD (Java retains ownership; do not close until track changes) + * @param offset start offset within the FD (0 for whole file) + * @param length byte length from offset ({@code -1} if unknown / to EOF) + */ +void LibPasada::play(int fd, long long offset, long long length) { + state = PLAYING; +} +/** PLAYING → PAUSED (silent output, graph kept alive). */ +void LibPasada::pause() { + if(state != PLAYING) return; + state = PAUSED; +} +/** PAUSED → PLAYING (same track, same decode position, same FD). */ +void LibPasada::resume() { + if(state != PAUSED) return; + state = PLAYING; +} +/** Tear down Oboe for this run segment → STOPPED. */ +void LibPasada::stop() { + state = STOPPED; +} + +/** + * Milliseconds from the start of the current track — same timebase as ExoPlayer + * {@code getCurrentPosition()}. Safe to poll from background threads. + */ +long LibPasada::getCurrentPositionMs() { return 0; } + +/** Track duration in ms, or {@code 0} if not yet known. */ +long LibPasada::getDurationMs() { return 0; } + +/** Whether adapted audio is actively being output (not paused, not finished). */ +bool LibPasada::isPlaying() { return false; } + +/** Current native state; see {@link PasadaState}. */ +int LibPasada::getState() { return state; } + +/** Seek within the current track. */ +void LibPasada::seekTo(long positionMs) {} + +/** Runtime metrics / last error string for logging and debug UI. */ +std::string LibPasada::getDiagnostics() { return ""; } + +/** Register listener for async events raised from the audio/native thread. */ +//void LibPasada::setPlaybackListener(PasadaPlaybackListener listener) {} // JNI-side only diff --git a/app/src/main/cpp/LibPasada.h b/app/src/main/cpp/LibPasada.h new file mode 100644 index 0000000..7c04497 --- /dev/null +++ b/app/src/main/cpp/LibPasada.h @@ -0,0 +1,98 @@ +// +// Created by david on 24.05.2026. +// + +#ifndef LOCKSTEP_LIBPASADA_H +#define LOCKSTEP_LIBPASADA_H + +#include + +// JNI helpers for calling into Java code (PasadaPlaybackListener) +void emitTrackFinished(); +void emitError(int errorCode, const std::string& message); + +/** + * Callbacks invoked from native (Oboe or internal worker thread). + */ +class PasadaPlaybackListener { +public: + /** Current track reached end; service should advance queue and call {@link LibPasada#play}. */ + void onTrackFinished(); + + /** Decode / Oboe / pipeline failure; service should stop safely and surface error. */ + void onError(int errorCode, std::string message); +}; + +/** + * Mirrors the libpasada state machine documented in DESIGN.md. + * Keep values in sync with PasadaState.java + */ +enum PasadaState { + LOADED = 0, + INITIALIZED = 1, + PLAYING = 2, + PAUSED = 3, + FINISHED = 4, + STOPPED = 5 +}; + +/** + * JNI entry point for libpasada. + *

Native state machine: LOADED → INITIALIZED → PLAYING ↔ PAUSED → FINISHED → STOPPED + */ +class LibPasada { +private: + PasadaState state; + //PasadaPlaybackListener listener; + +public: + LibPasada(); + ~LibPasada(); + + /** Called each time a run/playlist session starts (Oboe silent, buffers ready). */ + void init(); + + /** Submit one accelerometer sample (m/s²); may be called from a sensor thread. */ + void feedAccel(float x, float y, float z, long long timestamp_nanos); + + /** + * Open MP3 from an already-open file descriptor and begin adaptive playback. + * + * @param fd open read FD (Java retains ownership; do not close until track changes) + * @param offset start offset within the FD (0 for whole file) + * @param length byte length from offset ({@code -1} if unknown / to EOF) + */ + void play(int fd, long long offset, long long length); + /** PLAYING → PAUSED (silent output, graph kept alive). */ + void pause(); + /** PAUSED → PLAYING (same track, same decode position, same FD). */ + void resume(); + /** Tear down Oboe for this run segment → STOPPED. */ + void stop(); + + /** + * Milliseconds from the start of the current track — same timebase as ExoPlayer + * {@code getCurrentPosition()}. Safe to poll from background threads. + */ + long getCurrentPositionMs(); + + /** Track duration in ms, or {@code 0} if not yet known. */ + long getDurationMs(); + + /** Whether adapted audio is actively being output (not paused, not finished). */ + bool isPlaying(); + + /** Current native state; see {@link PasadaState}. */ + int getState(); + + /** Seek within the current track. */ + void seekTo(long positionMs); + + /** Runtime metrics / last error string for logging and debug UI. */ + std::string getDiagnostics(); + + /** Register listener for async events raised from the audio/native thread. */ + //void setPlaybackListener(PasadaPlaybackListener listener); // JNI-side only +}; + +#endif //LOCKSTEP_LIBPASADA_H diff --git a/app/src/main/cpp/jni_libpasada.cpp b/app/src/main/cpp/jni_libpasada.cpp new file mode 100644 index 0000000..2ee9099 --- /dev/null +++ b/app/src/main/cpp/jni_libpasada.cpp @@ -0,0 +1,205 @@ +// +// Created by david on 24.05.2026. +// +#include +#include +#include "LibPasada.h" + +static JavaVM* g_vm = nullptr; +static jobject g_listener = nullptr; // global ref, or nullptr +static jmethodID g_onTrackFinished = nullptr; +static jmethodID g_onError = nullptr; + +static LibPasada* g_libpasada = new LibPasada(); + +void clearListener(JNIEnv* env); + +extern "C" +JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void*) { + g_vm = vm; + return JNI_VERSION_1_6; +} + +/** Called each time a run/playlist session starts (Oboe silent, buffers ready). */ +extern "C" +JNIEXPORT void JNICALL +Java_at_lockstep_player_pasada_LibPasada_init( + JNIEnv *env, + jclass /*unused*/) { + if (g_libpasada) { + clearListener(env); + delete g_libpasada; + } + g_libpasada = new LibPasada(); + g_libpasada->init(); +} + +/** Submit one accelerometer sample (m/s^2); may be called from a sensor thread. */ +extern "C" +JNIEXPORT void JNICALL +Java_at_lockstep_player_pasada_LibPasada_feedAccel(JNIEnv *env, jclass clazz, jfloat x, jfloat y, + jfloat z, jlong timestamp_nanos) { + if (g_libpasada == nullptr) + return; + g_libpasada->feedAccel(x, y, z, timestamp_nanos); +} + +/** + * Open MP3 from an already-open file descriptor and begin adaptive playback. + * + * @param fd open read FD (Java retains ownership; do not close until track changes) + * @param offset start offset within the FD (0 for whole file) + * @param length byte length from offset ({@code -1} if unknown / to EOF) + */ +extern "C" +JNIEXPORT void JNICALL +Java_at_lockstep_player_pasada_LibPasada_play(JNIEnv *env, jclass clazz, jint fd, jlong offset, + jlong length) { + if (g_libpasada == nullptr) + return; + g_libpasada->play(fd, offset, length); +} +/** PLAYING → PAUSED (silent output, graph kept alive). */ +extern "C" +JNIEXPORT void JNICALL +Java_at_lockstep_player_pasada_LibPasada_pause(JNIEnv *env, jclass clazz) { + if (g_libpasada == nullptr) + return; + g_libpasada->pause(); +} +/** PAUSED → PLAYING (same track, same decode position, same FD). */ +extern "C" +JNIEXPORT void JNICALL +Java_at_lockstep_player_pasada_LibPasada_resume(JNIEnv *env, jclass clazz) { + if (g_libpasada == nullptr) + return; + g_libpasada->resume(); +} +/** Tear down Oboe for this run segment → STOPPED. */ +extern "C" +JNIEXPORT void JNICALL +Java_at_lockstep_player_pasada_LibPasada_stop(JNIEnv *env, jclass clazz) { + clearListener(env); + if (g_libpasada == nullptr) + return; + g_libpasada->stop(); + delete g_libpasada; + g_libpasada = nullptr; +} + +/** + * Milliseconds from the start of the current track — same timebase as ExoPlayer + * {@code getCurrentPosition()}. Safe to poll from background threads. + */ +extern "C" +JNIEXPORT jlong JNICALL +Java_at_lockstep_player_pasada_LibPasada_getCurrentPositionMs(JNIEnv *env, jclass clazz) { + if (g_libpasada == nullptr) + return 0; + return g_libpasada->getCurrentPositionMs(); +} +/** Track duration in ms, or {@code 0} if not yet known. */ +extern "C" +JNIEXPORT jlong JNICALL +Java_at_lockstep_player_pasada_LibPasada_getDurationMs(JNIEnv *env, jclass clazz) { + if (g_libpasada == nullptr) + return 0; + return g_libpasada->getDurationMs(); +} +/** Whether adapted audio is actively being output (not paused, not finished). */ +extern "C" +JNIEXPORT jboolean JNICALL +Java_at_lockstep_player_pasada_LibPasada_isPlaying(JNIEnv *env, jclass clazz) { + if (g_libpasada == nullptr) + return false; + return g_libpasada->isPlaying(); +} +/** Current native state; see {@link PasadaState}. */ +extern "C" +JNIEXPORT jint JNICALL +Java_at_lockstep_player_pasada_LibPasada_getState(JNIEnv *env, jclass clazz) { + if(g_libpasada == nullptr) + return STOPPED; + return g_libpasada->getState(); +} +/** Seek within the current track. */ +extern "C" +JNIEXPORT void JNICALL +Java_at_lockstep_player_pasada_LibPasada_seekTo(JNIEnv *env, jclass clazz, jlong position_ms) { + if (g_libpasada == nullptr) + return; + g_libpasada->seekTo((long) position_ms); +} +/** Runtime metrics / last error string for logging and debug UI. */ +extern "C" +JNIEXPORT jstring JNICALL +Java_at_lockstep_player_pasada_LibPasada_getDiagnostics(JNIEnv *env, jclass clazz) { + if (g_libpasada == nullptr) { + return env->NewStringUTF(""); + } + std::string message = g_libpasada->getDiagnostics(); + return env->NewStringUTF(message.c_str()); +} + + +/** Register listener for async events raised from the audio/native thread. */ +extern "C" +JNIEXPORT void JNICALL +Java_at_lockstep_player_pasada_LibPasada_setPlaybackListener(JNIEnv *env, jclass clazz, + jobject listener) { + // 1. Drop previous listener + if (g_listener != nullptr) { + clearListener(env); + } + if (listener == nullptr) { + clearListener(env); + return; + } + // 2. Keep listener alive past this call + g_listener = env->NewGlobalRef(listener); + // 3. Resolve methods once (valid until class unload) + jclass cls = env->GetObjectClass(g_listener); + g_onTrackFinished = env->GetMethodID(cls, "onTrackFinished", "()V"); + g_onError = env->GetMethodID( + cls, "onError", "(ILjava/lang/String;)V"); + env->DeleteLocalRef(cls); + if (g_onTrackFinished == nullptr || g_onError == nullptr) { + env->DeleteGlobalRef(g_listener); + g_listener = nullptr; + // ExceptionPending if method missing + } +} + +// +// JNI helpers for calling into Java code (PasadaPlaybackListener) +// +void emitTrackFinished() { + if (!g_vm || !g_listener || !g_onTrackFinished) return; + JNIEnv* env = nullptr; + if (g_vm->AttachCurrentThread(&env, nullptr) != JNI_OK) return; + env->CallVoidMethod(g_listener, g_onTrackFinished); + if (env->ExceptionCheck()) env->ExceptionClear(); + // DetachCurrentThread only if this thread won't call JNI again +} +void emitError(int errorCode, const char* message) { + if (!g_vm || !g_listener || !g_onError) return; + JNIEnv* env = nullptr; + if (g_vm->AttachCurrentThread(&env, nullptr) != JNI_OK) return; + jstring jMessage = env->NewStringUTF(message != nullptr ? message : ""); + env->CallVoidMethod(g_listener, g_onError, static_cast(errorCode), jMessage); + env->DeleteLocalRef(jMessage); + if (env->ExceptionCheck()) { + env->ExceptionClear(); + } +} +void emitError(int errorCode, const std::string& message) { + emitError(errorCode, message.c_str()); +} +void clearListener(JNIEnv* env) { + if (g_listener) { + env->DeleteGlobalRef(g_listener); + g_listener = nullptr; + } + g_onTrackFinished = nullptr; + g_onError = nullptr; +} diff --git a/app/src/main/java/at/lockstep/player/pasada/LibPasada.java b/app/src/main/java/at/lockstep/player/pasada/LibPasada.java new file mode 100644 index 0000000..c604a5e --- /dev/null +++ b/app/src/main/java/at/lockstep/player/pasada/LibPasada.java @@ -0,0 +1,72 @@ +package at.lockstep.player.pasada; + +/** + * JNI entry point for libpasada. + * + *

Native state machine: LOADED → INITIALIZED → PLAYING ↔ PAUSED → FINISHED → STOPPED + * + *

Call {@link #loadNative()} once before any other method. + */ +public final class LibPasada { + + private static boolean loaded; + + private LibPasada() {} + + /** Loads {@code libpasada.so}. Safe to call multiple times. */ + public static synchronized void loadNative() { + if (loaded) { + return; + } + System.loadLibrary("lockstep-native"); + loaded = true; + } + + /** Called each time a run/playlist session starts (Oboe silent, buffers ready). */ + public static native void init(); + + /** Submit one accelerometer sample (m/s²); may be called from a sensor thread. */ + public static native void feedAccel(float x, float y, float z, long timestampNanos); + + /** + * Open MP3 from an already-open file descriptor and begin adaptive playback. + * + * @param fd open read FD (Java retains ownership; do not close until track changes) + * @param offset start offset within the FD (0 for whole file) + * @param length byte length from offset ({@code -1} if unknown / to EOF) + */ + public static native void play(int fd, long offset, long length); + + /** PLAYING → PAUSED (silent output, graph kept alive). */ + public static native void pause(); + + /** PAUSED → PLAYING (same track, same decode position, same FD). */ + public static native void resume(); + + /** Tear down Oboe for this run segment → STOPPED. */ + public static native void stop(); + + /** + * Milliseconds from the start of the current track — same timebase as ExoPlayer + * {@code getCurrentPosition()}. Safe to poll from background threads. + */ + public static native long getCurrentPositionMs(); + + /** Track duration in ms, or {@code 0} if not yet known. */ + public static native long getDurationMs(); + + /** Whether adapted audio is actively being output (not paused, not finished). */ + public static native boolean isPlaying(); + + /** Current native state; see {@link PasadaState}. */ + public static native int getState(); + + /** Seek within the current track. */ + public static native void seekTo(long positionMs); + + /** Runtime metrics / last error string for logging and debug UI. */ + public static native String getDiagnostics(); + + /** Register listener for async events raised from the audio/native thread. */ + public static native void setPlaybackListener(PasadaPlaybackListener listener); +} diff --git a/app/src/main/java/at/lockstep/player/pasada/PasadaPlaybackListener.java b/app/src/main/java/at/lockstep/player/pasada/PasadaPlaybackListener.java new file mode 100644 index 0000000..48e9009 --- /dev/null +++ b/app/src/main/java/at/lockstep/player/pasada/PasadaPlaybackListener.java @@ -0,0 +1,14 @@ +package at.lockstep.player.pasada; + +/** + * Callbacks invoked from native (Oboe or internal worker thread). + * Implementations must post to the main thread if they touch UI or service state. + */ +public interface PasadaPlaybackListener { + + /** Current track reached end; service should advance queue and call {@link LibPasada#play}. */ + void onTrackFinished(); + + /** Decode / Oboe / pipeline failure; service should stop safely and surface error. */ + void onError(int errorCode, String message); +} diff --git a/app/src/main/java/at/lockstep/player/pasada/PasadaState.java b/app/src/main/java/at/lockstep/player/pasada/PasadaState.java new file mode 100644 index 0000000..67e63a1 --- /dev/null +++ b/app/src/main/java/at/lockstep/player/pasada/PasadaState.java @@ -0,0 +1,29 @@ +package at.lockstep.player.pasada; + +/** + * Mirrors the libpasada state machine documented in DESIGN.md. + * Keep values in sync with LibPasada.h + */ +public enum PasadaState { + LOADED(0), + INITIALIZED(1), + PLAYING(2), + PAUSED(3), + FINISHED(4), + STOPPED(5); + + public final int code; + + PasadaState(int code) { + this.code = code; + } + + public static PasadaState fromCode(int code) { + for (PasadaState state : values()) { + if (state.code == code) { + return state; + } + } + throw new IllegalArgumentException("Unknown PasadaState code: " + code); + } +}