Compare commits
30 Commits
e22478445d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b1b92ba45 | |||
| 6c5ac60ccb | |||
| 1a3991db45 | |||
| 3fe10a914a | |||
| 45b1003c9a | |||
| e8afb10f48 | |||
| cc1a2b5b7a | |||
| 7fb3029e8b | |||
| b6df86f49a | |||
| 708249a5ba | |||
| 3cf9607549 | |||
| 25e5cc19d0 | |||
| 6382c57ccc | |||
| b0f6d6d5c9 | |||
| b14ea02694 | |||
| 5b26203533 | |||
| 198dfc1630 | |||
| 02ebb17dc6 | |||
| a8234005df | |||
| a2fdf05cd5 | |||
| 79f37ca46d | |||
| 5045d6615e | |||
| 995c537fd4 | |||
| 3d55765a26 | |||
| 754b96a319 | |||
| 65ea8ba27c | |||
| 754ad3700f | |||
| 63ef5ae503 | |||
| 66a2b86ffc | |||
| d1b57aae82 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,3 +12,4 @@
|
||||
app/src/main/obj/
|
||||
.cxx
|
||||
/txts
|
||||
/data
|
||||
6
.gitmodules
vendored
Normal file
6
.gitmodules
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
[submodule "app/src/main/cpp/rubberband"]
|
||||
path = app/src/main/cpp/librubberband
|
||||
url = https://donkey.abanbytes.eu/david/librubberband.git
|
||||
[submodule "app/src/main/cpp/libpasada"]
|
||||
path = app/src/main/cpp/libpasada
|
||||
url = https://donkey.abanbytes.eu/david/libpasada.git
|
||||
45
TODO.md
45
TODO.md
@@ -1,9 +1,40 @@
|
||||
## TODO
|
||||
|
||||
* do not open oboe upon app startup, only if we are actually recording
|
||||
|
||||
* PlaybackEngine - Buffer overrun on output for channel (1.000000)
|
||||
- we are feeding too much data into 'stretcher'
|
||||
|
||||
* E attributionTag not declared in manifest of at.lockstep
|
||||
|
||||
* analyze the (secondly or so) noise beeps in the mp3 playback
|
||||
- introduced with this commit
|
||||
- is it librubberband, my failure to feed it properly (buffer exhaustion), or sth else?
|
||||
- the sizes of my buffers?
|
||||
|
||||
* correct sampling rate of libmpg123 vs. 48000 Hz using librubberband
|
||||
|
||||
* record accelero and write recording to file
|
||||
|
||||
* set feedForDelay in musicFeedThread() in PlaybackEngine.cpp
|
||||
- LOGI("feed %d silence samples", num_samples);
|
||||
- set the sleep delay
|
||||
- ideally, set the buf size
|
||||
* check playback buf size, and reduce buffer sizes
|
||||
- LOGI("onAudioReady() frames=%d", frames);
|
||||
|
||||
* reduce lib size, librubberband is 1.3 M (one .so file)
|
||||
- maybe we are compiling too many source files
|
||||
- # TODO: see Android.mk in librubberband and copy options from `LOCAL_CFLAGS`
|
||||
|
||||
O> 16 KB paging for NDK libs
|
||||
* target SDK level 35
|
||||
|
||||
* minimum amplitude for accelero
|
||||
* record the accelero and check why repeated fast beats result in occasional detection outages
|
||||
- guess: the threshold gets pushed so high that the later beats are not detected properly
|
||||
- maybe we need the `*0.6` factor after all (but verify this!)
|
||||
|
||||
* minimum amplitude for accelero (avoid detecting e.g. when phone is lying flat and you hammer on the table)
|
||||
* filter away bad SQI areas of the signal (do not detect steps if we have bad SQI)
|
||||
|
||||
## Nice-To
|
||||
@@ -17,3 +48,15 @@ O> 16 KB paging for NDK libs
|
||||
* re-visit sampling rate and channel count.
|
||||
MixingPlayer currently forces both to 48000 and 2 respectively,
|
||||
regardless of what Android says would be optimal.
|
||||
|
||||
* nice-to nice-to: ramp up audio pipelines twice - once to collect params, another one to use those
|
||||
|
||||
## Technical Debt
|
||||
|
||||
* remove duplication of `mp3file_open()` in `mp3file_open_fd()`
|
||||
* malloc() nullptr result handling
|
||||
|
||||
## Before release
|
||||
|
||||
* check librubberband license
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ dependencies {
|
||||
implementation libs.oboe
|
||||
implementation libs.slf4j.api
|
||||
implementation libs.logback.android
|
||||
implementation libs.gson
|
||||
|
||||
implementation libs.androidx.core.ktx
|
||||
implementation libs.androidx.lifecycle.runtime.ktx
|
||||
@@ -85,6 +86,8 @@ dependencies {
|
||||
implementation libs.androidx.ui.graphics
|
||||
implementation libs.androidx.ui.tooling.preview
|
||||
implementation libs.androidx.material3
|
||||
implementation libs.androidx.recyclerview
|
||||
implementation libs.androidx.appcompat
|
||||
testImplementation libs.junit
|
||||
androidTestImplementation libs.androidx.junit
|
||||
androidTestImplementation libs.androidx.espresso.core
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
@@ -23,10 +27,21 @@
|
||||
android:theme="@style/Theme.Lockstep">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="at.lockstep.app.MediaStoreBenchmarkActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity android:name="at.lockstep.ui.SongPickerActivity" />
|
||||
<activity android:name="at.lockstep.saf.SafPickerActivity" />
|
||||
|
||||
<service
|
||||
android:name="at.lockstep.app.LstForegroundService"
|
||||
android:exported="false"
|
||||
|
||||
25
app/src/main/cpp/AudioCallback.h
Normal file
25
app/src/main/cpp/AudioCallback.h
Normal file
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// Created by david on 20.03.2026.
|
||||
//
|
||||
|
||||
#ifndef LOCKSTEP_AUDIOCALLBACK_H
|
||||
#define LOCKSTEP_AUDIOCALLBACK_H
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
/**
|
||||
* Provides audio through a regular callback to oboe.
|
||||
*/
|
||||
class AudioCallbackProvider {
|
||||
public:
|
||||
virtual ~AudioCallbackProvider() {}
|
||||
/**
|
||||
* in current impl, this passes a buffer where data may already live.
|
||||
* the provider may add to it and re-normalize to [-1.0, 1.0].
|
||||
*
|
||||
* upon is_finished=true it will not do anything - the caller is responsible for 0-ing the data buffer.
|
||||
* */
|
||||
virtual void onAudioReady(float *data, int32_t frames) {}
|
||||
};
|
||||
|
||||
#endif //LOCKSTEP_AUDIOCALLBACK_H
|
||||
@@ -13,6 +13,7 @@ cmake_minimum_required(VERSION 3.22.1)
|
||||
project("lockstep-native")
|
||||
|
||||
add_subdirectory(libpasada/pasada-lib)
|
||||
add_subdirectory(librubberband)
|
||||
|
||||
# Creates and names a library, sets it as either STATIC
|
||||
# or SHARED, and provides the relative paths to its source code.
|
||||
@@ -29,17 +30,17 @@ add_subdirectory(libpasada/pasada-lib)
|
||||
# used in the AndroidManifest.xml file.
|
||||
add_library(${CMAKE_PROJECT_NAME} SHARED
|
||||
# List C/C++ source files with relative paths to this CMakeLists.txt.
|
||||
lockstep.cpp
|
||||
PlaybackEngine.cpp
|
||||
mp3file.cpp
|
||||
jni_bridge.cpp
|
||||
jni_mpg123.cpp
|
||||
jni_lockstep.cpp
|
||||
jni_stepdetector.cpp
|
||||
StepDetector.cpp
|
||||
)
|
||||
|
||||
find_package (oboe REQUIRED CONFIG)
|
||||
|
||||
add_library(ndk-logger SHARED logging.cpp logging_jni.cpp)
|
||||
add_library(ndk-logger SHARED logging.cpp jni_logging.cpp)
|
||||
target_link_libraries(ndk-logger log)
|
||||
|
||||
# Add pre-built libmpg123 library
|
||||
@@ -50,6 +51,7 @@ set_target_properties(mpg123 PROPERTIES IMPORTED_LOCATION
|
||||
include_directories(${mpg123_DIR}/lib/${ANDROID_ABI}/include)
|
||||
|
||||
target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE libpasada/pasada-lib/include)
|
||||
target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE librubberband/rubberband)
|
||||
|
||||
# Specifies libraries CMake should link to your target library. You
|
||||
# can link libraries from various origins, such as libraries defined in this
|
||||
@@ -57,6 +59,7 @@ target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE libpasada/pasada-lib/in
|
||||
target_link_libraries(${CMAKE_PROJECT_NAME}
|
||||
# List libraries link to the target library
|
||||
pasada
|
||||
rubberband
|
||||
oboe::oboe
|
||||
mpg123
|
||||
android
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
#define LOCKSTEP_MIXINGPLAYER_H
|
||||
|
||||
#include <oboe/Oboe.h>
|
||||
#include <math.h>
|
||||
#include <cmath>
|
||||
#include <atomic>
|
||||
#include "AudioCallback.h"
|
||||
|
||||
using namespace oboe;
|
||||
|
||||
/**
|
||||
@@ -19,8 +22,9 @@ protected:
|
||||
std::vector<int> beatIdx;
|
||||
std::atomic<int> startBeat;
|
||||
int numBeatsPlaying;
|
||||
bool mIsPlaying;
|
||||
public:
|
||||
explicit MixingPlayer(std::vector<float> beatSound) : beatSound(beatSound), startBeat(0), numBeatsPlaying(0) {}
|
||||
explicit MixingPlayer(std::vector<float> beatSound) : beatSound(beatSound), startBeat(0), numBeatsPlaying(0), mIsPlaying(false), mHaveMusic(false) {}
|
||||
|
||||
virtual ~MixingPlayer() = default;
|
||||
|
||||
@@ -49,9 +53,13 @@ public:
|
||||
|
||||
// Typically, start the stream after querying some stream information, as well as some input from the user
|
||||
result = mStream->requestStart();
|
||||
mIsPlaying = (result == Result::OK);
|
||||
return (int32_t) result;
|
||||
}
|
||||
|
||||
int getRate() { return kSampleRate; }
|
||||
int getNumChannels() { return kChannelCount; }
|
||||
|
||||
// Call this from Activity onPause()
|
||||
void stopAudio() {
|
||||
// Stop, close and delete in case not already closed.
|
||||
@@ -61,6 +69,15 @@ public:
|
||||
mStream->close();
|
||||
mStream.reset();
|
||||
}
|
||||
mIsPlaying = false;
|
||||
}
|
||||
|
||||
bool isPlaying() { return mIsPlaying; }
|
||||
|
||||
void setMusic(std::shared_ptr<AudioCallbackProvider> cb) {
|
||||
std::lock_guard<std::mutex> lock(mLock);
|
||||
mMusic = std::move(cb);
|
||||
mHaveMusic.store((bool) mMusic);
|
||||
}
|
||||
|
||||
oboe::DataCallbackResult onAudioReady(oboe::AudioStream *oboeStream, void *audioData, int32_t numFrames) override {
|
||||
@@ -78,7 +95,7 @@ public:
|
||||
int N = (int) beatSound.size();
|
||||
for (int i = 0; i < numFrames; i++) {
|
||||
float sample = 0.0;
|
||||
float norm = (float) numBeatsPlaying;
|
||||
float norm = (float) (std::max(numBeatsPlaying, 1));
|
||||
for (int k = 0; k < numBeatsPlaying; k++) {
|
||||
sample += beatSound[beatIdx[k]];
|
||||
beatIdx[k] += 1;
|
||||
@@ -102,12 +119,20 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
if(mHaveMusic.load()) {
|
||||
// note: the contract for onAudioReady() upon is_finished=true implies it will not do anything
|
||||
// (the buffer must be set to all-0 here in the caller)
|
||||
mMusic->onAudioReady(floatData, numFrames);
|
||||
}
|
||||
|
||||
return oboe::DataCallbackResult::Continue;
|
||||
}
|
||||
|
||||
private:
|
||||
std::mutex mLock;
|
||||
std::shared_ptr<oboe::AudioStream> mStream;
|
||||
std::shared_ptr<AudioCallbackProvider> mMusic;
|
||||
std::atomic<bool> mHaveMusic;
|
||||
|
||||
// Stream params
|
||||
static int constexpr kChannelCount = 2;
|
||||
|
||||
@@ -8,50 +8,357 @@
|
||||
#include "logging.h"
|
||||
#include "mpg123.h"
|
||||
#include "mp3file.h"
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
#include <chrono>
|
||||
|
||||
/**
|
||||
* Read samples from the next mp3-frame into the struct MP3File's buffer.
|
||||
* @return number of samples read (uses buffer_size which is much smaller than total length)
|
||||
*/
|
||||
static inline int readBuffer2(MP3File* mp3)
|
||||
{
|
||||
size_t done = 0;
|
||||
int err = mpg123_read(mp3->handle, mp3->buffer, mp3->buffer_size, &done);
|
||||
mp3->leftSamples = done / sizeof(int16_t);
|
||||
mp3->remaining_samples -= done / sizeof(int16_t);
|
||||
mp3->offset = 0;
|
||||
return err != MPG123_OK ? 0 : done;
|
||||
if (err != MPG123_OK && err != MPG123_DONE) LOGE("mpg123_read() err=%d done=%d", err, done);
|
||||
if(err == MPG123_DONE) return done / sizeof(int16_t);
|
||||
return err != MPG123_OK ? 0 : done / sizeof(int16_t);
|
||||
}
|
||||
|
||||
static bool read_mp3(std::string filename, std::vector<float>& samples) {
|
||||
// TODO: assumes 48000 Hz sampling rate of the file
|
||||
// note: our resource file is mono (1 channel = 1 samples_per_frame)!
|
||||
|
||||
MP3File *mFile = mp3file_open(filename.c_str(), 0); // MPG123_ENC_FLOAT_32 (but does not seem to work)
|
||||
memset(mFile->buffer, 0, mFile->buffer_size);
|
||||
bool ok1 = mFile != nullptr;
|
||||
bool ok2 = readBuffer2(mFile) != 0; // once is enough (maybe the other one needs to pre-fill other buffers, in oboe etc.)
|
||||
if(ok1 && ok2) {
|
||||
// num_frames, num_samples, samples_per_frame
|
||||
int ok2 = true;
|
||||
int i = 0;
|
||||
if(ok1) {
|
||||
samples.resize(mFile->num_samples);
|
||||
for(int i = 0; i < mFile->num_samples; i++) {
|
||||
int16_t *src = ((int16_t *) mFile->buffer) + i;
|
||||
samples[i] = (*src) / 32768.0f;
|
||||
while (ok2 && mFile->remaining_samples > 0) {
|
||||
ok2 = readBuffer2(mFile);
|
||||
if (!ok2) break;
|
||||
for (int j = 0; j < ok2 && i < mFile->num_samples; j++, i++) {
|
||||
int16_t *src = ((int16_t *) mFile->buffer) + j;
|
||||
samples[i] = (*src) / 32768.0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!ok2) {
|
||||
LOGI("ok2=false at i=%d", i);
|
||||
}
|
||||
if(mFile)
|
||||
mp3file_delete(mFile);
|
||||
return ok1 && ok2;
|
||||
}
|
||||
|
||||
PlaybackEngine::PlaybackEngine(std::string filesDir, int resid): mFilesDir(filesDir) {
|
||||
struct RbLogger : public RubberBand::RubberBandStretcher::Logger {
|
||||
virtual void log(const char *s) {
|
||||
LOGI("%s", s);
|
||||
}
|
||||
virtual void log(const char *s, double val) {
|
||||
LOGI("%s (%lf)", s, val);
|
||||
}
|
||||
virtual void log(const char *s, double val1, double val2) {
|
||||
LOGI("%s (%lf, %lf)", s, val1, val2);
|
||||
}
|
||||
virtual ~RbLogger() { }
|
||||
};
|
||||
|
||||
PlaybackEngine::PlaybackEngine(std::string filesDir, int resid):
|
||||
stretcher(
|
||||
/* sampleRate: */ 48000,
|
||||
/* channels: */ 2,
|
||||
/* logger: */ std::make_shared<RbLogger>(),
|
||||
/* options: */
|
||||
RubberBand::RubberBandStretcher::OptionEngineFaster |
|
||||
RubberBand::RubberBandStretcher::OptionProcessRealTime
|
||||
),
|
||||
mFilesDir(filesDir),
|
||||
haveMusicFile(false),
|
||||
exitMusicFeedThread(false),
|
||||
isSetMusic(false),
|
||||
android_fd(0),
|
||||
haveTimeRatio(false),
|
||||
timeRatio(1.0),
|
||||
|
||||
// these 3 values are preliminary -- will be set from MixingPlayer defaults in the ctor body below
|
||||
playbackRate(48000),
|
||||
numOutChannels(2),
|
||||
numInChannels(2),
|
||||
back_pressure(0)
|
||||
{
|
||||
LOGI("PlaybackEngine()");
|
||||
LOGI("NDK LOG_LEVEL=%d", LOG_LEVEL);
|
||||
// NDK LOG_LEVEL=3 (DEBUG)
|
||||
|
||||
// load "bump" sound effect
|
||||
std::vector<float> samples;
|
||||
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);
|
||||
|
||||
mPlayer = new MixingPlayer(samples);
|
||||
int32_t res = mPlayer->startAudio();
|
||||
LOGI("startAudio() = %d", res);
|
||||
|
||||
// configure stretcher and start musicFeedThread()
|
||||
initRubberBand();
|
||||
}
|
||||
|
||||
void PlaybackEngine::initRubberBand() {
|
||||
// we do not yet have a music file with actual sampling rate, so set the default ratio
|
||||
stretcher.reset();
|
||||
stretcher.setTimeRatio(1.0);
|
||||
stretcher.setPitchScale(1.0);
|
||||
|
||||
stretcher.setDebugLevel(1); // 1: errors only. generally 0..4
|
||||
|
||||
// feed samples into 'stretcher' and read bogus output
|
||||
// getPreferredStartPad() -> [ ... ] -> getStartDelay()
|
||||
|
||||
// write silence
|
||||
// process() input: de-interleaved audio data with one float array per channel.
|
||||
size_t num_pad = stretcher.getPreferredStartPad();
|
||||
float* buf = (float*) malloc(num_pad*2*sizeof(float));
|
||||
float* buf_ptr[] {buf, buf + num_pad};
|
||||
memset(buf, 0, num_pad*2*sizeof(float));
|
||||
stretcher.process(buf_ptr, num_pad, false);
|
||||
free(buf);
|
||||
//LOGI("start_pad = %d", num_pad);
|
||||
|
||||
// read bogus output
|
||||
size_t start_delay = stretcher.getStartDelay();
|
||||
float* out_buf = (float*) malloc(start_delay*2*sizeof(float));
|
||||
float* out_buf_ptr[] {out_buf, out_buf + start_delay};
|
||||
memset(out_buf, 0, start_delay*2*sizeof(float));
|
||||
stretcher.retrieve(out_buf_ptr, start_delay);
|
||||
//LOGI("start_delay = %d", start_delay);
|
||||
|
||||
// thread 1: oboe cb fetching via retrieve()
|
||||
//mPlayer->setMusic();
|
||||
|
||||
// thread 2: polling for decoding more mp3 -> process() -- getSamplesRequired()
|
||||
// setTimeRatio(), setPitchScale() -- always call them from thread 2
|
||||
musicFeed = std::make_unique<std::thread>(&PlaybackEngine::musicFeedThread, this);
|
||||
}
|
||||
|
||||
void PlaybackEngine::closeRubberBand() {
|
||||
if(musicFeed) {
|
||||
exitMusicFeedThread.store(true);
|
||||
musicFeed->join();
|
||||
musicFeed = nullptr;
|
||||
}
|
||||
closeMusicFile();
|
||||
}
|
||||
|
||||
void PlaybackEngine::closeMusicFile() {
|
||||
haveMusicFile.store(false);
|
||||
musicFile = nullptr;
|
||||
if(android_fd) {
|
||||
close(android_fd);
|
||||
android_fd = 0;
|
||||
}
|
||||
isSetMusic.store(false);
|
||||
mPlayer->setMusic(nullptr);
|
||||
}
|
||||
|
||||
void PlaybackEngine::mapChannels(int *channel_map, int num_ch_in, int num_ch_out) {
|
||||
if(num_ch_in == num_ch_out) {
|
||||
// map each channel as-is
|
||||
for(int i = 0; i < num_ch_out; i++)
|
||||
channel_map[i] = i;
|
||||
} else if(num_ch_in == 1) {
|
||||
// map mono to all output channels
|
||||
for(int i = 0; i < num_ch_out; i++)
|
||||
channel_map[i] = 0;
|
||||
} else if(num_ch_in >= 2) {
|
||||
// use a stereo mapping
|
||||
for(int i = 0; i < num_ch_out; i++)
|
||||
channel_map[i] = i;
|
||||
} else {
|
||||
LOGE("mapChannels(): strange channel layout, mapping to mono. num_ch_in=%d num_ch_out=%d", num_ch_in, num_ch_out);
|
||||
// map mono to all output channels
|
||||
for(int i = 0; i < num_ch_out; i++)
|
||||
channel_map[i] = 0;
|
||||
}
|
||||
// TODO: check broken input (0 channels etc) and bubble up an error to app
|
||||
}
|
||||
|
||||
void PlaybackEngine::musicFeedThread() {
|
||||
LOGI("starting musicFeedThread()");
|
||||
|
||||
// strecher num channels: same as output num channels
|
||||
// (this is because we play silence even without any input file, so we cannot set stretcher
|
||||
// channel count for the music file's channel count)
|
||||
int num_ch_in = numInChannels.load();
|
||||
int num_ch_out = numOutChannels.load();
|
||||
|
||||
size_t num_buf_samples = buf_size_samples;
|
||||
size_t buf_stride = num_buf_samples;
|
||||
size_t buf_size_bytes = num_buf_samples * num_ch_out * sizeof(float);
|
||||
float* buf = (float*) malloc(buf_size_bytes);
|
||||
float** buf_ptr = (float**) malloc(num_ch_out * sizeof(float*));
|
||||
for(int i = 0; i < num_ch_out; i++) {
|
||||
buf_ptr[i] = buf + i * num_buf_samples;
|
||||
}
|
||||
memset(buf, 0, buf_size_bytes);
|
||||
// preliminary allocation (actual music file buffer is unknown due to unknown channel count)
|
||||
size_t cbuf_size_bytes = num_buf_samples * num_ch_in * sizeof(int16_t);
|
||||
//size_t cbuf_load_bytes = num_buf_samples * num_ch_in * sizeof(int16_t);
|
||||
unsigned char* cbuf = (unsigned char*) malloc(cbuf_size_bytes);
|
||||
memset(cbuf, 0, cbuf_size_bytes);
|
||||
|
||||
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)
|
||||
size_t loop_delay_us = 1000000 * buf_size_samples / playbackRate.load();
|
||||
|
||||
int idebug = 0;
|
||||
|
||||
// thread 2: polling for decoding more mp3 -> process() -- getSamplesRequired()
|
||||
while(!exitMusicFeedThread.load()) {
|
||||
|
||||
if(!haveMusicFile.load()) {
|
||||
// while no MusicProvider is connected, no samples will be read from 'stretcher'
|
||||
// therefore, we do not write any samples into it!
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
continue;
|
||||
}
|
||||
|
||||
if(!isSetMusic.load()) {
|
||||
mPlayer->setMusic(std::make_shared<MusicProvider>(&stretcher, buf_size_samples, numOutChannels.load(), &back_pressure));
|
||||
isSetMusic.store(true);
|
||||
}
|
||||
|
||||
if(haveTimeRatio.load()) {
|
||||
double ratio = timeRatio.load();
|
||||
stretcher.setTimeRatio(ratio);
|
||||
stretcher.setPitchScale(1.0 / ratio);
|
||||
haveTimeRatio.store(false);
|
||||
}
|
||||
|
||||
// change buffer size, if necessary (changed input channel count)
|
||||
if(numInChannels.load() != num_ch_in) {
|
||||
LOGD("changed buffer size (changed input channel count)");
|
||||
num_ch_in = numInChannels.load();
|
||||
free(cbuf);
|
||||
cbuf_size_bytes = num_buf_samples * num_ch_in * sizeof(int16_t);
|
||||
cbuf = (unsigned char*) malloc(cbuf_size_bytes);
|
||||
memset(cbuf, 0, cbuf_size_bytes);
|
||||
}
|
||||
|
||||
mapChannels(channel_map, num_ch_in, num_ch_out);
|
||||
|
||||
// do work ...
|
||||
|
||||
// note: getSamplesRequired() itself only gives us how many samples to create another
|
||||
// output buffer increment, not if the output buffer has been emptied.
|
||||
// We need to manage the buffer sizes ourselves.
|
||||
|
||||
// this draft should always keep the output buffers filled at 50-100 ms
|
||||
int target_output_buffer_frames = 100 * playbackRate.load() / 1000; // 100 ms worth of audio
|
||||
if(idebug < 10) {
|
||||
LOGI("back_pressure available=%d target=%d", back_pressure.load(), target_output_buffer_frames);
|
||||
}
|
||||
if(back_pressure.load() >= target_output_buffer_frames) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
}
|
||||
|
||||
// at 48000 Hz playbackRate, the 512-1024 frames returned here give us additional (10-21 ms) output buffer
|
||||
// (this is somewhat approximate, but the above control loop should keep us within a reasonable buffer size)
|
||||
size_t num_samples = stretcher.getSamplesRequired();
|
||||
|
||||
// note: how much to sleep until output has played x samples...?
|
||||
// how large is one buffer, and when do we feed it more data?
|
||||
// (is it like double-buffering implemented in 'stretcher'?)
|
||||
|
||||
if (num_samples == 0) {
|
||||
// this was never the case in actual testing -- see note above.
|
||||
LOGD("waiting %d us for getSamplesRequired()", loop_delay_us);
|
||||
std::this_thread::sleep_for(std::chrono::microseconds(loop_delay_us));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (num_samples > num_buf_samples) {
|
||||
LOGE("wanted %d samples but buf is only %d samples", num_samples, num_buf_samples);
|
||||
num_samples = num_buf_samples;
|
||||
}
|
||||
|
||||
if (!haveMusicFile.load()) {
|
||||
loop_delay_us = 1000000 * num_samples / playbackRate.load();
|
||||
if(idebug++ < 10) {
|
||||
LOGI("feed %d silence samples", num_samples);
|
||||
// 1024, 512, 512
|
||||
// 7 x 512
|
||||
}
|
||||
memset(buf, 0, num_samples*num_ch_out*sizeof(float));
|
||||
stretcher.process(buf_ptr, num_samples, false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if(idebug++ < 10) {
|
||||
loop_delay_us = 1000000 * num_samples / musicFile->rate;
|
||||
LOGI("feed %d music samples", num_samples);
|
||||
// feed 1024 music samples
|
||||
// => stretcher is asking for 1024 = getSamplesRequired()
|
||||
// ca. 21 ms
|
||||
}
|
||||
|
||||
size_t done = 0; // bytes!
|
||||
size_t read_size_bytes = std::min(num_samples * num_ch_in * sizeof(int16_t), cbuf_size_bytes);
|
||||
int err = mpg123_read(musicFile->handle, cbuf, read_size_bytes, &done);
|
||||
musicFile->remaining_samples -= done / sizeof(int16_t);
|
||||
musicFile->offset = 0; // unused here
|
||||
if (err != MPG123_OK && err != MPG123_DONE) {
|
||||
// error!
|
||||
LOGE("error reading mp3 file: mpg123_read() err=%d done=%d", err, done);
|
||||
// next iteration will play silence
|
||||
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();
|
||||
continue;
|
||||
}
|
||||
if(err == MPG123_DONE) {
|
||||
// next iteration will play silence
|
||||
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();
|
||||
continue;
|
||||
}
|
||||
|
||||
size_t num_decoded_samples = done / sizeof(int16_t) / num_ch_in;
|
||||
//LOGD("num_decoded_samples = %d", num_decoded_samples);
|
||||
|
||||
// * convert interleaved int16 to de-interleaved float [-1.0, 1.0] format
|
||||
// * map input to output channels
|
||||
for(size_t i = 0; i < num_decoded_samples; i++) {
|
||||
for(size_t j = 0; j < num_ch_out; j++) {
|
||||
buf[i + buf_stride * j] = static_cast<float>(*(reinterpret_cast<int16_t*>(cbuf) + i * num_ch_in + channel_map[j])) / 32768.0f;
|
||||
}
|
||||
}
|
||||
|
||||
//LOGD("calling stretcher.process()");
|
||||
stretcher.process(buf_ptr, num_decoded_samples, false);
|
||||
}
|
||||
|
||||
LOGI("musicFeedThread() exiting ...");
|
||||
|
||||
free(buf);
|
||||
free(buf_ptr);
|
||||
free(cbuf);
|
||||
free(channel_map);
|
||||
|
||||
LOGI("musicFeedThread() exited.");
|
||||
}
|
||||
|
||||
PlaybackEngine::~PlaybackEngine() {
|
||||
LOGI("~PlaybackEngine()");
|
||||
closeRubberBand();
|
||||
mPlayer->stopAudio();
|
||||
delete mPlayer;
|
||||
mPlayer = nullptr;
|
||||
@@ -60,3 +367,94 @@ PlaybackEngine::~PlaybackEngine() {
|
||||
void PlaybackEngine::playBeat() {
|
||||
if(mPlayer) mPlayer->setStartBeat();
|
||||
}
|
||||
|
||||
void PlaybackEngine::playMusic(int fd) {
|
||||
if(!mPlayer) return;
|
||||
LOGI("PlaybackEngine::playMusic(fd=%d)", fd);
|
||||
android_fd = fd;
|
||||
musicFile.reset(mp3file_open_fd(android_fd, 0));
|
||||
if(musicFile) {
|
||||
timeRatio.store(((double) playbackRate.load()) / ((double) musicFile->rate));
|
||||
haveTimeRatio.store(true);
|
||||
numInChannels.store(musicFile->channels);
|
||||
haveMusicFile.store(true);
|
||||
}
|
||||
|
||||
bool is_finished = (stretcher.available() == -1);
|
||||
if(is_finished) {
|
||||
// so that we may play again after "final chunk"
|
||||
closeRubberBand();
|
||||
initRubberBand();
|
||||
}
|
||||
|
||||
if(!mPlayer->isPlaying()) {
|
||||
int32_t res = mPlayer->startAudio();
|
||||
playbackRate.store(mPlayer->getRate());
|
||||
numOutChannels.store(mPlayer->getNumChannels());
|
||||
LOGI("startAudio() = %d rate=%d channels=%d", res, playbackRate.load(), numOutChannels.load());
|
||||
}
|
||||
|
||||
// to wait the 50 ms that the musicFeedThread() is idling when it first receives a file
|
||||
// we don't call mPlayer->setMusic() here, but in the musicFeedThread()
|
||||
}
|
||||
|
||||
MusicProvider::MusicProvider(RubberBand::RubberBandStretcher *stretcher, size_t buf_size_samples, int num_ch_out, std::atomic<int> *back_pressure) :
|
||||
stretcher(stretcher),
|
||||
idebug(0),
|
||||
buf_size_samples(buf_size_samples),
|
||||
num_ch_out(num_ch_out),
|
||||
back_pressure(back_pressure)
|
||||
{
|
||||
buf = (float*) malloc(buf_size_samples*num_ch_out*sizeof(float));
|
||||
buf_ptr = (float**) malloc(num_ch_out * sizeof(float*));
|
||||
for(int i = 0; i < num_ch_out; i++) {
|
||||
buf_ptr[i] = buf + i * buf_size_samples;
|
||||
}
|
||||
}
|
||||
|
||||
MusicProvider::~MusicProvider() {
|
||||
free(buf);
|
||||
free(buf_ptr);
|
||||
}
|
||||
|
||||
void MusicProvider::onAudioReady(float *data, int32_t frames) {
|
||||
if(idebug++ < 10) {
|
||||
LOGI("onAudioReady() frames=%d", frames);
|
||||
// frames=96 (48 kHz => 2 ms!!)
|
||||
}
|
||||
|
||||
if(frames > buf_size_samples) {
|
||||
LOGE("audio buffer too small! adapt PlaybackEngine::buf_size_samples!! asked for frames=%d but buf_size=%d", frames, buf_size_samples);
|
||||
}
|
||||
|
||||
// 1. read from oboe into our temp de-interleaved buffer 'buf'
|
||||
int num_frames_requested = std::min((int) frames, (int) buf_size_samples);
|
||||
int num_frames_available = stretcher->available();
|
||||
bool is_finished = (num_frames_available == -1);
|
||||
(*back_pressure).store((int) num_frames_available);
|
||||
|
||||
if(is_finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(idebug < 10) {
|
||||
LOGI("onAudioReady() available=%d", num_frames_available);
|
||||
}
|
||||
if(num_frames_available < num_frames_requested) {
|
||||
// this is an audio glitch
|
||||
// TODO: bubble info upwards, in a counter (so we can collect device-specific glitch stats)
|
||||
LOGI("stretcher lag: %d requested, %d available", num_frames_requested, num_frames_available);
|
||||
}
|
||||
size_t num_frames = std::min(num_frames_available, num_frames_requested);
|
||||
stretcher->retrieve(buf_ptr, num_frames);
|
||||
|
||||
// 2. convert to add samples to interleaved *data
|
||||
for(size_t i = 0; i < num_frames; i++) {
|
||||
for(size_t j = 0; j < num_ch_out; j++) {
|
||||
float sample = data[i*num_ch_out + j];
|
||||
sample += buf_ptr[j][i];
|
||||
sample /= 2.0;
|
||||
data[i*num_ch_out + j] = sample;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,32 @@
|
||||
|
||||
#include "StepListener.h"
|
||||
#include "MixingPlayer.h"
|
||||
#include "RubberBandStretcher.h"
|
||||
#include "mp3file.h"
|
||||
#include "AudioCallback.h"
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <memory>
|
||||
#include <atomic>
|
||||
|
||||
/** Provides music through a regular callback to oboe. Called from separate oboe thread. */
|
||||
class MusicProvider : public AudioCallbackProvider {
|
||||
public:
|
||||
explicit MusicProvider(RubberBand::RubberBandStretcher *stretcher, size_t buf_size_samples, int num_ch_out, std::atomic<int> *back_pressure);
|
||||
~MusicProvider() override;
|
||||
|
||||
/** Called from separate oboe thread. */
|
||||
void onAudioReady(float *data, int32_t frames) override;
|
||||
private:
|
||||
RubberBand::RubberBandStretcher *stretcher;
|
||||
float *buf;
|
||||
float **buf_ptr;
|
||||
int idebug;
|
||||
size_t buf_size_samples;
|
||||
int num_ch_out;
|
||||
/** contains the current available() frames from 'stretcher' in the audio callback thread 2 (oboe) */
|
||||
std::atomic<int> *back_pressure;
|
||||
};
|
||||
|
||||
class PlaybackEngine : public StepListener {
|
||||
public:
|
||||
@@ -15,9 +40,32 @@ public:
|
||||
virtual ~PlaybackEngine();
|
||||
/** Play a beat sound. */
|
||||
virtual void playBeat();
|
||||
void playMusic(int fd);
|
||||
private:
|
||||
RubberBand::RubberBandStretcher stretcher;
|
||||
MixingPlayer *mPlayer;
|
||||
std::string mFilesDir;
|
||||
std::unique_ptr<MP3File> musicFile;
|
||||
std::atomic<bool> haveMusicFile;
|
||||
std::unique_ptr<std::thread> musicFeed;
|
||||
std::atomic<bool> exitMusicFeedThread;
|
||||
/** where musicFeedThread() keeps track of the fact that we have music set -- will start the audio cb */
|
||||
std::atomic<bool> isSetMusic;
|
||||
int android_fd;
|
||||
std::atomic<bool> haveTimeRatio;
|
||||
std::atomic<double> timeRatio;
|
||||
std::atomic<int> playbackRate;
|
||||
std::atomic<int> numOutChannels;
|
||||
std::atomic<int> numInChannels;
|
||||
/** contains the current available() frames from 'stretcher' in the audio callback thread 2 (oboe) */
|
||||
std::atomic<int> back_pressure;
|
||||
/** this is actually in frames, not samples */
|
||||
static size_t constexpr buf_size_samples = 1024;
|
||||
void initRubberBand();
|
||||
void closeRubberBand();
|
||||
void closeMusicFile();
|
||||
void musicFeedThread();
|
||||
void mapChannels(int *channel_map, int num_ch_in, int num_ch_out);
|
||||
};
|
||||
|
||||
#endif //LOCKSTEP_PLAYBACKENGINE_H
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
#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
|
||||
|
||||
@@ -24,7 +26,8 @@ StepDetector::StepDetector(StepListener *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_ssd(len_refr),
|
||||
f_sqi(upslope_width)
|
||||
{}
|
||||
|
||||
#if (FPS != 60)
|
||||
@@ -38,7 +41,9 @@ void StepDetector::filter(std::vector<float> values) {
|
||||
auto s2 = f_neg.filter(s1);
|
||||
auto s3 = f_ssf.filter(s2);
|
||||
auto s4 = f_ssd.filter(s3);
|
||||
if(s4 > 0.0 && listener != nullptr) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ protected:
|
||||
Filt f_neg;
|
||||
SsfFilter f_ssf;
|
||||
SsfStepDetector f_ssd;
|
||||
RunningQualityFilter f_sqi;
|
||||
|
||||
public:
|
||||
StepDetector(StepListener *listener);
|
||||
|
||||
@@ -1,21 +1,3 @@
|
||||
// Write C++ code here.
|
||||
//
|
||||
// Do not forget to dynamically load the C++ library into your application.
|
||||
//
|
||||
// For instance,
|
||||
//
|
||||
// In at.lockstep.app.MainActivity.java:
|
||||
// static {
|
||||
// System.loadLibrary("lockstep");
|
||||
// }
|
||||
//
|
||||
// Or, in at.lockstep.app.MainActivity.kt:
|
||||
// companion object {
|
||||
// init {
|
||||
// System.loadLibrary("lockstep")
|
||||
// }
|
||||
// }
|
||||
|
||||
#include "PlaybackEngine.h"
|
||||
#include <jni.h>
|
||||
#include <oboe/Oboe.h>
|
||||
@@ -58,4 +40,13 @@ Java_at_lockstep_pb_PlaybackEngine_native_1setDefaultStreamValues(JNIEnv *env,
|
||||
oboe::DefaultStreamValues::FramesPerBurst = (int32_t) framesPerBurst;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_at_lockstep_pb_PlaybackEngine_native_1playMusic(JNIEnv *env,
|
||||
jclass type,
|
||||
jlong engineHandle,
|
||||
jint fd) {
|
||||
auto engine = reinterpret_cast<PlaybackEngine *>(engineHandle);
|
||||
engine->playMusic(fd);
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
1
app/src/main/cpp/libpasada
Submodule
1
app/src/main/cpp/libpasada
Submodule
Submodule app/src/main/cpp/libpasada added at b919e845c7
1
app/src/main/cpp/librubberband
Submodule
1
app/src/main/cpp/librubberband
Submodule
Submodule app/src/main/cpp/librubberband added at ae0d81599d
@@ -5,6 +5,7 @@
|
||||
#define LOG_TAG "mp3file"
|
||||
|
||||
#include "mp3file.h"
|
||||
#include <unistd.h>
|
||||
#include <string.h>
|
||||
#include <cstdlib>
|
||||
#include "logging.h"
|
||||
@@ -25,6 +26,7 @@ void mp3file_delete(MP3File *mp3file) {
|
||||
free(mp3file->buffer);
|
||||
mp3file->buffer = 0;
|
||||
}
|
||||
if(mp3file->android_fd) close(mp3file->android_fd);
|
||||
free(mp3file);
|
||||
}
|
||||
|
||||
@@ -81,6 +83,8 @@ MP3File* mp3file_open(const char *filename, int forceEncoding) {
|
||||
else
|
||||
mp3->duration = mp3->num_samples / mp3->samples_per_frame * mp3->secs_per_frame;
|
||||
|
||||
mp3->offset = 0;
|
||||
mp3->remaining_samples = (int) mp3->num_samples;
|
||||
LOGV("channels: %d rate: %ld num_samples: %ld", mp3->channels, mp3->rate, mp3->num_samples);
|
||||
return mp3;
|
||||
|
||||
@@ -91,3 +95,70 @@ on_error:
|
||||
|
||||
#undef handleError
|
||||
}
|
||||
|
||||
MP3File* mp3file_open_fd(int fd, int forceEncoding) {
|
||||
const char *errorText = "";
|
||||
#define handleError(text) \
|
||||
do { \
|
||||
errorText = text; \
|
||||
goto on_error; \
|
||||
} while(0)
|
||||
|
||||
int err = MPG123_OK;
|
||||
mpg123_handle *mh = mpg123_new(NULL, &err);
|
||||
if(err != MPG123_OK || mh == NULL) {
|
||||
LOGE("mpg123_new() failed: %s", mpg123_plain_strerror(err));
|
||||
return NULL;
|
||||
}
|
||||
|
||||
MP3File* mp3 = mp3file_init(mh);
|
||||
if(mp3 == NULL) handleError("malloc() failed");
|
||||
err = mpg123_open_fd(mh, fd);
|
||||
if(err != MPG123_OK) handleError("mpg123_open()");
|
||||
|
||||
int encoding;
|
||||
err = mpg123_getformat(mh, &mp3->rate, &mp3->channels, &encoding);
|
||||
if(err != MPG123_OK) handleError("mpg123_getformat()");
|
||||
if(encoding != MPG123_ENC_SIGNED_16) handleError("unknown file encoding");
|
||||
|
||||
if(forceEncoding != 0) {
|
||||
encoding = forceEncoding;
|
||||
}
|
||||
|
||||
// Ensure that this output format will not change
|
||||
// (it could, when we allow it).
|
||||
mpg123_format_none(mh);
|
||||
err = mpg123_format(mh, mp3->rate, mp3->channels, encoding);
|
||||
if(err != MPG123_OK) handleError("could not set mpg123_format()");
|
||||
|
||||
mp3->buffer_size = mpg123_outblock(mh);
|
||||
mp3->buffer = (unsigned char*) malloc(mp3->buffer_size);
|
||||
if(mp3->buffer == NULL) handleError("malloc() failed");
|
||||
|
||||
mp3->num_samples = mpg123_length(mh);
|
||||
mp3->samples_per_frame = mpg123_spf(mh);
|
||||
mp3->secs_per_frame = mpg123_tpf(mh);
|
||||
|
||||
if (mp3->num_samples == MPG123_ERR || mp3->samples_per_frame < 0)
|
||||
mp3->num_frames = 0;
|
||||
else
|
||||
mp3->num_frames = mp3->num_samples / mp3->samples_per_frame;
|
||||
|
||||
if (mp3->num_samples == MPG123_ERR || mp3->samples_per_frame < 0 || mp3->secs_per_frame < 0)
|
||||
mp3->duration = 0;
|
||||
else
|
||||
mp3->duration = mp3->num_samples / mp3->samples_per_frame * mp3->secs_per_frame;
|
||||
|
||||
mp3->offset = 0;
|
||||
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;
|
||||
return mp3;
|
||||
|
||||
on_error:
|
||||
LOGE("%s, err = %s", errorText, mpg123_plain_strerror(err));
|
||||
mp3file_delete(mp3);
|
||||
return NULL;
|
||||
|
||||
#undef handleError
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
struct MP3File
|
||||
{
|
||||
mpg123_handle* handle;
|
||||
int android_fd;
|
||||
int channels;
|
||||
long rate;
|
||||
/** num samples in total (stereo of 10 frames will have 20 'samples' here) */
|
||||
long num_samples;
|
||||
int samples_per_frame;
|
||||
double secs_per_frame;
|
||||
@@ -19,12 +21,14 @@ struct MP3File
|
||||
double duration;
|
||||
size_t buffer_size;
|
||||
unsigned char* buffer;
|
||||
size_t leftSamples;
|
||||
/** total samples (stereo of 10 frames remaining will have 20 'remaining_samples' here) */
|
||||
int remaining_samples;
|
||||
size_t offset;
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
#endif //SAMPLES_MP3FILE_H
|
||||
|
||||
@@ -10,13 +10,23 @@ import android.hardware.Sensor;
|
||||
import android.hardware.SensorEvent;
|
||||
import android.hardware.SensorEventListener;
|
||||
import android.hardware.SensorManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Binder;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.os.PowerManager;
|
||||
import android.util.Log;
|
||||
import android.widget.Toast;
|
||||
import android.os.SystemClock;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
|
||||
import at.lockstep.filter.StepDetector;
|
||||
import at.lockstep.pb.PlaybackEngine;
|
||||
import at.lockstep.R;
|
||||
@@ -36,9 +46,10 @@ public class LstForegroundService extends Service implements SensorEventListener
|
||||
|
||||
private StepDetector stepDetector;
|
||||
|
||||
public static Intent startIntent(Context context) {
|
||||
public static Intent startIntent(Context context, String contentUri) {
|
||||
Intent intent = new Intent(context, LstForegroundService.class);
|
||||
intent.setAction(ACTION_START);
|
||||
intent.putExtra("content_uri", contentUri);
|
||||
return intent;
|
||||
}
|
||||
|
||||
@@ -52,6 +63,8 @@ public class LstForegroundService extends Service implements SensorEventListener
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
|
||||
Log.i("LstForegroundService", "onCreate()");
|
||||
|
||||
int resid = R.raw.track_beat;
|
||||
PlaybackEngine.create(this, resid);
|
||||
stepDetector = new StepDetector(PlaybackEngine.getEngineHandle());
|
||||
@@ -78,20 +91,53 @@ public class LstForegroundService extends Service implements SensorEventListener
|
||||
if (intent != null) {
|
||||
String action = intent.getAction();
|
||||
if (ACTION_START.equals(action)) {
|
||||
startCollection();
|
||||
Log.i("LstForegroundService", "onStartCommand() ACTION_START");
|
||||
|
||||
String contentUri = intent.getStringExtra("content_uri");
|
||||
try {
|
||||
if(contentUri != null) {
|
||||
PlaybackEngine.playMusic(uriToFd(contentUri));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// TODO proper error handling
|
||||
Toast.makeText(this, "Could not open music file contentUri", Toast.LENGTH_LONG).show();
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
startCollection(contentUri);
|
||||
} else if (ACTION_STOP.equals(action)) {
|
||||
Log.i("LstForegroundService", "ACTION_STOP");
|
||||
stopCollectionAndSelf();
|
||||
}
|
||||
}
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
private void startCollection() {
|
||||
/** caller is responsible for close()-ing returned file descriptor! */
|
||||
private int uriToFd(String persistedUriString) throws IOException {
|
||||
Uri uri = Uri.parse(persistedUriString);
|
||||
|
||||
ParcelFileDescriptor pfd =
|
||||
getContentResolver().openFileDescriptor(uri, "r");
|
||||
|
||||
if (pfd == null) {
|
||||
throw new IOException("openFileDescriptor() returned null for URI: " + uri);
|
||||
}
|
||||
|
||||
int fd = -1;
|
||||
try {
|
||||
fd = pfd.detachFd(); // Native side must close(fd)
|
||||
} finally {
|
||||
pfd.close(); // Safe after detach; Java wrapper no longer owns the fd
|
||||
}
|
||||
return fd;
|
||||
}
|
||||
|
||||
private void startCollection(String meta) {
|
||||
if (isCollecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
startForeground(NOTIFICATION_ID, buildNotification("Collecting sensor data"));
|
||||
startForeground(NOTIFICATION_ID, buildNotification());
|
||||
|
||||
if (wakeLock != null && !wakeLock.isHeld()) {
|
||||
// TODO: provide a timeout reasonable for a run
|
||||
@@ -99,12 +145,15 @@ public class LstForegroundService extends Service implements SensorEventListener
|
||||
}
|
||||
|
||||
if (accelerometer != null && sensorManager != null) {
|
||||
// TODO: use a HandlerThread to handle sensor events in background thread, not the main thread
|
||||
// see https://stackoverflow.com/q/17681870/1616948
|
||||
sensorManager.registerListener(
|
||||
this,
|
||||
accelerometer,
|
||||
SensorManager.SENSOR_DELAY_GAME
|
||||
);
|
||||
isCollecting = true;
|
||||
onStartRecording(meta);
|
||||
} else {
|
||||
stopCollectionAndSelf();
|
||||
}
|
||||
@@ -114,6 +163,7 @@ public class LstForegroundService extends Service implements SensorEventListener
|
||||
if (isCollecting && sensorManager != null) {
|
||||
sensorManager.unregisterListener(this);
|
||||
isCollecting = false;
|
||||
onStopRecording();
|
||||
}
|
||||
|
||||
if (wakeLock != null && wakeLock.isHeld()) {
|
||||
@@ -136,31 +186,91 @@ public class LstForegroundService extends Service implements SensorEventListener
|
||||
|
||||
// TODO: check threading to see if these run in separate threads - if so, deleting PlaybackEngine will leave a dangling pointer in StepDetector.
|
||||
// 2026-03-04 01:26:11.741 12507-12507 libc at.lockstep A Fatal signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0xb4000071d3a79000 in tid 12507 (at.lockstep), pid 12507 (at.lockstep)
|
||||
stepDetector.close();
|
||||
PlaybackEngine.delete();
|
||||
|
||||
Log.d("LstForegroundService", "onDestroy(), calling PlaybackEngine.delete()");
|
||||
|
||||
if(stepDetector != null) {
|
||||
stepDetector.close();
|
||||
PlaybackEngine.delete();
|
||||
stepDetector = null;
|
||||
}
|
||||
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
public class LocalBinder extends Binder {
|
||||
LstForegroundService getService() { return LstForegroundService.this; }
|
||||
}
|
||||
private final LocalBinder binder = new LocalBinder();
|
||||
@Nullable
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
Log.i("LstForegroundService", "onBind()");
|
||||
return binder;
|
||||
}
|
||||
|
||||
public interface OnResultListener {
|
||||
void onResult(SensorDataArray recording);
|
||||
}
|
||||
private OnResultListener listener;
|
||||
public void setOnResultListener(OnResultListener listener) { this.listener = listener; }
|
||||
|
||||
/** single sensor sample */
|
||||
public static class SensorData {
|
||||
private long timestamp;
|
||||
private float[] values;
|
||||
public SensorData(SensorEvent event) {
|
||||
timestamp = event.timestamp;
|
||||
values = Arrays.copyOf(event.values, event.values.length);
|
||||
}
|
||||
public SensorData(long timestamp, float[] values) {
|
||||
this.timestamp = timestamp;
|
||||
this.values = values;
|
||||
}
|
||||
}
|
||||
|
||||
/** array of sensor samples */
|
||||
public static class SensorDataArray {
|
||||
private ArrayList<SensorData> data = new ArrayList<SensorData>();
|
||||
private String meta;
|
||||
public void add(SensorEvent event) { data.add(new SensorData(event)); }
|
||||
public void add(SensorData d) { data.add(d); }
|
||||
public void clear() { data.clear(); }
|
||||
public void setMeta(String meta) { this.meta = meta; }
|
||||
}
|
||||
|
||||
private final SensorDataArray recording = new SensorDataArray();
|
||||
private long recordingStartTime = 0;
|
||||
|
||||
private void onStartRecording(String meta) {
|
||||
recordingStartTime = SystemClock.elapsedRealtimeNanos();
|
||||
recording.setMeta(meta);
|
||||
}
|
||||
private void onStopRecording() {
|
||||
if(listener != null) {
|
||||
listener.onResult(recording);
|
||||
}
|
||||
recording.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSensorChanged(SensorEvent event) {
|
||||
// pass on to C++ filter bank
|
||||
stepDetector.filter(event.timestamp, event.values);
|
||||
// collect accelerometer recording - adjust timebase to 0.0 sec beginning
|
||||
recording.add(new SensorData(event.timestamp - recordingStartTime, event.values));
|
||||
// TODO: acquires at 8 ms intervals ... 125 Hz?!
|
||||
// TODO: must compute actual sampling rate. and either downsample, or adapt the IIR filter parameters. (& length??) - easier to resample.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAccuracyChanged(Sensor sensor, int accuracy) {}
|
||||
|
||||
private Notification buildNotification(String contentText) {
|
||||
private Notification buildNotification() {
|
||||
return new NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("Lockstep is reading your pace.")
|
||||
.setContentText(contentText)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_compass)
|
||||
.setContentTitle(getString(R.string.app_name))
|
||||
.setContentText(getString(R.string.notification_text))
|
||||
.setSmallIcon(getApplicationInfo().icon)
|
||||
.setOngoing(true)
|
||||
.build();
|
||||
}
|
||||
@@ -169,7 +279,7 @@ public class LstForegroundService extends Service implements SensorEventListener
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Lockstep",
|
||||
getString(R.string.app_name),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
);
|
||||
|
||||
|
||||
@@ -1,17 +1,58 @@
|
||||
package at.lockstep.app;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Intent;
|
||||
import android.content.ServiceConnection;
|
||||
import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
import android.os.IBinder;
|
||||
import android.util.Log;
|
||||
import android.widget.Button;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import at.lockstep.R;
|
||||
import at.lockstep.pb.PlaybackEngine;
|
||||
import at.lockstep.saf.SafPickerActivity;
|
||||
import at.lockstep.ui.SongPickerActivity;
|
||||
import android.widget.Toast;
|
||||
|
||||
public class MainActivity extends Activity {
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.io.Writer;
|
||||
|
||||
public class MainActivity extends AppCompatActivity implements LstForegroundService.OnResultListener {
|
||||
private Button btnStart;
|
||||
private Button btnStop;
|
||||
private Button btnMediaStoreBenchmark;
|
||||
private Button btnPickSong;
|
||||
private final ActivityResultLauncher<Intent> launcher;
|
||||
|
||||
private String contentUri;
|
||||
|
||||
public MainActivity() {
|
||||
launcher = registerForActivityResult(
|
||||
new ActivityResultContracts.StartActivityForResult(),
|
||||
result -> {
|
||||
if (result.getResultCode() == Activity.RESULT_OK) {
|
||||
Intent data = result.getData();
|
||||
if (data != null) {
|
||||
String contentUri = data.getStringExtra("content_uri");
|
||||
Toast.makeText(this, "Item clicked: " + contentUri, Toast.LENGTH_LONG).show();
|
||||
this.contentUri = contentUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
@@ -20,11 +61,14 @@ public class MainActivity extends Activity {
|
||||
|
||||
btnStart = findViewById(R.id.btnStart);
|
||||
btnStop = findViewById(R.id.btnStop);
|
||||
btnMediaStoreBenchmark = findViewById(R.id.btnMediaStoreBenchmark);
|
||||
btnPickSong = findViewById(R.id.btnPickSong);
|
||||
|
||||
// TODO: handle clicking START button twice
|
||||
btnStart.setOnClickListener(v ->
|
||||
ContextCompat.startForegroundService(
|
||||
MainActivity.this,
|
||||
LstForegroundService.startIntent(MainActivity.this)
|
||||
LstForegroundService.startIntent(MainActivity.this, contentUri)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -38,5 +82,95 @@ public class MainActivity extends Activity {
|
||||
btnStop.setOnClickListener(v ->
|
||||
startService(LstForegroundService.stopIntent(MainActivity.this))
|
||||
);
|
||||
|
||||
btnMediaStoreBenchmark.setOnClickListener(v -> {
|
||||
Intent intent = new Intent(MainActivity.this, MediaStoreBenchmarkActivity.class);
|
||||
startActivity(intent);
|
||||
});
|
||||
|
||||
btnPickSong.setOnClickListener(v -> {
|
||||
Intent intent = new Intent(this, SafPickerActivity.class);
|
||||
launcher.launch(intent);
|
||||
});
|
||||
}
|
||||
|
||||
private ServiceConnection conn = new ServiceConnection() {
|
||||
@Override
|
||||
public void onServiceConnected(ComponentName componentName, IBinder iBinder) {
|
||||
LstForegroundService service = ((LstForegroundService.LocalBinder) iBinder).getService();
|
||||
service.setOnResultListener(MainActivity.this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceDisconnected(ComponentName componentName) {
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
// attach ServiceConnection (so we can attach a listener). incidentally, it seems to also create the service. (will currently create a PlaybackEngine, etc.)
|
||||
// TODO: check if this delays starting the application
|
||||
bindService(new Intent(this, LstForegroundService.class), conn, BIND_AUTO_CREATE);
|
||||
}
|
||||
@Override
|
||||
protected void onStop() {
|
||||
super.onStop();
|
||||
unbindService(conn);
|
||||
Log.i("MainActivity", "onStop()");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
Log.i("MainActivity", "onDestroy()");
|
||||
|
||||
// TODO: since the Service keeps running, we must signal oboe to stop playing
|
||||
// TODO: signal the pause to the C++ lib
|
||||
startService(LstForegroundService.stopIntent(MainActivity.this));
|
||||
}
|
||||
|
||||
private boolean isForeground = false;
|
||||
@Override
|
||||
protected void onPause() {
|
||||
super.onPause();
|
||||
isForeground = false;
|
||||
//
|
||||
// telltale signs: logcat: "PlaybackEngine - Buffer overrun on output for channel (0.000000)" or (1.000000)
|
||||
Log.i("MainActivity", "onPause()");
|
||||
}
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
isForeground = true;
|
||||
}
|
||||
|
||||
LstForegroundService.SensorDataArray recording;
|
||||
|
||||
@Override
|
||||
public void onResult(LstForegroundService.SensorDataArray recording) {
|
||||
if(!isForeground) {
|
||||
Log.i("MainActivity", "ignore onResult() from LstForegroundService due to backgrounded MainActivity");
|
||||
return;
|
||||
}
|
||||
this.recording = recording;
|
||||
|
||||
//
|
||||
// write accelero recording to file
|
||||
//
|
||||
File f = getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
|
||||
String dir = f != null ? f.toString() : "/"; // make compiler happy
|
||||
long unixTime = System.currentTimeMillis() / 1000L;
|
||||
String fileName = dir + "/acc_" + unixTime + ".json";
|
||||
Log.i("MainActivity", "written acc rec to " + fileName);
|
||||
try (Writer writer = new FileWriter(fileName)) {
|
||||
Gson gson = new GsonBuilder().create();
|
||||
gson.toJson(recording, writer);
|
||||
} catch (IOException e) {
|
||||
// TODO error handling
|
||||
Log.e("MainActivity", "IOException writing recording: " + e.getMessage());
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package at.lockstep.app;
|
||||
|
||||
import android.app.Activity;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.SystemClock;
|
||||
import android.provider.MediaStore;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import at.lockstep.R;
|
||||
|
||||
public class MediaStoreBenchmarkActivity extends Activity {
|
||||
|
||||
private TextView resultTextView;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_media_store_benchmark);
|
||||
|
||||
resultTextView = findViewById(R.id.resultTextView);
|
||||
|
||||
if (hasReadPermission()) {
|
||||
loadMusic();
|
||||
} else {
|
||||
requestReadPermission();
|
||||
}
|
||||
}
|
||||
|
||||
private static final int REQUEST_READ_PERMISSION = 1001;
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(
|
||||
int requestCode,
|
||||
String[] permissions,
|
||||
int[] grantResults
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
|
||||
if (requestCode == REQUEST_READ_PERMISSION) {
|
||||
boolean granted = grantResults.length > 0
|
||||
&& grantResults[0] == PackageManager.PERMISSION_GRANTED;
|
||||
|
||||
if (granted) {
|
||||
loadMusic();
|
||||
} else {
|
||||
resultTextView.setText("Permission denied.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasReadPermission() {
|
||||
String permission;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
permission = Manifest.permission.READ_MEDIA_AUDIO;
|
||||
} else {
|
||||
permission = Manifest.permission.READ_EXTERNAL_STORAGE;
|
||||
}
|
||||
|
||||
return ContextCompat.checkSelfPermission(this, permission)
|
||||
== PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
|
||||
private void requestReadPermission() {
|
||||
String permission;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
permission = Manifest.permission.READ_MEDIA_AUDIO;
|
||||
} else {
|
||||
permission = Manifest.permission.READ_EXTERNAL_STORAGE;
|
||||
}
|
||||
|
||||
requestPermissions(
|
||||
new String[]{ permission },
|
||||
REQUEST_READ_PERMISSION
|
||||
);
|
||||
}
|
||||
|
||||
private void loadMusic() {
|
||||
List<String> musicList = new ArrayList<>();
|
||||
|
||||
android.net.Uri collection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
|
||||
|
||||
String[] projection = new String[] {
|
||||
MediaStore.Audio.Media._ID,
|
||||
MediaStore.Audio.Media.TITLE,
|
||||
MediaStore.Audio.Media.DATA
|
||||
};
|
||||
|
||||
String selection = MediaStore.Audio.Media.IS_MUSIC + " != 0";
|
||||
|
||||
long start = SystemClock.elapsedRealtime();
|
||||
|
||||
try (android.database.Cursor cursor = getContentResolver().query(
|
||||
collection,
|
||||
projection,
|
||||
selection,
|
||||
null,
|
||||
MediaStore.Audio.Media.TITLE + " ASC"
|
||||
)) {
|
||||
if (cursor != null) {
|
||||
int titleColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE);
|
||||
int dataColumn = cursor.getColumnIndex(MediaStore.Audio.Media.DATA);
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
String title = cursor.getString(titleColumn);
|
||||
String path = dataColumn != -1 ? cursor.getString(dataColumn) : null;
|
||||
|
||||
if (path != null) {
|
||||
musicList.add(title + "\n" + path);
|
||||
} else {
|
||||
musicList.add(title + "\n[path unavailable]");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
long elapsedMs = SystemClock.elapsedRealtime() - start;
|
||||
|
||||
StringBuilder header = new StringBuilder();
|
||||
header.append("Found ").append(musicList.size()).append(" music files\n");
|
||||
header.append("Query time: ").append(elapsedMs).append(" ms\n\n");
|
||||
|
||||
resultTextView.setText(header.toString() + String.join("\n\n", musicList));
|
||||
|
||||
// 200 music files in 32 ms
|
||||
// paths like "/storage/emulated/0/Download/...mp3"
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ public class PlaybackEngine {
|
||||
System.loadLibrary("lockstep-native");
|
||||
// mpg123_init() must be called before createEngine()
|
||||
int ok = native_mpg123_init();
|
||||
if(ok != MPG123_OK)
|
||||
if (ok != MPG123_OK)
|
||||
throw new IllegalStateException("mpg123_init() failed");
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ public class PlaybackEngine {
|
||||
*/
|
||||
public static boolean create(Context context, int resid) {
|
||||
try {
|
||||
if(!mFilesystemInitialized) {
|
||||
if (!mFilesystemInitialized) {
|
||||
AudioResources.copyRawTracksToFilesystem(context);
|
||||
mFilesystemInitialized = true;
|
||||
}
|
||||
@@ -41,6 +41,11 @@ public class PlaybackEngine {
|
||||
setDefaultStreamValues(context);
|
||||
Log.i("PlaybackEngine", "Hello PlaybackEngine");
|
||||
mEngineHandle = native_createEngine(context.getFilesDir().toString(), resid);
|
||||
|
||||
// david:
|
||||
// int fd = ParcelFileDescriptor.detachFd()
|
||||
// -> C++ code (or upon failure, it is *NOT* closed)
|
||||
// -> except, ParcelFileDescriptor.adoptFd(fd).close()
|
||||
}
|
||||
return (mEngineHandle != 0);
|
||||
}
|
||||
@@ -50,7 +55,7 @@ public class PlaybackEngine {
|
||||
// * re-visit sampling rate and channel count.
|
||||
// In C++ code, MixingPlayer currently forces both to 48000 and 2 respectively,
|
||||
// regardless of what Android says would be optimal.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1){
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||
AudioManager myAudioMgr = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
|
||||
String sampleRateStr = myAudioMgr.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE);
|
||||
int defaultSampleRate = Integer.parseInt(sampleRateStr);
|
||||
@@ -62,7 +67,7 @@ public class PlaybackEngine {
|
||||
}
|
||||
|
||||
public static void delete() {
|
||||
if (mEngineHandle != 0){
|
||||
if (mEngineHandle != 0) {
|
||||
native_deleteEngine(mEngineHandle);
|
||||
}
|
||||
mEngineHandle = 0;
|
||||
@@ -71,9 +76,16 @@ public class PlaybackEngine {
|
||||
return mEngineHandle;
|
||||
}
|
||||
|
||||
public static void playMusic(int fd) {
|
||||
if (mEngineHandle != 0) {
|
||||
native_playMusic(mEngineHandle, fd);
|
||||
}
|
||||
}
|
||||
|
||||
private static native long native_createEngine(String filesDir, int resid);
|
||||
private static native void native_deleteEngine(long engineHandle);
|
||||
private static native void native_setDefaultStreamValues(int sampleRate, int framesPerBurst);
|
||||
|
||||
private static native int native_mpg123_init();
|
||||
private static native void native_playMusic(long engineHandle, int fd);
|
||||
}
|
||||
|
||||
138
app/src/main/java/at/lockstep/saf/SafPickerActivity.java
Normal file
138
app/src/main/java/at/lockstep/saf/SafPickerActivity.java
Normal file
@@ -0,0 +1,138 @@
|
||||
package at.lockstep.saf;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.activity.result.ActivityResult;
|
||||
import androidx.activity.result.ActivityResultCallback;
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
public class SafPickerActivity extends AppCompatActivity {
|
||||
|
||||
private static final String TAG = "SafExample";
|
||||
private static final int REQ_OPEN_DOCUMENT = 1001;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
pickDocumentForPersistentReadAccess();
|
||||
}
|
||||
|
||||
/**
|
||||
* Method 1:
|
||||
* Opens the SAF picker so the user can choose a file.
|
||||
*
|
||||
* You can call this from a button click, menu item, etc.
|
||||
*/
|
||||
public void pickDocumentForPersistentReadAccess() {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
|
||||
// Use "*/*" for any file, or narrow it, e.g. "audio/*", "application/pdf", etc.
|
||||
//intent.setType("audio/*");
|
||||
intent.setType("audio/mpeg");
|
||||
|
||||
// Optional: allow only local files
|
||||
// intent.putExtra(Intent.EXTRA_LOCAL_ONLY, true);
|
||||
|
||||
startActivityForResult(intent, REQ_OPEN_DOCUMENT);
|
||||
/*
|
||||
ActivityResultLauncher<Intent> startActivityIntent = registerForActivityResult(
|
||||
new ActivityResultContracts.StartActivityForResult(),
|
||||
new ActivityResultCallback<ActivityResult>() {
|
||||
@Override
|
||||
public void onActivityResult(ActivityResult result) {
|
||||
// Add same code that you want to add in onActivityResult method
|
||||
}
|
||||
});*/
|
||||
}
|
||||
|
||||
/**
|
||||
* Method 2:
|
||||
* Takes a previously persisted URI string and reads the file contents as bytes.
|
||||
*
|
||||
* Returns the full contents in memory. For large files, stream instead.
|
||||
*/
|
||||
public byte[] readPersistedUriBytes(@NonNull String persistedUriString) throws IOException {
|
||||
Uri uri = Uri.parse(persistedUriString);
|
||||
ContentResolver resolver = getContentResolver();
|
||||
|
||||
try (InputStream in = resolver.openInputStream(uri)) {
|
||||
if (in == null) {
|
||||
throw new IOException("openInputStream() returned null for URI: " + uri);
|
||||
}
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[8192];
|
||||
int n;
|
||||
|
||||
while ((n = in.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, n);
|
||||
}
|
||||
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("WrongConstant")
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
|
||||
if (requestCode == REQ_OPEN_DOCUMENT && resultCode == Activity.RESULT_OK && data != null) {
|
||||
Uri uri = data.getData();
|
||||
if (uri == null) {
|
||||
Log.w(TAG, "Picker returned null URI");
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep only the permission bits the system granted in this result intent.
|
||||
final int takeFlags = data.getFlags()
|
||||
& (Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||
|
||||
try {
|
||||
// Persist the granted access so it survives app/device restarts.
|
||||
getContentResolver().takePersistableUriPermission(uri, takeFlags);
|
||||
|
||||
// Persist the URI string somewhere durable.
|
||||
// Example only; real persistence not implemented per your request.
|
||||
String uriToPersist = uri.toString();
|
||||
Log.d(TAG, "Persist this URI string: " + uriToPersist);
|
||||
|
||||
// Example immediate read:
|
||||
byte[] bytes = readPersistedUriBytes(uriToPersist);
|
||||
Log.d(TAG, "Read " + bytes.length + " bytes");
|
||||
|
||||
Intent ct = new Intent();
|
||||
ct.putExtra("content_uri", uriToPersist);
|
||||
setResult(Activity.RESULT_OK, ct);
|
||||
finish();
|
||||
|
||||
} catch (SecurityException e) {
|
||||
Log.e(TAG, "Failed to persist URI permission for " + uri, e);
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Failed to read URI " + uri, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
app/src/main/java/at/lockstep/ui/SongItem.java
Normal file
40
app/src/main/java/at/lockstep/ui/SongItem.java
Normal file
@@ -0,0 +1,40 @@
|
||||
package at.lockstep.ui;
|
||||
|
||||
/**
|
||||
* Item DTO for song picker.
|
||||
*/
|
||||
public class SongItem {
|
||||
private String title;
|
||||
private String artist;
|
||||
private String contentUri;
|
||||
|
||||
public SongItem(String title, String artist, String contentUri) {
|
||||
this.title = title;
|
||||
this.artist = artist;
|
||||
this.contentUri = contentUri;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getArtist() {
|
||||
return artist;
|
||||
}
|
||||
|
||||
public void setArtist(String artist) {
|
||||
this.artist = artist;
|
||||
}
|
||||
|
||||
public String getContentUri() {
|
||||
return contentUri;
|
||||
}
|
||||
|
||||
public void setContentUri(String contentUri) {
|
||||
this.contentUri = contentUri;
|
||||
}
|
||||
}
|
||||
170
app/src/main/java/at/lockstep/ui/SongPickerActivity.java
Normal file
170
app/src/main/java/at/lockstep/ui/SongPickerActivity.java
Normal file
@@ -0,0 +1,170 @@
|
||||
package at.lockstep.ui;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.SystemClock;
|
||||
import android.provider.MediaStore;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import at.lockstep.R;
|
||||
|
||||
/**
|
||||
* Choose a song from the device library.
|
||||
*/
|
||||
public class SongPickerActivity extends Activity implements SongPickerAdapter.OnItemClickListener {
|
||||
private List<SongItem> songs = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_song_picker);
|
||||
|
||||
RecyclerView recyclerView = findViewById(R.id.recyclerView);
|
||||
|
||||
if (hasReadPermission()) {
|
||||
loadSongList();
|
||||
} else {
|
||||
requestReadPermission();
|
||||
}
|
||||
|
||||
recyclerView.setLayoutManager(new LinearLayoutManager(this));
|
||||
recyclerView.setAdapter(new SongPickerAdapter(songs, this));
|
||||
}
|
||||
|
||||
private static final int REQUEST_READ_PERMISSION = 1001;
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(
|
||||
int requestCode,
|
||||
String[] permissions,
|
||||
int[] grantResults
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
|
||||
if (requestCode == REQUEST_READ_PERMISSION) {
|
||||
boolean granted = grantResults.length > 0
|
||||
&& grantResults[0] == PackageManager.PERMISSION_GRANTED;
|
||||
|
||||
if (granted) {
|
||||
loadSongList();
|
||||
} else {
|
||||
songs.add(new SongItem("Have No Songs - Permission denied", "Re-open app and try again.", null));
|
||||
//resultTextView.setText("Permission denied.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasReadPermission() {
|
||||
String permission;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
permission = Manifest.permission.READ_MEDIA_AUDIO;
|
||||
} else {
|
||||
permission = Manifest.permission.READ_EXTERNAL_STORAGE;
|
||||
}
|
||||
|
||||
return ContextCompat.checkSelfPermission(this, permission)
|
||||
== PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
|
||||
private void requestReadPermission() {
|
||||
String permission;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
permission = Manifest.permission.READ_MEDIA_AUDIO;
|
||||
} else {
|
||||
permission = Manifest.permission.READ_EXTERNAL_STORAGE;
|
||||
}
|
||||
|
||||
requestPermissions(
|
||||
new String[]{ permission },
|
||||
REQUEST_READ_PERMISSION
|
||||
);
|
||||
}
|
||||
|
||||
private void loadSongList() {
|
||||
// TODO: cleanup, remove this list, etc
|
||||
List<String> musicList = new ArrayList<>();
|
||||
|
||||
android.net.Uri collection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
|
||||
|
||||
String[] projection = new String[] {
|
||||
MediaStore.Audio.Media._ID,
|
||||
MediaStore.Audio.Media.TITLE,
|
||||
MediaStore.Audio.Media.ARTIST,
|
||||
MediaStore.Audio.Media.DATA
|
||||
};
|
||||
|
||||
String selection = MediaStore.Audio.Media.IS_MUSIC + " != 0";
|
||||
|
||||
long start = SystemClock.elapsedRealtime();
|
||||
|
||||
//
|
||||
// perform MediaStore query
|
||||
//
|
||||
try (android.database.Cursor cursor = getContentResolver().query(
|
||||
collection,
|
||||
projection,
|
||||
selection,
|
||||
null,
|
||||
MediaStore.Audio.Media.TITLE + " ASC"
|
||||
)) {
|
||||
if (cursor != null) {
|
||||
int idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID);
|
||||
int titleColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE);
|
||||
int artistColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST);
|
||||
int dataColumn = cursor.getColumnIndex(MediaStore.Audio.Media.DATA);
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
String contentUri = cursor.getString(idColumn); // the content:// Uri for the MediaStore item
|
||||
String title = cursor.getString(titleColumn);
|
||||
String artist = cursor.getString(artistColumn);
|
||||
String path = dataColumn != -1 ? cursor.getString(dataColumn) : null;
|
||||
|
||||
if (path != null) {
|
||||
musicList.add(title + "\n" + path);
|
||||
songs.add(new SongItem(title, artist, contentUri));
|
||||
} else {
|
||||
musicList.add(title + "\n[path unavailable]");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
long elapsedMs = SystemClock.elapsedRealtime() - start;
|
||||
|
||||
StringBuilder header = new StringBuilder();
|
||||
header.append("Found ").append(musicList.size()).append(" music files\n");
|
||||
header.append("Query time: ").append(elapsedMs).append(" ms\n\n");
|
||||
|
||||
//resultTextView.setText(header.toString() + String.join("\n\n", musicList));
|
||||
|
||||
// 200 music files in 32 ms
|
||||
// paths like "/storage/emulated/0/Download/...mp3"
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onItemClick(SongItem item) {
|
||||
if(item.getContentUri() == null) {
|
||||
// clicked the prompt for missing permissions?
|
||||
// try acquiring them again.
|
||||
// nice-to: test this.
|
||||
requestReadPermission();
|
||||
return;
|
||||
}
|
||||
|
||||
Intent data = new Intent();
|
||||
data.putExtra("content_uri", item.getContentUri());
|
||||
setResult(Activity.RESULT_OK, data);
|
||||
finish();
|
||||
}
|
||||
}
|
||||
66
app/src/main/java/at/lockstep/ui/SongPickerAdapter.java
Normal file
66
app/src/main/java/at/lockstep/ui/SongPickerAdapter.java
Normal file
@@ -0,0 +1,66 @@
|
||||
package at.lockstep.ui;
|
||||
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import at.lockstep.R;
|
||||
|
||||
/**
|
||||
* RecyclerView Adapter for song picker.
|
||||
*/
|
||||
public class SongPickerAdapter extends RecyclerView.Adapter<SongPickerAdapter.SongPickerViewHolder> {
|
||||
private List<SongItem> songList;
|
||||
private final OnItemClickListener listener;
|
||||
|
||||
public interface OnItemClickListener {
|
||||
void onItemClick(SongItem item);
|
||||
}
|
||||
public SongPickerAdapter(List<SongItem> songList, OnItemClickListener listener) {
|
||||
this.songList = songList;
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public SongPickerViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
View view = LayoutInflater.from(parent.getContext())
|
||||
.inflate(R.layout.song_card, parent, false);
|
||||
return new SongPickerViewHolder(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull SongPickerViewHolder holder, int position) {
|
||||
SongItem songItem = songList.get(position);
|
||||
holder.songTitle.setText(songItem.getTitle());
|
||||
holder.songArtist.setText(songItem.getArtist());
|
||||
holder.itemView.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
listener.onItemClick(songItem);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return songList.size();
|
||||
}
|
||||
|
||||
static class SongPickerViewHolder extends RecyclerView.ViewHolder {
|
||||
TextView songTitle;
|
||||
TextView songArtist;
|
||||
public SongPickerViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
songTitle = itemView.findViewById(R.id.songTitle);
|
||||
songArtist = itemView.findViewById(R.id.songArtist);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -18,4 +18,18 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="Stop collection" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnMediaStoreBenchmark"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Benchmark media store" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnPickSong"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="Pick song" />
|
||||
|
||||
</LinearLayout>
|
||||
13
app/src/main/res/layout/activity_media_store_benchmark.xml
Normal file
13
app/src/main/res/layout/activity_media_store_benchmark.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/resultTextView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp"
|
||||
android:text="Loading..."
|
||||
android:textSize="14sp" />
|
||||
</ScrollView>
|
||||
28
app/src/main/res/layout/activity_song_picker.xml
Normal file
28
app/src/main/res/layout/activity_song_picker.xml
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:background="@android:color/background_light"
|
||||
tools:context=".ui.SongPickerActivity">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Song Picker"
|
||||
android:textAlignment="center"
|
||||
android:background="@android:color/darker_gray"
|
||||
android:paddingTop="54sp"
|
||||
android:paddingBottom="20sp"
|
||||
android:textSize="24sp"/>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:overScrollMode="always"/>
|
||||
|
||||
</LinearLayout>
|
||||
34
app/src/main/res/layout/song_card.xml
Normal file
34
app/src/main/res/layout/song_card.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_marginLeft="16dp"
|
||||
android:layout_marginRight="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
>
|
||||
<!-- android:layout_height="96dp" -->
|
||||
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:id="@+id/songTitle"
|
||||
android:textSize="16sp"
|
||||
android:text="Song Title"
|
||||
android:textColor="@color/black"
|
||||
android:maxLines="1"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/songArtist"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@+id/songTitle"
|
||||
android:text="Artist name"
|
||||
android:textSize="16sp"/>
|
||||
|
||||
</RelativeLayout>
|
||||
@@ -1,3 +1,4 @@
|
||||
<resources>
|
||||
<string name="app_name">Lockstep</string>
|
||||
<string name="notification_text">Reading your steps …</string>
|
||||
</resources>
|
||||
@@ -1,5 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.Lockstep" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
<style name="Theme.Lockstep" parent="Theme.AppCompat.DayNight.NoActionBar" />
|
||||
</resources>
|
||||
46
app/src/test/java/at/lockstep/GsonUnitTest.java
Normal file
46
app/src/test/java/at/lockstep/GsonUnitTest.java
Normal file
@@ -0,0 +1,46 @@
|
||||
package at.lockstep;
|
||||
|
||||
import android.hardware.SensorEvent;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
|
||||
public class GsonUnitTest {
|
||||
|
||||
/** single sensor sample */
|
||||
static class SensorData {
|
||||
private long timestamp;
|
||||
private float[] values;
|
||||
public SensorData(SensorEvent event) {
|
||||
timestamp = event.timestamp;
|
||||
values = Arrays.copyOf(event.values, event.values.length);
|
||||
}
|
||||
public SensorData(long timestamp, float[] values) {
|
||||
this.timestamp = timestamp;
|
||||
this.values = values;
|
||||
}
|
||||
}
|
||||
|
||||
/** array of sensor samples */
|
||||
public static class SensorDataArray {
|
||||
private ArrayList<SensorData> data = new ArrayList<SensorData>();
|
||||
public void add(long timestamp, float[] values) { data.add(new SensorData(timestamp, values)); }
|
||||
public void add(SensorEvent event) { data.add(new SensorData(event)); }
|
||||
public void clear() { data.clear(); }
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGson() {
|
||||
SensorDataArray recording = new SensorDataArray();
|
||||
recording.add(0, new float[]{1, 2, 3});
|
||||
recording.add(1, new float[]{10, 20, 30});
|
||||
Gson gson = new Gson();
|
||||
String json = gson.toJson(recording);
|
||||
System.out.println(json);
|
||||
// {"data":[{"timestamp":0,"values":[1.0,2.0,3.0]},{"timestamp":1,"values":[10.0,20.0,30.0]}]}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ composeBom = "2024.04.01"
|
||||
logbackAndroid = "2.0.0"
|
||||
oboe = "1.10.0"
|
||||
slf4jApi = "1.7.30"
|
||||
recyclerview = "1.3.1"
|
||||
appcompat = "1.7.1"
|
||||
gson = "2.11.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -30,6 +33,9 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3"
|
||||
logback-android = { module = "com.github.tony19:logback-android", version.ref = "logbackAndroid" }
|
||||
oboe = { module = "com.google.oboe:oboe", version.ref = "oboe" }
|
||||
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4jApi" }
|
||||
androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
|
||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||
gson = { group = "com.google.code.gson", name="gson", version.ref = "gson" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
Reference in New Issue
Block a user