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:
parent
d5b367e930
commit
db7e0b7bcd
5 changed files with 782 additions and 11 deletions
|
|
@ -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(" ")
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue