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
35 changed files with 1435 additions and 40 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue