From 491f502daabe392b00fd35dc8bf60864494c84ed Mon Sep 17 00:00:00 2001 From: Hasinjato Date: Thu, 9 Jul 2026 12:00:30 +0300 Subject: [PATCH] Add metronome count-in before play midi file on desktop & android --- .../kotlin/mg/dot/feufaro/midi/MidiPlayer.kt | 58 +++++++++++++++++- .../kotlin/mg/dot/feufaro/midi/MidiPlayer.kt | 2 +- .../kotlin/mg/dot/feufaro/solfa/Solfa.kt | 29 +++++++++ .../feufaro/viewmodel/SharedScreenModel.kt | 16 ++++- .../kotlin/mg/dot/feufaro/midi/MidiPlayer.kt | 60 +++++++++++++++++++ 5 files changed, 160 insertions(+), 5 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt index 8b8da24..e763790 100644 --- a/composeApp/src/androidMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt +++ b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt @@ -11,6 +11,7 @@ import java.io.RandomAccessFile actual class FMediaPlayer actual constructor( private val filename: String, + private val sharedScreenModel: SharedScreenModel, private val onFinished: () -> Unit ): KoinComponent { 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() { if (sequence == null) return - applyVoiceStates() - startPlaybackLoop() - println("Android MIDI play — tick=$currentTickPos bpm=$targetBpm") + if(currentTickPos == 0L) { + playCountIn() + } 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() { + countInJob?.cancel() isRunning = false; isHolding = false playJob?.cancel(); allNotesOff() } actual fun stop() { + countInJob?.cancel() isRunning = false; isHolding = false playJob?.cancel(); navigationJob?.cancel() allNotesOff(); currentTickPos = 0L diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt index 8d347e5..e73a593 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt @@ -4,7 +4,7 @@ import SharedScreenModel import mg.dot.feufaro.FileRepository 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 pause() fun stop() diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt index 4bbea70..a56c94f 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt @@ -1991,12 +1991,41 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository 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) { val measureLong = meta.getOrElse("m") { "4/4" } val measure = measureLong.split("/")[0].toIntOrNull() ?: 4 val uObject = ParseULine(line, measure) val parsedULine = uObject.parsed() 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) { diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt index fdafa83..6090df4 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt @@ -88,6 +88,19 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode } .stateIn(screenModelScope, SharingStarted.Lazily, emptyList()) + + private val _measureInt = MutableStateFlow(4) + val measureInt: StateFlow = _measureInt.asStateFlow() + fun setMeasureInt(measure: Int) { + _measureInt.value = measure + } + + private val _silentDurationBefore = MutableStateFlow(0) + val silentDurationBefore: StateFlow = _silentDurationBefore.asStateFlow() + fun setSilentDurBefr(silent: Int) { + _silentDurationBefore.value = silent + } + private val playlistFilename = "playlist.json" private val _playlistPaths = MutableStateFlow>(emptyList()) val playlistItems: StateFlow> = combine(_playlistPaths, _drawerItems) { paths, allItems -> @@ -567,7 +580,7 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode try { val midiFileName = fileRepository.getFileName(newMidiFile) println("Opening xx129 $midiFileName") - _mediaPlayer = FMediaPlayer(filename = midiFileName, onFinished = { + _mediaPlayer = FMediaPlayer(filename = midiFileName, sharedScreenModel = this, onFinished = { // _isPos.value = true // _isPlay.value = false _currentPos.value = 0f @@ -666,6 +679,7 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode _editModeState.value = false _harmonyView.value = false setTranspositionInterval(0) + setSilentDurBefr(0) _midiMarkersList.value = emptyList() _tuoTimestamps.value = emptyList() _searchTitle.value = "" diff --git a/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt b/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt index 5030c58..b183296 100644 --- a/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt +++ b/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt @@ -18,6 +18,7 @@ import javax.sound.sampled.FloatControl actual class FMediaPlayer actual constructor( private val filename: String, + private val sharedScreenModel: SharedScreenModel, private val onFinished: () -> Unit ) { private var sequencer: Sequencer? = try { @@ -636,7 +637,64 @@ actual class FMediaPlayer actual constructor( applyVoiceStates() } + private fun getCountInBeats(measureStr: String): Int { + val firstNumeratorChar = measureStr.substringBefore('/').trim() + return firstNumeratorChar.toIntOrNull() ?: 4 + } 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){ applyBpm() sequencer?.start() @@ -645,6 +703,7 @@ actual class FMediaPlayer actual constructor( } } actual fun pause(){ + countInJob?.cancel() sequencer?.stop() } actual fun requestSync(sharedScreenModel: SharedScreenModel) { @@ -700,6 +759,7 @@ actual class FMediaPlayer actual constructor( actual fun stop(){ // sequencer?.stop() + countInJob?.cancel() sequencer?.microsecondPosition = 0 navigationJob?.cancel() resetNavigationFlags()