feat: also sync beat annotations to api, store beat annotations in Documents

This commit is contained in:
2026-05-30 19:55:42 +02:00
parent 45c4980c05
commit e936dee142
6 changed files with 66 additions and 33 deletions

View File

@@ -2,6 +2,14 @@
Product goals, DSP scope, and repo layout are summarized in [SPECS.md](SPECS.md). This document captures **Android shell architecture**, **decisions made so far**, **libpasada JNI + state machine**, and **open UI questions**. Product goals, DSP scope, and repo layout are summarized in [SPECS.md](SPECS.md). This document captures **Android shell architecture**, **decisions made so far**, **libpasada JNI + state machine**, and **open UI questions**.
## UI screens
- Library: shows the playlists of the user
- Annotation: user presses a button to annotate the beat
- Now Playing: player showing an individual track, with pause/previous/next buttons
- Collection mode: app records sensor data during a run
- Settings: allows to enable modes above, logout button
## Already captured from SPECS.md ## Already captured from SPECS.md
- **Product**: Pace-aware playlist ordering + real-time playback adaptation via accelerometer, **libpasada** (C++/JNI) + **Oboe**, user-supplied **MP3** via file descriptors, feedback loop for sensor-driven playback. - **Product**: Pace-aware playlist ordering + real-time playback adaptation via accelerometer, **libpasada** (C++/JNI) + **Oboe**, user-supplied **MP3** via file descriptors, feedback loop for sensor-driven playback.

View File

@@ -167,12 +167,13 @@ class LockstepViewModel(
} }
/** /**
* Writes one JSON file under app files dir ([BeatAnnotationStorage]) for the track described * Writes one JSON file under public Documents/Lockstep/{sessionFolder}/ for the track described
* by [event]. Skips when [beatTimesMs] is empty. * by [event], records file metadata, and uploads when signed in. Skips when [beatTimesMs] is empty.
*/ */
fun persistBeatAnnotation( fun persistBeatAnnotation(
playlistId: String, playlistId: String,
playlistDisplayName: String, playlistDisplayName: String,
sessionFolder: String,
event: TrackBoundaryEvent, event: TrackBoundaryEvent,
beatTimesMs: List<Long>, beatTimesMs: List<Long>,
) { ) {
@@ -183,15 +184,26 @@ class LockstepViewModel(
val pairing = pairingDao.findForTrack(playlistId, event.trackId) val pairing = pairingDao.findForTrack(playlistId, event.trackId)
val docId = BeatAnnotationStorage.mp3DocumentContentId(pairing?.localUri) val docId = BeatAnnotationStorage.mp3DocumentContentId(pairing?.localUri)
val contentId = docId.ifBlank { event.trackId } val contentId = docId.ifBlank { event.trackId }
BeatAnnotationStorage.writeAnnotationsFile( val uri =
context = getApplication(), BeatAnnotationStorage.writeAnnotationsFile(
playlistDisplayName = playlistDisplayName, context = getApplication(),
trackQueueIndex0Based = event.queueIndex, sessionFolder = sessionFolder,
contentId = contentId, playlistDisplayName = playlistDisplayName,
title = event.title, trackQueueIndex0Based = event.queueIndex,
artist = event.artist, contentId = contentId,
beatTimesMs = beatTimesMs, title = event.title,
) artist = event.artist,
beatTimesMs = beatTimesMs,
) ?: return@launch
val entry =
FileMetadataEntity(
fileUri = uri.toString(),
trackId = event.trackId,
type = FileMetadataEntity.TYPE_ANNOTATION,
version = BuildConfig.VERSION_CODE,
)
val id = fileMetadataDao.insert(entry)
syncMetadataEntry(entry.copy(id = id))
} }
} }
@@ -299,7 +311,7 @@ class LockstepViewModel(
} }
val failures = pending.count { entry -> !syncMetadataEntry(entry) } val failures = pending.count { entry -> !syncMetadataEntry(entry) }
if (failures > 0) { if (failures > 0) {
"Failed to sync $failures collection(s)" "Failed to sync $failures file(s)"
} else { } else {
null null
} }

View File

@@ -11,7 +11,7 @@ data class FileMetadataEntity(
val fileUri: String, val fileUri: String,
/** Spotify track id for the collected run. */ /** Spotify track id for the collected run. */
val trackId: String, val trackId: String,
/** e.g. [TYPE_COLLECTION] for run-data JSON files. */ /** e.g. [TYPE_COLLECTION] or [TYPE_ANNOTATION]. */
val type: String, val type: String,
/** [BuildConfig.VERSION_CODE] at write time. */ /** [BuildConfig.VERSION_CODE] at write time. */
val version: Int, val version: Int,
@@ -19,5 +19,6 @@ data class FileMetadataEntity(
) { ) {
companion object { companion object {
const val TYPE_COLLECTION = "collection" const val TYPE_COLLECTION = "collection"
const val TYPE_ANNOTATION = "annotation"
} }
} }

View File

@@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp
import at.lockstep.player.LockstepViewModel import at.lockstep.player.LockstepViewModel
import at.lockstep.player.R import at.lockstep.player.R
import at.lockstep.player.playback.PlaybackService import at.lockstep.player.playback.PlaybackService
import at.lockstep.player.util.RunDataStorage
/** /**
* Beat taps record [PlaybackService.getPlaybackPositionMs] (Media3 [androidx.media3.common.Player] * Beat taps record [PlaybackService.getPlaybackPositionMs] (Media3 [androidx.media3.common.Player]
@@ -82,6 +83,7 @@ fun AnnotationRoute(
} }
val beatTimesMs = remember { mutableStateListOf<Long>() } val beatTimesMs = remember { mutableStateListOf<Long>() }
val annotationSessionFolder = remember { RunDataStorage.newRunSessionFolderName() }
var playlistDisplayName by remember { mutableStateOf("playlist") } var playlistDisplayName by remember { mutableStateOf("playlist") }
@@ -89,12 +91,18 @@ fun AnnotationRoute(
playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId) playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId)
} }
LaunchedEffect(playback, playlistId, playlistDisplayName) { LaunchedEffect(playback, playlistId, playlistDisplayName, annotationSessionFolder) {
val service = playback ?: return@LaunchedEffect val service = playback ?: return@LaunchedEffect
service.trackBoundaryEvents.collect { event -> service.trackBoundaryEvents.collect { event ->
val snapshot = beatTimesMs.toList() val snapshot = beatTimesMs.toList()
beatTimesMs.clear() beatTimesMs.clear()
viewModel.persistBeatAnnotation(playlistId, playlistDisplayName, event, snapshot) viewModel.persistBeatAnnotation(
playlistId,
playlistDisplayName,
annotationSessionFolder,
event,
snapshot,
)
} }
} }

View File

@@ -5,28 +5,24 @@ import android.net.Uri
import android.provider.DocumentsContract import android.provider.DocumentsContract
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.io.File
import java.util.Locale import java.util.Locale
object BeatAnnotationStorage { object BeatAnnotationStorage {
private const val DIR_NAME = "beat_annotations"
fun annotationsDir(context: Context): File =
File(context.filesDir, DIR_NAME).apply { mkdirs() }
/** /**
* [playlistDisplayName] + "_" + 1-based track index (000) + ".json". * [playlistDisplayName] + "_" + 1-based track index (000) + ".json" under
* Documents/Lockstep/{sessionFolder}/.
*/ */
fun writeAnnotationsFile( fun writeAnnotationsFile(
context: Context, context: Context,
sessionFolder: String,
playlistDisplayName: String, playlistDisplayName: String,
trackQueueIndex0Based: Int, trackQueueIndex0Based: Int,
contentId: String, contentId: String,
title: String, title: String,
artist: String, artist: String,
beatTimesMs: List<Long>, beatTimesMs: List<Long>,
): File { ): Uri? {
val safeName = val safeName =
playlistDisplayName playlistDisplayName
.replace(Regex("[\\\\/:*?\"<>|]"), "_") .replace(Regex("[\\\\/:*?\"<>|]"), "_")
@@ -35,17 +31,16 @@ object BeatAnnotationStorage {
.take(120) .take(120)
val suffix = val suffix =
String.format(Locale.US, "%03d", trackQueueIndex0Based + 1) String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
val file = File(annotationsDir(context), "${safeName}_$suffix.json") val fileName = "${safeName}_$suffix.json"
val sec = beatTimesMs.map { it / 1000.0 } val sec = beatTimesMs.map { it / 1000.0 }
val json = val jsonString =
JSONObject().apply { JSONObject().apply {
put("contentId", contentId) put("contentId", contentId)
put("title", title) put("title", title)
put("artist", artist) put("artist", artist)
put("beatTimesSec", JSONArray(sec)) put("beatTimesSec", JSONArray(sec))
} }.toString(2)
file.writeText(json.toString(2)) return RunDataStorage.writePublicJsonFile(context, sessionFolder, fileName, jsonString)
return file
} }
/** Document id for a content [Uri] when available; otherwise last path segment. */ /** Document id for a content [Uri] when available; otherwise last path segment. */

View File

@@ -58,13 +58,22 @@ object RunDataStorage {
put("versionCode", BuildConfig.VERSION_CODE) put("versionCode", BuildConfig.VERSION_CODE)
}.toString() }.toString()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return writePublicJsonFile(context, runSessionFolder, fileName, jsonString)
writeViaMediaStore(context, runSessionFolder, fileName, jsonString)
} else {
writeViaPublicDocumentsDir(runSessionFolder, fileName, jsonString)
}
} }
/** Writes [jsonString] under public Documents/Lockstep/{sessionFolder}/. */
fun writePublicJsonFile(
context: Context,
sessionFolder: String,
fileName: String,
jsonString: String,
): Uri? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
writeViaMediaStore(context, sessionFolder, fileName, jsonString)
} else {
writeViaPublicDocumentsDir(sessionFolder, fileName, jsonString)
}
private fun accelToJsonArray(samples: List<RunAccelSample>): JSONArray { private fun accelToJsonArray(samples: List<RunAccelSample>): JSONArray {
val array = JSONArray() val array = JSONArray()
for (sample in samples) { for (sample in samples) {