feat(train): marked playlists kept on the phone, their tempo worked out (ADR 0030, D47)

Music's second part: the marked playlists' tracks are downloaded as
Jellyfin keeps them, on unmetered networks only, while the app is open
or resumes, and deleted once no marked playlist holds them. Each
track's tempo is worked out once on the phone: a BPM tag where the file
has one, else a minute decoded with MediaCodec and its spectral flux
autocorrelated (Tempo.kt, tested on the JVM). The music screen shows
what is kept and deletes it after asking.

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 12:48:53 +01:00
commit 7f18f001f4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 1676 additions and 24 deletions

View file

@ -3,6 +3,9 @@
<!-- The app talks to its instance: release builds need this too (Flutter's
template grants it to debug and profile builds only). -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Whether the network is metered: music is downloaded on unmetered ones
only (ADR 0030, D47). -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<!-- Recording a workout (ADR 0030, D13, D19): GPS while the app records in
the foreground, never in the background; the step detector for
auto-pause on foot; the recording's notification; the processor kept

View file

@ -0,0 +1,99 @@
package app.halkyon.train
/**
* The tempo a track's file names itself (ADR 0030, D47), taken before Train's own analysis: an
* ID3v2 `TBPM` frame (MP3s, versions 2.3 and 2.4) or a `BPM` Vorbis comment (FLAC). Other
* containers (MP4's `tmpo`, Ogg) are analysed. A tag outside 40 to 250 beats per minute is
* taken for a wrong one.
*/
object BpmTag {
/** The tempo in [head], the file's first bytes; null where it names none. */
fun of(head: ByteArray): Double? =
when {
head.startsWith("ID3") -> id3(head)
head.startsWith("fLaC") -> flac(head)
else -> null
}?.takeIf { it in 40.0..250.0 }
private fun ByteArray.startsWith(magic: String) =
size >= magic.length && magic.indices.all { this[it] == magic[it].code.toByte() }
private fun ByteArray.u8(at: Int) = this[at].toInt() and 0xff
private fun synchsafe(bytes: ByteArray, at: Int) =
(bytes.u8(at) shl 21) or (bytes.u8(at + 1) shl 14) or (bytes.u8(at + 2) shl 7) or bytes.u8(at + 3)
private fun bigEndian(bytes: ByteArray, at: Int) =
(bytes.u8(at) shl 24) or (bytes.u8(at + 1) shl 16) or (bytes.u8(at + 2) shl 8) or bytes.u8(at + 3)
private fun littleEndian(bytes: ByteArray, at: Int) =
bytes.u8(at) or (bytes.u8(at + 1) shl 8) or (bytes.u8(at + 2) shl 16) or (bytes.u8(at + 3) shl 24)
private fun number(text: String) = text.trim().replace(',', '.').toDoubleOrNull()
private fun id3(head: ByteArray): Double? {
if (head.size < 10) return null
val version = head.u8(3)
if (version != 3 && version != 4) return null
// An extended header or unsynchronisation: rare, and not read.
if (head.u8(5) and 0xc0 != 0) return null
val end = minOf(head.size, 10 + synchsafe(head, 6))
var at = 10
while (at + 10 <= end) {
val id = String(head, at, 4, Charsets.ISO_8859_1)
if (id[0] == '\u0000') return null
val size = if (version == 4) synchsafe(head, at + 4) else bigEndian(head, at + 4)
if (size <= 0 || at + 10 + size > end) return null
if (id == "TBPM") {
val text = head.copyOfRange(at + 11, at + 10 + size)
val charset =
when (head.u8(at + 10)) {
1 -> Charsets.UTF_16
2 -> Charsets.UTF_16BE
3 -> Charsets.UTF_8
else -> Charsets.ISO_8859_1
}
return number(String(text, charset).trimEnd('\u0000'))
}
at += 10 + size
}
return null
}
private fun flac(head: ByteArray): Double? {
var at = 4
while (at + 4 <= head.size) {
val last = head.u8(at) and 0x80 != 0
val type = head.u8(at) and 0x7f
val length = (head.u8(at + 1) shl 16) or (head.u8(at + 2) shl 8) or head.u8(at + 3)
val start = at + 4
if (start + length > head.size) return null
if (type == 4) return vorbisComment(head, start, start + length)
if (last) return null
at = start + length
}
return null
}
private fun vorbisComment(head: ByteArray, from: Int, to: Int): Double? {
var at = from
if (at + 4 > to) return null
at += 4 + littleEndian(head, at)
if (at + 4 > to) return null
val count = littleEndian(head, at)
at += 4
repeat(count) {
if (at + 4 > to) return null
val length = littleEndian(head, at)
at += 4
if (length < 0 || at + length > to) return null
val comment = String(head, at, length, Charsets.UTF_8)
at += length
val equals = comment.indexOf('=')
if (equals > 0 && comment.substring(0, equals).equals("BPM", ignoreCase = true)) {
return number(comment.substring(equals + 1))
}
}
return null
}
}

View file

@ -30,6 +30,9 @@ class MainActivity : FlutterActivity() {
/** Routes' GPX files, in and out (ADR 0030, D45). */
private val files by lazy { RouteFiles(this) }
/** Music from Jellyfin: the network, and the tempo of what is downloaded (ADR 0030, D47). */
private val music by lazy { MusicBridge(this) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
health.onIntent(intent)
@ -102,6 +105,7 @@ class MainActivity : FlutterActivity() {
EventChannel(messenger, "app.halkyon.train/loop/search").setStreamHandler(loop.search)
MethodChannel(messenger, "app.halkyon.train/files").setMethodCallHandler(files)
EventChannel(messenger, "app.halkyon.train/files/opened").setStreamHandler(files.opened)
MethodChannel(messenger, "app.halkyon.train/music").setMethodCallHandler(music)
EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
@ -122,6 +126,7 @@ class MainActivity : FlutterActivity() {
RecorderService.lines = null
health.dispose()
loop.dispose()
music.close()
super.cleanUpFlutterEngine(flutterEngine)
}

View file

@ -0,0 +1,163 @@
package app.halkyon.train
import android.content.Context
import android.media.AudioFormat
import android.media.MediaCodec
import android.media.MediaExtractor
import android.media.MediaFormat
import android.net.ConnectivityManager
import android.os.Handler
import android.os.Looper
import android.util.Log
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.File
import java.nio.ByteOrder
import java.util.concurrent.Executors
/**
* Music's side (ADR 0030, D47), over `app.halkyon.train/music`: whether the network is metered,
* as music is downloaded on unmetered ones only; and a downloaded track's tempo, the tag its
* file names, else a minute from its middle decoded by Android's own decoder, mixed down to
* mono and handed to [Tempo], one track at a time, off the main thread.
*/
class MusicBridge(private val context: Context) : MethodChannel.MethodCallHandler {
private val worker = Executors.newSingleThreadExecutor()
private val main = Handler(Looper.getMainLooper())
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"unmetered" -> {
val connectivity = context.getSystemService(ConnectivityManager::class.java)
result.success(connectivity.activeNetwork != null && !connectivity.isActiveNetworkMetered)
}
"tempo" -> {
val path = call.argument<String>("path")!!
worker.execute {
val tempo =
try {
of(File(path))
} catch (error: Exception) {
// Its kind only: never the track's name.
Log.w("Music", "Tempo not worked out: ${error.javaClass.simpleName}")
null
}
main.post { result.success(tempo) }
}
}
else -> result.notImplemented()
}
}
fun close() = worker.shutdownNow()
companion object {
private const val SECONDS = 60
private const val TIMEOUT_US = 10_000L
/** The tempo of the track in [file]; null where neither its tag nor its beat says one. */
fun of(file: File): Double? {
val head = ByteArray(256 * 1024)
var read = 0
file.inputStream().use { input ->
while (read < head.size) {
val got = input.read(head, read, head.size - read)
if (got < 0) break
read += got
}
}
BpmTag.of(head.copyOf(read))?.let { return it }
val (samples, rate) = decode(file) ?: return null
return Tempo.of(samples, rate)
}
/** A minute from the middle of [file] in mono, with its sample rate. */
private fun decode(file: File): Pair<FloatArray, Int>? {
val extractor = MediaExtractor()
try {
extractor.setDataSource(file.path)
val track =
(0 until extractor.trackCount).firstOrNull {
extractor.getTrackFormat(it).getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true
} ?: return null
val format = extractor.getTrackFormat(track)
extractor.selectTrack(track)
if (format.containsKey(MediaFormat.KEY_DURATION)) {
val duration = format.getLong(MediaFormat.KEY_DURATION)
val from = (duration / 2 - SECONDS * 500_000L).coerceAtLeast(0)
extractor.seekTo(from, MediaExtractor.SEEK_TO_CLOSEST_SYNC)
}
val codec = MediaCodec.createDecoderByType(format.getString(MediaFormat.KEY_MIME)!!)
try {
codec.configure(format, null, null, 0)
codec.start()
return drain(extractor, codec, format)
} finally {
codec.release()
}
} finally {
extractor.release()
}
}
private fun drain(extractor: MediaExtractor, codec: MediaCodec, format: MediaFormat): Pair<FloatArray, Int>? {
var rate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
var channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
var floats = false
var samples = FloatArray(rate * SECONDS)
var count = 0
var inputDone = false
val info = MediaCodec.BufferInfo()
while (count < samples.size) {
if (!inputDone) {
val index = codec.dequeueInputBuffer(TIMEOUT_US)
if (index >= 0) {
val size = extractor.readSampleData(codec.getInputBuffer(index)!!, 0)
if (size < 0) {
codec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
inputDone = true
} else {
codec.queueInputBuffer(index, 0, size, extractor.sampleTime, 0)
extractor.advance()
}
}
}
val index = codec.dequeueOutputBuffer(info, TIMEOUT_US)
when {
index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
val now = codec.outputFormat
rate = now.getInteger(MediaFormat.KEY_SAMPLE_RATE)
channels = now.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
floats =
now.containsKey(MediaFormat.KEY_PCM_ENCODING) &&
now.getInteger(MediaFormat.KEY_PCM_ENCODING) == AudioFormat.ENCODING_PCM_FLOAT
if (count == 0) samples = FloatArray(rate * SECONDS)
}
index >= 0 -> {
val buffer = codec.getOutputBuffer(index)!!.order(ByteOrder.nativeOrder())
buffer.position(info.offset)
buffer.limit(info.offset + info.size)
if (floats) {
val pcm = buffer.asFloatBuffer()
while (pcm.remaining() >= channels && count < samples.size) {
var sum = 0f
repeat(channels) { sum += pcm.get() }
samples[count++] = sum / channels
}
} else {
val pcm = buffer.asShortBuffer()
while (pcm.remaining() >= channels && count < samples.size) {
var sum = 0f
repeat(channels) { sum += pcm.get() / 32768f }
samples[count++] = sum / channels
}
}
codec.releaseOutputBuffer(index, false)
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) break
}
}
}
return if (count == 0) null else samples.copyOf(count) to rate
}
}
}

View file

@ -0,0 +1,150 @@
package app.halkyon.train
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.ln
import kotlin.math.max
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlin.math.sqrt
/**
* A track's tempo worked out from its audio (ADR 0030, D47): the onsets as the spectrum's
* rise from frame to frame (spectral flux, about 86 frames a second), their autocorrelation
* over the lags of 50 to 220 beats per minute, weighed towards 120 by an octave either side
* as people tap along, the peak refined between its neighbours. A track the beat of which
* repeats too weakly has none. Its half and double fit a cadence too, which the player
* counts (10c), so a tempo found an octave off still serves.
*/
object Tempo {
private const val FRAMES_A_SECOND = 86.0
private const val SLOWEST = 50.0
private const val FASTEST = 220.0
/** How strongly the beat must repeat: noise stays well below it. */
const val STEADY = 0.15
/** The beats per minute of mono [samples] at [sampleRate]; null where there is no steady beat. */
fun of(samples: FloatArray, sampleRate: Int): Double? {
val hop = max(1, (sampleRate / FRAMES_A_SECOND).roundToInt())
val rate = sampleRate.toDouble() / hop
val envelope = onsets(samples, hop)
if (envelope.size < rate * 10) return null
val mean = envelope.average()
val centred = DoubleArray(envelope.size) { envelope[it] - mean }
val energy = centred.sumOf { it * it }
if (energy <= 0.0) return null
val shortest = (rate * 60 / FASTEST).toInt()
val longest = (rate * 60 / SLOWEST).toInt() + 1
if (longest + 2 >= centred.size) return null
val correlation = DoubleArray(longest + 2) { lag ->
if (lag == 0) {
1.0
} else {
var sum = 0.0
for (i in 0 until centred.size - lag) sum += centred[i] * centred[i + lag]
sum / energy * centred.size / (centred.size - lag)
}
}
var best = -1
var bestScore = 0.0
for (lag in max(shortest, 1)..longest) {
val peak = correlation[lag] >= correlation[lag - 1] && correlation[lag] >= correlation[lag + 1]
if (!peak) continue
val octaves = ln(60 * rate / lag / 120) / ln(2.0)
val score = correlation[lag] * exp(-0.5 * octaves * octaves)
if (score > bestScore) {
best = lag
bestScore = score
}
}
if (best < 0 || correlation[best] < STEADY) return null
// The parabola through the peak and its neighbours.
val left = correlation[best - 1]
val middle = correlation[best]
val right = correlation[best + 1]
val bend = left - 2 * middle + right
val shift = if (bend == 0.0) 0.0 else 0.5 * (left - right) / bend
return 60 * rate / (best + shift.coerceIn(-0.5, 0.5))
}
/** The spectral flux of [samples], a frame each [hop] samples, its local mean taken off. */
internal fun onsets(samples: FloatArray, hop: Int): DoubleArray {
var size = 1
while (size < hop * 4) size *= 2
val window = DoubleArray(size) { 0.5 - 0.5 * cos(2 * PI * it / size) }
val frames = if (samples.size < size) 0 else (samples.size - size) / hop + 1
val flux = DoubleArray(frames)
var before = DoubleArray(size / 2)
val real = DoubleArray(size)
val imaginary = DoubleArray(size)
for (frame in 0 until frames) {
val start = frame * hop
for (i in 0 until size) {
real[i] = samples[start + i] * window[i]
imaginary[i] = 0.0
}
fft(real, imaginary)
val now = DoubleArray(size / 2) { ln(1 + 100 * sqrt(real[it] * real[it] + imaginary[it] * imaginary[it])) }
if (frame > 0) {
var rise = 0.0
for (i in now.indices) rise += max(0.0, now[i] - before[i])
flux[frame] = rise
}
before = now
}
// A rise counts against the frames around it, about 0.4 s, so that loud passages don't
// outweigh the beat.
val half = (FRAMES_A_SECOND * 0.2).roundToInt()
val prefix = DoubleArray(frames + 1)
for (i in 0 until frames) prefix[i + 1] = prefix[i] + flux[i]
return DoubleArray(frames) {
val from = max(0, it - half)
val to = minOf(frames, it + half + 1)
max(0.0, flux[it] - (prefix[to] - prefix[from]) / (to - from))
}
}
/** The discrete Fourier transform of [real] and [imaginary] in place; their size a power of 2. */
private fun fft(real: DoubleArray, imaginary: DoubleArray) {
val n = real.size
var j = 0
for (i in 1 until n) {
var bit = n shr 1
while (j and bit != 0) {
j = j xor bit
bit = bit shr 1
}
j = j xor bit
if (i < j) {
real[i] = real[j].also { real[j] = real[i] }
imaginary[i] = imaginary[j].also { imaginary[j] = imaginary[i] }
}
}
var length = 2
while (length <= n) {
val angle = -2 * PI / length
val stepReal = cos(angle)
val stepImaginary = sin(angle)
for (start in 0 until n step length) {
var turnReal = 1.0
var turnImaginary = 0.0
for (k in 0 until length / 2) {
val a = start + k
val b = a + length / 2
val bReal = real[b] * turnReal - imaginary[b] * turnImaginary
val bImaginary = real[b] * turnImaginary + imaginary[b] * turnReal
real[b] = real[a] - bReal
imaginary[b] = imaginary[a] - bImaginary
real[a] += bReal
imaginary[a] += bImaginary
val next = turnReal * stepReal - turnImaginary * stepImaginary
turnImaginary = turnReal * stepImaginary + turnImaginary * stepReal
turnReal = next
}
}
length *= 2
}
}
}

View file

@ -0,0 +1,83 @@
package app.halkyon.train
import java.io.ByteArrayOutputStream
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** The tempo a file names itself (ADR 0030, D47). */
class BpmTagTest {
private fun synchsafe(size: Int) =
byteArrayOf((size shr 21 and 0x7f).toByte(), (size shr 14 and 0x7f).toByte(), (size shr 7 and 0x7f).toByte(), (size and 0x7f).toByte())
private fun plain(size: Int) =
byteArrayOf((size shr 24).toByte(), (size shr 16).toByte(), (size shr 8).toByte(), size.toByte())
/** An ID3v2 tag of [version] with a title and then [frames]. */
private fun id3(version: Int, vararg frames: Pair<String, ByteArray>): ByteArray {
val body = ByteArrayOutputStream()
for ((id, content) in listOf("TIT2" to byteArrayOf(0) + "Song".toByteArray()) + frames) {
body.write(id.toByteArray())
body.write(if (version == 4) synchsafe(content.size) else plain(content.size))
body.write(byteArrayOf(0, 0))
body.write(content)
}
val bytes = body.toByteArray()
return "ID3".toByteArray() + byteArrayOf(version.toByte(), 0, 0) + synchsafe(bytes.size) + bytes + ByteArray(64)
}
private fun le(value: Int) =
byteArrayOf(value.toByte(), (value shr 8).toByte(), (value shr 16).toByte(), (value shr 24).toByte())
/** A FLAC file's head: its stream info, then a Vorbis comment holding [comments]. */
private fun flac(vararg comments: String): ByteArray {
val comment = ByteArrayOutputStream()
val vendor = "reference libFLAC".toByteArray()
comment.write(le(vendor.size))
comment.write(vendor)
comment.write(le(comments.size))
for (text in comments) {
val bytes = text.toByteArray()
comment.write(le(bytes.size))
comment.write(bytes)
}
val block = comment.toByteArray()
return "fLaC".toByteArray() +
byteArrayOf(0, 0, 0, 34) + ByteArray(34) +
byteArrayOf(0x84.toByte(), (block.size shr 16).toByte(), (block.size shr 8).toByte(), block.size.toByte()) +
block
}
@Test
fun `an MP3's TBPM is read, in ID3 2·3 and 2·4, in Latin-1 and UTF-16`() {
assertEquals(128.0, BpmTag.of(id3(3, "TBPM" to byteArrayOf(0) + "128".toByteArray()))!!, 0.0)
assertEquals(
172.5,
BpmTag.of(id3(4, "TBPM" to byteArrayOf(1) + "172.5".toByteArray(Charsets.UTF_16)))!!,
0.0,
)
}
@Test
fun `a FLAC's BPM comment is read, whatever its case`() {
assertEquals(96.0, BpmTag.of(flac("TITLE=Song", "bpm=96"))!!, 0.0)
}
@Test
fun `no tag, a nonsense one, an implausible one and other files are none`() {
assertNull(BpmTag.of(id3(3)))
assertNull(BpmTag.of(id3(3, "TBPM" to byteArrayOf(0) + "fast".toByteArray())))
assertNull(BpmTag.of(id3(3, "TBPM" to byteArrayOf(0) + "0".toByteArray())))
assertNull(BpmTag.of(flac("TITLE=Song")))
assertNull(BpmTag.of("OggS and the rest".toByteArray()))
assertNull(BpmTag.of(ByteArray(0)))
}
@Test
fun `a tag cut short is none, not a crash`() {
val whole = id3(4, "TBPM" to byteArrayOf(0) + "128".toByteArray())
for (length in 0..whole.size) BpmTag.of(whole.copyOf(length))
assertNull(BpmTag.of(whole.copyOf(20)))
assertEquals(128.0, BpmTag.of(whole)!!, 0.0)
}
}

View file

@ -0,0 +1,72 @@
package app.halkyon.train
import kotlin.math.PI
import kotlin.math.exp
import kotlin.math.sin
import kotlin.random.Random
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
/** A track's tempo from its audio (ADR 0030, D47), on beats of known tempo. */
class TempoTest {
private val rate = 22_050
/**
* [seconds] of a kick on every beat at [bpm], a hi-hat on every off-beat where [hats], over
* a steady tone and some noise, as a mix of music has them.
*/
private fun beats(bpm: Double, seconds: Int = 30, hats: Boolean = false, noise: Float = 0.05f): FloatArray {
val random = Random(7)
val beat = rate * 60 / bpm
return FloatArray(rate * seconds) { i ->
val sinceBeat = (i % beat) / rate
val sinceHalf = ((i + beat / 2) % beat) / rate
val kick = exp(-sinceBeat * 30) * sin(2 * PI * 60 * sinceBeat)
val hat = if (hats) exp(-sinceHalf * 80) * (random.nextDouble() - 0.5) * 0.6 else 0.0
val tone = 0.2 * sin(2 * PI * 220 * i / rate)
(kick + hat + tone).toFloat() + (random.nextFloat() - 0.5f) * noise
}
}
@Test
fun `a steady kick gives its tempo within a beat a minute`() {
for (bpm in listOf(90.0, 128.0, 140.0, 174.0)) {
val found = Tempo.of(beats(bpm), rate)
assertNotNull("$bpm", found)
assertEquals("$bpm", bpm, found!!, 1.0)
}
}
@Test
fun `off-beat hi-hats don't double a tempo near 120`() {
assertEquals(100.0, Tempo.of(beats(100.0, hats = true), rate)!!, 1.0)
}
@Test
fun `a slow tempo may come out doubled, which the player counts as fitting`() {
val found = Tempo.of(beats(60.0), rate)!!
val fits = listOf(60.0, 120.0).any { kotlin.math.abs(found - it) <= 1.0 }
assertEquals("$found", true, fits)
}
@Test
fun `silence, noise and too short a piece have none`() {
assertNull(Tempo.of(FloatArray(rate * 30), rate))
val random = Random(3)
assertNull(Tempo.of(FloatArray(rate * 30) { random.nextFloat() - 0.5f }, rate))
assertNull(Tempo.of(beats(128.0, seconds = 5), rate))
}
@Test
fun `another sample rate gives the same tempo`() {
val at44 = 44_100
val beat = at44 * 60 / 128.0
val samples = FloatArray(at44 * 20) { i ->
val since = (i % beat) / at44
(exp(-since * 30) * sin(2 * PI * 60 * since)).toFloat()
}
assertEquals(128.0, Tempo.of(samples, at44)!!, 1.0)
}
}