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
7 changed files with 1435 additions and 40 deletions

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