feat(train): music by cadence in a recording, or the metronome (ADR 0030, D48)
A workout keeps its cadence as a series of its own (steps a minute, every
10 s while moving). The Record sheet offers Music, Metronome or Off on
foot, remembered; Music once a kept track has a tempo. The recorder
measures the cadence over the last 30 s and plays a fitting track (its
tempo, half or double, within 6 %) through Media3's ExoPlayer, retuning
the track playing where it still fits, else crossfading over 3 s; a
plan's new step picks anew. A run starts at the cadence learnt from the
person's runs, which also gives a plan's steps their cadence.
Media3 1.10.1, pinned by Gradle's checksums (trusted on first download
from Google's Maven, 29 Oct 2026), for review:
media3-common aar 39686f54ff6baf8cbc841199afd55619533eaef2ab6cd0efc32b8a3c55dd88c3
module a81b2f0ebc256fe0e09f05d93c5259ade5bf44707748b9295343bee67575d9c2
media3-container aar a198b62c577fa434941282fbfa31eb66ff0ff308a6573edf0b5dd01f4e38445d
module e2a1c62c7e5d1013b906bc6ff5056db3e797cda4505e2d7d229524b24ca67a0e
media3-database aar 520e6f814e42a7d428db187ac073b453c0fcba0f4f5f4edd1cf33fafd2cfb740
module c380fea161e80d8074215a20e86e3dc2c2d825de964ca87aee66c8331f5ae252
media3-datasource aar e22f9f779047ce951152c793af094593e7cbb52475a82d56fa4f869fd319e700
module 9d32d2ec6a983ee9f418bcc05777aa23eee4db37bf794f44fb66a05d6f22e963
media3-decoder aar a9fb2b8fa1d54b4a8d6bda98a1c655b04d59c74b3ace7e2503e9e39830f13aee
module 388d5226366edd1facf8eec42cb5420871a46032d545286e90eaea8454789580
media3-exoplayer aar 19090f829afbbb595d67618f59c212f72b433ac01dc72b47594cf42552c8ed61
module ba0b9ef325fa114baa0f2fbdb4caf54029606924be1999f4af02bed5f079756b
media3-extractor aar 3c823279356b64f4215a8c6cb1e6bfa60c533351a651786ad6fa69ad001ef1b1
module b3eb755d7373e4fd199ff80814005f3b303693dfb8ace16b5b9482655d49d493
Not tried on a phone yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
7f18f001f4
commit
d5b367e930
8 changed files with 1435 additions and 40 deletions
|
|
@ -63,6 +63,10 @@ dependencies {
|
|||
testImplementation("com.google.protobuf:protobuf-javalite:3.20.0")
|
||||
// The Loop read every few hours while Train is closed (ADR 0030, D37).
|
||||
implementation("androidx.work:work-runtime-ktx:2.10.1")
|
||||
// Music by cadence in the recording service (ADR 0030, D47, D48): Media3's
|
||||
// ExoPlayer, its speed changed with the pitch kept; pinned by its checksums
|
||||
// in gradle/verification-metadata.xml.
|
||||
implementation("androidx.media3:media3-exoplayer:1.10.1")
|
||||
// The recorder's pure Kotlin parts (auto-pause) tested on the JVM.
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,11 @@ class MainActivity : FlutterActivity() {
|
|||
call.argument<String>("route")?.let { route ->
|
||||
File(noBackupFilesDir, "route.json").apply { writeText(route) }.path
|
||||
},
|
||||
// In a file too: a library's tracks are many (D47).
|
||||
music =
|
||||
call.argument<String>("music")?.let { music ->
|
||||
File(noBackupFilesDir, "music.json").apply { writeText(music) }.path
|
||||
},
|
||||
)
|
||||
result.success(null)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ln
|
||||
import kotlin.random.Random
|
||||
|
||||
/** A kept track the music may play (ADR 0030, D47): its file and its beats per minute. */
|
||||
data class BeatTrack(val path: String, val bpm: Double)
|
||||
|
||||
/** What the player is to do (D48). */
|
||||
sealed interface MusicAction {
|
||||
/** Play [track] at [speed], fading over from the one playing where [fade]. */
|
||||
data class Play(val track: BeatTrack, val speed: Double, val fade: Boolean) : MusicAction
|
||||
|
||||
/** Play the track playing at [speed] from now on. */
|
||||
data class Speed(val speed: Double) : MusicAction
|
||||
|
||||
/** Tick at [spm] beats a minute. */
|
||||
data class Metronome(val spm: Int) : MusicAction
|
||||
}
|
||||
|
||||
/**
|
||||
* The steps' cadence over the last [windowMs] (D47: 30 s), from the step detector's steps;
|
||||
* none until they span a third of it, and none again after a pause until they do.
|
||||
*/
|
||||
class CadenceMeter(private val windowMs: Long = 30_000) {
|
||||
private val steps = ArrayDeque<Long>()
|
||||
|
||||
fun step(at: Long) {
|
||||
steps.addLast(at)
|
||||
while (steps.isNotEmpty() && steps.first() < at - windowMs) steps.removeFirst()
|
||||
}
|
||||
|
||||
/** Forgets the steps: after a pause they say nothing of the cadence now. */
|
||||
fun clear() = steps.clear()
|
||||
|
||||
/** The steps a minute at [now]; null where too few. */
|
||||
fun cadence(now: Long): Int? {
|
||||
while (steps.isNotEmpty() && steps.first() < now - windowMs) steps.removeFirst()
|
||||
if (steps.size < 10) return null
|
||||
val span = steps.last() - steps.first()
|
||||
if (span < windowMs / 3) return null
|
||||
return ((steps.size - 1) * 60_000.0 / span).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses the music by cadence (D47, D48): a track fits a cadence at its tempo, its half or
|
||||
* its double, sped up or slowed down by at most [stretch] (6 %); among those that fit, one not
|
||||
* played yet, at random, until every one that fits has played. A new cadence retunes the track
|
||||
* playing where it still fits, else fades into one that does; a plan's new step always picks
|
||||
* anew. Where nothing fits, the nearest plays as near as the stretch allows. Without tracks it
|
||||
* ticks a metronome. A change of under 2 steps a minute does nothing, so that the measured
|
||||
* cadence's wobble doesn't retune the music on every tick.
|
||||
*/
|
||||
class MusicConductor(
|
||||
private val tracks: List<BeatTrack>,
|
||||
private val random: Random = Random.Default,
|
||||
private val stretch: Double = 0.06,
|
||||
) {
|
||||
var playing: BeatTrack? = null
|
||||
private set
|
||||
|
||||
private var speed = 1.0
|
||||
private var target: Int? = null
|
||||
private val played = mutableSetOf<String>()
|
||||
|
||||
/** What to do for the cadence [spm]; [anew] where a plan's step changed. */
|
||||
fun cadence(spm: Int, anew: Boolean = false): MusicAction? {
|
||||
val before = target
|
||||
if (!anew && before != null && abs(spm - before) < 2) return null
|
||||
target = spm
|
||||
if (tracks.isEmpty()) return MusicAction.Metronome(spm)
|
||||
val now = playing
|
||||
if (now != null && !anew) {
|
||||
fitOf(now, spm)?.let { fitting ->
|
||||
if (abs(fitting - speed) < 0.005) return null
|
||||
speed = fitting
|
||||
return MusicAction.Speed(fitting)
|
||||
}
|
||||
}
|
||||
return play(spm, fade = now != null)
|
||||
}
|
||||
|
||||
/** What to do once the track playing ended. */
|
||||
fun ended(): MusicAction? {
|
||||
val spm = target ?: return null
|
||||
if (tracks.isEmpty()) return null
|
||||
return play(spm, fade = false)
|
||||
}
|
||||
|
||||
private fun play(spm: Int, fade: Boolean): MusicAction {
|
||||
val fitting = tracks.filter { fitOf(it, spm) != null }
|
||||
val track =
|
||||
if (fitting.isEmpty()) {
|
||||
tracks.minBy { abs(ln(nearestRatio(it, spm))) }
|
||||
} else {
|
||||
var fresh = fitting.filter { it.path !in played && it != playing }
|
||||
if (fresh.isEmpty()) {
|
||||
played.removeAll(fitting.map { it.path }.toSet())
|
||||
fresh = fitting.filter { it != playing }.ifEmpty { fitting }
|
||||
}
|
||||
fresh[random.nextInt(fresh.size)]
|
||||
}
|
||||
played += track.path
|
||||
playing = track
|
||||
speed = fitOf(track, spm) ?: nearestRatio(track, spm).coerceIn(1 - stretch, 1 + stretch)
|
||||
return MusicAction.Play(track, speed, fade)
|
||||
}
|
||||
|
||||
/** The speed at which [track] fits [spm]; null where none within the stretch does. */
|
||||
fun fitOf(track: BeatTrack, spm: Int): Double? =
|
||||
nearestRatio(track, spm).takeIf { abs(it - 1) <= stretch + 1e-9 }
|
||||
|
||||
/** The ratio of [spm] to the nearest of [track]'s tempo, its half and its double. */
|
||||
private fun nearestRatio(track: BeatTrack, spm: Int): Double =
|
||||
listOf(0.5, 1.0, 2.0).map { spm / (track.bpm * it) }.minBy { abs(ln(it)) }
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioTrack
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import java.io.File
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* Plays what [MusicConductor] chooses in the recording service (ADR 0030, D47, D48): a track
|
||||
* through Media3's ExoPlayer at the speed asked, its pitch kept; a new one faded over from the
|
||||
* one playing in 3 s, by a second player; [onEnded] once a track ends. The player playing holds
|
||||
* the audio focus, so the cues duck it. The metronome is a click looped by an [AudioTrack].
|
||||
* On the service's thread.
|
||||
*/
|
||||
class MusicPlayer(private val context: Context, private val onEnded: () -> Unit) {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val attributes =
|
||||
AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).build()
|
||||
private var current: ExoPlayer? = null
|
||||
private var fading: ExoPlayer? = null
|
||||
private var fade: Runnable? = null
|
||||
private var metronome: AudioTrack? = null
|
||||
private var paused = false
|
||||
|
||||
fun act(action: MusicAction) {
|
||||
when (action) {
|
||||
is MusicAction.Play -> play(action.track, action.speed, action.fade)
|
||||
is MusicAction.Speed -> current?.playbackParameters = PlaybackParameters(action.speed.toFloat())
|
||||
is MusicAction.Metronome -> tick(action.spm)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stops for a pause the person made, until [resume]. */
|
||||
fun pause() {
|
||||
paused = true
|
||||
fading?.pause()
|
||||
current?.pause()
|
||||
metronome?.pause()
|
||||
}
|
||||
|
||||
fun resume() {
|
||||
paused = false
|
||||
current?.play()
|
||||
metronome?.play()
|
||||
}
|
||||
|
||||
fun close() {
|
||||
fade?.let(handler::removeCallbacks)
|
||||
fading?.release()
|
||||
fading = null
|
||||
current?.release()
|
||||
current = null
|
||||
metronome?.release()
|
||||
metronome = null
|
||||
}
|
||||
|
||||
private fun player(track: BeatTrack, speed: Double, focus: Boolean): ExoPlayer =
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
setAudioAttributes(attributes, focus)
|
||||
setMediaItem(MediaItem.fromUri(File(track.path).toURI().toString()))
|
||||
playbackParameters = PlaybackParameters(speed.toFloat())
|
||||
addListener(
|
||||
object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(state: Int) {
|
||||
if (state == Player.STATE_ENDED && this@apply === current) onEnded()
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
// A file that won't play is skipped, as if it had ended.
|
||||
if (this@apply === current) onEnded()
|
||||
}
|
||||
},
|
||||
)
|
||||
prepare()
|
||||
playWhenReady = !paused
|
||||
}
|
||||
|
||||
private fun play(track: BeatTrack, speed: Double, crossfade: Boolean) {
|
||||
fade?.let(handler::removeCallbacks)
|
||||
fading?.release()
|
||||
fading = null
|
||||
val before = current
|
||||
if (!crossfade || before == null) {
|
||||
before?.release()
|
||||
current = player(track, speed, focus = true)
|
||||
return
|
||||
}
|
||||
// The new one plays without the focus until the old one lets it go.
|
||||
val next = player(track, speed, focus = false).apply { volume = 0f }
|
||||
fading = before
|
||||
current = next
|
||||
val steps = 30
|
||||
var step = 0
|
||||
fade =
|
||||
object : Runnable {
|
||||
override fun run() {
|
||||
step++
|
||||
val share = step.toFloat() / steps
|
||||
next.volume = share
|
||||
before.volume = 1 - share
|
||||
if (step < steps) {
|
||||
handler.postDelayed(this, 100)
|
||||
} else {
|
||||
before.release()
|
||||
if (fading === before) fading = null
|
||||
next.setAudioAttributes(attributes, true)
|
||||
}
|
||||
}
|
||||
}.also { handler.postDelayed(it, 100) }
|
||||
}
|
||||
|
||||
/** Loops a click at [spm] clicks a minute. */
|
||||
private fun tick(spm: Int) {
|
||||
metronome?.release()
|
||||
val rate = 44_100
|
||||
val length = rate * 60 / spm.coerceIn(60, 240)
|
||||
val samples =
|
||||
ShortArray(length) { i ->
|
||||
val t = i.toDouble() / rate
|
||||
if (t > 0.03) 0 else (sin(2 * PI * 1_500 * t) * exp(-t * 150) * 12_000).toInt().toShort()
|
||||
}
|
||||
metronome =
|
||||
AudioTrack.Builder()
|
||||
.setAudioAttributes(
|
||||
android.media.AudioAttributes.Builder()
|
||||
.setUsage(android.media.AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build(),
|
||||
)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(rate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.build(),
|
||||
)
|
||||
.setTransferMode(AudioTrack.MODE_STATIC)
|
||||
.setBufferSizeInBytes(length * 2)
|
||||
.build()
|
||||
.apply {
|
||||
write(samples, 0, length)
|
||||
setLoopPoints(0, length, -1)
|
||||
if (!paused) play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,12 +5,16 @@ import kotlin.math.cos
|
|||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/** A step as the recorder runs it (ADR 0030, D42): how it ends, its target, and what is said as it starts. */
|
||||
/**
|
||||
* A step as the recorder runs it (ADR 0030, D42): how it ends, its target, what is said as it
|
||||
* starts, and the cadence its music follows where its target asks for one (D47).
|
||||
*/
|
||||
data class RunStep(
|
||||
val seconds: Int? = null,
|
||||
val metres: Int? = null,
|
||||
val target: RunTarget? = null,
|
||||
val say: String = "",
|
||||
val cadence: Int? = null,
|
||||
)
|
||||
|
||||
/** A target's range, its kind one of `pace`, `speed`, `heartRate`, `split`, `watts`, `strokeRate` (zones come as heart rates). */
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
private const val EXTRA_LOOP = "loop"
|
||||
private const val EXTRA_PLAN = "plan"
|
||||
private const val EXTRA_ROUTE = "route"
|
||||
private const val EXTRA_MUSIC = "music"
|
||||
private const val CHANNEL = "recording"
|
||||
private const val NOTIFICATION_ID = 5301
|
||||
private const val FLUSH_EVERY_MS = 5_000L
|
||||
|
|
@ -80,7 +81,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
* by [autoPause] (null: never), with the strap at [strap], the rower
|
||||
* at [rower] and the Loop [loop] (null: none), its notification as
|
||||
* [notice] says, running [plan] (JSON) and following the route in the
|
||||
* file at [route] (D45; null: none of either).
|
||||
* file at [route] (D45; null: none of either), playing the music the
|
||||
* file at [music] says (D47, D48; null: none).
|
||||
*/
|
||||
fun start(
|
||||
context: Context,
|
||||
|
|
@ -93,6 +95,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
loop: String?,
|
||||
plan: String?,
|
||||
route: String?,
|
||||
music: String?,
|
||||
) {
|
||||
ContextCompat.startForegroundService(
|
||||
context,
|
||||
|
|
@ -106,7 +109,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
.putExtra(EXTRA_ROWER, rower)
|
||||
.putExtra(EXTRA_LOOP, loop)
|
||||
.putExtra(EXTRA_PLAN, plan)
|
||||
.putExtra(EXTRA_ROUTE, route),
|
||||
.putExtra(EXTRA_ROUTE, route)
|
||||
.putExtra(EXTRA_MUSIC, music),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -155,6 +159,17 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
private var cues: Cues? = null
|
||||
private val measures = PlanMeasures()
|
||||
|
||||
/**
|
||||
* Its music (D47, D48): what chooses it, the cadence measured, the player; the cadence the
|
||||
* plan's step asks for, else the person's usual one until steps say better.
|
||||
*/
|
||||
private var conductor: MusicConductor? = null
|
||||
private val cadence = CadenceMeter()
|
||||
private var music: MusicPlayer? = null
|
||||
private var stepCadence: Int? = null
|
||||
private var usualCadence = 165
|
||||
private var lastMusicAt = 0L
|
||||
|
||||
/** The route it follows (D45) and its words. */
|
||||
private var guide: RouteGuide? = null
|
||||
private var guideWords: JSONObject? = null
|
||||
|
|
@ -173,6 +188,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
plan?.let { runner ->
|
||||
cue(runner.tick(now, isPaused(), measures.metres) { kind -> measures.measure(kind, now) }, now)
|
||||
}
|
||||
// The measured cadence moves the music every 5 s at most.
|
||||
if (now - lastMusicAt >= 5_000) followCadence(now, anew = false)
|
||||
if (now - lastFlush >= FLUSH_EVERY_MS) {
|
||||
writer?.flush()
|
||||
lastFlush = now
|
||||
|
|
@ -213,7 +230,10 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
val rowerAt = intent.getStringExtra(EXTRA_ROWER)?.takeIf { connects }
|
||||
val loopAt = intent.getStringExtra(EXTRA_LOOP)?.takeIf { connects }
|
||||
val autoPauseBy = intent.getStringExtra(EXTRA_AUTO_PAUSE)
|
||||
val stepping = autoPauseBy == "steps" && counted
|
||||
// Steps on foot whatever pauses it: they are the workout's cadence and
|
||||
// what its music follows (D48).
|
||||
val onFoot = activity == "running" || activity == "walking" || activity == "hiking"
|
||||
val stepping = (autoPauseBy == "steps" || onFoot) && counted
|
||||
notice = intent.getStringExtra(EXTRA_NOTICE)?.let(::JSONObject)
|
||||
channel(notice?.optString("channelName"))
|
||||
// In the foreground first: Android allows a few seconds for it.
|
||||
|
|
@ -287,6 +307,9 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
intent.getStringExtra(EXTRA_ROUTE)?.let { at ->
|
||||
runCatching { JSONObject(File(at).readText()) }.getOrNull()?.let { followRoute(it, now) }
|
||||
}
|
||||
intent.getStringExtra(EXTRA_MUSIC)?.let { at ->
|
||||
runCatching { JSONObject(File(at).readText()) }.getOrNull()?.let { playMusic(it, now) }
|
||||
}
|
||||
lastFlush = now
|
||||
handler.postDelayed(ticker, 1_000)
|
||||
}
|
||||
|
|
@ -311,6 +334,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
RunTarget(it.getString("kind"), it.getDouble("low"), it.getDouble("high"))
|
||||
},
|
||||
say = step.optString("say"),
|
||||
cadence = step.optInt("cadence").takeIf { it > 0 },
|
||||
)
|
||||
}
|
||||
planWords = json.optJSONObject("say")
|
||||
|
|
@ -320,6 +344,36 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays the music [json] says (D47, D48): `tracks`, each with its `path` and `bpm`, chosen by
|
||||
* cadence; none, a metronome at it; `cadence`, the person's usual one, until a plan's step or
|
||||
* the steps measured say another.
|
||||
*/
|
||||
private fun playMusic(json: JSONObject, now: Long) {
|
||||
val tracks = json.optJSONArray("tracks")
|
||||
val beats =
|
||||
(0 until (tracks?.length() ?: 0)).mapNotNull { i ->
|
||||
val track = tracks!!.getJSONObject(i)
|
||||
BeatTrack(track.getString("path"), track.getDouble("bpm")).takeIf { File(it.path).exists() }
|
||||
}
|
||||
usualCadence = json.optInt("cadence", usualCadence)
|
||||
val chooser = MusicConductor(beats)
|
||||
conductor = chooser
|
||||
music =
|
||||
MusicPlayer(this) { chooser.ended()?.let { music?.act(it) } }.also { player ->
|
||||
chooser.cadence(stepCadence ?: usualCadence)?.let(player::act)
|
||||
}
|
||||
lastMusicAt = now
|
||||
}
|
||||
|
||||
/** The cadence the music follows now: the step's, else the steps', else the usual one. */
|
||||
private fun followCadence(now: Long, anew: Boolean) {
|
||||
val chooser = conductor ?: return
|
||||
val spm = stepCadence ?: cadence.cadence(now) ?: usualCadence
|
||||
chooser.cadence(spm, anew)?.let { music?.act(it) }
|
||||
lastMusicAt = now
|
||||
}
|
||||
|
||||
/** The cues, in the app's language [locale], made once for the plan and the route. */
|
||||
private fun cuesIn(locale: String) {
|
||||
if (cues == null) cues = Cues(this, locale.takeIf { it.isNotEmpty() })
|
||||
|
|
@ -399,11 +453,14 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
is PlanEvent.Started -> {
|
||||
write("step $at ${event.index}")
|
||||
cues?.change(planSteps[event.index].say)
|
||||
stepCadence = planSteps[event.index].cadence
|
||||
followCadence(at, anew = true)
|
||||
}
|
||||
is PlanEvent.Countdown -> cues?.countdown()
|
||||
is PlanEvent.OffTarget ->
|
||||
cues?.offTarget(planWords?.optString(if (event.harder) "harder" else "easier").orEmpty())
|
||||
PlanEvent.Done -> {
|
||||
stepCadence = null
|
||||
write("step $at done")
|
||||
cues?.change(planWords?.optString("done").orEmpty())
|
||||
}
|
||||
|
|
@ -520,6 +577,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
if (event.sensor.type != Sensor.TYPE_STEP_DETECTOR) return
|
||||
val at = wallTime(event.timestamp)
|
||||
write("s $at")
|
||||
cadence.step(at)
|
||||
autoPause?.step(at)?.let { paused -> mark(paused, auto = true, at = at) }
|
||||
}
|
||||
|
||||
|
|
@ -533,12 +591,15 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
val now = System.currentTimeMillis()
|
||||
pausedByHand = paused
|
||||
autoPause?.byHand(paused, now)
|
||||
// The music waits for the person, not for a light's red (auto-pause).
|
||||
if (paused) music?.pause() else music?.resume()
|
||||
mark(paused, auto = false, at = now)
|
||||
}
|
||||
|
||||
/** Writes a pause or its end, and shows it at once, the app there or not. */
|
||||
private fun mark(paused: Boolean, auto: Boolean, at: Long) {
|
||||
measures.pause(paused)
|
||||
cadence.clear()
|
||||
write("${if (paused) "pause" else "resume"} $at ${if (auto) "auto" else "manual"}")
|
||||
writer?.flush()
|
||||
repost()
|
||||
|
|
@ -567,6 +628,9 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
guide = null
|
||||
cues?.close()
|
||||
cues = null
|
||||
music?.close()
|
||||
music = null
|
||||
conductor = null
|
||||
loopId?.let { PolarSdk.reads(it, now = false) }
|
||||
if (loopId != null) PolarSdk.release(this)
|
||||
loopId = null
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import kotlin.random.Random
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** The music chosen by cadence (ADR 0030, D47, D48). */
|
||||
class MusicConductorTest {
|
||||
private val fast = BeatTrack("fast", 172.0)
|
||||
private val slow = BeatTrack("slow", 88.0)
|
||||
private val mid = BeatTrack("mid", 150.0)
|
||||
private val other = BeatTrack("other", 174.0)
|
||||
|
||||
@Test
|
||||
fun `a track fits at its tempo, its half or its double, within 6 %`() {
|
||||
val conductor = MusicConductor(emptyList())
|
||||
assertEquals(1.0, conductor.fitOf(fast, 172)!!, 1e-9)
|
||||
assertEquals(176.0 / 176.0, conductor.fitOf(slow, 176)!!, 1e-9)
|
||||
assertEquals(180 / 172.0, conductor.fitOf(fast, 180)!!, 1e-9)
|
||||
assertNull(conductor.fitOf(fast, 190))
|
||||
assertNull(conductor.fitOf(mid, 172))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first cadence plays a fitting track at once, without a fade`() {
|
||||
val action = MusicConductor(listOf(mid, fast), Random(1)).cadence(170)
|
||||
assertEquals(MusicAction.Play(fast, 170 / 172.0, fade = false), action)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cadence within the stretch retunes, a small wobble does nothing`() {
|
||||
val conductor = MusicConductor(listOf(fast), Random(1))
|
||||
conductor.cadence(172)
|
||||
assertNull(conductor.cadence(173))
|
||||
assertEquals(MusicAction.Speed(176 / 172.0), conductor.cadence(176))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `beyond the stretch it fades into a track that fits`() {
|
||||
val conductor = MusicConductor(listOf(fast, mid), Random(1))
|
||||
conductor.cadence(172)
|
||||
assertEquals(MusicAction.Play(mid, 152 / 150.0, fade = true), conductor.cadence(152))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a plan's new step picks anew even where the track fits`() {
|
||||
val conductor = MusicConductor(listOf(fast, other), Random(1))
|
||||
val first = (conductor.cadence(173) as MusicAction.Play).track
|
||||
val next = conductor.cadence(173, anew = true) as MusicAction.Play
|
||||
assertTrue(next.fade)
|
||||
assertTrue(next.track != first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every fitting track plays once before any plays again`() {
|
||||
val tracks = (1..5).map { BeatTrack("t$it", 170.0 + it) }
|
||||
val conductor = MusicConductor(tracks, Random(3))
|
||||
conductor.cadence(173)
|
||||
val heard = mutableListOf(conductor.playing!!.path)
|
||||
repeat(4) { heard += (conductor.ended() as MusicAction.Play).track.path }
|
||||
assertEquals(tracks.map { it.path }.toSet(), heard.toSet())
|
||||
val again = (conductor.ended() as MusicAction.Play).track.path
|
||||
assertTrue(again != heard.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `where nothing fits, the nearest plays as near as the stretch allows`() {
|
||||
val conductor = MusicConductor(listOf(mid, fast), Random(1))
|
||||
val action = conductor.cadence(200) as MusicAction.Play
|
||||
assertEquals(fast, action.track)
|
||||
assertEquals(1.06, action.speed, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `without tracks it ticks a metronome at the cadence`() {
|
||||
val conductor = MusicConductor(emptyList())
|
||||
assertEquals(MusicAction.Metronome(168), conductor.cadence(168))
|
||||
assertNull(conductor.cadence(169))
|
||||
assertEquals(MusicAction.Metronome(176), conductor.cadence(176))
|
||||
assertNull(conductor.ended())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the cadence is the steps over the last 30 s, none until they span 10 s, none after a pause`() {
|
||||
val meter = CadenceMeter()
|
||||
for (i in 0 until 20) meter.step(i * 350L)
|
||||
assertNull(meter.cadence(20 * 350L))
|
||||
for (i in 20 until 100) meter.step(i * 350L)
|
||||
assertEquals(171, meter.cadence(99 * 350L))
|
||||
meter.clear()
|
||||
assertNull(meter.cadence(99 * 350L))
|
||||
}
|
||||
}
|
||||
|
|
@ -3,8 +3,10 @@
|
|||
Polar's BLE SDK comes from JitPack (ADR 0030, D34), which builds it from
|
||||
Polar's repository itself: its files are pinned here by their SHA-256, as
|
||||
JitPack served them on 28 Oct 2026, so that a file changed there fails the
|
||||
build. Everything else comes from Google's and Maven Central's repositories
|
||||
and is trusted as before.
|
||||
build. Media3, the recording's music player (ADR 0030, D48), is pinned so
|
||||
too, as Google's Maven served it on 29 Oct 2026, written by Gradle
|
||||
(its option write-verification-metadata, sha256). Everything else comes from
|
||||
Google's and Maven Central's repositories and is trusted as before.
|
||||
-->
|
||||
<verification-metadata xmlns="https://schema.gradle.org/dependency-verification"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
|
|
@ -13,10 +15,66 @@
|
|||
<verify-metadata>true</verify-metadata>
|
||||
<verify-signatures>false</verify-signatures>
|
||||
<trusted-artifacts>
|
||||
<trust group="^(?!com[.]github[.]polarofficial$).*$" regex="true"/>
|
||||
<trust group="^(?!(com[.]github[.]polarofficial|androidx[.]media3)$).*$" regex="true"/>
|
||||
</trusted-artifacts>
|
||||
</configuration>
|
||||
<components>
|
||||
<component group="androidx.media3" name="media3-common" version="1.10.1">
|
||||
<artifact name="media3-common-1.10.1.aar">
|
||||
<sha256 value="39686f54ff6baf8cbc841199afd55619533eaef2ab6cd0efc32b8a3c55dd88c3" origin="Google's Maven, 29 Oct 2026"/>
|
||||
</artifact>
|
||||
<artifact name="media3-common-1.10.1.module">
|
||||
<sha256 value="a81b2f0ebc256fe0e09f05d93c5259ade5bf44707748b9295343bee67575d9c2" origin="Google's Maven, 29 Oct 2026"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.media3" name="media3-container" version="1.10.1">
|
||||
<artifact name="media3-container-1.10.1.aar">
|
||||
<sha256 value="a198b62c577fa434941282fbfa31eb66ff0ff308a6573edf0b5dd01f4e38445d" origin="Google's Maven, 29 Oct 2026"/>
|
||||
</artifact>
|
||||
<artifact name="media3-container-1.10.1.module">
|
||||
<sha256 value="e2a1c62c7e5d1013b906bc6ff5056db3e797cda4505e2d7d229524b24ca67a0e" origin="Google's Maven, 29 Oct 2026"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.media3" name="media3-database" version="1.10.1">
|
||||
<artifact name="media3-database-1.10.1.aar">
|
||||
<sha256 value="520e6f814e42a7d428db187ac073b453c0fcba0f4f5f4edd1cf33fafd2cfb740" origin="Google's Maven, 29 Oct 2026"/>
|
||||
</artifact>
|
||||
<artifact name="media3-database-1.10.1.module">
|
||||
<sha256 value="c380fea161e80d8074215a20e86e3dc2c2d825de964ca87aee66c8331f5ae252" origin="Google's Maven, 29 Oct 2026"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.media3" name="media3-datasource" version="1.10.1">
|
||||
<artifact name="media3-datasource-1.10.1.aar">
|
||||
<sha256 value="e22f9f779047ce951152c793af094593e7cbb52475a82d56fa4f869fd319e700" origin="Google's Maven, 29 Oct 2026"/>
|
||||
</artifact>
|
||||
<artifact name="media3-datasource-1.10.1.module">
|
||||
<sha256 value="9d32d2ec6a983ee9f418bcc05777aa23eee4db37bf794f44fb66a05d6f22e963" origin="Google's Maven, 29 Oct 2026"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.media3" name="media3-decoder" version="1.10.1">
|
||||
<artifact name="media3-decoder-1.10.1.aar">
|
||||
<sha256 value="a9fb2b8fa1d54b4a8d6bda98a1c655b04d59c74b3ace7e2503e9e39830f13aee" origin="Google's Maven, 29 Oct 2026"/>
|
||||
</artifact>
|
||||
<artifact name="media3-decoder-1.10.1.module">
|
||||
<sha256 value="388d5226366edd1facf8eec42cb5420871a46032d545286e90eaea8454789580" origin="Google's Maven, 29 Oct 2026"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.media3" name="media3-exoplayer" version="1.10.1">
|
||||
<artifact name="media3-exoplayer-1.10.1.aar">
|
||||
<sha256 value="19090f829afbbb595d67618f59c212f72b433ac01dc72b47594cf42552c8ed61" origin="Google's Maven, 29 Oct 2026"/>
|
||||
</artifact>
|
||||
<artifact name="media3-exoplayer-1.10.1.module">
|
||||
<sha256 value="ba0b9ef325fa114baa0f2fbdb4caf54029606924be1999f4af02bed5f079756b" origin="Google's Maven, 29 Oct 2026"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.media3" name="media3-extractor" version="1.10.1">
|
||||
<artifact name="media3-extractor-1.10.1.aar">
|
||||
<sha256 value="3c823279356b64f4215a8c6cb1e6bfa60c533351a651786ad6fa69ad001ef1b1" origin="Google's Maven, 29 Oct 2026"/>
|
||||
</artifact>
|
||||
<artifact name="media3-extractor-1.10.1.module">
|
||||
<sha256 value="b3eb755d7373e4fd199ff80814005f3b303693dfb8ace16b5b9482655d49d493" origin="Google's Maven, 29 Oct 2026"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.github.polarofficial" name="polar-ble-sdk" version="8.3.0">
|
||||
<artifact name="polar-ble-sdk-8.3.0.aar">
|
||||
<sha256 value="f207c8b4a1812890e4737ac5c72b46bbcb1889826ef89d82ddc66c5772a826d0" origin="JitPack, 28 Oct 2026"/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue