Compare commits

..

15 Commits

26 changed files with 1169 additions and 141 deletions

View File

@@ -12,8 +12,8 @@ android {
applicationId "at.lockstep" applicationId "at.lockstep"
minSdk 24 minSdk 24
targetSdk 34 targetSdk 34
versionCode 1 versionCode 10004
versionName "1.0" versionName "1.0.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@@ -24,6 +24,7 @@ android {
//path 'src/main/cpp/CMakeLists.txt' //path 'src/main/cpp/CMakeLists.txt'
//cppFlags '' //cppFlags ''
arguments "-DANDROID_STL=c++_shared" arguments "-DANDROID_STL=c++_shared"
cppFlags "-DLIBPASADA_VERSION=1.0.4"
//cppFlags "-std=c++14" //cppFlags "-std=c++14"
//arguments '-DANDROID_STL=c++_static' //arguments '-DANDROID_STL=c++_static'

View File

@@ -35,12 +35,14 @@ add_library(${CMAKE_PROJECT_NAME} SHARED
jni_mpg123.cpp jni_mpg123.cpp
jni_lockstep.cpp jni_lockstep.cpp
jni_stepdetector.cpp jni_stepdetector.cpp
StepDetector.cpp jni_libpasada.cpp
LibPasada.cpp
#// jni_logging.cpp // same implemented in jni_libpasada.cpp // JNI_OnLoad and JNI_OnUnload
) )
find_package (oboe REQUIRED CONFIG) find_package (oboe REQUIRED CONFIG)
add_library(ndk-logger SHARED logging.cpp jni_logging.cpp) add_library(ndk-logger SHARED logging.cpp) # jni_logging.cpp
target_link_libraries(ndk-logger log) target_link_libraries(ndk-logger log)
# Add pre-built libmpg123 library # Add pre-built libmpg123 library

View File

@@ -0,0 +1,199 @@
#include "LibPasada.h"
#include "PlaybackEngine.h"
#include "step_detector.h"
#include <string>
/** 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);
}
void PasadaPlaybackListener::onTrackClosed(int fd) {
emitTrackClosed(fd);
}
/**
* 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.
* <p>Native state machine: LOADED → INITIALIZED → PLAYING ↔ PAUSED → FINISHED → STOPPED
*/
LibPasada::LibPasada() : state(LOADED), engine(nullptr), detector(nullptr)
{}
/** We must clean up here whatever is left over when init() or stop() fails. */
LibPasada::~LibPasada() {
// delete StepDetector first, lest it spuriously pushes StepListener events
std::lock_guard<std::mutex> lock(mtxDetector);
if (detector != nullptr) {
auto *stepDetector = reinterpret_cast<StepDetector *>(detector);
delete stepDetector;
detector = nullptr;
}
// init(): if new StepDetector() fails, we have a leftover 'engine'
if (engine != nullptr) {
auto *playbackEngine = reinterpret_cast<PlaybackEngine *>(engine);
delete playbackEngine;
engine = nullptr;
}
}
/** Called each time a run/playlist session starts (Oboe silent, buffers ready). */
void LibPasada::init() {
std::lock_guard<std::mutex> lockState(mtxState);
std::lock_guard<std::mutex> lock(mtxDetector);
if(state == LOADED || state == STOPPED) {
// perform init
auto *playbackEngine = new PlaybackEngine("", 0, this);
engine = playbackEngine;
auto *stepListener = reinterpret_cast<StepListener *>(playbackEngine);
// the hardcoded 'fps' is only an initial value, which is re-computed in StepDetector
auto *stepDetector = new StepDetector(60.0 /* FPS */, stepListener);
detector = stepDetector;
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) {
std::lock_guard<std::mutex> lockState(mtxState);
std::lock_guard<std::mutex> lock(mtxDetector);
if(state == LOADED || state == STOPPED) {
// player is not in an initialized state - ignore accelerometer data
return;
}
auto *stepDetector = reinterpret_cast<StepDetector *>(detector);
stepDetector->filter(static_cast<double>(timestamp_nanos), std::vector<float> {x, y, z});
}
/**
* 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) {
std::lock_guard<std::mutex> lockState(mtxState);
state = PLAYING;
auto *playbackEngine = reinterpret_cast<PlaybackEngine *>(engine);
playbackEngine->playMusic(fd, offset, length);
}
/** PLAYING → PAUSED (silent output, graph kept alive). */
void LibPasada::pause() {
std::lock_guard<std::mutex> lockState(mtxState);
if(state != PLAYING) return;
state = PAUSED;
// TODO: pause plays a rect noise in PlaybackEngine. debug it!
auto *playbackEngine = reinterpret_cast<PlaybackEngine *>(engine);
playbackEngine->pause();
}
/** PAUSED → PLAYING (same track, same decode position, same FD). */
void LibPasada::resume() {
std::lock_guard<std::mutex> lockState(mtxState);
if(state != PAUSED) return;
state = PLAYING;
auto *playbackEngine = reinterpret_cast<PlaybackEngine *>(engine);
playbackEngine->resume();
}
/** Tear down Oboe for this run segment → STOPPED. */
void LibPasada::stop() {
std::lock_guard<std::mutex> lockState(mtxState);
state = STOPPED;
auto *playbackEngine = reinterpret_cast<PlaybackEngine *>(engine);
playbackEngine->stop();
}
/**
* Milliseconds from the start of the current track — same timebase as ExoPlayer
* {@code getCurrentPosition()}. Safe to poll from background threads.
*/
long LibPasada::getCurrentPositionMs() {
std::lock_guard<std::mutex> lockState(mtxState);
if(state == LOADED)
return 0;
auto *playbackEngine = reinterpret_cast<PlaybackEngine *>(engine);
return static_cast<long>(playbackEngine->getCurrentPosition() * 1000);
}
/** Track duration in ms, or {@code 0} if not yet known. */
long LibPasada::getDurationMs() {
std::lock_guard<std::mutex> lockState(mtxState);
if(state == LOADED)
return 0;
auto *playbackEngine = reinterpret_cast<PlaybackEngine *>(engine);
return static_cast<long>(playbackEngine->getDuration() * 1000);
}
// defined in jni_libpasada.cpp
void emitTrackFinished();
void LibPasada::onTrackFinished() {
{
std::lock_guard<std::mutex> lockState(mtxState);
if (state != PLAYING)
return; // this should never happen
state = FINISHED;
}
emitTrackFinished();
}
/** Whether adapted audio is actively being output (not paused, not finished). */
bool LibPasada::isPlaying() {
std::lock_guard<std::mutex> lockState(mtxState);
return state == PLAYING;
}
/** Current native state; see {@link PasadaState}. Must not throw. */
int LibPasada::getState() {
std::lock_guard<std::mutex> lockState(mtxState);
return state;
}
/** Seek within the current track. */
void LibPasada::seekTo(long positionMs) {
std::lock_guard<std::mutex> lockState(mtxState);
if(state != PLAYING && state != PAUSED)
return; // silently ignore seeks if we do not have a music file loaded
auto *playbackEngine = reinterpret_cast<PlaybackEngine *>(engine);
playbackEngine->seekTo(positionMs / 1000.0);
}
/** Runtime metrics / last error string for logging and debug UI. Must not throw. */
std::string LibPasada::getDiagnostics() { return ""; }
/** Register listener for async events raised from the audio/native thread. */
//void LibPasada::setPlaybackListener(PasadaPlaybackListener listener) {} // JNI-side only
#define STRINGIFY(s) #s
std::string LibPasada::getVersion() {
#ifdef LIBPASADA_VERSION
return STRINGIFY(LIBPASADA_VERSION);
#else
return "0.0.1";
#endif
}

View File

@@ -0,0 +1,98 @@
//
// Created by david on 24.05.2026.
//
#ifndef LOCKSTEP_LIBPASADA_H
#define LOCKSTEP_LIBPASADA_H
#include "PasadaPlaybackListener.h"
#include <string>
#include <mutex>
// JNI helpers for calling into Java code (PasadaPlaybackListener)
void emitTrackFinished();
void emitError(int errorCode, const std::string& message);
void emitTrackClosed(int fd);
/**
* 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.
* <p>Native state machine: LOADED → INITIALIZED → PLAYING ↔ PAUSED → FINISHED → STOPPED
*/
class LibPasada : public PasadaPlaybackListener {
private:
PasadaState state;
std::mutex mtxState;
//PasadaPlaybackListener listener;
void *engine;
void *detector;
std::mutex mtxDetector;
public:
LibPasada();
virtual ~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}. Must not throw. */
int getState();
/** Seek within the current track. */
void seekTo(long positionMs);
/** Runtime metrics / last error string for logging and debug UI. Must not throw. */
std::string getDiagnostics();
/** Register listener for async events raised from the audio/native thread. */
//void setPlaybackListener(PasadaPlaybackListener listener); // JNI-side only
/** Current track reached end; service should advance queue and call {@link LibPasada#play}. */
void onTrackFinished() override;
std::string getVersion();
};
#endif //LOCKSTEP_LIBPASADA_H

View File

@@ -0,0 +1,25 @@
//
// Created by david on 02.06.2026.
//
#ifndef LOCKSTEP_PASADAPLAYBACKLISTENER_H
#define LOCKSTEP_PASADAPLAYBACKLISTENER_H
#include <string>
/**
* Callbacks invoked from native (Oboe or internal worker thread).
*/
class PasadaPlaybackListener {
public:
PasadaPlaybackListener() = default;
virtual ~PasadaPlaybackListener() = default;
/** Current track reached end; service should advance queue and call {@link LibPasada#play}. */
virtual void onTrackFinished();
virtual void onTrackClosed(int fd);
/** Decode / Oboe / pipeline failure; service should stop safely and surface error. */
virtual void onError(int errorCode, std::string message);
};
#endif //LOCKSTEP_PASADAPLAYBACKLISTENER_H

View File

@@ -11,6 +11,9 @@
#include <memory> #include <memory>
#include <vector> #include <vector>
#include <chrono> #include <chrono>
#include <sys/types.h>
#include <unistd.h>
#include <random>
/** /**
* Read samples from the next mp3-frame into the struct MP3File's buffer. * Read samples from the next mp3-frame into the struct MP3File's buffer.
@@ -67,7 +70,8 @@ struct RbLogger : public RubberBand::RubberBandStretcher::Logger {
virtual ~RbLogger() { } virtual ~RbLogger() { }
}; };
PlaybackEngine::PlaybackEngine(std::string filesDir, int resid): PlaybackEngine::PlaybackEngine(std::string filesDir, int resid, PasadaPlaybackListener *listener):
listener(listener),
stretcher( stretcher(
/* sampleRate: */ 48000, /* sampleRate: */ 48000,
/* channels: */ 2, /* channels: */ 2,
@@ -80,6 +84,11 @@ PlaybackEngine::PlaybackEngine(std::string filesDir, int resid):
haveMusicFile(false), haveMusicFile(false),
exitMusicFeedThread(false), exitMusicFeedThread(false),
isSetMusic(false), isSetMusic(false),
isPaused(false),
markerPos(0.0),
duration(0.0),
seekPos(0.0),
requestSeek(false),
android_fd(0), android_fd(0),
haveTimeRatio(false), haveTimeRatio(false),
timeRatio(1.0), timeRatio(1.0),
@@ -96,8 +105,13 @@ PlaybackEngine::PlaybackEngine(std::string filesDir, int resid):
// load "bump" sound effect // load "bump" sound effect
std::vector<float> samples; std::vector<float> samples;
if (!filesDir.empty()) {
bool is_ok = read_mp3(mFilesDir + "/" + std::to_string(resid) + ".mp3", samples); bool is_ok = read_mp3(mFilesDir + "/" + std::to_string(resid) + ".mp3", samples);
LOGI("read_mp3() for bump effect, is_ok=%d", is_ok); LOGI("read_mp3() for bump effect, is_ok=%d", is_ok);
} else {
samples.resize(1);
LOGI("keeping bump effect silent, since have no filesDir");
}
mPlayer = new MixingPlayer(samples); mPlayer = new MixingPlayer(samples);
@@ -145,7 +159,9 @@ void PlaybackEngine::initRubberBand() {
void PlaybackEngine::closeRubberBand() { void PlaybackEngine::closeRubberBand() {
if(musicFeed) { if(musicFeed) {
exitMusicFeedThread.store(true); exitMusicFeedThread.store(true);
LOGI("PlaybackEngine: musicFeed->join() ...");
musicFeed->join(); musicFeed->join();
LOGI("PlaybackEngine: musicFeed->join() done.");
musicFeed = nullptr; musicFeed = nullptr;
} }
closeMusicFile(); closeMusicFile();
@@ -155,10 +171,14 @@ void PlaybackEngine::closeMusicFile() {
haveMusicFile.store(false); haveMusicFile.store(false);
musicFile = nullptr; musicFile = nullptr;
if(android_fd) { if(android_fd) {
close(android_fd); //close(android_fd);
LOGI("onTrackClosed(fd=%d) ...", android_fd);
if(listener) listener->onTrackClosed(android_fd);
LOGI("onTrackClosed(fd=%d) done.", android_fd);
android_fd = 0; android_fd = 0;
} }
isSetMusic.store(false); isSetMusic.store(false);
if(mPlayer)
mPlayer->setMusic(nullptr); mPlayer->setMusic(nullptr);
} }
@@ -185,6 +205,10 @@ void PlaybackEngine::mapChannels(int *channel_map, int num_ch_in, int num_ch_out
} }
void PlaybackEngine::musicFeedThread() { void PlaybackEngine::musicFeedThread() {
/*
std::mt19937 mt(42);
std::uniform_real_distribution<double> dist(0.98, 1.0);
*/
LOGI("starting musicFeedThread()"); LOGI("starting musicFeedThread()");
// strecher num channels: same as output num channels // strecher num channels: same as output num channels
@@ -211,7 +235,8 @@ void PlaybackEngine::musicFeedThread() {
int* channel_map = (int*) malloc(num_ch_out * sizeof(int)); int* channel_map = (int*) malloc(num_ch_out * sizeof(int));
// initial guess, as long as we do not have a music file (otherwise we should divide by mp3->rate) // initial guess, as long as we do not have a music file (otherwise we should divide by mp3->rate)
size_t loop_delay_us = 1000000 * buf_size_samples / playbackRate.load(); size_t pr = std::max(1, playbackRate.load());
size_t loop_delay_us = std::min((size_t) 50000, (size_t) 1000000 * buf_size_samples / pr);
int idebug = 0; int idebug = 0;
@@ -284,31 +309,87 @@ void PlaybackEngine::musicFeedThread() {
num_samples = num_buf_samples; num_samples = num_buf_samples;
} }
if (!haveMusicFile.load()) { if (!haveMusicFile.load() || isPaused.load()) {
loop_delay_us = 1000000 * num_samples / playbackRate.load(); pr = std::max(1, playbackRate.load());
loop_delay_us = std::min((size_t) 50000, (size_t) 1000000 * num_samples / pr);
if(idebug++ < 10) { if(idebug++ < 10) {
LOGI("feed %d silence samples", num_samples); LOGI("feed %d silence samples", num_samples);
// 1024, 512, 512 // 1024, 512, 512
// 7 x 512 // 7 x 512
} }
memset(buf, 0, num_samples*num_ch_out*sizeof(float)); //memset(buf, 0, num_samples*num_ch_out*sizeof(float)); // wrong, because buffer is not contiguous for channels
for(int i = 0; i < num_ch_out; i++) {
memset(buf_ptr[i], 0, num_samples * sizeof(float));
}
stretcher.process(buf_ptr, num_samples, false); stretcher.process(buf_ptr, num_samples, false);
continue; continue;
} }
if(idebug++ < 10) { if(idebug++ < 10) {
loop_delay_us = 1000000 * num_samples / musicFile->rate; pr = std::max(1L, musicFile->rate);
loop_delay_us = std::min((size_t) 50000, (size_t) 1000000 * num_samples / pr);
LOGI("feed %d music samples", num_samples); LOGI("feed %d music samples", num_samples);
// feed 1024 music samples // feed 1024 music samples
// => stretcher is asking for 1024 = getSamplesRequired() // => stretcher is asking for 1024 = getSamplesRequired()
// ca. 21 ms // ca. 21 ms
/*
int64_t a = mpg123_seek(musicFile->handle, 0, SEEK_CUR);
int64_t b = mpg123_tell(musicFile->handle);
LOGI(" checking mpg123_seek(): len=%lld seek=%lld tell=%lld",
(long long)musicFile->num_samples, (long long)a, (long long)b);
*/
}
markerPos.store((musicFile->num_samples - musicFile->remaining_samples) / musicFile->samples_per_frame * musicFile->secs_per_frame);
duration.store(musicFile->duration);
if(requestSeek.load()) {
double pos = seekPos.load();
// compute seek target in samples, clip to [0..num_samples]
auto seek_samples = static_cast<long>(pos * musicFile->samples_per_frame / musicFile->secs_per_frame);
LOGI("seek to %.3lf sec = %lld samples", pos, (long long) seek_samples);
seek_samples = std::max(seek_samples, 0L);
seek_samples = std::min(seek_samples, musicFile->num_samples);
// perform the seek
off_t seekResult = mp3file_seek(musicFile.get(), seek_samples);
idebug = 0;
/*if(seekResult > seek_samples) {
LOGE("error seeking in mp3 file: mpg123_seek(handle, %ld, SEEK_SET)=%lld", seek_samples, (long long) seekResult);
seekPos.store(pos * dist(mt)); // randomize seek target a bit
} else {*/
// update structure
// compute seek delta, to update byte 'offset'
//auto seek_samples_delta = seekResult - (musicFile->num_samples - musicFile->remaining_samples);
//auto seek_bytes_delta = seek_samples_delta * num_ch_in * sizeof(int16_t);
musicFile->remaining_samples = static_cast<int>(musicFile->num_samples - seekResult);
//musicFile->offset += seek_bytes_delta;
musicFile->offset = lseek(musicFile->android_fd, 0, SEEK_CUR);
// it is not possible to obtain bytes offset reliably??
//musicFile->num_bytes = -1; // pretend we can read until the end
requestSeek.store(false);
/*}*/
} }
size_t done = 0; // bytes! size_t done = 0; // bytes!
size_t read_size_bytes = std::min(num_samples * num_ch_in * sizeof(int16_t), cbuf_size_bytes); size_t read_size_bytes = std::min(num_samples * num_ch_in * sizeof(int16_t), cbuf_size_bytes);
/*if (musicFile->offset != 0 && musicFile->num_bytes != -1) { read_size_bytes = std::min(read_size_bytes_calc, (size_t) musicFile->num_bytes - musicFile->offset); }*/
//if (idebug < 10) LOGI("mft rsbc=%lld rsb=%lld nb=%lld o=%lld", (long long) read_size_bytes_calc, (long long) read_size_bytes, (long long) musicFile->num_bytes, (long long) musicFile->offset);
int err = mpg123_read(musicFile->handle, cbuf, read_size_bytes, &done); int err = mpg123_read(musicFile->handle, cbuf, read_size_bytes, &done);
musicFile->remaining_samples -= done / sizeof(int16_t); //if (idebug < 10) LOGI("mft done=%lld err=%lld small=%d", (long long) done, (long long) err, read_size_bytes < read_size_bytes_calc);
musicFile->offset = 0; // unused here /*if (musicFile->offset != 0 && musicFile->num_bytes != -1 && err == MPG123_OK && read_size_bytes < read_size_bytes_calc) {
LOGW("out of bytes, finished playing");
err = MPG123_DONE;
}*/
musicFile->remaining_samples -= done / num_ch_in / sizeof(int16_t);
//musicFile->offset += done;
musicFile->offset = lseek(musicFile->android_fd, 0, SEEK_CUR);
if(musicFile->offset >= musicFile->num_bytes) {
// TODO: this is inaccurate. either we do not decode everything, or we overrun past the end
// TODO: maybe decode as a "stream" instead.
LOGW("out of bytes, finished playing");
err = MPG123_DONE;
}
if (err != MPG123_OK && err != MPG123_DONE) { if (err != MPG123_OK && err != MPG123_DONE) {
// error! // error!
LOGE("error reading mp3 file: mpg123_read() err=%d done=%d", err, done); LOGE("error reading mp3 file: mpg123_read() err=%d done=%d", err, done);
@@ -322,12 +403,11 @@ void PlaybackEngine::musicFeedThread() {
} }
if(err == MPG123_DONE) { if(err == MPG123_DONE) {
// next iteration will play silence // next iteration will play silence
// we keep Stretcher and Oboe alive
LOGI("finished reading mp3 file (MPG123_DONE)"); LOGI("finished reading mp3 file (MPG123_DONE)");
idebug = 0;
closeMusicFile(); closeMusicFile();
stretcher.setTimeRatio(1.0); // buffer size for playing silence is computed from 'playbackRate', so reset timeRatio if(listener) listener->onTrackFinished();
stretcher.setPitchScale(1.0);
stretcher.process(buf_ptr, 0, true); // set end of playback
mPlayer->stopAudio();
continue; continue;
} }
@@ -356,23 +436,67 @@ void PlaybackEngine::musicFeedThread() {
LOGI("musicFeedThread() exited."); LOGI("musicFeedThread() exited.");
} }
void PlaybackEngine::pause() {
LOGI("PlaybackEngine::pause() set isPaused.");
// next iteration will play silence
isPaused.store(true);
}
void PlaybackEngine::resume() {
// next iteration will continue to decode
isPaused.store(false);
}
double PlaybackEngine::getCurrentPosition() {
return markerPos.load();
}
double PlaybackEngine::getDuration() {
return duration.load();
}
void PlaybackEngine::seekTo(double markerPosSec) {
seekPos.store(markerPosSec);
requestSeek.store(true);
}
void PlaybackEngine::stop() {
LOGI("PlaybackEngine::stop()");
closeRubberBand();
if (mPlayer) {
LOGI("PlaybackEngine: mPlayer->stopAudio() ...");
mPlayer->stopAudio();
LOGI("PlaybackEngine: mPlayer->stopAudio() done.");
delete mPlayer;
mPlayer = nullptr;
}
markerPos.store(0.0);
duration.store(0.0);
}
PlaybackEngine::~PlaybackEngine() { PlaybackEngine::~PlaybackEngine() {
LOGI("~PlaybackEngine()"); LOGI("~PlaybackEngine()");
closeRubberBand(); closeRubberBand();
if (mPlayer) {
mPlayer->stopAudio(); mPlayer->stopAudio();
delete mPlayer; delete mPlayer;
mPlayer = nullptr; mPlayer = nullptr;
} }
}
void PlaybackEngine::playBeat() { void PlaybackEngine::playBeat() {
if(mPlayer) mPlayer->setStartBeat(); if(mPlayer) mPlayer->setStartBeat();
} }
void PlaybackEngine::playMusic(int fd) { void PlaybackEngine::playMusic(int fd, long long offset, long long length) {
if(!mPlayer) return; if(!mPlayer) return;
LOGI("PlaybackEngine::playMusic(fd=%d)", fd); LOGI("PlaybackEngine::playMusic(fd=%d)", fd);
if(musicFile) {
// when changing tracks in the UI, close the previous file
closeMusicFile();
}
android_fd = fd; android_fd = fd;
musicFile.reset(mp3file_open_fd(android_fd, 0)); if(fd != 0)
musicFile.reset(mp3file_open_fd(android_fd, offset, length, 0));
if(musicFile) { if(musicFile) {
timeRatio.store(((double) playbackRate.load()) / ((double) musicFile->rate)); timeRatio.store(((double) playbackRate.load()) / ((double) musicFile->rate));
haveTimeRatio.store(true); haveTimeRatio.store(true);
@@ -382,6 +506,7 @@ void PlaybackEngine::playMusic(int fd) {
bool is_finished = (stretcher.available() == -1); bool is_finished = (stretcher.available() == -1);
if(is_finished) { if(is_finished) {
LOGE("stretcher.available() == -1, this should not happen anymore with current wiring");
// so that we may play again after "final chunk" // so that we may play again after "final chunk"
closeRubberBand(); closeRubberBand();
initRubberBand(); initRubberBand();

View File

@@ -5,11 +5,12 @@
#ifndef LOCKSTEP_PLAYBACKENGINE_H #ifndef LOCKSTEP_PLAYBACKENGINE_H
#define LOCKSTEP_PLAYBACKENGINE_H #define LOCKSTEP_PLAYBACKENGINE_H
#include "StepListener.h" #include "step_detector.h"
#include "MixingPlayer.h" #include "MixingPlayer.h"
#include "RubberBandStretcher.h" #include "RubberBandStretcher.h"
#include "mp3file.h" #include "mp3file.h"
#include "AudioCallback.h" #include "AudioCallback.h"
#include "PasadaPlaybackListener.h"
#include <string> #include <string>
#include <thread> #include <thread>
#include <memory> #include <memory>
@@ -36,12 +37,28 @@ private:
class PlaybackEngine : public StepListener { class PlaybackEngine : public StepListener {
public: public:
PlaybackEngine(std::string filesDir, int resid); /**
* @param filesDir resources directory, used only to fetch the beat sound template
* @param resid beat sound template file
* @param listener contract: onTrackFinished() only fires during regular playback finished - not when stopped, when exiting, etc.
*/
PlaybackEngine(std::string filesDir, int resid, PasadaPlaybackListener *listener = nullptr);
virtual ~PlaybackEngine(); virtual ~PlaybackEngine();
/** Play a beat sound. */ /** Play a beat sound. */
virtual void playBeat(); virtual void playBeat();
void playMusic(int fd); /** pass -1 to length for read until eof. */
void playMusic(int fd, long long offset = 0, long long length = -1);
void pause();
void resume();
void stop();
/** read marker in the music file in sec */
double getCurrentPosition();
/** duration of the music file in sec */
double getDuration();
/** seek to a marker in the music file in sec */
void seekTo(double markerPos);
private: private:
PasadaPlaybackListener *listener;
RubberBand::RubberBandStretcher stretcher; RubberBand::RubberBandStretcher stretcher;
MixingPlayer *mPlayer; MixingPlayer *mPlayer;
std::string mFilesDir; std::string mFilesDir;
@@ -49,8 +66,17 @@ private:
std::atomic<bool> haveMusicFile; std::atomic<bool> haveMusicFile;
std::unique_ptr<std::thread> musicFeed; std::unique_ptr<std::thread> musicFeed;
std::atomic<bool> exitMusicFeedThread; std::atomic<bool> exitMusicFeedThread;
/** where musicFeedThread() keeps track of the fact that we have music set -- will start the audio cb */ /** where musicFeedThread() keeps track of the fact that we have music set -- will start the audio callback */
std::atomic<bool> isSetMusic; std::atomic<bool> isSetMusic;
std::atomic<bool> isPaused;
/** read marker in the music file in sec */
std::atomic<double> markerPos;
/** duration of the music file in sec */
std::atomic<double> duration;
/** seek position request in the music file in sec, valid during seek requests */
std::atomic<double> seekPos;
/** whether a seek request is active */
std::atomic<bool> requestSeek;
int android_fd; int android_fd;
std::atomic<bool> haveTimeRatio; std::atomic<bool> haveTimeRatio;
std::atomic<double> timeRatio; std::atomic<double> timeRatio;

View File

@@ -1,49 +0,0 @@
//
// Created by david on 03.03.2026.
//
#include "StepDetector.h"
// TODO: repeated code in libpasada. We could use the StepDetector class from there in jni_stepdetector.cpp
// 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),
f_sqi(upslope_width)
{}
#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);
auto q5 = f_sqi.filter(s2, s3, s4);
// is step, step quality is OK, and we have a listener?
if(s4 > 0.0 && q5 > 0.0 && listener != nullptr) {
listener->playBeat();
}
}

View File

@@ -1,27 +0,0 @@
//
// 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;
RunningQualityFilter f_sqi;
public:
StepDetector(StepListener *listener);
void filter(std::vector<float> values);
};
#endif //LOCKSTEP_STEPDETECTOR_H

View File

@@ -1,14 +0,0 @@
//
// 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,399 @@
//
// Created by david on 24.05.2026.
//
#include <jni.h>
#include <string>
#include "LibPasada.h"
#include "logging.h"
static JavaVM* g_vm = nullptr;
static jobject g_listener = nullptr; // global ref, or nullptr
static jmethodID g_onTrackFinished = nullptr;
static jmethodID g_onTrackClosed = nullptr;
static jmethodID g_onError = nullptr;
static LibPasada* g_libpasada = nullptr;
void clearListener(JNIEnv* env);
extern "C"
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void*) {
g_vm = vm;
JNIEnv * env = NULL;
//jclass clazz;
//LOGV("JNI_Onload vm:%p reserved:%p", vm, reserved);
if (JNI_OK != vm->GetEnv((void**) &env, JNI_VERSION_1_6)) {
return JNI_VERSION_1_6;
}
logback_init(vm, env);
LOGD("liblockstep-native jni_libpasada.cpp JNI_OnLoad()");
return JNI_VERSION_1_6;
}
extern "C" void
JNI_OnUnload(JavaVM * vm, void * reserved)
{
JNIEnv * env = NULL;
//LOGV("JNI_OnUnload vm:%p reserved:%p", vm, reserved);
if (JNI_OK != vm->GetEnv((void**) &env, JNI_VERSION_1_6)) {
return;
}
logback_uninit(env);
}
/** 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*/) {
LOGD("liblockstep-native jni_libpasada.cpp LibPasada_init()");
if (g_libpasada) {
clearListener(env);
delete g_libpasada;
}
try {
g_libpasada = new LibPasada();
g_libpasada->init();
return;
} catch (const std::bad_alloc&) {
jclass cls = env->FindClass("java/lang/OutOfMemoryError");
if (cls) env->ThrowNew(cls, "native allocation failed");
} catch (const std::exception& e) {
jclass cls = env->FindClass("java/lang/RuntimeException");
if (cls) env->ThrowNew(cls, e.what());
} catch (...) {
jclass cls = env->FindClass("java/lang/RuntimeException");
if (cls) env->ThrowNew(cls, "unknown native exception");
}
delete g_libpasada;
g_libpasada = nullptr;
}
/** 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;
try {
g_libpasada->feedAccel(x, y, z, timestamp_nanos);
} catch (const std::bad_alloc&) {
jclass cls = env->FindClass("java/lang/OutOfMemoryError");
if (cls) env->ThrowNew(cls, "native allocation failed");
return;
} catch (const std::exception& e) {
jclass cls = env->FindClass("java/lang/RuntimeException");
if (cls) env->ThrowNew(cls, e.what());
return;
} catch (...) {
jclass cls = env->FindClass("java/lang/RuntimeException");
if (cls) env->ThrowNew(cls, "unknown native exception");
return;
}
}
/**
* 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) {
LOGD("liblockstep-native jni_libpasada.cpp LibPasada_play()");
if (g_libpasada == nullptr)
return;
try {
g_libpasada->play(fd, offset, length);
} catch (const std::bad_alloc&) {
jclass cls = env->FindClass("java/lang/OutOfMemoryError");
if (cls) env->ThrowNew(cls, "native allocation failed");
return;
} catch (const std::exception& e) {
jclass cls = env->FindClass("java/lang/RuntimeException");
if (cls) env->ThrowNew(cls, e.what());
return;
} catch (...) {
jclass cls = env->FindClass("java/lang/RuntimeException");
if (cls) env->ThrowNew(cls, "unknown native exception");
return;
}
}
/** 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;
try {
g_libpasada->pause();
} catch (const std::bad_alloc&) {
jclass cls = env->FindClass("java/lang/OutOfMemoryError");
if (cls) env->ThrowNew(cls, "native allocation failed");
return;
} catch (const std::exception& e) {
jclass cls = env->FindClass("java/lang/RuntimeException");
if (cls) env->ThrowNew(cls, e.what());
return;
} catch (...) {
jclass cls = env->FindClass("java/lang/RuntimeException");
if (cls) env->ThrowNew(cls, "unknown native exception");
return;
}
}
/** 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;
try {
g_libpasada->resume();
} catch (const std::bad_alloc&) {
jclass cls = env->FindClass("java/lang/OutOfMemoryError");
if (cls) env->ThrowNew(cls, "native allocation failed");
return;
} catch (const std::exception& e) {
jclass cls = env->FindClass("java/lang/RuntimeException");
if (cls) env->ThrowNew(cls, e.what());
return;
} catch (...) {
jclass cls = env->FindClass("java/lang/RuntimeException");
if (cls) env->ThrowNew(cls, "unknown native exception");
return;
}
}
/** 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;
try {
g_libpasada->stop();
} catch (const std::bad_alloc&) {
jclass cls = env->FindClass("java/lang/OutOfMemoryError");
if (cls) env->ThrowNew(cls, "native allocation failed");
} catch (const std::exception& e) {
jclass cls = env->FindClass("java/lang/RuntimeException");
if (cls) env->ThrowNew(cls, e.what());
} catch (...) {
jclass cls = env->FindClass("java/lang/RuntimeException");
if (cls) env->ThrowNew(cls, "unknown native exception");
}
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;
try {
return g_libpasada->getCurrentPositionMs();
} catch (const std::bad_alloc&) {
jclass cls = env->FindClass("java/lang/OutOfMemoryError");
if (cls) env->ThrowNew(cls, "native allocation failed");
return 0;
} catch (const std::exception& e) {
jclass cls = env->FindClass("java/lang/RuntimeException");
if (cls) env->ThrowNew(cls, e.what());
return 0;
} catch (...) {
jclass cls = env->FindClass("java/lang/RuntimeException");
if (cls) env->ThrowNew(cls, "unknown native exception");
return 0;
}
}
/** 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;
try {
return g_libpasada->getDurationMs();
} catch (const std::bad_alloc&) {
jclass cls = env->FindClass("java/lang/OutOfMemoryError");
if (cls) env->ThrowNew(cls, "native allocation failed");
return 0;
} catch (const std::exception& e) {
jclass cls = env->FindClass("java/lang/RuntimeException");
if (cls) env->ThrowNew(cls, e.what());
return 0;
} catch (...) {
jclass cls = env->FindClass("java/lang/RuntimeException");
if (cls) env->ThrowNew(cls, "unknown native exception");
return 0;
}
}
/** 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;
try {
return g_libpasada->isPlaying();
} catch (const std::bad_alloc&) {
jclass cls = env->FindClass("java/lang/OutOfMemoryError");
if (cls) env->ThrowNew(cls, "native allocation failed");
return false;
} catch (const std::exception& e) {
jclass cls = env->FindClass("java/lang/RuntimeException");
if (cls) env->ThrowNew(cls, e.what());
return false;
} catch (...) {
jclass cls = env->FindClass("java/lang/RuntimeException");
if (cls) env->ThrowNew(cls, "unknown native exception");
return false;
}
}
/** 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;
// explicitly no C++/JNI exception translation - keep getState() simple!
// there is no reasonable state for us to return here otherwise.
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;
try {
g_libpasada->seekTo((long) position_ms);
} catch (const std::bad_alloc&) {
jclass cls = env->FindClass("java/lang/OutOfMemoryError");
if (cls) env->ThrowNew(cls, "native allocation failed");
return;
} catch (const std::exception& e) {
jclass cls = env->FindClass("java/lang/RuntimeException");
if (cls) env->ThrowNew(cls, e.what());
return;
} catch (...) {
jclass cls = env->FindClass("java/lang/RuntimeException");
if (cls) env->ThrowNew(cls, "unknown native exception");
return;
}
}
/** 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_onTrackClosed = env->GetMethodID(cls, "onTrackClosed", "(I)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<jint>(errorCode), jMessage);
env->DeleteLocalRef(jMessage);
if (env->ExceptionCheck()) {
env->ExceptionClear();
}
}
void emitTrackClosed(int fd) {
if (!g_vm || !g_listener || !g_onTrackClosed) return;
JNIEnv* env = nullptr;
if (g_vm->AttachCurrentThread(&env, nullptr) != JNI_OK) return;
env->CallVoidMethod(g_listener, g_onTrackClosed, static_cast<jint>(fd));
if (env->ExceptionCheck()) env->ExceptionClear();
// DetachCurrentThread only if this thread won't call JNI again
}
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;
}
extern "C"
JNIEXPORT jstring JNICALL
Java_at_lockstep_player_pasada_LibPasada_getVersion(JNIEnv *env, jclass clazz) {
if (g_libpasada == nullptr) {
return env->NewStringUTF("");
}
std::string version = g_libpasada->getVersion();
return env->NewStringUTF(version.c_str());
}

View File

@@ -22,6 +22,27 @@ Java_at_lockstep_pb_PlaybackEngine_native_1createEngine(
return reinterpret_cast<jlong>(engine); return reinterpret_cast<jlong>(engine);
} }
JNIEXPORT void JNICALL
Java_at_lockstep_pb_PlaybackEngine_native_1stop(
JNIEnv *env,
jclass,
jlong engineHandle) {
auto *engine = reinterpret_cast<PlaybackEngine *>(engineHandle);
engine->stop();
}
JNIEXPORT void JNICALL
Java_at_lockstep_pb_PlaybackEngine_native_1pause(
JNIEnv *env,
jclass,
jlong engineHandle) {
auto *engine = reinterpret_cast<PlaybackEngine *>(engineHandle);
// TODO: this is broken. there is a square wave noise played, instead of silence
engine->pause(); // do not tear down the Oboe pipeline, such that we can play again later (without having to re-construct PlaybackEngine)
}
JNIEXPORT void JNICALL JNIEXPORT void JNICALL
Java_at_lockstep_pb_PlaybackEngine_native_1deleteEngine( Java_at_lockstep_pb_PlaybackEngine_native_1deleteEngine(
JNIEnv *env, JNIEnv *env,

View File

@@ -66,14 +66,14 @@ JNI_OnLoad(JavaVM * vm, void * reserved)
jclass clazz; jclass clazz;
//LOGV("JNI_Onload vm:%p reserved:%p", vm, reserved); //LOGV("JNI_Onload vm:%p reserved:%p", vm, reserved);
if (JNI_OK != vm->GetEnv((void**) &env, JNI_VERSION_1_4)) { if (JNI_OK != vm->GetEnv((void**) &env, JNI_VERSION_1_6)) {
//LOGE("JNI_OnUnload GetEnv JNI_VERSION_1_4 failed"); //LOGE("JNI_OnUnload GetEnv JNI_VERSION_1_6 failed");
return JNI_VERSION_1_4; return JNI_VERSION_1_6;
} }
logback_init(vm, env); logback_init(vm, env);
return JNI_VERSION_1_4; return JNI_VERSION_1_6;
} }
extern "C" void extern "C" void
@@ -82,8 +82,8 @@ JNI_OnUnload(JavaVM * vm, void * reserved)
JNIEnv * env = NULL; JNIEnv * env = NULL;
//LOGV("JNI_OnUnload vm:%p reserved:%p", vm, reserved); //LOGV("JNI_OnUnload vm:%p reserved:%p", vm, reserved);
if (JNI_OK != vm->GetEnv((void**) &env, JNI_VERSION_1_4)) { if (JNI_OK != vm->GetEnv((void**) &env, JNI_VERSION_1_6)) {
//LOGE("JNI_OnUnload GetEnv JNI_VERSION_1_4 failed"); //LOGE("JNI_OnUnload GetEnv JNI_VERSION_1_6 failed");
return; return;
} }

View File

@@ -3,7 +3,7 @@
// //
#include <jni.h> #include <jni.h>
#include "StepDetector.h" #include "step_detector.h"
#include <new> #include <new>
#include <vector> #include <vector>
@@ -26,7 +26,8 @@ Java_at_lockstep_filter_StepDetector_native_1create(
jclass /*unused*/, jlong engineHandle) { jclass /*unused*/, jlong engineHandle) {
auto *listener = reinterpret_cast<StepListener *>(engineHandle); auto *listener = reinterpret_cast<StepListener *>(engineHandle);
// We use std::nothrow so `new` returns a nullptr if the engine creation fails // We use std::nothrow so `new` returns a nullptr if the engine creation fails
auto *detector = new(std::nothrow) StepDetector(listener); // the hardcoded 'fps' is only an initial value, which is re-computed in StepDetector
auto *detector = new(std::nothrow) StepDetector(60.0 /* FPS */, listener);
return reinterpret_cast<jlong>(detector); return reinterpret_cast<jlong>(detector);
} }
@@ -46,7 +47,7 @@ Java_at_lockstep_filter_StepDetector_native_1filter(
jsize length = env->GetArrayLength(values); jsize length = env->GetArrayLength(values);
std::vector<float> vecValues(nativeValues, nativeValues + length); std::vector<float> vecValues(nativeValues, nativeValues + length);
auto *detector = reinterpret_cast<StepDetector *>(handle); auto *detector = reinterpret_cast<StepDetector *>(handle);
detector->filter(vecValues); detector->filter((double) timestamp, vecValues);
} }
} }

View File

@@ -91,7 +91,7 @@ __logback_print(const int level, const char * format, ...)
JNIEnv * env = NULL; JNIEnv * env = NULL;
jint err = JNI_OK; jint err = JNI_OK;
if (! __logback_jvm) return; if (! __logback_jvm) return;
if (JNI_EDETACHED == (err = __logback_jvm->GetEnv((void**) &env, JNI_VERSION_1_4))) { if (JNI_EDETACHED == (err = __logback_jvm->GetEnv((void**) &env, JNI_VERSION_1_6))) {
if (__logback_jvm->AttachCurrentThread(&env, NULL) < 0) { if (__logback_jvm->AttachCurrentThread(&env, NULL) < 0) {
__android_log_write(LOG_LEVEL_WARN, LOG_TAG, "__logback_write failed to get env neither attach current thread"); __android_log_write(LOG_LEVEL_WARN, LOG_TAG, "__logback_write failed to get env neither attach current thread");
} }
@@ -115,7 +115,7 @@ __logback_vprint(const int level, const char * format, va_list ap)
JNIEnv * env = NULL; JNIEnv * env = NULL;
jint err = JNI_OK; jint err = JNI_OK;
if (! __logback_jvm) return; if (! __logback_jvm) return;
if (JNI_EDETACHED == (err = __logback_jvm->GetEnv((void**) &env, JNI_VERSION_1_4))) { if (JNI_EDETACHED == (err = __logback_jvm->GetEnv((void**) &env, JNI_VERSION_1_6))) {
if (__logback_jvm->AttachCurrentThread(&env, NULL) < 0) { if (__logback_jvm->AttachCurrentThread(&env, NULL) < 0) {
__android_log_write(LOG_LEVEL_WARN, LOG_TAG, "__logback_write failed to get env neither attach current thread"); __android_log_write(LOG_LEVEL_WARN, LOG_TAG, "__logback_write failed to get env neither attach current thread");
} }
@@ -136,7 +136,7 @@ __logback_write(const int level, const char * msg)
JNIEnv * env = NULL; JNIEnv * env = NULL;
jint err = JNI_OK; jint err = JNI_OK;
if (! __logback_jvm) return; if (! __logback_jvm) return;
if (JNI_EDETACHED == (err = __logback_jvm->GetEnv((void**) &env, JNI_VERSION_1_4))) { if (JNI_EDETACHED == (err = __logback_jvm->GetEnv((void**) &env, JNI_VERSION_1_6))) {
if (__logback_jvm->AttachCurrentThread(&env, NULL) < 0) { if (__logback_jvm->AttachCurrentThread(&env, NULL) < 0) {
__android_log_write(LOG_LEVEL_WARN, LOG_TAG, "__logback_write failed to get env neither attach current thread"); __android_log_write(LOG_LEVEL_WARN, LOG_TAG, "__logback_write failed to get env neither attach current thread");
} }

View File

@@ -15,6 +15,7 @@ MP3File* mp3file_init(mpg123_handle *handle) {
if(mp3file == NULL) return NULL; if(mp3file == NULL) return NULL;
memset(mp3file, 0, sizeof(MP3File)); memset(mp3file, 0, sizeof(MP3File));
mp3file->handle = handle; mp3file->handle = handle;
mp3file->num_bytes = -1;
return mp3file; return mp3file;
} }
@@ -96,7 +97,7 @@ on_error:
#undef handleError #undef handleError
} }
MP3File* mp3file_open_fd(int fd, int forceEncoding) { MP3File* mp3file_open_fd(int fd, long long offset, long long length, int forceEncoding) {
const char *errorText = ""; const char *errorText = "";
#define handleError(text) \ #define handleError(text) \
do { \ do { \
@@ -104,6 +105,8 @@ MP3File* mp3file_open_fd(int fd, int forceEncoding) {
goto on_error; \ goto on_error; \
} while(0) } while(0)
int64_t len, a, b;
int err = MPG123_OK; int err = MPG123_OK;
mpg123_handle *mh = mpg123_new(NULL, &err); mpg123_handle *mh = mpg123_new(NULL, &err);
if(err != MPG123_OK || mh == NULL) { if(err != MPG123_OK || mh == NULL) {
@@ -113,9 +116,14 @@ MP3File* mp3file_open_fd(int fd, int forceEncoding) {
MP3File* mp3 = mp3file_init(mh); MP3File* mp3 = mp3file_init(mh);
if(mp3 == NULL) handleError("malloc() failed"); if(mp3 == NULL) handleError("malloc() failed");
lseek(fd, static_cast<off_t>(offset), SEEK_SET);
mp3->num_bytes = length;
err = mpg123_open_fd(mh, fd); err = mpg123_open_fd(mh, fd);
if(err != MPG123_OK) handleError("mpg123_open()"); if(err != MPG123_OK) handleError("mpg123_open()");
// hopefully the magic bullet for seeking in the file (otherwise, mpg123_seek() returns totally random offsets)
mpg123_scan(mh);
int encoding; int encoding;
err = mpg123_getformat(mh, &mp3->rate, &mp3->channels, &encoding); err = mpg123_getformat(mh, &mp3->rate, &mp3->channels, &encoding);
if(err != MPG123_OK) handleError("mpg123_getformat()"); if(err != MPG123_OK) handleError("mpg123_getformat()");
@@ -149,10 +157,21 @@ MP3File* mp3file_open_fd(int fd, int forceEncoding) {
else else
mp3->duration = mp3->num_samples / mp3->samples_per_frame * mp3->secs_per_frame; mp3->duration = mp3->num_samples / mp3->samples_per_frame * mp3->secs_per_frame;
mp3->offset = 0; mp3->offset = lseek(fd, 0, SEEK_CUR);
mp3->remaining_samples = (int) mp3->num_samples; mp3->remaining_samples = (int) mp3->num_samples;
LOGI("channels: %d rate: %ld num_samples: %ld", mp3->channels, mp3->rate, mp3->num_samples); LOGI("channels: %d rate: %ld num_samples: %ld", mp3->channels, mp3->rate, mp3->num_samples);
mp3->android_fd = fd; mp3->android_fd = fd;
// begin debug: seek returns wildly random offsets
len = mpg123_length(mh);
a = mpg123_seek(mh, 0, SEEK_SET);
b = mpg123_tell(mh);
LOGI("using mpg123_scan: len=%lld seek=%lld tell=%lld",
(long long)len, (long long)a, (long long)b);
// end debug
return mp3; return mp3;
on_error: on_error:
@@ -162,3 +181,22 @@ on_error:
#undef handleError #undef handleError
} }
off_t mp3file_seek(MP3File *mp3file, off_t samples) {
LOGD("mp3file_seek(): seek to %ld samples", samples);
off_t seekResult = mpg123_seek(mp3file->handle, samples, SEEK_SET);
LOGD(" mp3file_seek(): %ld samples remaining", samples - seekResult);
if(seekResult > samples + 4800) {
LOGE("mp3file_seek(): error: landed beyond the seek position!");
return seekResult;
}
// try to refine the seek position
int max_iter = 6;
int cur_iter = 0;
for(; cur_iter < max_iter && seekResult < samples - 4800; cur_iter++) {
seekResult = mpg123_seek(mp3file->handle, samples - seekResult, SEEK_CUR);
LOGD(" mp3file_seek(): %ld samples remaining", samples - seekResult);
}
LOGD("mp3file_seek(): finished");
return seekResult;
}

View File

@@ -22,13 +22,17 @@ struct MP3File
size_t buffer_size; size_t buffer_size;
unsigned char* buffer; unsigned char* buffer;
/** total samples (stereo of 10 frames remaining will have 20 'remaining_samples' here) */ /** total samples (stereo of 10 frames remaining will have 20 'remaining_samples' here) */
int remaining_samples; long remaining_samples;
/** zero if opened using a filename, otherwise bytes offset currently read into 'android_fd' */
size_t offset; size_t offset;
/** number of bytes to read at most from 'android_fd' */
long long num_bytes;
}; };
MP3File* mp3file_init(mpg123_handle *handle); MP3File* mp3file_init(mpg123_handle *handle);
void mp3file_delete(MP3File *mp3file); void mp3file_delete(MP3File *mp3file);
MP3File* mp3file_open(const char *filename, int forceEncoding = 0); MP3File* mp3file_open(const char *filename, int forceEncoding = 0);
MP3File* mp3file_open_fd(int fd, int forceEncoding = 0); MP3File* mp3file_open_fd(int fd, long long offset = 0, long long length = -1, int forceEncoding = 0);
off_t mp3file_seek(MP3File *mp3file, off_t samples);
#endif //SAMPLES_MP3FILE_H #endif //SAMPLES_MP3FILE_H

View File

@@ -6,6 +6,8 @@ import android.app.NotificationManager;
import android.app.Service; import android.app.Service;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.hardware.Sensor; import android.hardware.Sensor;
import android.hardware.SensorEvent; import android.hardware.SensorEvent;
import android.hardware.SensorEventListener; import android.hardware.SensorEventListener;
@@ -38,6 +40,7 @@ public class LstForegroundService extends Service implements SensorEventListener
public static final String ACTION_START = "at.lockstep.action.START"; public static final String ACTION_START = "at.lockstep.action.START";
public static final String ACTION_STOP = "at.lockstep.action.STOP"; public static final String ACTION_STOP = "at.lockstep.action.STOP";
public static final String ACTION_PAUSE = "at.lockstep.action.PAUSE";
private SensorManager sensorManager; private SensorManager sensorManager;
private Sensor accelerometer; private Sensor accelerometer;
@@ -58,6 +61,11 @@ public class LstForegroundService extends Service implements SensorEventListener
intent.setAction(ACTION_STOP); intent.setAction(ACTION_STOP);
return intent; return intent;
} }
public static Intent pauseIntent(Context context) {
Intent intent = new Intent(context, LstForegroundService.class);
intent.setAction(ACTION_PAUSE);
return intent;
}
@Override @Override
public void onCreate() { public void onCreate() {
@@ -96,7 +104,9 @@ public class LstForegroundService extends Service implements SensorEventListener
String contentUri = intent.getStringExtra("content_uri"); String contentUri = intent.getStringExtra("content_uri");
try { try {
if(contentUri != null) { if(contentUri != null) {
PlaybackEngine.playMusic(uriToFd(contentUri)); PlaybackEngine.playMusic(uriToFd(contentUri), this, R.raw.track_beat);
} else {
PlaybackEngine.playMusic(0, this, R.raw.track_beat);
} }
} catch (IOException e) { } catch (IOException e) {
// TODO proper error handling // TODO proper error handling
@@ -107,6 +117,10 @@ public class LstForegroundService extends Service implements SensorEventListener
} else if (ACTION_STOP.equals(action)) { } else if (ACTION_STOP.equals(action)) {
Log.i("LstForegroundService", "ACTION_STOP"); Log.i("LstForegroundService", "ACTION_STOP");
stopCollectionAndSelf(); stopCollectionAndSelf();
PlaybackEngine.stop();
} else if (ACTION_PAUSE.equals(action)) {
Log.i("LstForegroundService", "ACTION_PAUSE");
PlaybackEngine.pause();
} }
} }
return START_STICKY; return START_STICKY;
@@ -150,7 +164,8 @@ public class LstForegroundService extends Service implements SensorEventListener
sensorManager.registerListener( sensorManager.registerListener(
this, this,
accelerometer, accelerometer,
SensorManager.SENSOR_DELAY_GAME SensorManager.SENSOR_DELAY_GAME,
0 // maxReportLatencyUs (we want real-time delivery)
); );
isCollecting = true; isCollecting = true;
onStartRecording(meta); onStartRecording(meta);
@@ -225,7 +240,7 @@ public class LstForegroundService extends Service implements SensorEventListener
} }
public SensorData(long timestamp, float[] values) { public SensorData(long timestamp, float[] values) {
this.timestamp = timestamp; this.timestamp = timestamp;
this.values = values; this.values = Arrays.copyOf(values, values.length);
} }
} }
@@ -233,10 +248,12 @@ public class LstForegroundService extends Service implements SensorEventListener
public static class SensorDataArray { public static class SensorDataArray {
private ArrayList<SensorData> data = new ArrayList<SensorData>(); private ArrayList<SensorData> data = new ArrayList<SensorData>();
private String meta; private String meta;
private int versionCode;
public void add(SensorEvent event) { data.add(new SensorData(event)); } public void add(SensorEvent event) { data.add(new SensorData(event)); }
public void add(SensorData d) { data.add(d); } public void add(SensorData d) { data.add(d); }
public void clear() { data.clear(); } public void clear() { data.clear(); }
public void setMeta(String meta) { this.meta = meta; } public void setMeta(String meta) { this.meta = meta; }
public void setVersionCode(int versionCode) { this.versionCode = versionCode; }
} }
private final SensorDataArray recording = new SensorDataArray(); private final SensorDataArray recording = new SensorDataArray();
@@ -245,6 +262,11 @@ public class LstForegroundService extends Service implements SensorEventListener
private void onStartRecording(String meta) { private void onStartRecording(String meta) {
recordingStartTime = SystemClock.elapsedRealtimeNanos(); recordingStartTime = SystemClock.elapsedRealtimeNanos();
recording.setMeta(meta); recording.setMeta(meta);
try {
PackageInfo pInfo = this.getPackageManager().getPackageInfo(this.getPackageName(), 0);
recording.setVersionCode(pInfo.versionCode);
} catch (PackageManager.NameNotFoundException ignored) {}
} }
private void onStopRecording() { private void onStopRecording() {
if(listener != null) { if(listener != null) {

View File

@@ -32,6 +32,7 @@ import java.io.Writer;
public class MainActivity extends AppCompatActivity implements LstForegroundService.OnResultListener { public class MainActivity extends AppCompatActivity implements LstForegroundService.OnResultListener {
private Button btnStart; private Button btnStart;
private Button btnStop; private Button btnStop;
private Button btnPause;
private Button btnMediaStoreBenchmark; private Button btnMediaStoreBenchmark;
private Button btnPickSong; private Button btnPickSong;
private final ActivityResultLauncher<Intent> launcher; private final ActivityResultLauncher<Intent> launcher;
@@ -61,6 +62,7 @@ public class MainActivity extends AppCompatActivity implements LstForegroundServ
btnStart = findViewById(R.id.btnStart); btnStart = findViewById(R.id.btnStart);
btnStop = findViewById(R.id.btnStop); btnStop = findViewById(R.id.btnStop);
btnPause = findViewById(R.id.btnPause);
btnMediaStoreBenchmark = findViewById(R.id.btnMediaStoreBenchmark); btnMediaStoreBenchmark = findViewById(R.id.btnMediaStoreBenchmark);
btnPickSong = findViewById(R.id.btnPickSong); btnPickSong = findViewById(R.id.btnPickSong);
@@ -83,6 +85,10 @@ public class MainActivity extends AppCompatActivity implements LstForegroundServ
startService(LstForegroundService.stopIntent(MainActivity.this)) startService(LstForegroundService.stopIntent(MainActivity.this))
); );
btnPause.setOnClickListener(v ->
startService(LstForegroundService.pauseIntent(MainActivity.this))
);
btnMediaStoreBenchmark.setOnClickListener(v -> { btnMediaStoreBenchmark.setOnClickListener(v -> {
Intent intent = new Intent(MainActivity.this, MediaStoreBenchmarkActivity.class); Intent intent = new Intent(MainActivity.this, MediaStoreBenchmarkActivity.class);
startActivity(intent); startActivity(intent);

View File

@@ -106,15 +106,17 @@ public class MediaStoreBenchmarkActivity extends Activity {
MediaStore.Audio.Media.TITLE + " ASC" MediaStore.Audio.Media.TITLE + " ASC"
)) { )) {
if (cursor != null) { if (cursor != null) {
int idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID);
int titleColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE); int titleColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE);
int dataColumn = cursor.getColumnIndex(MediaStore.Audio.Media.DATA); int dataColumn = cursor.getColumnIndex(MediaStore.Audio.Media.DATA);
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
String contentUri = cursor.getString(idColumn); // the content:// Uri for the MediaStore item
String title = cursor.getString(titleColumn); String title = cursor.getString(titleColumn);
String path = dataColumn != -1 ? cursor.getString(dataColumn) : null; String path = dataColumn != -1 ? cursor.getString(dataColumn) : null;
if (path != null) { if (path != null) {
musicList.add(title + "\n" + path); musicList.add(title + "\n" + path + "\n" + contentUri);
} else { } else {
musicList.add(title + "\n[path unavailable]"); musicList.add(title + "\n[path unavailable]");
} }

View File

@@ -76,16 +76,38 @@ public class PlaybackEngine {
return mEngineHandle; return mEngineHandle;
} }
public static void playMusic(int fd) { public static void playMusic(int fd, Context context, int beat_resid) {
if (mEngineHandle == 0) {
create(context, beat_resid);
}
if (mEngineHandle != 0) { if (mEngineHandle != 0) {
native_playMusic(mEngineHandle, fd); native_playMusic(mEngineHandle, fd);
} }
} }
public static void stop() {
if (mEngineHandle != 0) {
//native_stop(mEngineHandle);
// for now, (pause is bugged), deleting engine is the only clean way
native_deleteEngine(mEngineHandle);
mEngineHandle = 0;
}
}
public static void pause() {
if (mEngineHandle != 0) {
Log.i("PlaybackEngine", "native_pause() ...");
native_pause(mEngineHandle);
Log.i("PlaybackEngine", "native_pause() done.");
}
}
private static native long native_createEngine(String filesDir, int resid); private static native long native_createEngine(String filesDir, int resid);
private static native void native_deleteEngine(long engineHandle); private static native void native_deleteEngine(long engineHandle);
private static native void native_setDefaultStreamValues(int sampleRate, int framesPerBurst); private static native void native_setDefaultStreamValues(int sampleRate, int framesPerBurst);
private static native int native_mpg123_init(); private static native int native_mpg123_init();
private static native void native_playMusic(long engineHandle, int fd); private static native void native_playMusic(long engineHandle, int fd);
private static native void native_stop(long engineHandle);
private static native void native_pause(long engineHandle);
} }

View File

@@ -0,0 +1,75 @@
package at.lockstep.player.pasada;
/**
* JNI entry point for libpasada.
*
* <p>Native state machine: LOADED → INITIALIZED → PLAYING ↔ PAUSED → FINISHED → STOPPED
*
* <p>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);
/** native version string */
public static native String getVersion();
}

View File

@@ -0,0 +1,16 @@
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();
void onTrackClosed(int fd);
/** Decode / Oboe / pipeline failure; service should stop safely and surface error. */
void onError(int errorCode, String message);
}

View File

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

View File

@@ -19,6 +19,13 @@
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:text="Stop collection" /> android:text="Stop collection" />
<Button
android:id="@+id/btnPause"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Pause" />
<Button <Button
android:id="@+id/btnMediaStoreBenchmark" android:id="@+id/btnMediaStoreBenchmark"
android:layout_width="wrap_content" android:layout_width="wrap_content"