diff --git a/app/build.gradle b/app/build.gradle index 7c9c45c..d85e56f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -24,6 +24,7 @@ android { //path 'src/main/cpp/CMakeLists.txt' //cppFlags '' arguments "-DANDROID_STL=c++_shared" + cppFlags "-DLIBPASADA_VERSION=1.0.4" //cppFlags "-std=c++14" //arguments '-DANDROID_STL=c++_static' diff --git a/app/src/main/cpp/LibPasada.cpp b/app/src/main/cpp/LibPasada.cpp index 48cc64b..0c88191 100644 --- a/app/src/main/cpp/LibPasada.cpp +++ b/app/src/main/cpp/LibPasada.cpp @@ -1,4 +1,6 @@ #include "LibPasada.h" +#include "PlaybackEngine.h" +#include "step_detector.h" #include /** Current track reached end; service should advance queue and call {@link LibPasada#play}. */ @@ -29,21 +31,55 @@ void PasadaPlaybackListener::onError(int errorCode, std::string message) { *

Native state machine: LOADED → INITIALIZED → PLAYING ↔ PAUSED → FINISHED → STOPPED */ -LibPasada::LibPasada() : state(LOADED) +LibPasada::LibPasada() : state(LOADED), engine(nullptr), detector(nullptr) {} -LibPasada::~LibPasada() {} +/** 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 lock(mtxDetector); + if (detector != nullptr) { + auto *stepDetector = reinterpret_cast(detector); + delete stepDetector; + detector = nullptr; + } + // init(): if new StepDetector() fails, we have a leftover 'engine' + if (engine != nullptr) { + auto *playbackEngine = reinterpret_cast(engine); + delete playbackEngine; + engine = nullptr; + } +} /** Called each time a run/playlist session starts (Oboe silent, buffers ready). */ void LibPasada::init() { + std::lock_guard lockState(mtxState); + std::lock_guard lock(mtxDetector); if(state == LOADED || state == STOPPED) { // perform init + auto *playbackEngine = new PlaybackEngine("", 0); + engine = playbackEngine; + + auto *stepListener = reinterpret_cast(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) {} +void LibPasada::feedAccel(float x, float y, float z, long long timestamp_nanos) { + std::lock_guard lockState(mtxState); + std::lock_guard lock(mtxDetector); + if(state == LOADED || state == STOPPED) { + // player is not in an initialized state - ignore accelerometer data + return; + } + auto *stepDetector = reinterpret_cast(detector); + stepDetector->filter(static_cast(timestamp_nanos), std::vector {x, y, z}); +} /** * Open MP3 from an already-open file descriptor and begin adaptive playback. @@ -53,43 +89,106 @@ void LibPasada::feedAccel(float x, float y, float z, long long timestamp_nanos) * @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 lockState(mtxState); state = PLAYING; + auto *playbackEngine = reinterpret_cast(engine); + playbackEngine->playMusic(fd, offset, length); } + /** PLAYING → PAUSED (silent output, graph kept alive). */ void LibPasada::pause() { + std::lock_guard lockState(mtxState); if(state != PLAYING) return; state = PAUSED; + auto *playbackEngine = reinterpret_cast(engine); + playbackEngine->pause(); } + /** PAUSED → PLAYING (same track, same decode position, same FD). */ void LibPasada::resume() { + std::lock_guard lockState(mtxState); if(state != PAUSED) return; state = PLAYING; + auto *playbackEngine = reinterpret_cast(engine); + playbackEngine->resume(); } + /** Tear down Oboe for this run segment → STOPPED. */ void LibPasada::stop() { + std::lock_guard lockState(mtxState); state = STOPPED; + auto *playbackEngine = reinterpret_cast(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() { return 0; } +long LibPasada::getCurrentPositionMs() { + std::lock_guard lockState(mtxState); + if(state == LOADED) + return 0; + auto *playbackEngine = reinterpret_cast(engine); + return static_cast(playbackEngine->getCurrentPosition() * 1000); +} /** Track duration in ms, or {@code 0} if not yet known. */ -long LibPasada::getDurationMs() { return 0; } +long LibPasada::getDurationMs() { + std::lock_guard lockState(mtxState); + if(state == LOADED) + return 0; + auto *playbackEngine = reinterpret_cast(engine); + return static_cast(playbackEngine->getDuration() * 1000); +} + +// defined in jni_libpasada.cpp +void emitTrackFinished(); + +void LibPasada::onTrackFinished() { + { + std::lock_guard 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() { return false; } +bool LibPasada::isPlaying() { + std::lock_guard lockState(mtxState); + return state == PLAYING; +} /** Current native state; see {@link PasadaState}. Must not throw. */ -int LibPasada::getState() { return state; } +int LibPasada::getState() { + std::lock_guard lockState(mtxState); + return state; +} /** Seek within the current track. */ -void LibPasada::seekTo(long positionMs) {} +void LibPasada::seekTo(long positionMs) { + std::lock_guard lockState(mtxState); + if(state != PLAYING && state != PAUSED) + return; // silently ignore seeks if we do not have a music file loaded + + auto *playbackEngine = reinterpret_cast(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 +} diff --git a/app/src/main/cpp/LibPasada.h b/app/src/main/cpp/LibPasada.h index 6cc452e..f1e8538 100644 --- a/app/src/main/cpp/LibPasada.h +++ b/app/src/main/cpp/LibPasada.h @@ -5,24 +5,14 @@ #ifndef LOCKSTEP_LIBPASADA_H #define LOCKSTEP_LIBPASADA_H +#include "PasadaPlaybackListener.h" #include +#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 @@ -40,14 +30,18 @@ enum PasadaState { * JNI entry point for libpasada. *

Native state machine: LOADED → INITIALIZED → PLAYING ↔ PAUSED → FINISHED → STOPPED */ -class LibPasada { +class LibPasada : public PasadaPlaybackListener { private: PasadaState state; + std::mutex mtxState; //PasadaPlaybackListener listener; + void *engine; + void *detector; + std::mutex mtxDetector; public: LibPasada(); - ~LibPasada(); + virtual ~LibPasada(); /** Called each time a run/playlist session starts (Oboe silent, buffers ready). */ void init(); @@ -93,6 +87,11 @@ public: /** 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 diff --git a/app/src/main/cpp/PasadaPlaybackListener.h b/app/src/main/cpp/PasadaPlaybackListener.h new file mode 100644 index 0000000..d6ed39e --- /dev/null +++ b/app/src/main/cpp/PasadaPlaybackListener.h @@ -0,0 +1,24 @@ +// +// Created by david on 02.06.2026. +// + +#ifndef LOCKSTEP_PASADAPLAYBACKLISTENER_H +#define LOCKSTEP_PASADAPLAYBACKLISTENER_H + +#include + +/** + * 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(); + + /** Decode / Oboe / pipeline failure; service should stop safely and surface error. */ + virtual void onError(int errorCode, std::string message); +}; + +#endif //LOCKSTEP_PASADAPLAYBACKLISTENER_H diff --git a/app/src/main/cpp/PlaybackEngine.cpp b/app/src/main/cpp/PlaybackEngine.cpp index f5d4623..9627dbb 100644 --- a/app/src/main/cpp/PlaybackEngine.cpp +++ b/app/src/main/cpp/PlaybackEngine.cpp @@ -11,6 +11,8 @@ #include #include #include +#include +#include /** * Read samples from the next mp3-frame into the struct MP3File's buffer. @@ -67,7 +69,8 @@ struct RbLogger : public RubberBand::RubberBandStretcher::Logger { virtual ~RbLogger() { } }; -PlaybackEngine::PlaybackEngine(std::string filesDir, int resid): +PlaybackEngine::PlaybackEngine(std::string filesDir, int resid, PasadaPlaybackListener *listener): + listener(listener), stretcher( /* sampleRate: */ 48000, /* channels: */ 2, @@ -80,6 +83,11 @@ PlaybackEngine::PlaybackEngine(std::string filesDir, int resid): haveMusicFile(false), exitMusicFeedThread(false), isSetMusic(false), + isPaused(false), + markerPos(0.0), + duration(0.0), + seekPos(0.0), + requestSeek(false), android_fd(0), haveTimeRatio(false), timeRatio(1.0), @@ -96,8 +104,13 @@ PlaybackEngine::PlaybackEngine(std::string filesDir, int resid): // load "bump" sound effect std::vector samples; - bool is_ok = read_mp3(mFilesDir + "/" + std::to_string(resid) + ".mp3", samples); - LOGI("read_mp3() for bump effect, is_ok=%d", is_ok); + if (!filesDir.empty()) { + bool is_ok = read_mp3(mFilesDir + "/" + std::to_string(resid) + ".mp3", samples); + 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); @@ -284,7 +297,7 @@ void PlaybackEngine::musicFeedThread() { num_samples = num_buf_samples; } - if (!haveMusicFile.load()) { + if (!haveMusicFile.load() || isPaused.load()) { loop_delay_us = 1000000 * num_samples / playbackRate.load(); if(idebug++ < 10) { LOGI("feed %d silence samples", num_samples); @@ -304,11 +317,37 @@ void PlaybackEngine::musicFeedThread() { // ca. 21 ms } + 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(pos * musicFile->samples_per_frame / musicFile->secs_per_frame); + seek_samples = std::max(seek_samples, 0L); + seek_samples = std::min(seek_samples, musicFile->num_samples); + // compute seek delta, to update byte 'offset' + auto seek_samples_delta = seek_samples - (musicFile->num_samples - musicFile->remaining_samples); + auto seek_bytes_delta = seek_samples_delta * num_ch_in * sizeof(int16_t); + // perform the seek + off_t seekResult = mpg123_seek(musicFile->handle, musicFile->num_samples - musicFile->remaining_samples, SEEK_SET); + if(seekResult != seek_samples) { + LOGE("error seeking in mp3 file: mpg123_seek(handle, %d, SEEK_SET)=%d", seek_samples, seekResult); + } + // update structure + musicFile->remaining_samples = static_cast(musicFile->num_samples - seek_samples); + musicFile->offset += seek_bytes_delta; + requestSeek.store(false); + } + 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_calc = std::min(num_samples * num_ch_in * sizeof(int16_t), cbuf_size_bytes); + size_t read_size_bytes = read_size_bytes_calc; + if (musicFile->offset != 0 && musicFile->num_bytes != -1) { read_size_bytes = std::min(read_size_bytes_calc, (size_t) musicFile->num_bytes - musicFile->offset); } int err = mpg123_read(musicFile->handle, cbuf, read_size_bytes, &done); + if (musicFile->offset != 0 && musicFile->num_bytes != -1 && err == MPG123_OK && read_size_bytes <= read_size_bytes_calc) { err = MPG123_DONE; } musicFile->remaining_samples -= done / sizeof(int16_t); - musicFile->offset = 0; // unused here + musicFile->offset += done; if (err != MPG123_OK && err != MPG123_DONE) { // error! LOGE("error reading mp3 file: mpg123_read() err=%d done=%d", err, done); @@ -322,12 +361,10 @@ void PlaybackEngine::musicFeedThread() { } if(err == MPG123_DONE) { // next iteration will play silence + // we keep Stretcher and Oboe alive LOGI("finished reading mp3 file (MPG123_DONE)"); closeMusicFile(); - stretcher.setTimeRatio(1.0); // buffer size for playing silence is computed from 'playbackRate', so reset timeRatio - stretcher.setPitchScale(1.0); - stretcher.process(buf_ptr, 0, true); // set end of playback - mPlayer->stopAudio(); + if(listener) listener->onTrackFinished(); continue; } @@ -356,23 +393,63 @@ void PlaybackEngine::musicFeedThread() { LOGI("musicFeedThread() exited."); } +void PlaybackEngine::pause() { + // 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) { + mPlayer->stopAudio(); + delete mPlayer; + mPlayer = nullptr; + } + markerPos.store(0.0); + duration.store(0.0); +} + PlaybackEngine::~PlaybackEngine() { LOGI("~PlaybackEngine()"); closeRubberBand(); - mPlayer->stopAudio(); - delete mPlayer; - mPlayer = nullptr; + if (mPlayer) { + mPlayer->stopAudio(); + delete mPlayer; + mPlayer = nullptr; + } } void PlaybackEngine::playBeat() { if(mPlayer) mPlayer->setStartBeat(); } -void PlaybackEngine::playMusic(int fd) { +void PlaybackEngine::playMusic(int fd, long long offset, long long length) { if(!mPlayer) return; LOGI("PlaybackEngine::playMusic(fd=%d)", fd); + if(musicFile) { + // when changing tracks in the UI, close the previous file + closeMusicFile(); + } android_fd = fd; - musicFile.reset(mp3file_open_fd(android_fd, 0)); + musicFile.reset(mp3file_open_fd(android_fd, offset, length, 0)); if(musicFile) { timeRatio.store(((double) playbackRate.load()) / ((double) musicFile->rate)); haveTimeRatio.store(true); @@ -382,6 +459,7 @@ void PlaybackEngine::playMusic(int fd) { bool is_finished = (stretcher.available() == -1); if(is_finished) { + LOGE("stretcher.available() == -1, this should not happen anymore with current wiring"); // so that we may play again after "final chunk" closeRubberBand(); initRubberBand(); diff --git a/app/src/main/cpp/PlaybackEngine.h b/app/src/main/cpp/PlaybackEngine.h index c9241a1..de5b249 100644 --- a/app/src/main/cpp/PlaybackEngine.h +++ b/app/src/main/cpp/PlaybackEngine.h @@ -5,11 +5,12 @@ #ifndef LOCKSTEP_PLAYBACKENGINE_H #define LOCKSTEP_PLAYBACKENGINE_H -#include "StepListener.h" +#include "step_detector.h" #include "MixingPlayer.h" #include "RubberBandStretcher.h" #include "mp3file.h" #include "AudioCallback.h" +#include "PasadaPlaybackListener.h" #include #include #include @@ -36,12 +37,28 @@ private: class PlaybackEngine : public StepListener { 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(); /** Play a beat sound. */ 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: + PasadaPlaybackListener *listener; RubberBand::RubberBandStretcher stretcher; MixingPlayer *mPlayer; std::string mFilesDir; @@ -49,8 +66,17 @@ private: std::atomic haveMusicFile; std::unique_ptr musicFeed; std::atomic 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 isSetMusic; + std::atomic isPaused; + /** read marker in the music file in sec */ + std::atomic markerPos; + /** duration of the music file in sec */ + std::atomic duration; + /** seek position request in the music file in sec, valid during seek requests */ + std::atomic seekPos; + /** whether a seek request is active */ + std::atomic requestSeek; int android_fd; std::atomic haveTimeRatio; std::atomic timeRatio; diff --git a/app/src/main/cpp/StepListener.h b/app/src/main/cpp/StepListener.h deleted file mode 100644 index 708f923..0000000 --- a/app/src/main/cpp/StepListener.h +++ /dev/null @@ -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 diff --git a/app/src/main/cpp/jni_libpasada.cpp b/app/src/main/cpp/jni_libpasada.cpp index 52a8fef..62b217c 100644 --- a/app/src/main/cpp/jni_libpasada.cpp +++ b/app/src/main/cpp/jni_libpasada.cpp @@ -36,16 +36,15 @@ Java_at_lockstep_player_pasada_LibPasada_init( } 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; } + delete g_libpasada; + g_libpasada = nullptr; } /** Submit one accelerometer sample (m/s^2); may be called from a sensor thread. */ @@ -350,3 +349,13 @@ void clearListener(JNIEnv* env) { 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()); +} diff --git a/app/src/main/cpp/mp3file.cpp b/app/src/main/cpp/mp3file.cpp index fb0f269..9755ae2 100644 --- a/app/src/main/cpp/mp3file.cpp +++ b/app/src/main/cpp/mp3file.cpp @@ -15,6 +15,7 @@ MP3File* mp3file_init(mpg123_handle *handle) { if(mp3file == NULL) return NULL; memset(mp3file, 0, sizeof(MP3File)); mp3file->handle = handle; + mp3file->num_bytes = -1; return mp3file; } @@ -96,7 +97,7 @@ on_error: #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 = ""; #define handleError(text) \ do { \ @@ -113,6 +114,8 @@ MP3File* mp3file_open_fd(int fd, int forceEncoding) { MP3File* mp3 = mp3file_init(mh); if(mp3 == NULL) handleError("malloc() failed"); + lseek(fd, static_cast(offset), SEEK_SET); + mp3->num_bytes = length; err = mpg123_open_fd(mh, fd); if(err != MPG123_OK) handleError("mpg123_open()"); @@ -149,7 +152,7 @@ MP3File* mp3file_open_fd(int fd, int forceEncoding) { else 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; LOGI("channels: %d rate: %ld num_samples: %ld", mp3->channels, mp3->rate, mp3->num_samples); mp3->android_fd = fd; diff --git a/app/src/main/cpp/mp3file.h b/app/src/main/cpp/mp3file.h index e6a22b1..1015f05 100644 --- a/app/src/main/cpp/mp3file.h +++ b/app/src/main/cpp/mp3file.h @@ -22,13 +22,16 @@ struct MP3File size_t buffer_size; unsigned char* buffer; /** 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; + /** number of bytes to read at most from 'android_fd' */ + long long num_bytes; }; MP3File* mp3file_init(mpg123_handle *handle); void mp3file_delete(MP3File *mp3file); 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); #endif //SAMPLES_MP3FILE_H diff --git a/app/src/main/java/at/lockstep/player/pasada/LibPasada.java b/app/src/main/java/at/lockstep/player/pasada/LibPasada.java index c604a5e..de3896d 100644 --- a/app/src/main/java/at/lockstep/player/pasada/LibPasada.java +++ b/app/src/main/java/at/lockstep/player/pasada/LibPasada.java @@ -69,4 +69,7 @@ public final class LibPasada { /** 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(); }