-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
68b89a7
commit 35a469f
Showing
9 changed files
with
374 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
36 changes: 36 additions & 0 deletions
36
metronome/src/androidMain/kotlin/DefaultMetronomeMediaPlayer.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} | ||
} | ||
} |
8 changes: 8 additions & 0 deletions
8
metronome/src/commonMain/kotlin/DefaultMetronomeMediaPlayer.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
package com.splendo.kaluga.metronome | ||
|
||
interface MetronomeMediaPlayer : AutoCloseable { | ||
fun play() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
172
metronome/src/iosMain/kotlin/DefaultMetronomeMediaPlayer.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} | ||
} |