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:
parent
3336fe4eca
commit
7f18f001f4
7 changed files with 1676 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue