2 Commits

Author SHA1 Message Date
12adfbc960 feat: add canonical beat pattern TYPE_BEATS, not synced to server 2026-05-31 10:21:31 +02:00
b332c7b2f3 ui: shoe logo, now bent 2026-05-31 09:51:17 +02:00
13 changed files with 268 additions and 56 deletions

View File

@@ -11,6 +11,64 @@ Product goals, DSP scope, and repo layout are summarized in [SPECS.md](SPECS.md)
- Collection mode: app records sensor data during a run - Collection mode: app records sensor data during a run
- Settings: allows to enable modes above, logout button - Settings: allows to enable modes above, logout button
## Data files
Filesystem layout of JSON files on primary storage (e.g. Pixel 5: `/storage/emulated/0/Documents/Lockstep/`):
```
/storage/emulated/0/Documents/Lockstep/
├── 2026-05-31_10-15-30/ ← annotation session (TYPE_ANNOTATION)
│ ├── Running Mix_001.json
│ └── Running Mix_002.json
├── 2026-05-31_14-22-05/ ← now-playing collection session (TYPE_COLLECTION)
│ ├── Running Mix_001.json
│ └── Running Mix_002.json
└── Beats/ ← canonical beats per playlist (TYPE_BEATS)
└── Running Mix/
├── 001.json
└── 002.json
```
Each type is recorded in Room `file_metadata` (`FileMetadataEntity`) with `fileUri`, `trackId`, `type`, `version`, `synced`, and `lastSyncedAt` (epoch millis).
| Type | Constant | Written by | Path pattern | Server sync |
|------|----------|------------|--------------|-------------|
| **Annotation session** | `TYPE_ANNOTATION` | Annotation screen (`BeatAnnotationStorage`) | `Documents/Lockstep/{sessionFolder}/{playlist}_{NNN}.json` — one timestamped folder per annotation session | Yes — uploaded via `POST /metadata` when signed in |
| **Run collection** | `TYPE_COLLECTION` | Now Playing with collect-run-data enabled (`RunDataStorage`) | `Documents/Lockstep/{sessionFolder}/{playlist}_{NNN}.json` — one timestamped folder per run session | Yes |
| **Canonical beats** | `TYPE_BEATS` | Annotation screen (`BeatAnnotationStorage`) — written alongside each session annotation | `Documents/Lockstep/Beats/{playlist_name}/{NNN}.json` — one folder per playlist; **overwrites** on re-annotation | **No** — tracked locally only; `synced = 1` and `lastSyncedAt` set at write time |
During an **annotation session**, leaving a track writes **both** `TYPE_ANNOTATION` (append-only session archive) and `TYPE_BEATS` (latest beats for that playlist slot).
### Beat JSON schema (`TYPE_ANNOTATION` and `TYPE_BEATS`)
Same schema for session annotations and canonical beats:
```json
{
"contentId": "primary:Documents/Music/track.mp3",
"title": "Track Title",
"artist": "Artist Name",
"beatTimesSec": [1.23, 2.45, 3.67]
}
```
- **`contentId`** — SAF document id of the paired local MP3 when available; otherwise Spotify track id.
- **`beatTimesSec`** — user-tapped beat times in seconds (playback position).
### Collection JSON schema (`TYPE_COLLECTION`)
```json
{
"data": [ { "timestamp": 0, "positionMs": 0, "values": [0.0, 0.0, 9.8] } ],
"gyro": [ { "timestamp": 0, "values": [0.0, 0.0, 0.0] } ],
"gps": [ { "timestamp": 0, "values": [48.2, 16.3, 200.0] } ],
"meta": "content://…",
"title": "Track Title",
"artist": "Artist Name",
"versionCode": 1
}
```
## 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,8 +167,9 @@ class LockstepViewModel(
} }
/** /**
* Writes one JSON file under public Documents/Lockstep/{sessionFolder}/ for the track described * Writes session annotation JSON under Documents/Lockstep/{sessionFolder}/ (synced when signed in)
* by [event], records file metadata, and uploads when signed in. Skips when [beatTimesMs] is empty. * and canonical beat JSON under Documents/Lockstep/Beats/{playlist_name}/ (local file_metadata only).
* Skips when [beatTimesMs] is empty.
*/ */
fun persistBeatAnnotation( fun persistBeatAnnotation(
playlistId: String, playlistId: String,
@@ -184,9 +185,10 @@ 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 }
val uri = val appContext = getApplication<Application>()
val annotationUri =
BeatAnnotationStorage.writeAnnotationsFile( BeatAnnotationStorage.writeAnnotationsFile(
context = getApplication(), context = appContext,
sessionFolder = sessionFolder, sessionFolder = sessionFolder,
playlistDisplayName = playlistDisplayName, playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = event.queueIndex, trackQueueIndex0Based = event.queueIndex,
@@ -195,18 +197,66 @@ class LockstepViewModel(
artist = event.artist, artist = event.artist,
beatTimesMs = beatTimesMs, beatTimesMs = beatTimesMs,
) ?: return@launch ) ?: return@launch
val entry = recordMetadataEntry(
FileMetadataEntity( fileUri = annotationUri.toString(),
fileUri = uri.toString(),
trackId = event.trackId, trackId = event.trackId,
type = FileMetadataEntity.TYPE_ANNOTATION, type = FileMetadataEntity.TYPE_ANNOTATION,
)
val beatsUri =
BeatAnnotationStorage.writeBeatsFile(
context = appContext,
playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = event.queueIndex,
contentId = contentId,
title = event.title,
artist = event.artist,
beatTimesMs = beatTimesMs,
) ?: return@launch
recordOrUpdateMetadataEntry(
fileUri = beatsUri.toString(),
trackId = event.trackId,
type = FileMetadataEntity.TYPE_BEATS,
)
}
}
private suspend fun recordMetadataEntry(
fileUri: String,
trackId: String,
type: String,
) {
val now = System.currentTimeMillis()
val beats = type == FileMetadataEntity.TYPE_BEATS
val entry =
FileMetadataEntity(
fileUri = fileUri,
trackId = trackId,
type = type,
version = BuildConfig.VERSION_CODE, version = BuildConfig.VERSION_CODE,
synced = beats,
lastSyncedAt = if (beats) now else null,
) )
val id = fileMetadataDao.insert(entry) val id = fileMetadataDao.insert(entry)
if (!beats) {
syncMetadataEntry(entry.copy(id = id)) syncMetadataEntry(entry.copy(id = id))
} }
} }
private suspend fun recordOrUpdateMetadataEntry(
fileUri: String,
trackId: String,
type: String,
) {
val version = BuildConfig.VERSION_CODE
val now = System.currentTimeMillis()
val existing = fileMetadataDao.findByTrackIdAndType(trackId, type)
if (existing != null) {
fileMetadataDao.updateFile(existing.id, fileUri, version, now)
} else {
recordMetadataEntry(fileUri, trackId, type)
}
}
/** /**
* Writes one JSON file under public Documents/Lockstep/{runSessionFolder}/ when a track finishes or is skipped. * Writes one JSON file under public Documents/Lockstep/{runSessionFolder}/ when a track finishes or is skipped.
* Skips when [samples] is empty or the track has no paired local URI. * Skips when [samples] is empty or the track has no paired local URI.
@@ -333,7 +383,7 @@ class LockstepViewModel(
version = entry.version, version = entry.version,
collection = collection, collection = collection,
) )
fileMetadataDao.markSynced(entry.id) fileMetadataDao.markSynced(entry.id, System.currentTimeMillis())
true true
} catch (e: IOException) { } catch (e: IOException) {
Log.w(METADATA_TAG, "metadata sync failed id=${entry.id}", e) Log.w(METADATA_TAG, "metadata sync failed id=${entry.id}", e)

View File

@@ -33,7 +33,7 @@ class UserPreferencesRepository(
prefs[KEY_ANNOTATION_MODE] == true prefs[KEY_ANNOTATION_MODE] == true
} }
/** When true, Now Playing records accelerometer samples per track into JSON under filesDir/run_data. */ /** When true, Now Playing records sensor samples per track into JSON under Documents/Lockstep/{sessionFolder}/. */
val collectRunData: Flow<Boolean> = val collectRunData: Flow<Boolean> =
dataStore.data.map { prefs -> dataStore.data.map { prefs ->
prefs[KEY_COLLECT_RUN_DATA] == true prefs[KEY_COLLECT_RUN_DATA] == true

View File

@@ -10,7 +10,7 @@ import androidx.room.RoomDatabase
TrackPairingEntity::class, TrackPairingEntity::class,
FileMetadataEntity::class, FileMetadataEntity::class,
], ],
version = 4, version = 5,
exportSchema = false, exportSchema = false,
) )
abstract class AppDatabase : abstract class AppDatabase :

View File

@@ -9,9 +9,17 @@ interface FileMetadataDao {
@Insert @Insert
suspend fun insert(row: FileMetadataEntity): Long suspend fun insert(row: FileMetadataEntity): Long
@Query("SELECT * FROM file_metadata WHERE synced = 0 ORDER BY id ASC") @Query("SELECT * FROM file_metadata WHERE synced = 0 AND type != 'beats' ORDER BY id ASC")
suspend fun listUnsynced(): List<FileMetadataEntity> suspend fun listUnsynced(): List<FileMetadataEntity>
@Query("UPDATE file_metadata SET synced = 1 WHERE id = :id") @Query("UPDATE file_metadata SET synced = 1, lastSyncedAt = :syncedAt WHERE id = :id")
suspend fun markSynced(id: Long) suspend fun markSynced(id: Long, syncedAt: Long)
@Query("SELECT * FROM file_metadata WHERE trackId = :trackId AND type = :type LIMIT 1")
suspend fun findByTrackIdAndType(trackId: String, type: String): FileMetadataEntity?
@Query(
"UPDATE file_metadata SET fileUri = :fileUri, version = :version, synced = 1, lastSyncedAt = :syncedAt WHERE id = :id",
)
suspend fun updateFile(id: Long, fileUri: String, version: Int, syncedAt: Long)
} }

View File

@@ -11,14 +11,18 @@ 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] or [TYPE_ANNOTATION]. */ /** e.g. [TYPE_COLLECTION], [TYPE_ANNOTATION], or [TYPE_BEATS]. */
val type: String, val type: String,
/** [BuildConfig.VERSION_CODE] at write time. */ /** [BuildConfig.VERSION_CODE] at write time. */
val version: Int, val version: Int,
val synced: Boolean = false, val synced: Boolean = false,
/** Epoch millis when [synced] was set; set on local write for [TYPE_BEATS], on server sync otherwise. */
val lastSyncedAt: Long? = null,
) { ) {
companion object { companion object {
const val TYPE_COLLECTION = "collection" const val TYPE_COLLECTION = "collection"
const val TYPE_ANNOTATION = "annotation" const val TYPE_ANNOTATION = "annotation"
/** Canonical beat files under Documents/Lockstep/Beats/; tracked locally, not synced to server. */
const val TYPE_BEATS = "beats"
} }
} }

View File

@@ -23,24 +23,53 @@ object BeatAnnotationStorage {
artist: String, artist: String,
beatTimesMs: List<Long>, beatTimesMs: List<Long>,
): Uri? { ): Uri? {
val safeName = val fileName = trackFileName(playlistDisplayName, trackQueueIndex0Based)
playlistDisplayName val jsonString = buildAnnotationJson(contentId, title, artist, beatTimesMs).toString(2)
.replace(Regex("[\\\\/:*?\"<>|]"), "_") return RunDataStorage.writePublicJsonFile(context, sessionFolder, fileName, jsonString)
.trim() }
.ifBlank { "playlist" }
.take(120) /**
val suffix = * 1-based track index (000) + ".json" under Documents/Lockstep/Beats/{playlist_name}/.
String.format(Locale.US, "%03d", trackQueueIndex0Based + 1) * Overwrites an existing file for the same track index.
val fileName = "${safeName}_$suffix.json" */
fun writeBeatsFile(
context: Context,
playlistDisplayName: String,
trackQueueIndex0Based: Int,
contentId: String,
title: String,
artist: String,
beatTimesMs: List<Long>,
): Uri? {
val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
val fileName = "$suffix.json"
val jsonString = buildAnnotationJson(contentId, title, artist, beatTimesMs).toString(2)
val relativePath = RunDataStorage.beatsPlaylistRelativePath(playlistDisplayName)
return RunDataStorage.writeOrReplacePublicJsonFile(context, relativePath, fileName, jsonString)
}
fun buildAnnotationJson(
contentId: String,
title: String,
artist: String,
beatTimesMs: List<Long>,
): JSONObject {
val sec = beatTimesMs.map { it / 1000.0 } val sec = beatTimesMs.map { it / 1000.0 }
val jsonString = return 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) }
return RunDataStorage.writePublicJsonFile(context, sessionFolder, fileName, jsonString) }
private fun trackFileName(
playlistDisplayName: String,
trackQueueIndex0Based: Int,
): String {
val safeName = RunDataStorage.sanitizeFileLabel(playlistDisplayName)
val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
return "${safeName}_$suffix.json"
} }
/** 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

@@ -1,5 +1,6 @@
package at.lockstep.player.util package at.lockstep.player.util
import android.content.ContentUris
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
@@ -16,15 +17,27 @@ import java.util.Locale
object RunDataStorage { object RunDataStorage {
private const val APP_DIR = "Lockstep" private const val APP_DIR = "Lockstep"
private const val BEATS_DIR = "Beats"
/** e.g. `2026-05-24_18-36-42` — one folder per Now Playing run session. */ /** e.g. `2026-05-24_18-36-42` — one folder per Now Playing run session. */
fun newRunSessionFolderName(): String = fun newRunSessionFolderName(): String =
SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(Date()) SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(Date())
fun sanitizeFileLabel(name: String): String =
name
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
.trim()
.ifBlank { "playlist" }
.take(120)
/** Public path segment under Documents, for display: `Documents/Lockstep/{runSessionFolder}/`. */ /** Public path segment under Documents, for display: `Documents/Lockstep/{runSessionFolder}/`. */
fun documentsRelativePath(runSessionFolder: String): String = fun documentsRelativePath(runSessionFolder: String): String =
"${Environment.DIRECTORY_DOCUMENTS}/$APP_DIR/$runSessionFolder" "${Environment.DIRECTORY_DOCUMENTS}/$APP_DIR/$runSessionFolder"
/** Public path segment: `Documents/Lockstep/Beats/{playlist_name}/`. */
fun beatsPlaylistRelativePath(playlistDisplayName: String): String =
"${Environment.DIRECTORY_DOCUMENTS}/$APP_DIR/$BEATS_DIR/${sanitizeFileLabel(playlistDisplayName)}"
fun writeRunDataFile( fun writeRunDataFile(
context: Context, context: Context,
runSessionFolder: String, runSessionFolder: String,
@@ -37,12 +50,7 @@ object RunDataStorage {
): Uri? { ): Uri? {
if (snapshot.isEmpty()) return null if (snapshot.isEmpty()) return null
val safeName = val safeName = sanitizeFileLabel(playlistDisplayName)
playlistDisplayName
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
.trim()
.ifBlank { "playlist" }
.take(120)
val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1) val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
val fileName = "${safeName}_$suffix.json" val fileName = "${safeName}_$suffix.json"
@@ -67,11 +75,25 @@ object RunDataStorage {
sessionFolder: String, sessionFolder: String,
fileName: String, fileName: String,
jsonString: String, jsonString: String,
): Uri? =
writeOrReplacePublicJsonFile(
context,
documentsRelativePath(sessionFolder),
fileName,
jsonString,
)
/** Writes or overwrites [jsonString] at public Documents/{relativePath}/{fileName}. */
fun writeOrReplacePublicJsonFile(
context: Context,
relativePath: String,
fileName: String,
jsonString: String,
): Uri? = ): Uri? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
writeViaMediaStore(context, sessionFolder, fileName, jsonString) writeOrReplaceViaMediaStore(context, relativePath, fileName, jsonString)
} else { } else {
writeViaPublicDocumentsDir(sessionFolder, fileName, jsonString) writeOrReplaceViaPublicDocumentsDir(relativePath, fileName, jsonString)
} }
private fun accelToJsonArray(samples: List<RunAccelSample>): JSONArray { private fun accelToJsonArray(samples: List<RunAccelSample>): JSONArray {
@@ -135,22 +157,57 @@ object RunDataStorage {
return array return array
} }
private fun writeViaMediaStore( private fun writeOrReplaceViaMediaStore(
context: Context, context: Context,
runSessionFolder: String, relativePath: String,
fileName: String, fileName: String,
jsonString: String, jsonString: String,
): Uri? { ): Uri? {
val resolver = context.applicationContext.contentResolver val resolver = context.applicationContext.contentResolver
val relativePath = documentsRelativePath(runSessionFolder) val collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val existingUri = findMediaStoreFileUri(resolver, collection, relativePath, fileName)
if (existingUri != null) {
resolver.openOutputStream(existingUri, "wt")?.use { stream ->
stream.write(jsonString.toByteArray(Charsets.UTF_8))
} ?: return null
return existingUri
}
return insertViaMediaStore(resolver, collection, relativePath, fileName, jsonString)
}
private fun findMediaStoreFileUri(
resolver: android.content.ContentResolver,
collection: Uri,
relativePath: String,
fileName: String,
): Uri? {
val projection = arrayOf(MediaStore.MediaColumns._ID)
val selection =
"${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaStore.MediaColumns.DISPLAY_NAME} = ?"
val selectionArgs = arrayOf(relativePathWithTrailingSlash(relativePath), fileName)
resolver.query(collection, projection, selection, selectionArgs, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
return ContentUris.withAppendedId(collection, id)
}
}
return null
}
private fun insertViaMediaStore(
resolver: android.content.ContentResolver,
collection: Uri,
relativePath: String,
fileName: String,
jsonString: String,
): Uri? {
val pending = val pending =
ContentValues().apply { ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.MIME_TYPE, "application/json") put(MediaStore.MediaColumns.MIME_TYPE, "application/json")
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) put(MediaStore.MediaColumns.RELATIVE_PATH, relativePathWithTrailingSlash(relativePath))
put(MediaStore.MediaColumns.IS_PENDING, 1) put(MediaStore.MediaColumns.IS_PENDING, 1)
} }
val collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val uri = resolver.insert(collection, pending) ?: return null val uri = resolver.insert(collection, pending) ?: return null
try { try {
resolver.openOutputStream(uri)?.use { stream -> resolver.openOutputStream(uri)?.use { stream ->
@@ -168,17 +225,23 @@ object RunDataStorage {
} }
} }
/** MediaStore stores directory relative paths with a trailing separator. */
private fun relativePathWithTrailingSlash(relativePath: String): String =
if (relativePath.endsWith("/")) relativePath else "$relativePath/"
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
private fun writeViaPublicDocumentsDir( private fun writeOrReplaceViaPublicDocumentsDir(
runSessionFolder: String, relativePath: String,
fileName: String, fileName: String,
jsonString: String, jsonString: String,
): Uri? { ): Uri? {
val dir = val documentsRoot = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
File( val lockstepPrefix = "$APP_DIR/"
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), val subPath =
"$APP_DIR/$runSessionFolder", relativePath.removePrefix("${Environment.DIRECTORY_DOCUMENTS}/")
) .removePrefix("${Environment.DIRECTORY_DOCUMENTS}${File.separator}")
.removePrefix(lockstepPrefix)
val dir = File(documentsRoot, "$APP_DIR/$subPath")
if (!dir.exists() && !dir.mkdirs()) { if (!dir.exists() && !dir.mkdirs()) {
return null return null
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB