feat(train): each km announced on foot, each 5 km by bike (ADR 0030, D49)

Step 11 takes up what OpenTracks does that Train didn't (D49). 11a: a
recording announces each kilometre on foot and each 5 km by bike, never
on a rower, where the Record sheet's switch is on for the activity
(remembered in the person's announce.json, off at first): the distance,
the moving time, the split's pace or speed and the heart rate. The app
hands the recorder the words in its language; Announcer.kt counts the
moving time and the distance, Cues.kt speaks the line after a cue still
speaking.

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 14:25:13 +01:00
commit db7e0b7bcd
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 782 additions and 11 deletions

View file

@ -0,0 +1,111 @@
package app.halkyon.train
import java.text.NumberFormat
import java.util.Locale
import kotlin.math.roundToInt
import kotlin.math.roundToLong
import org.json.JSONObject
/** A distance passed (ADR 0030, D49): the [count]th, the moving time so far, the split's. */
data class Announcement(val count: Int, val movingMs: Long, val splitMs: Long, val splitMetres: Double)
/**
* Tells when a recording has gone another [every] metres, on the distance the plan's measures
* keep (paused fixes left out), and the moving time it took, a tick a second; a jump past several
* says the last of them; from the recording's [start] on.
*/
class Announcer(private val every: Int, start: Long) {
private var movingMs = 0L
private var lastTick = start
private var announced = 0
private var lastMovingMs = 0L
private var lastMetres = 0.0
fun tick(now: Long, paused: Boolean, metres: Double): Announcement? {
if (!paused) movingMs += now - lastTick
lastTick = now
val count = (metres / every).toInt()
if (count <= announced) return null
announced = count
val passed = count * every.toDouble()
val announcement =
Announcement(count, movingMs, splitMs = movingMs - lastMovingMs, splitMetres = passed - lastMetres)
lastMovingMs = movingMs
lastMetres = passed
return announcement
}
}
/**
* What is said of each [Announcement], in the app's language (D49, `recorderAnnouncementsOf`):
* the distances spelled out, the counts of [hours], [minutes] and [seconds] a time and a pace are
* put together from, and the sentences with their placeholders; [pace] on foot, [speed] by bike.
*/
data class AnnounceWords(
val every: Int,
val distances: List<String>,
val hours: List<String>,
val minutes: List<String>,
val seconds: List<String>,
val time: String,
val pace: String?,
val speed: String?,
val heartRate: String,
val locale: Locale?,
) {
companion object {
fun of(json: JSONObject): AnnounceWords {
fun strings(name: String): List<String> =
json.optJSONArray(name)?.let { array -> List(array.length()) { array.optString(it) } }.orEmpty()
return AnnounceWords(
every = json.optInt("every", 1000),
distances = strings("distances"),
hours = strings("hours"),
minutes = strings("minutes"),
seconds = strings("seconds"),
time = json.optString("time"),
pace = json.optString("pace").takeIf { it.isNotEmpty() },
speed = json.optString("speed").takeIf { it.isNotEmpty() },
heartRate = json.optString("heartRate"),
locale = json.optString("locale").takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag),
)
}
}
/** The line said for [announcement], with the heart rate [bpm] where one came lately. */
fun say(announcement: Announcement, bpm: Int?): String {
val distance =
distances.getOrNull(announcement.count - 1) ?: "${announcement.count * every / 1000} km"
val parts = mutableListOf(distance, time.replace("{time}", spoken(announcement.movingMs)))
if (announcement.splitMs > 0 && announcement.splitMetres > 0) {
pace?.let {
val perKm = (announcement.splitMs * 1000 / announcement.splitMetres).roundToLong()
parts += it.replace("{pace}", spoken(perKm))
}
speed?.let {
val kmh = announcement.splitMetres / 1000 / (announcement.splitMs / 3_600_000.0)
val format =
NumberFormat.getNumberInstance(locale ?: Locale.getDefault()).apply {
minimumFractionDigits = 1
maximumFractionDigits = 1
}
parts += it.replace("{speed}", format.format(kmh))
}
}
bpm?.let { parts += heartRate.replace("{bpm}", it.toString()) }
return parts.joinToString(". ", postfix = ".")
}
/** [ms] as hours and minutes, or as minutes and seconds within the first hour; nones left out. */
private fun spoken(ms: Long): String {
val total = (ms / 1000.0).roundToInt()
val (h, m, s) = Triple(total / 3600, total / 60 % 60, total % 60)
val parts =
if (h > 0) {
listOfNotNull(hours.getOrNull(h) ?: "$h h", minutes.getOrNull(m)?.takeIf { m > 0 })
} else {
listOfNotNull(minutes.getOrNull(m)?.takeIf { m > 0 }, seconds.getOrNull(s)?.takeIf { s > 0 || m == 0 })
}
return parts.joinToString(" ")
}
}

View file

@ -20,7 +20,8 @@ import java.util.Locale
* step changes, a short tone for each of a timed step's last seconds, a
* double vibration off target; a route's (D45), a tone before a turn, a
* double vibration off the route, a tone back on it; and a spoken line where
* the phone has a text-to-speech engine (GrapheneOS ships none; RHVoice or
* the phone has a text-to-speech engine, the distance's announcement (D49)
* after whatever is being said (GrapheneOS ships none; RHVoice or
* eSpeak give one), other sound ducked while it speaks.
*/
class Cues(context: Context, locale: String?) : TextToSpeech.OnInitListener {
@ -100,6 +101,9 @@ class Cues(context: Context, locale: String?) : TextToSpeech.OnInitListener {
handler.postDelayed({ speak(say) }, 400)
}
/** A distance passed (D49): spoken without a tone, after a cue still speaking. */
fun announce(say: String) = speak(say, TextToSpeech.QUEUE_ADD)
fun close() {
handler.removeCallbacksAndMessages(null)
speech.stop()
@ -108,10 +112,10 @@ class Cues(context: Context, locale: String?) : TextToSpeech.OnInitListener {
audio.abandonAudioFocusRequest(focus)
}
private fun speak(text: String) {
private fun speak(text: String, queue: Int = TextToSpeech.QUEUE_FLUSH) {
if (!speaks || text.isBlank()) return
audio.requestAudioFocus(focus)
speech.speak(text, TextToSpeech.QUEUE_FLUSH, null, "cue")
speech.speak(text, queue, null, "cue")
}
private fun vibrate(pattern: LongArray) {

View file

@ -85,6 +85,7 @@ class MainActivity : FlutterActivity() {
call.argument<String>("music")?.let { music ->
File(noBackupFilesDir, "music.json").apply { writeText(music) }.path
},
announce = call.argument<String>("announce"),
)
result.success(null)
}

View file

@ -63,6 +63,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private const val EXTRA_PLAN = "plan"
private const val EXTRA_ROUTE = "route"
private const val EXTRA_MUSIC = "music"
private const val EXTRA_ANNOUNCE = "announce"
private const val CHANNEL = "recording"
private const val NOTIFICATION_ID = 5301
private const val FLUSH_EVERY_MS = 5_000L
@ -82,7 +83,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
* 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), playing the music the
* file at [music] says (D47, D48; null: none).
* file at [music] says (D47, D48; null: none), announcing each distance
* passed in the words of [announce] (JSON, D49; null: never).
*/
fun start(
context: Context,
@ -96,6 +98,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
plan: String?,
route: String?,
music: String?,
announce: String?,
) {
ContextCompat.startForegroundService(
context,
@ -110,7 +113,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
.putExtra(EXTRA_LOOP, loop)
.putExtra(EXTRA_PLAN, plan)
.putExtra(EXTRA_ROUTE, route)
.putExtra(EXTRA_MUSIC, music),
.putExtra(EXTRA_MUSIC, music)
.putExtra(EXTRA_ANNOUNCE, announce),
)
}
@ -170,6 +174,10 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private var usualCadence = 165
private var lastMusicAt = 0L
/** What tells each distance passed (D49), and its words. */
private var announcer: Announcer? = null
private var announceWords: AnnounceWords? = null
/** The route it follows (D45) and its words. */
private var guide: RouteGuide? = null
private var guideWords: JSONObject? = null
@ -188,6 +196,10 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
plan?.let { runner ->
cue(runner.tick(now, isPaused(), measures.metres) { kind -> measures.measure(kind, now) }, now)
}
announcer?.tick(now, isPaused(), measures.metres)?.let { passed ->
val bpm = measures.measure("heartRate", now)?.toInt()
announceWords?.let { words -> cues?.announce(words.say(passed, bpm)) }
}
// 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) {
@ -310,6 +322,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
intent.getStringExtra(EXTRA_MUSIC)?.let { at ->
runCatching { JSONObject(File(at).readText()) }.getOrNull()?.let { playMusic(it, now) }
}
intent.getStringExtra(EXTRA_ANNOUNCE)?.let { announce(JSONObject(it), now) }
lastFlush = now
handler.postDelayed(ticker, 1_000)
}
@ -366,6 +379,14 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
lastMusicAt = now
}
/** Announces each distance passed in the words [json] holds (D49, `recorderAnnouncementsOf`). */
private fun announce(json: JSONObject, now: Long) {
val words = AnnounceWords.of(json)
announceWords = words
cuesIn(json.optString("locale"))
announcer = Announcer(words.every, start = 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
@ -626,6 +647,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
loopReading = null
plan = null
guide = null
announcer = null
cues?.close()
cues = null
music?.close()

View file

@ -0,0 +1,108 @@
package app.halkyon.train
import java.util.Locale
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** The announcements every km on foot, every 5 km by bike (ADR 0030, D49). */
class AnnouncerTest {
private fun counts(word: String, plural: String, size: Int) =
List(size) { if (it == 1) "1 $word" else "$it $plural" }
private val run =
AnnounceWords(
every = 1000,
distances = List(100) { if (it == 0) "1 kilometre" else "${it + 1} kilometres" },
hours = counts("hour", "hours", 24),
minutes = counts("minute", "minutes", 60),
seconds = counts("second", "seconds", 60),
time = "Time {time}",
pace = "Pace {pace} a kilometre",
speed = null,
heartRate = "Heart rate {bpm}",
locale = Locale.UK,
)
@Test
fun `each km passed is announced once, with the moving time and the split`() {
val announcer = Announcer(1000, start = 0)
var now = 0L
var metres = 0.0
val heard = mutableListOf<Announcement>()
// 4 m/s for 600 s: 2400 m, a tick a second.
repeat(600) {
now += 1000
metres += 4
announcer.tick(now, paused = false, metres = metres)?.let(heard::add)
}
assertEquals(listOf(1, 2), heard.map { it.count })
assertEquals(250_000L, heard[0].movingMs)
assertEquals(250_000L, heard[0].splitMs)
assertEquals(500_000L, heard[1].movingMs)
assertEquals(250_000L, heard[1].splitMs)
assertEquals(1000.0, heard[1].splitMetres, 1e-9)
}
@Test
fun `a pause's time counts neither in the moving time nor the split`() {
val announcer = Announcer(1000, start = 0)
var now = 0L
var metres = 0.0
repeat(200) {
now += 1000
metres += 2.5
assertNull(announcer.tick(now, paused = false, metres = metres))
}
repeat(60) {
now += 1000
assertNull(announcer.tick(now, paused = true, metres = metres))
}
var heard: Announcement? = null
while (heard == null) {
now += 1000
metres += 2.5
heard = announcer.tick(now, paused = false, metres = metres)
}
assertEquals(400_000L, heard.movingMs)
}
@Test
fun `a jump of several km says the last of them`() {
val announcer = Announcer(1000, start = 0)
assertNull(announcer.tick(1000, paused = false, metres = 10.0))
assertEquals(3, announcer.tick(2000, paused = false, metres = 3100.0)?.count)
assertNull(announcer.tick(3000, paused = false, metres = 3200.0))
}
@Test
fun `a run's line says the distance, the time, the split's pace and the heart rate`() {
val line = run.say(Announcement(count = 2, movingMs = 625_000, splitMs = 312_000, splitMetres = 1000.0), bpm = 152)
assertEquals(
"2 kilometres. Time 10 minutes 25 seconds. Pace 5 minutes 12 seconds a kilometre. Heart rate 152.",
line,
)
}
@Test
fun `parts that are none are left out, seconds too after the first hour, the heart rate where none came`() {
val line = run.say(Announcement(count = 1, movingMs = 3_725_000, splitMs = 300_000, splitMetres = 1000.0), bpm = null)
assertEquals("1 kilometre. Time 1 hour 2 minutes. Pace 5 minutes a kilometre.", line)
}
@Test
fun `a ride says its speed in the app's language, and a distance past the spelled ones as digits`() {
val ride =
run.copy(
every = 5000,
distances = listOf("5 Kilometer"),
pace = null,
speed = "Tempo {speed} Kilometer pro Stunde",
locale = Locale.GERMANY,
)
val line = ride.say(Announcement(count = 1, movingMs = 600_000, splitMs = 600_000, splitMetres = 5000.0), bpm = null)
assertEquals("5 Kilometer. Time 10 minutes. Tempo 30,0 Kilometer pro Stunde.", line)
val far = ride.say(Announcement(count = 2, movingMs = 1_200_000, splitMs = 600_000, splitMetres = 5000.0), bpm = null)
assertEquals("10 km. Time 20 minutes. Tempo 30,0 Kilometer pro Stunde.", far)
}
}