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:
Tizian 「ティツィアーン」 2026-10-29 13:31:41 +01:00
commit d5b367e930
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
35 changed files with 1435 additions and 40 deletions

File diff suppressed because one or more lines are too long

View file

@ -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")
}

View file

@ -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)
}

View file

@ -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)) }
}

View file

@ -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()
}
}
}

View file

@ -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). */

View file

@ -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

View file

@ -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))
}
}

View file

@ -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"/>

View file

@ -501,5 +501,9 @@
"musicKeptDone": "{count} Titel, {size} MB, {tempo} mit Tempo",
"musicKeptDelete": "Löschen",
"musicKeptDeleteTitle": "Die Musik auf diesem Telefon löschen?",
"musicKeptDeleteText": "Alle Playlists werden abgewählt und ihre Titel von diesem Telefon gelöscht. Auf deinem Jellyfin-Server bleiben sie."
"musicKeptDeleteText": "Alle Playlists werden abgewählt und ihre Titel von diesem Telefon gelöscht. Auf deinem Jellyfin-Server bleiben sie.",
"recordMusic": "Musik",
"recordMusicOn": "Musik",
"recordMetronome": "Metronom",
"recordMusicOff": "Aus"
}

View file

@ -631,5 +631,10 @@
"@musicKeptDone": {"placeholders": {"count": {"type": "int"}, "size": {"type": "String", "example": "230.4"}, "tempo": {"type": "int"}}},
"musicKeptDelete": "Delete",
"musicKeptDeleteTitle": "Delete the music on this phone?",
"musicKeptDeleteText": "Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server."
"musicKeptDeleteText": "Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server.",
"recordMusic": "Music",
"recordMusicOn": "Music",
"@recordMusicOn": {"description": "The segment playing the kept tracks by cadence."},
"recordMetronome": "Metronome",
"recordMusicOff": "Off"
}

View file

@ -3033,6 +3033,30 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server.'**
String get musicKeptDeleteText;
/// No description provided for @recordMusic.
///
/// In en, this message translates to:
/// **'Music'**
String get recordMusic;
/// The segment playing the kept tracks by cadence.
///
/// In en, this message translates to:
/// **'Music'**
String get recordMusicOn;
/// No description provided for @recordMetronome.
///
/// In en, this message translates to:
/// **'Metronome'**
String get recordMetronome;
/// No description provided for @recordMusicOff.
///
/// In en, this message translates to:
/// **'Off'**
String get recordMusicOff;
}
class _AppLocalizationsDelegate

View file

@ -1809,4 +1809,16 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get musicKeptDeleteText =>
'Alle Playlists werden abgewählt und ihre Titel von diesem Telefon gelöscht. Auf deinem Jellyfin-Server bleiben sie.';
@override
String get recordMusic => 'Musik';
@override
String get recordMusicOn => 'Musik';
@override
String get recordMetronome => 'Metronom';
@override
String get recordMusicOff => 'Aus';
}

View file

@ -1797,6 +1797,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get musicKeptDeleteText =>
'Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server.';
@override
String get recordMusic => 'Music';
@override
String get recordMusicOn => 'Music';
@override
String get recordMetronome => 'Metronome';
@override
String get recordMusicOff => 'Off';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -7,6 +7,9 @@ import 'package:halkyon_train_flutter/src/app/train_frame.dart';
import 'package:halkyon_train_flutter/src/app/train_languages.dart';
import 'package:halkyon_train_flutter/src/feature/day/bloc/days_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/health/widget/health_screen.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/cadence_model.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_downloads.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/recorder_music.dart';
import 'package:halkyon_train_flutter/src/feature/plan/bloc/plans_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_speech.dart';
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
@ -15,6 +18,7 @@ import 'package:halkyon_train_flutter/src/feature/route/bloc/routes_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/route/widget/route_import.dart';
import 'package:halkyon_train_flutter/src/feature/route/widget/route_speech.dart';
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workouts_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_train_flutter/src/home/home_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
@ -147,7 +151,7 @@ class const _Recording({required final Widget child}) extends StatelessWidget {
),
loop: () => train.loop?.status.loop?.id,
// Spoken in the app's language, zones from the person's profile.
planOf: (plan) async {
planOf: (plan, {cadence}) async {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toLanguageTag();
final profile = await train.profile.read();
@ -156,8 +160,28 @@ class const _Recording({required final Widget child}) extends StatelessWidget {
locale,
plan,
maxHeartRate: planMaxHeartRate(profile, clock.now()),
cadence: cadence,
);
},
// The kept tracks with a tempo, the cadence learnt from the
// person's runs (D47, D48).
musicOf: (choice, activity) async {
final kept = train.keptMusic;
if (choice == MusicChoice.off || !playsMusic(activity)) {
return null;
}
final cadence = activity == Activity.running
? await cadenceModelFrom(train.workouts)
: null;
final music = recorderMusicOf(
choice,
activity,
kept: kept?.kept.value ?? const KeptMusic(),
pathOf: (id) => kept?.fileOf(id)?.path,
cadence: cadence,
);
return music == null ? null : (music: music, cadence: cadence);
},
routeOf: (route, activity) => recorderRouteOf(
AppLocalizations.of(context),
Localizations.localeOf(context).toLanguageTag(),

View file

@ -0,0 +1,158 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// How many runs with a cadence it takes before Train trusts what they
/// show (ADR 0030, D47).
const minCadenceRuns = 3;
/// How many cadence samples a run needs to count among them: a few
/// minutes' worth.
const minCadenceSamples = 12;
/// How a person's cadence follows their speed on foot (D47, D48): steps a
/// minute = [intercept] + [slope] × metres a second, a line fitted over
/// their past runs; the music follows it for a step that aims at a pace or
/// a speed.
final class const CadenceModel({
required final double intercept,
required final double slope,
/// The cadence the runs had on average: the music's start, before a
/// step or the steps say another.
required final int usual,
}) with Equatable {
/// The cadence at [metresPerSecond], within what running has.
int at(double metresPerSecond) =>
(intercept + slope * metresPerSecond).round().clamp(120, 220);
/// The cadence a step aiming at [target] asks for, at the middle of its
/// range: a pace (seconds a km) or a speed (km/h); null for any other.
int? forTarget(Target target) {
final middle = (target.low + target.high) / 2;
return switch (target.kind) {
TargetKind.pace when middle > 0 => at(1000 / middle),
TargetKind.speed when middle > 0 => at(middle / 3.6),
_ => null,
};
}
@override
List<Object?> get props => [intercept, slope, usual];
}
/// The cadence [runs] show against their speed: each cadence sample with
/// the speed over its 10 s through the fixes precise enough; null until
/// [minCadenceRuns] runs have [minCadenceSamples] such samples each.
/// Speeds that hardly vary give the person's usual cadence at any speed.
CadenceModel? cadenceModelOf(
Iterable<(WorkoutContent, List<SeriesChunk>)> runs,
) {
final speeds = <double>[];
final cadences = <double>[];
var counted = 0;
for (final (content, series) in runs) {
final pairs = _pairsOf(recordingOf(content, series), series);
if (pairs.length < minCadenceSamples) {
continue;
}
counted++;
for (final (speed, cadence) in pairs) {
speeds.add(speed);
cadences.add(cadence);
}
}
if (counted < minCadenceRuns) {
return null;
}
final n = speeds.length;
final meanSpeed = speeds.reduce((a, b) => a + b) / n;
final meanCadence = cadences.reduce((a, b) => a + b) / n;
var spread = 0.0;
var together = 0.0;
for (var i = 0; i < n; i++) {
spread += (speeds[i] - meanSpeed) * (speeds[i] - meanSpeed);
together += (speeds[i] - meanSpeed) * (cadences[i] - meanCadence);
}
// Under 0.3 m/s between its runs' speeds, a slope is noise; and none is
// steeper than what runners show (some 8 to 15 steps a minute per m/s).
final slope = spread / n < 0.09 ? 0.0 : (together / spread).clamp(0.0, 25.0);
return CadenceModel(
intercept: meanCadence - slope * meanSpeed,
slope: slope,
usual: meanCadence.round(),
);
}
/// How many of the latest runs [cadenceModelFrom] reads.
const cadenceRunsRead = 10;
/// The cadence model of the person's latest [cadenceRunsRead] runs Train
/// recorded (D47).
Future<CadenceModel?> cadenceModelFrom(WorkoutRepository workouts) async {
final runs = [
for (final workout in await workouts.all())
if (workout.content case final content?
when content.activity == Activity.running && content.origin == null)
(workout.id, content),
]..sort((a, b) => b.$2.startedAt.compareTo(a.$2.startedAt));
return cadenceModelOf([
for (final (id, content) in runs.take(cadenceRunsRead))
(content, await workouts.series(id)),
]);
}
/// [recording]'s speed in metres a second beside each cadence sample of
/// [series], where its fixes say one: running cadences and speeds only.
List<(double, double)> _pairsOf(
Recording recording,
List<SeriesChunk> series,
) {
final fixes = [
for (final fix in recording.fixes)
if (fix.accuracyCm <= maxAccuracyCm) fix,
];
final samples = [
for (final chunk in series)
if (chunk.kind == SeriesKind.cadence)
for (final (i, time) in chunk.absoluteTimes.indexed)
if (chunk.columns['spm']?[i] case final spm?
when spm >= 120 && spm <= 230)
(time: time, spm: spm),
]..sort((a, b) => a.time.compareTo(b.time));
final pairs = <(double, double)>[];
var first = 0;
for (final (:time, :spm) in samples) {
final from = time.subtract(cadenceWindow);
while (first < fixes.length && fixes[first].time.isBefore(from)) {
first++;
}
var metres = 0.0;
Fix? start;
Fix? last;
for (var j = first; j < fixes.length && !fixes[j].time.isAfter(time); j++) {
if (last != null) {
metres += metresBetween(last, fixes[j]);
}
start ??= fixes[j];
last = fixes[j];
}
if (start == null || last == null) {
continue;
}
final seconds = last.time.difference(start.time).inMilliseconds / 1000;
if (seconds < cadenceWindow.inSeconds / 2) {
continue;
}
final speed = metres / seconds;
if (speed >= 1.5 && speed <= 7) {
pairs.add((speed, spm.toDouble()));
}
}
return pairs;
}

View file

@ -109,6 +109,10 @@ class MusicLibrary(
_set(settings.value.copyWith(playlists: playlists));
}
/// Remembers what the Record sheet plays, by its name (D48).
void playInRecording(String choice) =>
_set(settings.value.copyWith(inRecording: choice));
/// Unmarks every playlist, which deletes their music on this phone.
void unmarkAll() => _set(settings.value.copyWith(playlists: const {}));
@ -134,6 +138,7 @@ class MusicLibrary(
_set(
MusicSettings(
deviceId: settings.value.deviceId,
inRecording: settings.value.inRecording,
address: server.address,
serverName: server.name,
serverId: server.id,

View file

@ -23,15 +23,23 @@ final class const MusicSettings({
/// The playlists marked for running: their names by their IDs.
final Map<String, String> playlists = const {},
/// What the Record sheet plays, as last chosen (D48): a `MusicChoice`'s
/// name; null before.
final String? inRecording,
}) with Equatable {
/// Whether someone is signed in (their token in the keyring).
bool get signedIn => address != null && userId != null;
/// Signed out of the server: the device's ID kept, as Jellyfin lists
/// devices by it.
MusicSettings signedOut() => MusicSettings(deviceId: deviceId);
MusicSettings signedOut() =>
MusicSettings(deviceId: deviceId, inRecording: inRecording);
MusicSettings copyWith({Map<String, String>? playlists}) => MusicSettings(
MusicSettings copyWith({
Map<String, String>? playlists,
String? inRecording,
}) => MusicSettings(
deviceId: deviceId,
address: address,
serverName: serverName,
@ -39,6 +47,7 @@ final class const MusicSettings({
userId: userId,
userName: userName,
playlists: playlists ?? this.playlists,
inRecording: inRecording ?? this.inRecording,
);
@override
@ -50,6 +59,7 @@ final class const MusicSettings({
userId,
userName,
playlists,
inRecording,
];
}
@ -78,6 +88,7 @@ class MusicStore(final File _file) {
...(json['playlists'] as Map<String, Object?>? ?? const {})
.cast<String, String>(),
},
inRecording: json['inRecording'] as String?,
);
}
} on Object catch (error) {
@ -100,6 +111,7 @@ class MusicStore(final File _file) {
'userId': settings.userId,
'userName': settings.userName,
'playlists': settings.playlists,
'inRecording': settings.inRecording,
}),
);
}

View file

@ -0,0 +1,65 @@
import 'package:halkyon_train_flutter/src/feature/music/data/cadence_model.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_downloads.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// What a recording plays (ADR 0030, D48), chosen on the Record sheet.
enum MusicChoice() {
/// The kept tracks, chosen by cadence (D47).
music,
/// A click at the cadence (D4).
metronome,
off,
}
/// Whether [activity] is on foot, where cadence is steps and music plays.
bool playsMusic(Activity activity) =>
activity == Activity.running ||
activity == Activity.walking ||
activity == Activity.hiking;
/// The cadence music starts at on foot before a model or the steps say
/// another: a run's, a walk's.
int usualCadenceOf(Activity activity) =>
activity == Activity.running ? 165 : 110;
/// What a recording plays, as the recorder takes it (D47, D48), with the
/// cadence model a plan's steps are given cadences by.
typedef RecorderMusic = ({Map<String, Object?> music, CadenceModel? cadence});
/// [choice] for [activity] as the recorder plays it: `tracks`, each kept
/// track with a tempo by its `path` and `bpm` (none for the metronome);
/// `cadence`, where the music starts, the runs' usual one where [cadence]
/// knows it. Null where nothing plays: off, not on foot, or music without a
/// track with a tempo.
Map<String, Object?>? recorderMusicOf(
MusicChoice choice,
Activity activity, {
required KeptMusic kept,
required String? Function(String id) pathOf,
CadenceModel? cadence,
}) {
if (choice == MusicChoice.off || !playsMusic(activity)) {
return null;
}
final tracks = [
if (choice == MusicChoice.music)
for (final track in kept.tracks.values)
if ((track.downloaded, track.tempo, pathOf(track.id)) case (
true,
final bpm?,
final path?,
))
{'path': path, 'bpm': bpm},
];
if (choice == MusicChoice.music && tracks.isEmpty) {
return null;
}
return {
'tracks': tracks,
'cadence': activity == Activity.running && cadence != null
? cadence.usual
: usualCadenceOf(activity),
};
}

View file

@ -3,6 +3,7 @@ import 'dart:typed_data';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/loop/model/loop_figures.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/cadence_model.dart';
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_payload.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_texts.dart';
@ -23,13 +24,16 @@ int planMaxHeartRate(ProfileContent? profile, DateTime today) =>
/// [locale] (a BCP 47 tag): `plan`, its payload and [maxHeartRate], which
/// the recording file keeps; `steps`, the run spelled out, each with its end (`seconds` or
/// `metres`, neither for a lap tap), its target, zones as the heart rates
/// they span for [maxHeartRate], and what is said as it starts; `say`, the
/// words for off target and for the end.
/// they span for [maxHeartRate], what is said as it starts and, where
/// [cadence] knows the person's runs, the cadence a pace's or a speed's
/// step asks of the music (D47); `say`, the words for off target and for
/// the end.
Map<String, Object?> recorderPlanOf(
AppLocalizations l10n,
String locale,
PlanContent plan, {
required int maxHeartRate,
CadenceModel? cadence,
}) => {
'plan': {
...jsonDecode(utf8.decode(Uint8List.sublistView(encodePlan(plan))))
@ -64,6 +68,8 @@ Map<String, Object?> recorderPlanOf(
},
},
'say': spokenStep(l10n, locale, run),
if ((cadence, run.step.target) case (final model?, final target?))
'cadence': ?model.forTarget(target),
},
],
'say': {

Some files were not shown because too many files have changed in this diff Show more