Files
lockstep-player/DESIGN.md
2026-05-13 21:27:19 +02:00

9.4 KiB
Raw Permalink Blame History

Lockstep Android MVP — design

Product goals, DSP scope, and repo layout are summarized in SPECS.md. This document captures Android shell architecture, decisions made so far, libpasada JNI + state machine, and open UI questions.

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.
  • Metadata: jukebox submodule — Spotify-backed metadata in SQLite, separate from DSP.
  • Native: libpasada independently buildable/testable; Android side may stub JNI until native is ready.

Your decisions (this session)

Topic Choice
Session context Background / screen off — needs foreground service, ongoing notification, and media-style controls expectation
Playlist assembly Manual local MP3 pick + align/display with jukebox metadata where possible
Playback ownership Run mode owned by a foreground service — Oboe, libpasada JNI, and accelerometer feeding live in the service while a run is active; UI binds for display/control only
Accel → native path SensorManager callbacks on a dedicated HandlerThread (not main)onSensorChanged may call JNI directly; no intermediate Java queue for MVP. libpasada must be safe relative to concurrent Oboe/audio-thread work (locking or lock-free handoff inside native).
Jukebox scope (MVP) Playlist identity + BPM/metadata in SQLite; album/track artwork deferred — artwork is intended to live in jukebox later, not in the app long-term.
Spotify credentials Refresh is triggered and handled by the host app (lifecycle, WorkManager, error handling, user re-login). Jukebox remains a library: expose/sync APIs that accept valid credentials or sessions the app supplies after refresh; avoid owning background refresh loops unless you later consolidate that into jukebox by explicit choice.
MP3 ↔ jukebox matching 1) Match primarily using embedded MP3 tags (ID3 / common frames—title, artist, album; plus ISRC when present on both file and jukebox row). 2) If no confident unique match, prompt the user to link manually (pick jukebox track ↔ local file). Persist manual links in app or jukebox-backed mapping store. Ambiguous multi-match should fall through to manual/disambiguation (exact field weights deferred at implementation).
libpasada JNI + states State machine (below); JNI entry points init (per run start), feedAccel, play (MP3 FD + begin adaptive playback), pause, resume, stop, getDiagnostics.

libpasada state machine (native)

Oboe behavior is summarized per state:

State Meaning
LOADED JNI .so loaded (System.loadLibrary); no Oboe callbacks registered yet.
INITIALIZED Oboe stream active, outputting silence; callbacks registered; ready for play().
PLAYING Oboe outputting decoded/adapted music (pace-driven processing active).
PAUSED User paused: Oboe still runs but outputs silence (distinct from STOPPED).
FINISHED Current track ended: Oboe outputs silence until the app starts another play() (or you later add explicit advance rules).
STOPPED Oboe callbacks unregistered / stream torn down for this run segment; lowest-power idle after stop().

JNI calls (frozen names for stubs)

  • init() — Called each time a run is started (not once per app launch): transition into INITIALIZED (register Oboe, run silent audio, allocate buffers, reset pace pipeline as needed). Preconditions: typically LOADED or returning from STOPPED after prior stop().
  • feedAccel() — Submit accelerometer samples to the pace estimator (unchanged intent).
  • play(...) — Accept open MP3 file descriptor (and any offset/length/index the native decoder needs), begin decoding and adaptive playbackPLAYING. Used when starting or switching tracks from INITIALIZED or FINISHED (new FD); not used to leave PAUSED (use resume()).
  • pause()PLAYING → PAUSED (silent output; keep graph alive).
  • resume()PAUSED → PLAYING: continue the same track/decode position and adaptive processing without re-opening the FD.
  • stop() — Tear down to STOPPED (unregister Oboe callbacks / release stream for this run).
  • getDiagnostics() — Runtime metrics / last error for logging and UI (unchanged intent).
stateDiagram-v2
  direction LR
  [*] --> Loaded
  Loaded --> Initialized: init on run start
  Initialized --> Playing: play fd
  Playing --> Paused: pause
  Paused --> Playing: resume
  Playing --> Finished: track ends native
  Finished --> Playing: play next fd
  Paused --> Stopped: stop
  Playing --> Stopped: stop
  Finished --> Stopped: stop
  Initialized --> Stopped: stop
  Stopped --> Initialized: init next run

Architecture implications (non-negotiable follow-through)

  • Foreground service + notification channel (Android 8+) with media controls (Compact/MediaStyle or MediaSession) so runs survive screen-off and fit user expectations.
  • Lifecycle split: ForegroundService is the single owner of the real-time pipeline during a run (Oboe + JNI + SensorManager registration with a Handler on a background HandlerThread for listener delivery). Activity/Compose is for setup, library mapping, and reflecting service state—not for owning playback threads. Accelerometer-driven JNI runs on that sensor HandlerThread, concurrent with Oboes audio callback path unless libpasada merges them internally. (Separate process for the service remains optional if OEM kills warrant it—defer unless needed.)
  • Permissions & battery: POST_NOTIFICATIONS (13+), possibly exact alarm if you schedule anything (likely N/A for MVP); document ignore battery optimizations only if you discover OEM kills sensor/audio — question below.
  • Jukebox vs app: Treat jukebox as metadata persistence + Spotify fetch helpers; the app owns OAuth/token refresh scheduling and passes renewed tokens (or session handles) into jukebox when syncing. Reads should stay useful offline from SQLite; network sync requires valid credentials (exact failure UX still covered under onboarding/errors below).
  • Library ingestion: Reading tags runs off main thread (e.g. during folder scan or when user adds files); store resolved local URI/path ↔ jukebox track id so the foreground service can open FDs without redoing fuzzy matching.
  • ForegroundService ↔ JNI: Starting a run calls init(); handing off each local track calls play(fd, …); pause() / notification pause map to pause(); resume() when the user resumes from pause; stop() when the run ends. Sensor HandlerThread keeps calling feedAccel() whenever registered.

UI / UX questions (MVP 1)

Navigation and screens

  1. Minimum screens: e.g. Run (now playing + pace indicator), Library (local files + jukebox side-by-side?), Settings — which are must-have vs deferred?
  2. Run screen layout: Dominant Now Playing + subtle pace vs dominant pace/BPM match + compact transport?
  3. Start/stop run: Explicit Start run that arms sensors + service vs auto-start on play?

System UI integration

  1. Notification: Full MediaSession (lockscreen, BT headsets, Wear) vs minimal ongoing notification only?
  2. Widgets / Wear: Explicitly out of scope for MVP?

Onboarding and permissions

  1. First-run flow: Grant notifications, pick folder, sign-in to Spotify (if required by jukebox), battery optimization warning — single wizard or inline prompts?
  2. Empty states: No MP3s mapped, no Spotify login, no BPM in DB — copy and primary actions?

Visual design

  1. Design system: Material 3 / dynamic color vs fixed brand palette for sports contexts (high contrast sunlight)?
  2. Accessibility: Minimum targets for sweaty fingers, TalkBack labeling for pace and controls?

Feedback and failure modes

  1. When pace detection is noisy: Show confidence, freeze adaptation, or fallback to nominal BPM?
  2. Errors: Missing file, codec failure, JNI assertion — blocking dialog vs toast + safe stop?

Suggested order to resolve

flowchart LR
  subgraph core [Core commitments]
    FS[ForegroundService]
    MS[MediaSession optional depth]
    JNI[libpasada JNI states]
  end
  subgraph data [Data layer]
    Match[Tags then manual map]
    JB[Jukebox SQLite plus app auth]
  end
  subgraph ui [UI shell]
    Nav[Screen map and navigation]
    RunUX[Run lifecycle UX]
  end
  FS --> MS
  FS --> JNI
  JNI --> Match
  Match --> JB
  FS --> RunUX
  Nav --> RunUX
  1. JNI + libpasada states — frozen (see libpasada state machine section).
  2. MP3↔metadata matchingembedded tags first, then manual link — decided.
  3. Lock screen map + notification depth.
  4. Fill edge-case UX (permissions, empty states, failures).

Optional codebase alignment

Quick pass over existing Gradle modules and any placeholder Activity will turn this questionnaire into file-specific tasks (app module layout, jukebox submodule APIs).