Add metronome count-in before play midi file on desktop & android

This commit is contained in:
Hasinjato 2026-07-09 12:00:30 +03:00
parent ca2e10b9b2
commit 491f502daa
5 changed files with 160 additions and 5 deletions

View file

@ -11,6 +11,7 @@ import java.io.RandomAccessFile
actual class FMediaPlayer actual constructor( actual class FMediaPlayer actual constructor(
private val filename: String, private val filename: String,
private val sharedScreenModel: SharedScreenModel,
private val onFinished: () -> Unit private val onFinished: () -> Unit
): KoinComponent { ): KoinComponent {
private data class MidiEvent( private data class MidiEvent(
@ -643,17 +644,68 @@ actual class FMediaPlayer actual constructor(
} }
} }
private var countInJob: Job? = null
private fun getCountInBeats(measureStr: String): Int {
val firstNumeratorChar = measureStr.substringBefore('/').trim()
return firstNumeratorChar.toIntOrNull() ?: 4
}
actual fun play() { actual fun play() {
if (sequence == null) return if (sequence == null) return
applyVoiceStates() if(currentTickPos == 0L) {
startPlaybackLoop() playCountIn()
println("Android MIDI play — tick=$currentTickPos bpm=$targetBpm") } else {
applyVoiceStates()
startPlaybackLoop()
println("Android MIDI play — tick=$currentTickPos bpm=$targetBpm")
}
}
private fun playCountIn() {
countInJob?.cancel()
countInJob = playerScope.launch(Dispatchers.Default) {
val bpm = targetBpm
val beatDurationMs = (60_000L / bpm).toLong()
val noteEvent = 42 // 39:Hand clap
val velocity = 100
val originalMeasure = sharedScreenModel.measureInt.value
val nbSilent = sharedScreenModel.silentDurationBefore.value
val adjustedMeasure = when {
originalMeasure == 6 -> 3
originalMeasure % 2 != 0 && originalMeasure > 3 -> 3
originalMeasure % 2 == 0 && originalMeasure > 4 -> 4
else -> originalMeasure
}
val anacrouseBeats = nbSilent / 4
val totalBeats = if (anacrouseBeats > 5) anacrouseBeats else adjustedMeasure + anacrouseBeats
// println("Android Décompte total : $totalBeats bips ($adjustedMeasure de préparation + $anacrouseBeats d'anacrouse) à $bpm BPM")
val noteOnEvent = byteArrayOf(0x99.toByte(), noteEvent.toByte(), velocity.toByte())
val noteOffEvent = byteArrayOf(0x89.toByte(), noteEvent.toByte(), 0.toByte())
for (i in 1..totalBeats) {
midiDriver.write(noteOnEvent)
delay(100)
midiDriver.write(noteOffEvent)
delay((beatDurationMs - 100).coerceAtLeast(0L))
}
withContext(Dispatchers.Main) {
applyVoiceStates()
startPlaybackLoop()
}
}
} }
actual fun pause() { actual fun pause() {
countInJob?.cancel()
isRunning = false; isHolding = false isRunning = false; isHolding = false
playJob?.cancel(); allNotesOff() playJob?.cancel(); allNotesOff()
} }
actual fun stop() { actual fun stop() {
countInJob?.cancel()
isRunning = false; isHolding = false isRunning = false; isHolding = false
playJob?.cancel(); navigationJob?.cancel() playJob?.cancel(); navigationJob?.cancel()
allNotesOff(); currentTickPos = 0L allNotesOff(); currentTickPos = 0L

View file

@ -4,7 +4,7 @@ import SharedScreenModel
import mg.dot.feufaro.FileRepository import mg.dot.feufaro.FileRepository
import mg.dot.feufaro.viewmodel.MidiMarkers import mg.dot.feufaro.viewmodel.MidiMarkers
expect class FMediaPlayer(filename: String, onFinished: () -> Unit) { expect class FMediaPlayer(filename: String, sharedScreenModel: SharedScreenModel, onFinished: () -> Unit) {
fun play() fun play()
fun pause() fun pause()
fun stop() fun stop()

View file

@ -1991,12 +1991,41 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
T.add(newTemplateItem) T.add(newTemplateItem)
} }
private fun decodeDuration(duration: String): Int {
return when (duration) {
"0" -> 0
"1" -> 1
"2" -> 2
"3" -> 3
"4" -> 4
"6" -> 6
"8" -> 8
"A" -> 10
"B" -> 11
"C" -> 12
"E" -> 14
"G" -> 16
"K" -> 20
"O" -> 24
"S" -> 28
"W" -> 32
else -> 0
}
}
fun loadU(line: String) { fun loadU(line: String) {
val measureLong = meta.getOrElse("m") { "4/4" } val measureLong = meta.getOrElse("m") { "4/4" }
val measure = measureLong.split("/")[0].toIntOrNull() ?: 4 val measure = measureLong.split("/")[0].toIntOrNull() ?: 4
val uObject = ParseULine(line, measure) val uObject = ParseULine(line, measure)
val parsedULine = uObject.parsed() val parsedULine = uObject.parsed()
loadT(parsedULine) loadT(parsedULine)
val regex = Regex("""z([^:]):""")
val match = regex.find(line)
val silentDuration = match?.groupValues?.get(1) ?: "0"
sharedScreenModel.setMeasureInt(measure)
sharedScreenModel.setSilentDurBefr(decodeDuration(silentDuration))
// println(silentDuration)
} }
fun loadO(index: Int, line: String) { fun loadO(index: Int, line: String) {

View file

@ -88,6 +88,19 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
} }
.stateIn(screenModelScope, SharingStarted.Lazily, emptyList()) .stateIn(screenModelScope, SharingStarted.Lazily, emptyList())
private val _measureInt = MutableStateFlow<Int>(4)
val measureInt: StateFlow<Int> = _measureInt.asStateFlow()
fun setMeasureInt(measure: Int) {
_measureInt.value = measure
}
private val _silentDurationBefore = MutableStateFlow<Int>(0)
val silentDurationBefore: StateFlow<Int> = _silentDurationBefore.asStateFlow()
fun setSilentDurBefr(silent: Int) {
_silentDurationBefore.value = silent
}
private val playlistFilename = "playlist.json" private val playlistFilename = "playlist.json"
private val _playlistPaths = MutableStateFlow<List<String>>(emptyList()) private val _playlistPaths = MutableStateFlow<List<String>>(emptyList())
val playlistItems: StateFlow<List<DrawerItem>> = combine(_playlistPaths, _drawerItems) { paths, allItems -> val playlistItems: StateFlow<List<DrawerItem>> = combine(_playlistPaths, _drawerItems) { paths, allItems ->
@ -567,7 +580,7 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
try { try {
val midiFileName = fileRepository.getFileName(newMidiFile) val midiFileName = fileRepository.getFileName(newMidiFile)
println("Opening xx129 $midiFileName") println("Opening xx129 $midiFileName")
_mediaPlayer = FMediaPlayer(filename = midiFileName, onFinished = { _mediaPlayer = FMediaPlayer(filename = midiFileName, sharedScreenModel = this, onFinished = {
// _isPos.value = true // _isPos.value = true
// _isPlay.value = false // _isPlay.value = false
_currentPos.value = 0f _currentPos.value = 0f
@ -666,6 +679,7 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
_editModeState.value = false _editModeState.value = false
_harmonyView.value = false _harmonyView.value = false
setTranspositionInterval(0) setTranspositionInterval(0)
setSilentDurBefr(0)
_midiMarkersList.value = emptyList() _midiMarkersList.value = emptyList()
_tuoTimestamps.value = emptyList() _tuoTimestamps.value = emptyList()
_searchTitle.value = "" _searchTitle.value = ""

View file

@ -18,6 +18,7 @@ import javax.sound.sampled.FloatControl
actual class FMediaPlayer actual constructor( actual class FMediaPlayer actual constructor(
private val filename: String, private val filename: String,
private val sharedScreenModel: SharedScreenModel,
private val onFinished: () -> Unit private val onFinished: () -> Unit
) { ) {
private var sequencer: Sequencer? = try { private var sequencer: Sequencer? = try {
@ -636,7 +637,64 @@ actual class FMediaPlayer actual constructor(
applyVoiceStates() applyVoiceStates()
} }
private fun getCountInBeats(measureStr: String): Int {
val firstNumeratorChar = measureStr.substringBefore('/').trim()
return firstNumeratorChar.toIntOrNull() ?: 4
}
actual fun play(){ actual fun play(){
if (sequencer?.isOpen == true) {
val currentTick = sequencer?.tickPosition ?: 0L
if (currentTick == 0L) {
playCountIn()
} else {
startMidiPlayback()
}
}
}
private var countInJob: Job? = null
private fun playCountIn() {
val nbMeasure = sharedScreenModel.measureInt.value
val nbSilent = sharedScreenModel.silentDurationBefore.value
countInJob?.cancel()
countInJob = playerScope.launch(Dispatchers.Default) {
if (sequencer?.isOpen == true && synthetizer?.isOpen == true) {
val bpm = targetBpm
val beatDurationMs = (60_000L / bpm).toLong()
val metronomeChannel = synthetizer?.channels?.getOrNull(9)
val noteEvent = 42
val velocity = 100
val originalMeasure = sharedScreenModel.measureInt.value
val nbSilent = sharedScreenModel.silentDurationBefore.value
val adjustedMeasure = when {
originalMeasure == 6 -> 3
originalMeasure % 2 != 0 && originalMeasure > 3 -> 3
originalMeasure % 2 == 0 && originalMeasure > 4 -> 4
else -> originalMeasure
}
val anacrouseBeats = nbSilent / 4
val totalBeats = if(anacrouseBeats > 5) anacrouseBeats else adjustedMeasure + anacrouseBeats
// println("Décompte total : $totalBeats bips ($adjustedMeasure de préparation + $anacrouseBeats d'anacrouse [$nbSilent / $4 ]) à $bpm BPM")
for (i in 1..totalBeats) {
metronomeChannel?.noteOn(noteEvent, velocity)
delay(100)
metronomeChannel?.noteOff(noteEvent)
delay((beatDurationMs - 100).coerceAtLeast(0L))
}
withContext(Dispatchers.Main) {
startMidiPlayback()
}
}
}
}
private fun startMidiPlayback() {
if (sequencer?.isOpen == true){ if (sequencer?.isOpen == true){
applyBpm() applyBpm()
sequencer?.start() sequencer?.start()
@ -645,6 +703,7 @@ actual class FMediaPlayer actual constructor(
} }
} }
actual fun pause(){ actual fun pause(){
countInJob?.cancel()
sequencer?.stop() sequencer?.stop()
} }
actual fun requestSync(sharedScreenModel: SharedScreenModel) { actual fun requestSync(sharedScreenModel: SharedScreenModel) {
@ -700,6 +759,7 @@ actual class FMediaPlayer actual constructor(
actual fun stop(){ actual fun stop(){
// sequencer?.stop() // sequencer?.stop()
countInJob?.cancel()
sequencer?.microsecondPosition = 0 sequencer?.microsecondPosition = 0
navigationJob?.cancel() navigationJob?.cancel()
resetNavigationFlags() resetNavigationFlags()