Skip to content

Commit

Permalink
Draft metronome implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
dasdranagon committed Dec 30, 2024
1 parent 68b89a7 commit 35a469f
Show file tree
Hide file tree
Showing 9 changed files with 374 additions and 0 deletions.
2 changes: 2 additions & 0 deletions metronome/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ kaluga {
common {
main {
implementation(project(":base"))
implementation(project(":media"))
implementation(project(":scientific"))
implementation(project(":logging"))
}
test {
Expand Down
36 changes: 36 additions & 0 deletions metronome/src/androidMain/kotlin/DefaultMetronomeMediaPlayer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.splendo.kaluga.metronome

import android.media.AudioAttributes
import android.media.SoundPool
import com.splendo.kaluga.media.MediaSource
import java.net.URL

// TODO: Make sure it works in the app
// As at the moment we don't have only iOS application, it was not possible to test metronome.
// Code was tested in a separate sandbox and it seems to work
// The audio file we are using on iOS (metronome_sound.mp3) doesn't seem to work on android
// Converting mp3 into wav fixed the problem

actual class DefaultMetronomeMediaPlayer actual constructor(source: MediaSource) : MetronomeMediaPlayer {

private val soundPool = SoundPool.Builder().apply {
val attributes = AudioAttributes.Builder().apply {
setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
}.build()
setAudioAttributes(attributes)
}.build()

private val soundId: Int = soundPool.load(source)

actual override fun play() {
soundPool.play(soundId, 1F, 1F, 1, 0, 1F)
}

actual override fun close() {
soundPool.release()
}

private fun SoundPool.load(source: MediaSource): Int = if (source is MediaSource.Url) load(source.url) else throw MetronomeError.UnexpectedMediaSourceShouldBeURL

private fun SoundPool.load(url: URL): Int = if (url.path != null) load(url.path, 1) else throw MetronomeError.CannotAccessMediaSource
}
44 changes: 44 additions & 0 deletions metronome/src/commonMain/kotlin/DefaultMetronome.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.splendo.kaluga.metronome

import com.splendo.kaluga.logging.error
import com.splendo.kaluga.media.MediaSource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.runningFold
import kotlin.time.Duration

class DefaultMetronome(private val settings: MetronomeSettings, private val timer: Flow<Duration>, private val mediaPlayer: MetronomeMediaPlayer) : Metronome {
private class Builder(private val mediaPlayer: MetronomeMediaPlayer) : Metronome.Builder {
override fun intervalMetronome(settings: MetronomeSettings, timer: Flow<Duration>) = DefaultMetronome(settings, timer, mediaPlayer)
}

class Manager(source: MediaSource, private val mediaPlayer: MetronomeMediaPlayer = DefaultMetronomeMediaPlayer(source)) : Metronome.Manager {

override suspend fun prepare(): Metronome.Builder = Builder(mediaPlayer)

override fun close() {
mediaPlayer.close()
}
}

override suspend fun run() {
timer.runningFold<Duration, Pair<Duration?, Duration>>(
null to settings.beatAfter(null),
) { (lastBeat, nextBeat), elapsed ->
// Since timer only ticks every 1 ms, we should include the next 0.5ms as well to keep a smooth metronome
if (elapsed < nextBeat) {
lastBeat to nextBeat
} else {
nextBeat to settings.beatAfter(nextBeat)
}
}.filter { (lastBeat, _) -> lastBeat != null }.distinctUntilChanged().collectLatest {
try {
mediaPlayer.play()
} catch (e: MetronomeError) {
error(e)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.splendo.kaluga.metronome

import com.splendo.kaluga.media.MediaSource

expect class DefaultMetronomeMediaPlayer(source: MediaSource) : MetronomeMediaPlayer {
override fun close()
override fun play()
}
22 changes: 22 additions & 0 deletions metronome/src/commonMain/kotlin/Metronome.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.splendo.kaluga.metronome

import kotlinx.coroutines.flow.Flow
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds

interface Metronome {

companion object {
val TIMER_TICK_INTERVAL = 1.milliseconds
}

interface Builder {
fun intervalMetronome(settings: MetronomeSettings, timer: Flow<Duration>): Metronome
}

interface Manager : AutoCloseable {
suspend fun prepare(): Builder
}

suspend fun run()
}
9 changes: 9 additions & 0 deletions metronome/src/commonMain/kotlin/MetronomeError.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.splendo.kaluga.metronome

sealed class MetronomeError(message: String) : Error(message) {
data object UnexpectedMediaSourceShouldBeURL : MetronomeError("Unexpected media source type. Should be URL.")
data object CannotAccessMediaSource : MetronomeError("Cannot access media source.")
class CannotAccessMediaFile(detailedDescription: String? = null) : MetronomeError("Cannot access media file. $detailedDescription")
class CannotStartAudioEngine(detailedDescription: String? = null) : MetronomeError("Cannot start audio engine. $detailedDescription")
class CannotSetAudioSessionConfiguration(detailedDescription: String? = null) : MetronomeError("Failed to set the audio session configuration. $detailedDescription")
}
5 changes: 5 additions & 0 deletions metronome/src/commonMain/kotlin/MetronomeMediaPlayer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.splendo.kaluga.metronome

interface MetronomeMediaPlayer : AutoCloseable {
fun play()
}
76 changes: 76 additions & 0 deletions metronome/src/commonMain/kotlin/MetronomeSettings.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
Copyright 2024 Splendo Consulting B.V. The Netherlands
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package com.splendo.kaluga.metronome

import com.splendo.kaluga.scientific.PhysicalQuantity
import com.splendo.kaluga.scientific.ScientificValue
import com.splendo.kaluga.scientific.converter.frequency.time
import com.splendo.kaluga.scientific.unit.BeatsPerMinute
import kotlin.jvm.JvmInline
import kotlin.time.Duration
import kotlin.time.Duration.Companion.ZERO
import kotlin.time.Duration.Companion.minutes

sealed class MetronomeSettings {
data object None : MetronomeSettings() {
override fun beatAfter(lastBeat: Duration?): Duration = Duration.INFINITE
}
data class Fixed(val bpm: BPM) : MetronomeSettings() {
override fun beatAfter(lastBeat: Duration?): Duration = lastBeat?.let(bpm::beatAfter) ?: ZERO
}
data class Increasing(val startBPM: BPM, val increaseBPM: BPM, val increaseInterval: Duration) : MetronomeSettings() {
fun bpmAtDuration(duration: Duration): BPM {
val timesToIncrease = (duration / increaseInterval).toInt()
return BPM(startBPM.value + timesToIncrease * increaseBPM.value)
}

override fun beatAfter(lastBeat: Duration?): Duration {
val previousBeat = lastBeat ?: return ZERO
// First grab the BPM used at the previous beat
val bpmAtPreviousBeat = bpmAtDuration(previousBeat)
// Assuming this BPM calculate the next beat
val nextBeatAssumingConstantBPM = bpmAtPreviousBeat.beatAfter(previousBeat)
// However, BPM might increase in the mean time.
// This may lead to three scenarios:
// 1) The BPM is constant between the previous and next beat. Use the current BPM to calculate the next beat
// 2) The next beat would come after the increase of the BPM for both the current and next BPM. Use the increased BPM to calculate the next beat
// 3) The next beat would come after the increase of the BPM only if we are using the current BPM. The new beat should sound immediately when the BPM increases
val nextIncreaseOfBPM = increaseInterval * ((previousBeat / increaseInterval).toInt() + 1)
val nextBeatAssumingIncreaseBPM = BPM(bpmAtPreviousBeat.value + increaseBPM.value).beatAfter(previousBeat)
return when {
nextBeatAssumingConstantBPM < nextIncreaseOfBPM -> nextBeatAssumingConstantBPM
nextBeatAssumingIncreaseBPM > nextIncreaseOfBPM -> nextBeatAssumingIncreaseBPM
else -> nextIncreaseOfBPM
}
}
}

@JvmInline
value class BPM(override val value: Double) : ScientificValue<PhysicalQuantity.Frequency, BeatsPerMinute> {
override val unit: BeatsPerMinute
get() = BeatsPerMinute

init {
require(value > 0.0) { "BPM cannot be zero or lower" }
}

fun beatAfter(duration: Duration) = duration + time().value.minutes
}

abstract fun beatAfter(lastBeat: Duration?): Duration
}
172 changes: 172 additions & 0 deletions metronome/src/iosMain/kotlin/DefaultMetronomeMediaPlayer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package com.splendo.kaluga.metronome

import com.splendo.kaluga.media.MediaSource
import kotlinx.cinterop.CPointer
import kotlinx.cinterop.ObjCObjectVar
import kotlinx.cinterop.alloc
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.pointed
import kotlinx.cinterop.ptr
import kotlinx.cinterop.value
import platform.AVFAudio.AVAudioEngine
import platform.AVFAudio.AVAudioPlayerNode
import platform.AVFAudio.AVAudioFile
import platform.AVFAudio.AVAudioSession
import platform.AVFAudio.AVAudioSessionCategoryPlayback
import platform.AVFAudio.AVAudioSessionRouteChangeNotification
import platform.Foundation.NSError
import platform.Foundation.NSNotification
import platform.Foundation.NSNotificationCenter
import platform.Foundation.NSNotificationName
import platform.Foundation.NSOperationQueue.Companion.currentQueue
import platform.Foundation.NSOperationQueue.Companion.mainQueue
import platform.Foundation.NSURL
import platform.UIKit.UIApplicationDidEnterBackgroundNotification
import platform.UIKit.UIApplicationWillEnterForegroundNotification

actual class DefaultMetronomeMediaPlayer actual constructor(source: MediaSource) : MetronomeMediaPlayer {
private val url = if (source is MediaSource.URL) source.url else throw MetronomeError.UnexpectedMediaSourceShouldBeURL
private val file = accessFile(url)

init {
observeNotifications()
}

sealed interface State {
fun enterBackground(): State
fun enterForeground(mediaPlayerBuilder: () -> MetronomeMediaPlayer): State
fun changeRoute(mediaPlayerBuilder: () -> MetronomeMediaPlayer): State
fun finish(): State

data class Foreground(val mediaPlayer: MetronomeMediaPlayer) : State {
override fun enterBackground() = Background
override fun enterForeground(mediaPlayerBuilder: () -> MetronomeMediaPlayer) = Foreground(mediaPlayer)
override fun changeRoute(mediaPlayerBuilder: () -> MetronomeMediaPlayer) = Foreground(mediaPlayerBuilder())
override fun finish() = Finished
}
data object Background : State {
override fun enterBackground() = Background
override fun enterForeground(mediaPlayerBuilder: () -> MetronomeMediaPlayer) = Foreground(mediaPlayerBuilder())
override fun changeRoute(mediaPlayerBuilder: () -> MetronomeMediaPlayer) = Background
override fun finish() = Finished
}
data object Finished : State {
override fun enterBackground() = Finished
override fun enterForeground(mediaPlayerBuilder: () -> MetronomeMediaPlayer) = Finished
override fun changeRoute(mediaPlayerBuilder: () -> MetronomeMediaPlayer) = Finished
override fun finish() = Finished
}
}

private var state: State = Foreground(ForegroundMetronomeMediaPlayer())
private inline fun <reified T : State> ifState(block: T.() -> Unit) {
(state as? T)?.block()
}

inner class ForegroundMetronomeMediaPlayer : MetronomeMediaPlayer {
private val node = AVAudioPlayerNode()

private val audioEngine = AVAudioEngine().apply {
attachNode(node)
connect(node, outputNode, file.processingFormat)
prepare()
execute(
block = { errorPtr ->
startAndReturnError(errorPtr)
},
handleError = { error ->
MetronomeError.CannotStartAudioEngine(error.localizedDescription)
},
).also {
prepareAudioSession()
}

node.play()
}

private fun prepareAudioSession() {
val audioSession = AVAudioSession.sharedInstance()
execute(
block = { errorPtr ->
// Play audio in silent mode
audioSession.setCategory(AVAudioSessionCategoryPlayback, errorPtr)
},
handleError = { error ->
MetronomeError.CannotSetAudioSessionConfiguration(error.localizedDescription)
},
)
}

override fun close() {
node.stop()
audioEngine.stop()
audioEngine.reset()
}

override fun play() {
node.scheduleFile(file, null, null)
}
}

actual override fun close() {
ifState<Foreground> {
mediaPlayer.close()
}
state = state.finish()
}

actual override fun play() {
ifState<Foreground> {
mediaPlayer.play()
}
}

private fun observeNotifications() {
observeNotification(UIApplicationDidEnterBackgroundNotification) {
ifState<Foreground> {
mediaPlayer.close()
}
state = state.enterBackground()
}
observeNotification(UIApplicationWillEnterForegroundNotification) {
state = state.enterForeground {
ForegroundMetronomeMediaPlayer()
}
}

// Based on Apple documentation we should not play on speaker if user unplugged headphones
// Responding to audio route changes: https://developer.apple.com/documentation/avfaudio/responding-to-audio-route-changes
observeNotification(AVAudioSessionRouteChangeNotification) {
state = state.changeRoute {
ForegroundMetronomeMediaPlayer()
}
}
}

private fun observeNotification(name: NSNotificationName, onNotification: (NSNotification?) -> Unit) = NSNotificationCenter.defaultCenter.addObserverForName(
name,
null,
currentQueue ?: mainQueue,
onNotification,
)

private fun accessFile(url: NSURL): AVAudioFile = execute(
block = { errorPtr ->
AVAudioFile(forReading = url, error = errorPtr)
},
handleError = { error ->
MetronomeError.CannotAccessMediaFile(error.localizedDescription)
},
)

private fun <R> execute(block: (CPointer<ObjCObjectVar<NSError?>>) -> R, handleError: (error: NSError) -> MetronomeError) = memScoped {
val errorPtr: CPointer<ObjCObjectVar<NSError?>> = alloc<ObjCObjectVar<NSError?>>().ptr
val result = block(errorPtr)
val error: NSError? = errorPtr.pointed.value
if (error != null) {
throw handleError(error)
} else {
result
}
}
}

0 comments on commit 35a469f

Please sign in to comment.