From b38bfebfe600a0b437d07cd4a361f0434bd840af Mon Sep 17 00:00:00 2001 From: Hasinjato Date: Fri, 7 Aug 2026 11:37:49 +0300 Subject: [PATCH] Settings: countIn control; key&time signature on midifile; MidiPlayer: support change tempo , modulation . Support minor key --- .../kotlin/mg/dot/feufaro/SettingsProvider.kt | 11 + .../kotlin/mg/dot/feufaro/midi/MidiPlayer.kt | 111 ++++-- .../kotlin/mg/dot/feufaro/SettingsProvider.kt | 5 + .../kotlin/mg/dot/feufaro/midi/Dynamic.kt | 16 +- .../kotlin/mg/dot/feufaro/midi/MidiPitch.kt | 38 +- .../kotlin/mg/dot/feufaro/midi/MidiPlayer.kt | 2 +- .../mg/dot/feufaro/midi/MidiSequence.kt | 37 +- .../mg/dot/feufaro/midi/MidiWriterKotlin.kt | 3 +- .../dot/feufaro/midi/MidiWriterforEXport.kt | 340 ++++++++++++++++++ .../kotlin/mg/dot/feufaro/solfa/Solfa.kt | 41 ++- .../kotlin/mg/dot/feufaro/solfa/Transpose.kt | 48 ++- .../kotlin/mg/dot/feufaro/ui/DrawerUI.kt | 12 +- .../mg/dot/feufaro/ui/MidiControlPanel.kt | 22 +- .../kotlin/mg/dot/feufaro/ui/Settings.kt | 126 +++++-- .../feufaro/viewmodel/SharedScreenModel.kt | 21 ++ .../dot/feufaro/viewmodel/SolfaScreenModel.kt | 3 + .../kotlin/mg/dot/feufaro/SettingsProvider.kt | 10 + .../kotlin/mg/dot/feufaro/midi/MidiPlayer.kt | 123 ++++++- 18 files changed, 854 insertions(+), 115 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/mg/dot/feufaro/SettingsProvider.kt create mode 100644 composeApp/src/commonMain/kotlin/mg/dot/feufaro/SettingsProvider.kt create mode 100644 composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiWriterforEXport.kt create mode 100644 composeApp/src/desktopMain/kotlin/mg/dot/feufaro/SettingsProvider.kt diff --git a/composeApp/src/androidMain/kotlin/mg/dot/feufaro/SettingsProvider.kt b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/SettingsProvider.kt new file mode 100644 index 0000000..f3028c6 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/SettingsProvider.kt @@ -0,0 +1,11 @@ +package mg.dot.feufaro + +import com.russhwolf.settings.Settings +import org.koin.core.component.KoinComponent +import org.koin.core.component.get + +private object AndroidSettingsResolver : KoinComponent { + fun resolve(): Settings = get() +} + +actual fun provideSettings(): Settings = AndroidSettingsResolver.resolve() \ No newline at end of file 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 db4c25a..4f65401 100644 --- a/composeApp/src/androidMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt +++ b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt @@ -3,6 +3,7 @@ package mg.dot.feufaro.midi import SharedScreenModel import com.russhwolf.settings.Settings import kotlinx.coroutines.* +import mg.dot.feufaro.provideSettings import org.billthefarmer.mididriver.MidiDriver import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -76,7 +77,16 @@ actual class FMediaPlayer actual constructor( val mt=readByte(); val len=readVarLen().toInt(); val md=ByteArray(len){data[pos++]}; - out.add(MidiEvent(tick,0xFF,0,0,0,mt,md)) + + if (mt == 0x51 && md.size == 3) { + val usPerQuarter = ((md[0].toInt() and 0xFF) shl 16) or + ((md[1].toInt() and 0xFF) shl 8) or + (md[2].toInt() and 0xFF) + val bpm = 60_000_000f / usPerQuarter + out.add(MidiEvent(tick, 0xFF, 0, 0, 0, mt, md)) + } else { + out.add(MidiEvent(tick, 0xFF, 0, 0, 0, mt, md)) + } } status == 0xF0 || status == 0xF7 -> repeat(readVarLen().toInt()) { pos++ @@ -164,16 +174,25 @@ actual class FMediaPlayer actual constructor( val targetTempoMultiplier: Float = 0.6f ) private val navigationSteps = mutableListOf() + private val prefs: Settings = provideSettings() init { midiDriver.start() loadVoiceVolumes() - loadSavedInstrumentsToPlayer(this) val file = File(filename) if (file.exists()) { try { sequence = MidiParser.parse(file) resolution = sequence!!.resolution + val tempoEvent = sequence!!.events.firstOrNull { it.type == 0xFF && it.metaType == 0x51 } + if (tempoEvent != null && tempoEvent.metaData.size == 3) { + val md = tempoEvent.metaData + val usPerQuarter = ((md[0].toInt() and 0xFF) shl 16) or + ((md[1].toInt() and 0xFF) shl 8) or + (md[2].toInt() and 0xFF) + targetBpm = 60_000_000f / usPerQuarter + } + usPerTick = (60_000_000.0 / targetBpm) / resolution } catch (e: Exception) { e.printStackTrace() } } @@ -277,28 +296,37 @@ actual class FMediaPlayer actual constructor( } private fun startPlaybackLoop() { - val seq = sequence ?: return + val seq = sequence ?: run { + return + } isRunning = true + needsClockSync = true + playJob?.cancel() playJob = playerScope.launch(Dispatchers.Default) { val events = seq.events - var idx = events.indexOfFirst { it.tickAbsolute >= currentTickPos } - .takeIf { it >= 0 } ?: events.size + var clockNano = System.nanoTime() var clockTick = currentTickPos + var idx = events.indexOfFirst { it.tickAbsolute >= currentTickPos } + .takeIf { it >= 0 } ?: events.size while (isActive && isRunning) { if (needsClockSync) { clockNano = System.nanoTime() clockTick = currentTickPos - idx = events.indexOfFirst { it.tickAbsolute >= currentTickPos } + + val targetIdx = events.indexOfFirst { it.tickAbsolute >= currentTickPos } .takeIf { it >= 0 } ?: events.size + if (targetIdx > idx) { + idx = targetIdx + } needsClockSync = false } + if (isHolding) { yield() -// delay(10) clockNano = System.nanoTime() clockTick = currentTickPos continue @@ -309,7 +337,8 @@ actual class FMediaPlayer actual constructor( if (dc != null) { allNotesOff() seekToGrid(dc.targetGrid) - clockNano = System.nanoTime(); clockTick = currentTickPos + clockNano = System.nanoTime() + clockTick = currentTickPos idx = events.indexOfFirst { it.tickAbsolute >= currentTickPos } .takeIf { it >= 0 } ?: events.size continue @@ -322,16 +351,11 @@ actual class FMediaPlayer actual constructor( } val ev = events[idx] -// val waitNano = (clockNano + ((ev.tickAbsolute - clockTick) * usPerTick * 1000) -// .toLong()) - System.nanoTime() - val targetNano = clockNano + ((ev.tickAbsolute - clockTick) * usPerTick * 1000).toLong() val now = System.nanoTime() val waitNano = targetNano - now -// if (waitNano > 0) delay((waitNano / 1_000_000).coerceAtLeast(0L)) - if (waitNano > 0) { - // Thread.sleep est beaucoup plus prรฉcis que delay() pour les micro-dรฉlais MIDI + if (waitNano > 0 && waitNano < 3_000_000_000L) { val ms = waitNano / 1_000_000 val ns = (waitNano % 1_000_000).toInt() try { @@ -339,19 +363,23 @@ actual class FMediaPlayer actual constructor( } catch (e: Exception) { yield() } - } - if (needsClockSync) { - continue + } else if (waitNano < -100_000_000L) { + clockNano = System.nanoTime() + clockTick = ev.tickAbsolute + } else { + yield() } currentTickPos = ev.tickAbsolute - lastEventNano = System.nanoTime() + lastEventNano = System.nanoTime() // A-B loop if (isLoopingAB && pointA >= 0 && pointB > pointA && ticksToMs(currentTickPos) >= pointB) { - allNotesOff(); currentTickPos = msToTicks(pointA) - clockNano = System.nanoTime(); clockTick = currentTickPos + allNotesOff() + currentTickPos = msToTicks(pointA) + clockNano = System.nanoTime() + clockTick = currentTickPos idx = events.indexOfFirst { it.tickAbsolute >= currentTickPos } .takeIf { it >= 0 } ?: events.size continue @@ -364,9 +392,21 @@ actual class FMediaPlayer actual constructor( 0xB0 -> if (ev.data1 != 7 && ev.data1 != 11) send(0xB0 or ev.channel, ev.data1, ev.data2) 0xC0 -> send(0xC0 or ev.channel, ev.data1) - 0xFF -> { /* tempo ignorรฉ */ } + 0xFF -> { + if (ev.metaType == 0x51 && ev.metaData.size == 3) { + val md = ev.metaData + val usPerQuarter = ((md[0].toInt() and 0xFF) shl 16) or + ((md[1].toInt() and 0xFF) shl 8) or + (md[2].toInt() and 0xFF) + currentPlaybackBpm = targetBpm + usPerTick = (60_000_000.0 / targetBpm) / resolution + clockNano = System.nanoTime() + clockTick = ev.tickAbsolute + } + } } - val currentGrid = (currentTickPos/* ev.tickAbsolute*/ / resolution).toLong() + + val currentGrid = (currentTickPos / resolution).toLong() if (boundModel?.activeIndex?.value != currentGrid.toInt()) { boundModel?.updateActiveIndex(currentGrid) } @@ -704,7 +744,10 @@ actual class FMediaPlayer actual constructor( } actual fun play() { if (sequence == null) return - if(currentTickPos == 0L) { + usPerTick = (60_000_000.0 / targetBpm.toDouble()) / resolution.toDouble() + currentPlaybackBpm = targetBpm + val countInEnable = prefs.getBoolean("isCountIn", true) + if (currentTickPos == 0L && countInEnable) { playCountIn() } else { applyVoiceStates() @@ -778,6 +821,7 @@ actual class FMediaPlayer actual constructor( withContext(Dispatchers.Main) { applyVoiceStates() + needsClockSync = true startPlaybackLoop() } } @@ -812,9 +856,11 @@ actual class FMediaPlayer actual constructor( applyVoiceStates() } actual fun setTempo(bpm: Float) { - targetBpm = bpm; usPerTick = (60_000_000.0/bpm)/resolution + targetBpm = bpm + currentPlaybackBpm = bpm + usPerTick = (60_000_000.0 / bpm.toDouble()) / resolution.toDouble() boundModel?.let { prepareNavigation(it) } - println("Tempo โ†’ $bpm BPM") + needsClockSync = true } actual fun getCurrentBPM(): Float = targetBpm actual fun requestSync(sharedScreenModel: SharedScreenModel) { @@ -865,12 +911,14 @@ actual class FMediaPlayer actual constructor( for (ch in 0 until 4) send(0xC0 or ch, noInstru) } actual fun getAvalaibleInstruments(): List { - val gmInstruments = listOf( - "Acoustic Grand Piano", "Bright Acoustic Piano", "Electric Grand Piano", "Honky-tonk Piano", "Electric Piano 1", "Electric Piano 2", "Harpsichord", "Clavi", "Celesta", "Glockenspiel", "Music Box", "Vibraphone", "Marimba", "Xylophone", "Tubular Bells", "Dulcimer", "Drawbar Organ", "Percussive Organ", "Rock Organ", "Church Organ", "Reed Organ", "Accordion", "Harmonica", "Tango Accordion", "Acoustic Guitar (nylon)", "Acoustic Guitar (steel)", "Electric Guitar (jazz)", "Electric Guitar (clean)", "Electric Guitar (muted)", "Overdriven Guitar", "Distortion Guitar", "Guitar harmonics", "Acoustic Bass", "Electric Bass (finger)", "Electric Bass (pick)", "Fretless Bass", "Slap Bass 1", "Slap Bass 2", "Synth Bass 1", "Synth Bass 2", "Violin", "Viola", "Cello", "Contrabass", "Tremolo Strings", "Pizzicato Strings", "Orchestral Harp", "Timpani", "String Ensemble 1", "String Ensemble 2", "SynthStrings 1", "SynthStrings 2", "Choir Aahs", "Voice Oohs", "Synth Voice", "Orchestra Hit", "Trumpet", "Trombone", "Tuba", "Muted Trumpet", "French Horn", "Brass Section", "SynthBrass 1", "SynthBrass 2", "Soprano Sax", "Alto Sax", "Tenor Sax", "Baritone Sax", "Oboe", "English Horn", "Bassoon", "Clarinet", "Piccolo", "Flute", "Recorder", "Pan Flute", "Blown Bottle", "Shakuhachi", "Whistle", "Ocarina", "Lead 1 (square)", "Lead 2 (sawtooth)", "Lead 3 (calliope)", "Lead 4 (chiff)", "Lead 5 (charang)", "Lead 6 (voice)", "Lead 7 (fifths)", "Lead 8 (bass+lead)", "Pad 1 (new age)", "Pad 2 (warm)", "Pad 3 (polysynth)", "Pad 4 (choir)", "Pad 5 (bowed)", "Pad 6 (metallic)", "Pad 7 (halo)", "Pad 8 (sweep)", "FX 1 (rain)", "FX 2 (soundtrack)", "FX 3 (crystal)", "FX 4 (atmosphere)", "FX 5 (brightness)", "FX 6 (goblins)", "FX 7 (echoes)", "FX 8 (sci-fi)", "Sitar", "Banjo", "Shamisen", "Koto", "Kalimba", "Bag pipe", "Fiddle", "Shanai", "Tinkle Bell", "Agogo", "Steel Drums", "Woodblock", "Taiko Drum", "Melodic Tom", "Synth Drum", "Reverse Cymbal", "Guitar Fret Noise", "Breath Noise", "Seashore", "Bird Tweet", "Telephone Ring", "Helicopter", "Applause", "Gunshot" - ) + val gmInstruments = listOf("๐ŸŽน Acoustic Grand Piano", "๐ŸŽน Bright Acoustic Piano", "๐ŸŽน Electric Grand Piano", "๐ŸŽน Honky-tonk Piano", "๐ŸŽน Electric Piano 1", "๐ŸŽน Electric Piano 2", "๐ŸŽน Harpsichord", "๐ŸŽน Clavi", "๐Ÿ”” Celesta", "๐Ÿ”” Glockenspiel", "๐Ÿ”” Music Box", "๐Ÿ”” Vibraphone", "๐Ÿ”” Marimba", "๐Ÿ”” Xylophone", "๐Ÿ”” Tubular Bells", "๐Ÿ”” Dulcimer", "๐ŸŽน Drawbar Organ", "๐ŸŽน Percussive Organ", "๐ŸŽน Rock Organ", "๐ŸŽน Church Organ", "๐ŸŽน Reed Organ", "๐ŸŽน Accordion", "๐ŸŽน Harmonica", "๐ŸŽน Tango Accordion", "๐ŸŽธ Acoustic Guitar (nylon)", "๐ŸŽธ Acoustic Guitar (steel)", "๐ŸŽธ Electric Guitar (jazz)", "๐ŸŽธ Electric Guitar (clean)", "๐ŸŽธ Electric Guitar (muted)", "๐ŸŽธ Overdriven Guitar", "๐ŸŽธ Distortion Guitar", "๐ŸŽธ Guitar harmonics", "๐ŸŽธ Acoustic Bass", "๐ŸŽธ Electric Bass (finger)", "๐ŸŽธ Electric Bass (pick)", "๐ŸŽธ Fretless Bass", "๐ŸŽธ Slap Bass 1", "๐ŸŽธ Slap Bass 2", "๐ŸŽธ Synth Bass 1", "๐ŸŽธ Synth Bass 2", "๐ŸŽป Violin", "๐ŸŽป Viola", "๐ŸŽป Cello", "๐ŸŽป Contrabass", "๐ŸŽป Tremolo Strings", "๐ŸŽป Pizzicato Strings", "๐ŸŽป Orchestral Harp", "๐ŸŽป Timpani", "๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘ String Ensemble 1", "๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘ String Ensemble 2", "๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘ SynthStrings 1", "๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘ SynthStrings 2", "๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘ Choir Aahs", "๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘ Voice Oohs", "๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘ Synth Voice", "๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘ Orchestra Hit", "๐ŸŽบ Trumpet", "๐ŸŽบ Trombone", "๐ŸŽบ Tuba", "๐ŸŽบ Muted Trumpet", "๐ŸŽบ French Horn", "๐ŸŽบ Brass Section", "๐ŸŽบ SynthBrass 1", "๐ŸŽบ SynthBrass 2", "๐ŸŽท Soprano Sax", "๐ŸŽท Alto Sax", "๐ŸŽท Tenor Sax", "๐ŸŽท Baritone Sax", "๐Ÿพ Oboe", "๐Ÿพ English Horn", "๐Ÿพ Bassoon", "๐Ÿพ Clarinet", "๐Ÿพ Piccolo", "๐Ÿพ Flute", "๐Ÿพ Recorder", "๐Ÿพ Pan Flute", "๐Ÿพ Blown Bottle", "๐Ÿพ Shakuhachi", "๐Ÿพ Whistle", "๐Ÿพ Ocarina", "๐Ÿ•น๏ธ Lead 1 (square)", "๐Ÿ•น๏ธ Lead 2 (sawtooth)", "๐Ÿ•น๏ธ Lead 3 (calliope)", "๐Ÿ•น๏ธ Lead 4 (chiff)", "๐Ÿ•น๏ธ Lead 5 (charang)", "๐Ÿ•น๏ธ Lead 6 (voice)", "๐Ÿ•น๏ธ Lead 7 (fifths)", "๐Ÿ•น๏ธ Lead 8 (bass+lead)", "๐Ÿ”ฎ Pad 1 (new age)", "๐Ÿ”ฎ Pad 2 (warm)", "๐Ÿ”ฎ Pad 3 (polysynth)", "๐Ÿ”ฎ Pad 4 (choir)", "๐Ÿ”ฎ Pad 5 (bowed)", "๐Ÿ”ฎ Pad 6 (metallic)", "๐Ÿ”ฎ Pad 7 (halo)", "๐Ÿ”ฎ Pad 8 (sweep)", "โšก FX 1 (rain)", "โšก FX 2 (soundtrack)", "โšก FX 3 (crystal)", "โšก FX 4 (atmosphere)", "โšก FX 5 (brightness)", "โšก FX 6 (goblins)", "โšก FX 7 (echoes)", "โšก FX 8 (sci-fi)", "๐Ÿ› Sitar", "๐Ÿ› Banjo", "๐Ÿ› Shamisen", "๐Ÿ› Koto", "๐Ÿ› Kalimba", "๐Ÿ› Bag pipe", "๐Ÿ› Fiddle", "๐Ÿ› Shanai", "๐Ÿฅ Tinkle Bell", "๐Ÿฅ Agogo", "๐Ÿฅ Steel Drums", "๐Ÿฅ Woodblock", "๐Ÿฅ Taiko Drum", "๐Ÿฅ Melodic Tom", "๐Ÿฅ Synth Drum", "๐Ÿฅ Reverse Cymbal", "๐Ÿš Guitar Fret Noise", "๐Ÿš Breath Noise", "๐Ÿš Seashore", "๐Ÿš Bird Tweet", "๐Ÿš Telephone Ring", "๐Ÿš Helicopter", "๐Ÿš Applause", "๐Ÿš Gunshot") + + return gmInstruments.mapIndexed { index, text -> + val icon = text.substringBefore(' ') + val name = text.substringAfter(' ') - return gmInstruments.mapIndexed { index, name -> MidiInstrument( + icon = icon, program = index, name = name ) @@ -885,9 +933,4 @@ actual class FMediaPlayer actual constructor( val defaultProgram = 0 return settings.getInt("voice_instrument", defaultProgram) } - - fun loadSavedInstrumentsToPlayer(mediaPlayer: FMediaPlayer) { - val savedProgram = getVoiceInstrument() - mediaPlayer.changeInstru(savedProgram) - } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/SettingsProvider.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/SettingsProvider.kt new file mode 100644 index 0000000..7a51948 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/SettingsProvider.kt @@ -0,0 +1,5 @@ +package mg.dot.feufaro + +import com.russhwolf.settings.Settings + +expect fun provideSettings(): Settings \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/Dynamic.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/Dynamic.kt index 89160e0..ffce286 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/Dynamic.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/Dynamic.kt @@ -1,5 +1,7 @@ package mg.dot.feufaro.midi +import com.russhwolf.settings.Settings +import mg.dot.feufaro.provideSettings import java.util.prefs.Preferences enum class Dynamic(val velocity: Int, val label: String, val defaultFactor: Float) { @@ -10,8 +12,8 @@ enum class Dynamic(val velocity: Int, val label: String, val defaultFactor: Floa MF (80, "mf", 1.00f), F (96, "f", 1.15f), FF (112, "ff", 1.35f), - FFF(126, "fff", 1.60f); - private val prefs = Preferences.userRoot().node("mg.dot.feufaro") + FFF(127, "fff", 1.60f); + private val prefs: Settings = provideSettings() var savedFactor: Float = prefs.getFloat("dynamic_${name.lowercase()}", defaultFactor) set(value) { field = value @@ -22,11 +24,11 @@ enum class Dynamic(val velocity: Int, val label: String, val defaultFactor: Floa get() = if (isGloballyEnabled) savedFactor else 1.00f companion object { - private val globalPrefs = Preferences.userRoot().node("mg.dot.feufaro") - var isGloballyEnabled: Boolean = globalPrefs.getBoolean("dynamics_enabled", true) + private val prefs: Settings = provideSettings() + var isGloballyEnabled: Boolean = prefs.getBoolean("dynamics_enabled", true) set(value) { field = value - globalPrefs.putBoolean("dynamics_enabled", value) + prefs.putBoolean("dynamics_enabled", value) } fun fromVelocity(v: Int): Dynamic = @@ -35,5 +37,9 @@ enum class Dynamic(val velocity: Int, val label: String, val defaultFactor: Floa fun resetToDefaults() { entries.forEach { it.savedFactor = it.defaultFactor } } + fun fromLabel(label: String): Dynamic? { + val cleanedLabel = label.trim().lowercase() + return entries.find { it.label == cleanedLabel } + } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiPitch.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiPitch.kt index 384dd17..acb9c5e 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiPitch.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiPitch.kt @@ -12,7 +12,7 @@ data class MidiPitch ( var duration: Int = 0, var markers: List = listOf(), var tick : Int = 0, - var metaBytes: String = "", + var metaBytes: ByteArray = byteArrayOf(), var metaByteSize: Int = 0, var metaType : Int = -1 ) { @@ -22,8 +22,11 @@ data class MidiPitch ( var curVoiceNumber: Int = 0 var nextTick : MutableList = mutableListOf() } - fun setMeta(type: Int, tickMeta: Int, nb: Int, data0: Int, data1: Int = 0, data2: Int = 0, data3: Int = 0, data4: Int = 0) { - metaBytes = ""+ data0.toChar() + data1.toChar() + data2.toChar() + data3.toChar() + data4.toChar() + fun setMeta(type: Int, tickMeta: Int, nb: Int, vararg data: Int) { + val bytes = ByteArray(nb) { i -> + (data.getOrElse(i) { 0 } and 0xFF).toByte() + } + metaBytes = bytes tick = tickMeta metaByteSize = nb metaType = type @@ -55,29 +58,32 @@ data class MidiPitch ( nextTick.add(0) } } - fun initKey(theKey: String) { + fun initKey(theKey: String, isMinor: Boolean = false) { key = theKey - val armature = Transpose.toArmature(key) + val armature = Transpose.toArmature(theKey) + val tickMeta = nextTick.getOrNull(0) ?: 0 - setMeta(0x59, tickMeta, 2, armature) + val mode = if (isMinor) 1 else 0 + setMeta(0x59, tickMeta, 2, armature, mode) } fun initMeasure(theMeasure: String): Boolean { - val numerator = theMeasure.replace(Regex("/.*"), "").toIntOrNull() - val denominator = theMeasure.replace(Regex("^[^/]*/(\\d+).*$"), "$1").toIntOrNull() ?: 4 - val data1 = when (denominator) { + val parts = theMeasure.split("/") + val numerator = parts.getOrNull(0)?.toIntOrNull() ?: return false + val denominator = parts.getOrNull(1)?.toIntOrNull() ?: 4 + + val denomPower = when (denominator) { 1 -> 0 2 -> 1 4 -> 2 8 -> 3 16 -> 4 - else -> 5 + 32 -> 5 + else -> 2 } - val blankDuration = (4 * (numerator ?: 4) - ParseULine.blankDuration) * 15 - if (numerator != null) { - setMeta(0x58, blankDuration, 4, numerator, data1, 60, 20) - return true - } - return false + val tickMeta = nextTick.getOrNull(0) ?: 0 + setMeta(0x58, tickMeta, 4, numerator, denomPower, 24, 8) + + return true } fun reset() { tick = 0 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 421e9dd..55a05ec 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt @@ -31,4 +31,4 @@ expect class FMediaPlayer(filename: String, sharedScreenModel: SharedScreenModel fun getVoiceInstrument(): Int } -data class MidiInstrument(val program: Int, val name: String) \ No newline at end of file +data class MidiInstrument(val icon: String = "\uD83C\uDFB9", val program: Int, val name: String) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiSequence.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiSequence.kt index 1b65aee..15529cb 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiSequence.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiSequence.kt @@ -36,7 +36,26 @@ class MidiSequence(val resolution: Int = 60) { fun addSequence(channel: Int, pitch: Int, currentTick: Long, type: Int = 80, finalVelocity: Int = 100) { val myTrack = tracks[0] - myTrack.addNote(channel, pitch, currentTick, type, finalVelocity) + if (type == 0xC0) { + myTrack.addProgramChange(channel, pitch, currentTick) + } else { + myTrack.addNote(channel, pitch, currentTick, type, finalVelocity) + } + } + fun MutableList.addProgramChange(channel: Int, program: Int, currentTick: Long) { + val delta = currentTick - lastTick + lastTick = currentTick + val status = 0xC0 or (channel and 0x0F) + + this.addMidiEvent(delta, status, program, null) + } + fun addControlChange(channel: Int, controller: Int, value: Int, currentTick: Long) { + val myTrack = if (tracks.isEmpty()) createTrack() else tracks[0] + val delta = (currentTick - lastTick).coerceAtLeast(0L) + lastTick = currentTick + + val status = 0xB0 or (channel and 0x0F) + myTrack.addMidiEvent(delta, status, controller, value) } fun MutableList.addNote(channel: Int, pitch: Int, currentTick: Long, type: Int = 80, finalVelocity: Int = 100) { // Calcul du Delta-Time @@ -127,4 +146,20 @@ class MidiSequence(val resolution: Int = 60) { outTrack.addAll(data.toList().take(nbData)) } + fun addMetaFe(type: Int, tick: Int, nbData: Int, metaByte: ByteArray) { + if (tracks.isEmpty()) { + tracks.add(mutableListOf(0)) + } + val outTrack = tracks[0] + // Un Meta Message commence toujours par FF, puis le type, puis la longueur + outTrack.addAll(0L.toVLQList()) + outTrack.add(0xFF.toByte()) // Status byte + outTrack.add(type.toByte()) // Meta-event type + outTrack.addAll(nbData.toLong().toVLQList()) + // Ajout des donnรฉes + for (i in 0 until nbData) { + val byteVal = metaByte.getOrElse(i) { 0 } + outTrack.add(byteVal) + } + } } diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiWriterKotlin.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiWriterKotlin.kt index e62d02b..5bc94de 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiWriterKotlin.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiWriterKotlin.kt @@ -53,7 +53,8 @@ class MidiWriterKotlin constructor(private val fileRepository: FileRepository) tick = 0 pitches.forEach { if (it.metaType > 0) { - addMetaMessage(it.metaType, it.tick, it.metaByteSize, it.metaBytes) + val metaDataString = String(it.metaBytes, Charsets.ISO_8859_1) + addMetaMessage(it.metaType, it.tick, it.metaByteSize, metaDataString) } else if (it.pitch != "") { addNote(it.voiceNumber, it.pitch.toInt(), 100, it.tick.toLong()) } diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiWriterforEXport.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiWriterforEXport.kt new file mode 100644 index 0000000..f98c1fe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiWriterforEXport.kt @@ -0,0 +1,340 @@ +package mg.dot.feufaro.midi + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import mg.dot.feufaro.FileRepository +import mg.dot.feufaro.solfa.Transpose +import mg.dot.feufaro.viewmodel.MidiMarkers + +class MidiWriterforEXport constructor(private val fileRepository: FileRepository) { + private val sequence = MidiSequence(480) + private val track = sequence.createTrack() + private var tick: Long = 0 + private var nextTick: MutableList = mutableListOf() + private val lastPitch : MutableList = mutableListOf() + private val useChord : Boolean = true + fun addNote( voiceNumber: Int, note: Int, velocity: Int, tick: Long) { + val channel = (voiceNumber -1).coerceIn(0, 3) + var finalNote = note + + if (voiceNumber == 3 || voiceNumber == 4) { + finalNote -= 12 + } + if (lastPitch.size > voiceNumber && lastPitch[voiceNumber] > 0) { + sequence.addSequence(channel, lastPitch[voiceNumber], tick) + } + var finalVelocity = velocity + var midiNote = finalNote + if (finalNote <= 0) { + midiNote = 40 + finalVelocity = 0 + } + sequence.addSequence(channel, midiNote, tick, 90, finalVelocity) + + while(lastPitch.size <= voiceNumber) { + lastPitch.add(0) + } + lastPitch[voiceNumber] = midiNote + } + fun save(filePath: String) { + val parseScope = CoroutineScope(Dispatchers.Default) + parseScope.launch { + sequence.write() + fileRepository.saveFile(filePath, sequence.out.toByteArray()) + //val fout = fileRepository.getOutputStream(filePath) + } + } + fun addMetaMessage(type: Int, tick: Int, nbData: Int, metaBytes: ByteArray) { + sequence.addMeta(type, tick, nbData, metaBytes.toString()) + } + + fun setTempoMeta(bpm: Float, tickPosition: Int = 0) { + val mpqn = (60_000_000f / bpm.coerceAtLeast(1f)).toInt() + val bytes = byteArrayOf( + (mpqn shr 16 and 0xFF).toByte(), + (mpqn shr 8 and 0xFF).toByte(), + (mpqn and 0xFF).toByte() + ) + addMetaMessage(0x51, tickPosition, 3, bytes) + } + fun setProgramChange(voiceNumber: Int, instrumentProgram: Int, tickPosition: Long = 0) { + val channel = (voiceNumber - 1).coerceIn(0, 3) + val program = instrumentProgram.coerceIn(0, 127) + sequence.addSequence(channel, program, tickPosition, 0xC0, 0) + } + + fun addControlChange(voiceNumber: Int, controller: Int, value: Int, tickPosition: Long) { + val channel = (voiceNumber - 1).coerceIn(0, 3) + val ctrl = controller.coerceIn(0, 127) + val valByte = value.coerceIn(0, 127) + sequence.addControlChange(channel, ctrl, valByte, tickPosition) + } + + fun process( + pitches: List, + markers: List = emptyList(), + initialBpm: Float = 120.0f, + voiceInstruments: Map = mapOf(1 to 0, 2 to 0, 3 to 0, 4 to 0), + initialKey: String = "C" + ) { + val lastTick = 0 + nextTick.clear() + // addMetaMessage(0x59, 4, 2, 2,0) + tick = 0 + setTempoMeta(initialBpm, 0) + voiceInstruments.forEach { (voiceIndex, programNumber) -> + setProgramChange(voiceNumber = voiceIndex, instrumentProgram = programNumber, tickPosition = 0L) + } + + val nuanceMap = mutableMapOf() + val fermataTicks = mutableSetOf() + val textMetaMap = mutableMapOf>() + val keyChangeMap = mutableMapOf() + val tempoChangeMap = mutableMapOf() + val hairpinMap = mutableMapOf>() + var pendingHairpinType: String? = null + var pendingHairpinStartTick: Long? = null + + val modulaRegex = Regex("""D(รด|o)?\s*dia\s*(C|Db|D|Eb|E|F|Gb|G|Ab|A|Bb|B)""", RegexOption.IGNORE_CASE) + val tempoRegex = Regex("""^โ™ฉ\s*=\s*(\d+)""", RegexOption.IGNORE_CASE) + + markers.forEach { midiMarker -> + val gridIdx = (midiMarker.gridIndex ?: 0) * 480L + midiMarker.marker.forEach { text -> + val cleanText = text.trim() + val vel = getVelocityFromNuance(cleanText, -1) + val tempoMatch = tempoRegex.find(cleanText) + val modMatch = modulaRegex.find(cleanText) + /* Les nuances */ + if (vel != -1) { + nuanceMap[gridIdx] = vel + } + + /* tempo */ + if (tempoMatch != null) { + val bpmValue = tempoMatch.groupValues[1].toFloatOrNull() + if (bpmValue != null) tempoChangeMap[gridIdx] = bpmValue + } + if (text.contains("๐„")) { + fermataTicks.add(gridIdx) + } + + /* Modulation */ + if (modMatch != null) { + val targetKey = modMatch.groupValues[2] + keyChangeMap[gridIdx] = targetKey + } + + /* Hairpins */ + val isCres = cleanText == "<" || cleanText.lowercase().startsWith("cres") + val isDecres = cleanText == ">" || cleanText.lowercase().startsWith("decresc") + + if (isCres || isDecres) { + if (pendingHairpinStartTick != null && pendingHairpinType != null) { + val defaultEnd = pendingHairpinStartTick!! + (2 * 480L) + hairpinMap[pendingHairpinStartTick!!] = Pair(pendingHairpinType!!, defaultEnd) + } + + pendingHairpinType = if (isCres) "<" else ">" + pendingHairpinStartTick = gridIdx + } else if (cleanText == "=" && pendingHairpinStartTick != null && pendingHairpinType != null) { + hairpinMap[pendingHairpinStartTick!!] = Pair(pendingHairpinType!!, gridIdx) + pendingHairpinType = null + pendingHairpinStartTick = null + } + + if (!isCres && !isDecres && cleanText != "<" && cleanText != ">" && cleanText != "=" && !cleanText.contains("๐„") && vel == -1 && tempoMatch == null && modMatch == null) { + textMetaMap.getOrPut(gridIdx) { mutableListOf() }.add(text) + } + } + } + + val processedFermataTicks = mutableSetOf() + val pendingSustainOff = mutableListOf>() + + + + var timeOffset = 0L + val holdDuration = 480L *3 // 480L:2noire *2:3noir *4:ronde + + if (pendingHairpinStartTick != null && pendingHairpinType != null) { + val defaultEnd = pendingHairpinStartTick!! + (2 * 480L) + hairpinMap[pendingHairpinStartTick!!] = Pair(pendingHairpinType!!, defaultEnd) + } + var currentKey = initialKey + var baseVelocity = Dynamic.MF.velocity + var baseExpression = 64 // Expression MIDI standard (0 ร  127) + val stepExpressionDelta = 10 + val baseKeyIdx = Transpose.keyToNumber.indexOf(initialKey).coerceAtLeast(0) + + val stepDelta = 20 + val processedHairpins = mutableSetOf() + + val pitchesByTick = pitches.groupBy { (it.tick) * 8L }.toSortedMap() + pitchesByTick.forEach { (rawTick, eventsAtTick) -> + + /* Fermata CC*/ + val adjustedTickForRelease = rawTick + timeOffset + val iterator = pendingSustainOff.iterator() + while (iterator.hasNext()) { + val (offTick, voiceNum) = iterator.next() + if (offTick <= adjustedTickForRelease) { + addControlChange(voiceNumber = voiceNum, controller = 64, value = 0, tickPosition = offTick) + iterator.remove() + } + } + + var isFermataAtThisTick = false + if (fermataTicks.contains(rawTick) && !processedFermataTicks.contains(rawTick)) { + processedFermataTicks.add(rawTick) + isFermataAtThisTick = true + + val fermataStartTick = rawTick + timeOffset + val offTickTarget = fermataStartTick + holdDuration + + (1..4).forEach { v -> + addControlChange(voiceNumber = v, controller = 64, value = 127, tickPosition = fermataStartTick) + pendingSustainOff.add(Pair(offTickTarget, v)) + } + } + + + /* Nuances */ + if (nuanceMap.containsKey(rawTick)) { + baseVelocity = nuanceMap[rawTick] ?: Dynamic.MF.velocity + } + + val adjustedTick = rawTick/* + timeOffset*/ + + + + // hairpin + hairpinMap.forEach { (startTick, pair) -> + val (type, endTick) = pair + if (rawTick >= endTick && !processedHairpins.contains(startTick)) { + if (type == "<") { + baseExpression = (baseExpression + stepDelta).coerceIn(0, 127) + } else if (type == ">") { + baseExpression = (baseExpression - stepDelta).coerceIn(0, 127) + } + processedHairpins.add(startTick) + } + } + var currentExpression = baseExpression + + val activeHairpin = hairpinMap.entries.firstOrNull { (start, pair) -> + rawTick >= start && rawTick < pair.second + } + + if (activeHairpin != null) { + val startTick = activeHairpin.key + val (type, endTick) = activeHairpin.value + + if (endTick > startTick) { + val progress = (rawTick - startTick).toDouble() / (endTick - startTick).toDouble() + + if (type == "<") { + currentExpression = (baseExpression + (stepExpressionDelta * progress)).toInt().coerceIn(0, 127) + } else if (type == ">") { + currentExpression = (baseExpression - (stepExpressionDelta * progress)).toInt().coerceIn(0, 127) + } + + (1..4).forEach { voiceNum -> + addControlChange(voiceNumber = voiceNum, controller = 11, value = currentExpression, tickPosition = rawTick) + } + } + } + + + // Tempo Meta 0x51 + val pendingTempoTicks = tempoChangeMap.keys.filter { it <= rawTick }.sorted() + pendingTempoTicks.forEach { tempoRawTick -> + val newBpm = tempoChangeMap[tempoRawTick] ?: initialBpm + val tempoAdjustedTick = tempoRawTick/* + timeOffset*/ + + setTempoMeta(newBpm, tempoAdjustedTick.toInt()) + tempoChangeMap.remove(tempoRawTick) + } + + // Modulation Meta 0x59 + val pendingKeyTicks = keyChangeMap.keys.filter { it <= rawTick }.sorted() + pendingKeyTicks.forEach { keyRawTick -> + val targetKey = keyChangeMap[keyRawTick] ?: initialKey + currentKey = targetKey + + val keyAdjustedTick = keyRawTick/* + timeOffset*/ + val keyIdx = Transpose.keyToNumber.indexOf(targetKey) + + if (keyIdx != -1) { + val sf = Transpose.keyToArmature[keyIdx] + val keyBytes = byteArrayOf(sf.toByte(), 0.toByte()) // 0 = Majeur + + addMetaMessage(0x59, keyAdjustedTick.toInt(), 2, keyBytes) + } + keyChangeMap.remove(keyRawTick) + } + + + /* Tetxe; marker */ + val pendingTextTicks = textMetaMap.keys.filter { it <= rawTick }.sorted() + pendingTextTicks.forEach { textRawTick -> + val textAdjustedTick = textRawTick/* + timeOffset*/ + + textMetaMap[textRawTick]?.forEach { annotationText -> + addTextMeta(text = annotationText, tickPosition = textAdjustedTick, metaType = 0x06) + } + textMetaMap.remove(textRawTick) + } + + eventsAtTick.forEach { noteEvent -> + // Note + transpose + val currentKeyIdx = Transpose.keyToNumber.indexOf(currentKey).coerceAtLeast(0) + val transpositionSemitones = currentKeyIdx - baseKeyIdx + + if (noteEvent.metaType > 0) { + addMetaMessage(noteEvent.metaType, adjustedTick.toInt(), noteEvent.metaByteSize, noteEvent.metaBytes) + } else if (noteEvent.pitch != "") { + val finalTransposedPitch = noteEvent.pitch.toInt() + transpositionSemitones + addNote(noteEvent.voiceNumber, finalTransposedPitch, baseVelocity, adjustedTick) + } + } + + if (fermataTicks.contains(rawTick)) { + timeOffset += holdDuration + } + } + + tempoChangeMap.forEach { (tempoRawTick, bpm) -> + val tempoAdjustedTick = tempoRawTick/* + timeOffset*/ + setTempoMeta(bpm, tempoAdjustedTick.toInt()) + } + tempoChangeMap.clear() + + + pendingSustainOff.forEach { (offTick, voiceNum) -> + addControlChange(voiceNumber = voiceNum, controller = 64, value = 0, tickPosition = offTick) + } + pendingSustainOff.clear() + } + + fun getVelocityFromNuance(markerText: String, defaultVelocity: Int = -1): Int { + val dynamic = Dynamic.fromLabel(markerText) + if (dynamic != null) { + return dynamic.velocity + } + return -1 + } + /** + * (Type 0x01 = Text, 0x06 = Marker, 0x05 = Lyric). + */ + fun addTextMeta(text: String, tickPosition: Long, metaType: Int = 0x01) { + val bytes = text.toByteArray(Charsets.UTF_8) + addMetaMessage( + type = metaType, + tick = tickPosition.toInt(), + nbData = bytes.size, + metaBytes = bytes + ) + } +} \ No newline at end of file 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 dc6c60a..83899eb 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt @@ -1,16 +1,23 @@ package mg.dot.feufaro.solfa import SharedScreenModel +import com.russhwolf.settings.Settings import kotlinx.coroutines.* import mg.dot.feufaro.FileRepository import mg.dot.feufaro.SaveSettings import mg.dot.feufaro.getGlobalTemplate +import mg.dot.feufaro.getPlatform import mg.dot.feufaro.launchFilePicker import mg.dot.feufaro.midi.MidiPitch import mg.dot.feufaro.midi.MidiWriterKotlin +import mg.dot.feufaro.midi.MidiWriterforEXport +import mg.dot.feufaro.provideSettings import mg.dot.feufaro.viewmodel.PartitionMetadata import mg.dot.feufaro.transformLyricsInput +import org.koin.core.component.inject import java.io.File +import java.util.prefs.Preferences +import kotlin.getValue import kotlin.math.min //@todo: split voices (ffpm19/ews22) ${S:mfs} in N4:, idem ffpm-212 @@ -251,6 +258,25 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository sharedScreenModel.setStanza(1) } } + + private val prefs: Settings = provideSettings() + + fun generateMidiFile(bpm: Float) { + val parseScope = CoroutineScope(Dispatchers.Default) + parseScope.launch { + val pitchesSorted = pitches.sortedWith(compareBy({ it.tick }, { it.voiceNumber })) + val midiWriter = MidiWriterforEXport(fileRepository) + + val instruments = mapOf( + 1 to prefs.getInt("voice_instrument", 0), + 2 to prefs.getInt("voice_instrument", 0), + 3 to prefs.getInt("voice_instrument", 0), + 4 to prefs.getInt("voice_instrument", 0), + ) + midiWriter.process(pitchesSorted, sharedScreenModel.getFullMarkers(), initialBpm = bpm, instruments, initialKey = sharedScreenModel.songKey.value) + midiWriter.save("whawyd3.mid") + } + } fun justBuild(sourceFile: String, sourceContent: String) { currentFile = sourceFile val parseScope = CoroutineScope(Dispatchers.Default) @@ -1987,12 +2013,17 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository val z = midiPitch.copy() if (z.tick != 0) { val tickMeta = midiPitch.tick - val numerator = midiPitch.metaBytes.toByteArray()[0].toInt() - val denominatorPower = midiPitch.metaBytes.toByteArray()[1].toInt() + val numerator = (midiPitch.metaBytes.getOrNull(0)?.toInt() ?: 4) and 0xFF + val denominatorPower = (midiPitch.metaBytes.getOrNull(1)?.toInt() ?: 2) and 0xFF val denominator = 1 shl denominatorPower z.tick = 0 val newNumerator = tickMeta / 15 / denominator - z.metaBytes = "" + newNumerator.toChar() + denominatorPower.toChar() + 60.toChar() + 20.toChar() + z.metaBytes = byteArrayOf( + newNumerator.toByte(), + denominatorPower.toByte(), + 60.toByte(), + 20.toByte() + ) pitches.add(z) } @@ -2005,7 +2036,7 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository } else if (typeBlock == "meta") { pitches.add(midiPitch.copy()) } - midiPitch.metaBytes = "" + midiPitch.metaBytes = byteArrayOf() midiPitch.metaType = -1 midiPitch.duration = 0 lastNoteString = "" @@ -2015,7 +2046,7 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository midiPitch.currentVoiceNumber(voiceNumber) if (voiceNumber == 1) { - midiPitch.initKey(meta["C"] ?: "C") + midiPitch.initKey(meta["C"] ?: "C", meta["C"]?.endsWith("m") ?: false) pushMidi("meta") if (midiPitch.initMeasure(meta["m"] ?: "4/4")) { pushMidi("measure") diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Transpose.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Transpose.kt index a4c51e8..14c0ec5 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Transpose.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Transpose.kt @@ -24,7 +24,7 @@ class Transpose { val octaveSigns = listOf("โ‚„", "โ‚ƒ", "โ‚‚", "โ‚", "", "ยน", "ยฒ", "ยณ", "โด") val noteToNumber = listOf("d", "di", "r", "ri", "m", "f", "fi", "s", "si", "l", "ta", "t") val keyToNumber = listOf("C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B" ) - val keyToArmature = listOf(0, -7, 2, -3, 4, -1, -6, 1, -4, 3, -2, 5 ) + val keyToArmature = listOf(0, -5, 2, -3, 4, -1, -6, 1, -4, 3, -2, 5 ) fun transposeText(text: String, interval: Int): String { if (text.isEmpty()) return text @@ -100,9 +100,51 @@ class Transpose { } } fun toArmature(note: String): Int { - val index = keyToNumber.indexOf(note) - return keyToArmature.getOrNull(index) ?: 0 + val cleanKey = note.trim() + val isMinor = cleanKey.endsWith("m", ignoreCase = true) && !cleanKey.lowercase().endsWith("dim") + val rootNote = if (isMinor) cleanKey.dropLast(1) else cleanKey + if (isMinor) { + val relativeMajor = when (rootNote.uppercase()) { + "AB" -> "CB" + "EB" -> "GB" + "BB" -> "DB" + "F" -> "AB" + "C" -> "EB" + "G" -> "BB" + "D" -> "F" + "A" -> "C" + "E" -> "G" + "B" -> "D" + "F#" -> "A" + "C#" -> "E" + "G#" -> "B" + "D#" -> "F#" + "A#" -> "C#" + else -> rootNote + } + return toArmature(relativeMajor) + } + + return when (rootNote.uppercase()) { + "C" -> 0 + "G" -> 1 + "D" -> 2 + "A" -> 3 + "E" -> 4 + "B" -> 5 + "F#" -> 6 + "C#" -> 7 + "F" -> -1 + "BB" -> -2 + "EB" -> -3 + "AB" -> -4 + "DB" -> -5 + "GB" -> -6 + "CB" -> -7 + else -> 0 + } } + fun transposeMidi(note: String, fromKey: String) : Pair { return Pair(note, 4) } diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt index a567fe6..ebea0e8 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt @@ -254,6 +254,7 @@ fun MainScreenWithDrawer( if (showSettingsDialog) { Settings( sharedScreenModel = sharedScreenModel, + solfaScreenModel, onDismissRequest = { showSettingsDialog = false }, @@ -448,8 +449,10 @@ fun MainScreenWithDrawer( }, actions = { var tempInterval by remember(fileContent) { mutableStateOf(0) } var isEyeVisible by remember { mutableStateOf(false) } + val isMinor = songKey.endsWith("m", ignoreCase = true) + val cleanSongKey = if (isMinor) songKey.dropLast(1) else songKey val keysOrder = Transpose.keyToNumber - val songKeyIndex = keysOrder.indexOf(songKey).takeIf { it != -1 } ?: 0 + val songKeyIndex = keysOrder.indexOf(cleanSongKey).takeIf { it != -1 } ?: 0 val rawKeyIndex = (songKeyIndex + tempInterval) % 12 val tempUiKey = keysOrder[if (rawKeyIndex < 0) rawKeyIndex + 12 else rawKeyIndex] val appliedInterval by sharedScreenModel.transpositionInterval.collectAsState() @@ -465,7 +468,7 @@ fun MainScreenWithDrawer( verticalAlignment = Alignment.CenterVertically ) { Text( - text = tempUiKey, + text = if(isMinor) "${tempUiKey}m" else tempUiKey, style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Black, textAlign = TextAlign.Center, @@ -477,7 +480,7 @@ fun MainScreenWithDrawer( ) { isEyeVisible = !isEyeVisible } - .width(45.dp) + .width(65.dp) ) } @@ -809,7 +812,8 @@ fun MainScreenWithDrawer( }, onVoiceVolumeChange = { index, volume -> player?.updateVoiceVolume(index, volume) - } + }, + sharedScreenModel = sharedScreenModel ) } else { Text("Sรฉlectionner un morceau") diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/MidiControlPanel.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/MidiControlPanel.kt index ec24e3a..edfb0bb 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/MidiControlPanel.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/MidiControlPanel.kt @@ -1,5 +1,6 @@ package mg.dot.feufaro.ui +import SharedScreenModel import androidx.compose.animation.* import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween @@ -32,6 +33,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.russhwolf.settings.Settings import feufaro.composeapp.generated.resources.Res import feufaro.composeapp.generated.resources.ic_mixer_satb import feufaro.composeapp.generated.resources.ic_organ @@ -40,6 +42,7 @@ import feufaro.composeapp.generated.resources.mixer_fader import kotlinx.coroutines.delay import mg.dot.feufaro.getPlatform import mg.dot.feufaro.midi.FMediaPlayer +import mg.dot.feufaro.provideSettings import org.jetbrains.compose.resources.painterResource @OptIn(ExperimentalMaterial3Api::class) @@ -55,6 +58,7 @@ fun MidiControlPanel( onVolumeChange: (Float) -> Unit, onVoiceVolumeChange: (voiceIndex: Int, newVolume: Float) -> Unit, mediaPlayer: FMediaPlayer, + sharedScreenModel: SharedScreenModel, modifier: Modifier = Modifier ) { val momo = duration.toInt() - currentPos.toInt() @@ -71,8 +75,14 @@ fun MidiControlPanel( } val labels = listOf("S", "A", "T", "B") listOf("Soprano", "Alto", "Tรฉnor", "Basse") + val activeBpm by sharedScreenModel.getBpmFlow().collectAsState(initial = 120f) + var tempo by remember { mutableStateOf(120f) } - var tempo by remember { mutableStateOf(120.toFloat()) } + LaunchedEffect(activeBpm) { + if (activeBpm > 0f) { + tempo = activeBpm + } + } var currentBpm by remember { mutableStateOf(mediaPlayer.getCurrentBPM()) } var isPianoSelected by remember { mutableStateOf(true) } @@ -82,6 +92,7 @@ fun MidiControlPanel( val platform = getPlatform().name val coroutineScope = rememberCoroutineScope() + val prefs: Settings = provideSettings() LaunchedEffect(currentPos, duration, solfaScrollState.viewportSize, solfaScrollState.maxValue) { if (duration > 0f && solfaScrollState.maxValue > 0) { val progress = (currentPos / duration).coerceIn(0f, 1f) @@ -99,9 +110,9 @@ fun MidiControlPanel( ) } } - LaunchedEffect(tempo) { + /*LaunchedEffect(tempo) { currentBpm = mediaPlayer.getCurrentBPM() - } + }*/ fun updateTempoToBpm(newBpm: Int) { tempo = newBpm.toFloat() mediaPlayer?.setTempo(tempo) @@ -319,7 +330,7 @@ fun MidiControlPanel( } } - if (tempo <= 160) { // limite 160BPM + if (tempo <= 320) { IconButton( modifier = Modifier.background(Color(0XFF2C3130)), onClick = { updateTempoByBpmStep(10) }) { @@ -436,11 +447,12 @@ fun MidiControlPanel( } } + val selectedInstru = prefs.getInt("voice_instrument", 1) val instrumentButton = @Composable { IconButton( onClick = { isPianoSelected = !isPianoSelected - mediaPlayer?.changeInstru(if (isPianoSelected) 1 else 20) + mediaPlayer?.changeInstru(if (isPianoSelected) selectedInstru else 16) } ) { if (isPianoSelected) { diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/Settings.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/Settings.kt index 6c182fe..7a1d09a 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/Settings.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/Settings.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.DialogProperties +import com.russhwolf.settings.Settings import feufaro.composeapp.generated.resources.Emmentaler import feufaro.composeapp.generated.resources.Res import kotlinx.coroutines.CoroutineScope @@ -36,18 +37,22 @@ import mg.dot.feufaro.DisplayConfigManager import mg.dot.feufaro.getPlatform import mg.dot.feufaro.midi.Dynamic import mg.dot.feufaro.midi.MidiInstrument +import mg.dot.feufaro.provideSettings +import mg.dot.feufaro.viewmodel.SolfaScreenModel import org.jetbrains.compose.resources.Font -import org.jetbrains.compose.resources.FontResource import org.koin.compose.koinInject @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun Settings( sharedScreenModel: SharedScreenModel, + solfaScreenModel: SolfaScreenModel, onDismissRequest: () -> Unit, expandedDropdownN: Boolean, isAndroid: Boolean ) { + val prefs: Settings = provideSettings() + var hasCountIn by remember { mutableStateOf(prefs.getBoolean("isCountIn", true)) } var isDynamicEnabled by remember { mutableStateOf(Dynamic.isGloballyEnabled) } var refreshTrigger by remember { mutableStateOf(0) } val isFullScreenEnabled by sharedScreenModel.isFullScreen.collectAsState() @@ -86,7 +91,7 @@ fun Settings( mutableStateOf(player?.getVoiceInstrument() ?: 1) } val selectedInstrument = instruments.find { it.program == globalInstrumentProgram } - ?: MidiInstrument(globalInstrumentProgram, "Instrument $globalInstrumentProgram") + ?: MidiInstrument(program = globalInstrumentProgram, name = "Instrument $globalInstrumentProgram") val menuScrollState = rememberScrollState() var expandedDropdown by remember { mutableStateOf(false) } @@ -369,24 +374,40 @@ fun Settings( Column(modifier = Modifier.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(2.dp) ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically + Text( + text = "Rรฉglage de la nuance", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth() ) { Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) + modifier = Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Text(if(isDynamicEnabled) "Dรฉsactiver" else "Activer", fontWeight = FontWeight.Bold, fontSize = 15.sp) - Switch( - checked = isDynamicEnabled, - onCheckedChange = { enabled -> - isDynamicEnabled = enabled - Dynamic.isGloballyEnabled = enabled - refreshTrigger++ - } - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp) + ) { + Switch( + checked = isDynamicEnabled, + onCheckedChange = { enabled -> + isDynamicEnabled = enabled + Dynamic.isGloballyEnabled = enabled + refreshTrigger++ + } + ) + Text( + if (isDynamicEnabled) "Dรฉsactiver" else "Activer", + fontSize = 15.sp + ) + } OutlinedButton( onClick = { @@ -606,7 +627,7 @@ fun Settings( modifier = Modifier.weight(1f) ) { OutlinedTextField( - value = "${selectedInstrument.name}", + value = "${selectedInstrument.icon} - ${selectedInstrument.name}", onValueChange = {}, readOnly = true, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedDropdown) }, @@ -642,13 +663,13 @@ fun Settings( }, onClick = { globalInstrumentProgram = instrument.program - - player?.changeInstru(instrument.program) - player?.saveVoiceInstrument(instrument.program) + sharedScreenModel.saveVoiceInstrument(instrument.program) + solfaScreenModel.regenMidi(120.0f) + sharedScreenModel.loadNewSong("whawyd3.mid") expandedDropdown = false }, leadingIcon = { - Text("๐ŸŽน") + Text(instrument.icon) }, trailingIcon = { if (isSelected) { @@ -672,6 +693,67 @@ fun Settings( } } } + + HorizontalDivider( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f), + modifier = Modifier.padding(vertical = 12.dp) + ) + + Text( + text = "Rรจglage du player", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp) + ) + + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Dรฉcompte avant la lecture ", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp) + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + if(!isAndroid) { + Text( + if (hasCountIn) "Dรฉsactiver" else "Activer", + fontSize = 15.sp + ) + } + Switch( + checked = hasCountIn, + onCheckedChange = { enabled -> + hasCountIn = enabled + prefs.putBoolean("isCountIn", enabled) + }, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.onPrimary, + checkedTrackColor = MaterialTheme.colorScheme.primary, + uncheckedThumbColor = MaterialTheme.colorScheme.outline, + uncheckedTrackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + uncheckedBorderColor = MaterialTheme.colorScheme.outline + ), + modifier = Modifier.size(45.dp) + ) + } + } + } } } } 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 4195a9d..12e6438 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt @@ -1,9 +1,11 @@ import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope +import com.russhwolf.settings.Settings import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -22,6 +24,7 @@ import mg.dot.feufaro.data.GridTUOData import mg.dot.feufaro.data.getCombinedList import mg.dot.feufaro.solfa.TimeUnitObject import mg.dot.feufaro.midi.FMediaPlayer +import mg.dot.feufaro.provideSettings import mg.dot.feufaro.solfa.TUOEditState import mg.dot.feufaro.solfa.getAllMarker import mg.dot.feufaro.viewmodel.MidiMarkers @@ -72,6 +75,17 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode private val _drawerItems = MutableStateFlow>(emptyList()) val drawerItems: StateFlow> = _drawerItems.asStateFlow() + private val prefs: Settings = provideSettings() + + private val _currentBpmFlow = MutableStateFlow(120f) + val currentBpmFlow: StateFlow = _currentBpmFlow.asStateFlow() + + fun setBpmFlow(newBpm: Float) { + _currentBpmFlow.value = newBpm + } + fun getBpmFlow(): StateFlow { + return currentBpmFlow + } val internalItems: StateFlow> = _drawerItems .map { list -> list.filter { it.path.startsWith("assets://") } } .stateIn(screenModelScope, SharingStarted.Lazily, emptyList()) @@ -845,4 +859,11 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode return _playlist.value[playlistIndex] } + fun getVoiceInstrument(): Int { + val defaultProgram = 0 + return prefs.getInt("voice_instrument", defaultProgram) + } + fun saveVoiceInstrument(program: Int) { + prefs.putInt("voice_instrument", program) + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SolfaScreenModel.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SolfaScreenModel.kt index 9c5df97..82d74d3 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SolfaScreenModel.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SolfaScreenModel.kt @@ -55,4 +55,7 @@ class SolfaScreenModel( solfa.createNewSolfa(metadata) } } + fun regenMidi(bpm: Float) { + solfa.generateMidiFile(bpm) + } } \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/SettingsProvider.kt b/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/SettingsProvider.kt new file mode 100644 index 0000000..837e585 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/SettingsProvider.kt @@ -0,0 +1,10 @@ +package mg.dot.feufaro + +import com.russhwolf.settings.PreferencesSettings +import com.russhwolf.settings.Settings +import java.util.prefs.Preferences + +actual fun provideSettings(): Settings { + val javaPrefs = Preferences.userRoot().node("mg.dot.feufaro") + return PreferencesSettings(javaPrefs) +} \ No newline at end of file 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 e5013f1..7c059ef 100644 --- a/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt +++ b/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt @@ -1,18 +1,12 @@ package mg.dot.feufaro.midi import SharedScreenModel +import com.russhwolf.settings.Settings import kotlinx.coroutines.* -import mg.dot.feufaro.getConfigDirectoryPath -import mg.dot.feufaro.viewmodel.MidiMarkers +import mg.dot.feufaro.provideSettings +import mg.dot.feufaro.solfa.Transpose import java.io.File -import java.util.prefs.Preferences -import javax.sound.midi.MetaMessage -import javax.sound.midi.MidiSystem -import javax.sound.midi.Sequence -import javax.sound.midi.Sequencer -import javax.sound.midi.ShortMessage -import javax.sound.midi.Synthesizer -import javax.sound.midi.Track +import javax.sound.midi.* import javax.sound.sampled.AudioSystem import javax.sound.sampled.FloatControl @@ -29,7 +23,7 @@ actual class FMediaPlayer actual constructor( null } - private val prefs = Preferences.userRoot().node("mg.dot.feufaro") + private val prefs: Settings = provideSettings() private var synthetizer = MidiSystem.getSynthesizer() as Synthesizer? private var pointA: Long = -1L @@ -41,8 +35,8 @@ actual class FMediaPlayer actual constructor( private var currentDynamicVelocity: Int = Dynamic.MF.velocity private var currentDynamicFactor: Float = Dynamic.MF.factor - private var currentTempo: Float = 1.0f private var targetBpm: Float = 120f + private var initialBpm: Float = 120f private val playerScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private var abJob: Job? = null @@ -69,7 +63,9 @@ actual class FMediaPlayer actual constructor( val isTempoChange: Boolean = false, val tempoType: String = "", val endGridForTempo: Int = -1, - val targetTempoMultiplier: Float = 0.6f + val targetTempoMultiplier: Float = 0.6f, + val newBpm: Float? = null, + val newKey: String? = null ) private val navigationSteps = mutableListOf() @@ -254,6 +250,8 @@ actual class FMediaPlayer actual constructor( var lastFactor = Dynamic.MF.factor val ritRegex = Regex("""rit\.?|ritard\.?|ritenuto\.?|ritardando""", RegexOption.IGNORE_CASE) val rallRegex = Regex("""rall\.?|rallent\.?|rallentando""", RegexOption.IGNORE_CASE) + val modulaRegex = Regex("""D(รด|o)?\s*dia\s*(C|Db|D|Eb|E|F|Gb|G|Ab|A|Bb|B)""", RegexOption.IGNORE_CASE) + val tempoRegex = Regex("""^โ™ฉ\s*=\s*(\d+)""", RegexOption.IGNORE_CASE) metadataList.forEach { midiMarker -> val currentIndex = midiMarker.gridIndex ?: 0 @@ -278,6 +276,37 @@ actual class FMediaPlayer actual constructor( val hasFin = finReg.containsMatchIn(mTrim) || finReg.containsMatchIn(nextTrim) val isDcFin = hasDc && hasFin val isDsFin = hasDs && hasFin + + // Changement de Tempo โ™ฉ= + val tempoMatch = tempoRegex.find(mTrim) + if (tempoMatch != null) { + val bpmVal = tempoMatch.groupValues[1].toFloatOrNull() + if (bpmVal != null) { + navigationSteps.add( + NavigationStep( + marker = mTrim, + gridIndex = currentIndex, + newBpm = bpmVal + ) + ) + println("Changement de BPM mรฉmorisรฉ ร  la grille $currentIndex -> $bpmVal BPM") + } + } + + // ModulationDo dia C + val modMatch = modulaRegex.find(mTrim) + if (modMatch != null) { + val targetKey = modMatch.groupValues[2] + navigationSteps.add( + NavigationStep( + marker = mTrim, + gridIndex = currentIndex, + newKey = targetKey + ) + ) + println("Modulation mรฉmorisรฉe ร  la grille $currentIndex -> $targetKey") + } + when { //DSFin isDsFin || isDcFin -> { @@ -529,11 +558,46 @@ actual class FMediaPlayer actual constructor( val currentSteps = navigationSteps.filter { it.gridIndex == currentIndex && !it.alreadyDone } - currentSteps.forEach { step -> + for (step in currentSteps) { if (Math.abs(sequencer!!.tempoInBPM - targetBpm) > 0.1 && !isInTempoChange) { forceTempo(targetBpm.toDouble()) } when { + //โ™ฉ + step.newBpm != null -> { + step.alreadyDone = true + println("Exรฉcution changement de tempo : ${step.newBpm} BPM ร  la grille $currentIndex") + targetBpm = step.newBpm + forceTempo(targetBpm.toDouble()) + sharedScreenModel.setBpmFlow(step.newBpm) + } + + // Modulation + step.newKey != null -> { + step.alreadyDone = true + //println("Exรฉcution modulation vers tonalitรฉ ${step.newKey} ร  la grille $currentIndex") + val baseKeyIdx = Transpose.keyToNumber.indexOf("C").coerceAtLeast(0) + val targetKeyIdx = Transpose.keyToNumber.indexOf(step.newKey).coerceAtLeast(0) + val semitonesShift = targetKeyIdx - baseKeyIdx + + val pitchBendValue = (8192 + (semitonesShift * 2048)).coerceIn(0, 16383) + + try { + val synth = synthetizer + if (synth != null) { + val channels = synth.channels + if (channels != null) { + val count = if (channels.size < 4) channels.size else 4 + for (i in 0 until count) { + val channel: MidiChannel? = channels[i] + channel?.setPitchBend(pitchBendValue) + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } step.isFin -> { if(step.finActive) { step.alreadyDone = true @@ -658,13 +722,24 @@ actual class FMediaPlayer actual constructor( val tick = (gridIndex.toDouble() * resolution).toLong() sequencer?.tickPosition = tick + val initialOrPreviousBpm = navigationSteps + .filter { it.newBpm != null && it.gridIndex <= gridIndex } + .maxByOrNull { it.gridIndex }?.newBpm ?: 120f + + targetBpm = initialOrPreviousBpm applyBpm() + sharedScreenModel.setBpmFlow(initialOrPreviousBpm) // Voir les velocity avant val lastDynamic = navigationSteps .filter { it.dynamic != null && it.gridIndex <= gridIndex } .maxByOrNull { it.gridIndex } ?.dynamic ?: Dynamic.MF currentDynamicFactor = lastDynamic.factor + navigationSteps.forEach { step -> + if (step.gridIndex > gridIndex) { + step.alreadyDone = false + } + } applyVoiceStates() } @@ -676,6 +751,13 @@ actual class FMediaPlayer actual constructor( if (sequencer?.isOpen == true) { val currentTick = sequencer?.tickPosition ?: 0L if (currentTick == 0L) { + resetNavigationFlags() + targetBpm = initialBpm + forceTempo(initialBpm.toDouble()) + boundModel?.setBpmFlow(initialBpm) + } + val countInEnable = prefs.getBoolean("isCountIn", true) + if (currentTick == 0L && countInEnable) { playCountIn() } else { startMidiPlayback() @@ -805,6 +887,9 @@ actual class FMediaPlayer actual constructor( navigationJob?.cancel() resetNavigationFlags() navigationSteps.clear() + targetBpm = initialBpm + sequencer?.tempoInBPM = initialBpm + boundModel?.setBpmFlow(initialBpm) clearLoop() currentDynamicFactor = Dynamic.MF.factor resetTempoToNormal() @@ -929,12 +1014,14 @@ actual class FMediaPlayer actual constructor( actual fun setTempo(bpm: Float){ this.targetBpm = bpm + this.initialBpm = bpm sequencer?.tempoInBPM = bpm boundModel?.let { modele -> val seq = sequencer?.sequence if (seq != null) { syncTuoWithMidi(seq, modele) } + modele.setBpmFlow(bpm) prepareNavigation(modele) } println("Tempo rรฉglรฉ ร  : $bpm BPM") @@ -947,18 +1034,18 @@ actual class FMediaPlayer actual constructor( } synthesizer.availableInstruments.map { instrument -> MidiInstrument( + icon = "\uD83C\uDFB9", program = instrument.patch.program, name = instrument.name.trim() ) }.sortedBy { it.program } } catch (e: Exception) { - listOf(MidiInstrument(0, "Acoustic Grand Piano"), MidiInstrument(19, "Church Organ")) + listOf(MidiInstrument("๐ŸŽน", 0, "Acoustic Grand Piano"), MidiInstrument("๐ŸŽน", 19, "Church Organ")) } } actual fun saveVoiceInstrument(program: Int) { prefs.putInt("voice_instrument", program) - try { prefs.flush() } catch (e: Exception) { e.printStackTrace() } } actual fun getVoiceInstrument(): Int { @@ -976,11 +1063,11 @@ actual class FMediaPlayer actual constructor( private fun saveVoicesVolumes() { val data = voiceVolumes.joinToString(",") - prefs.put("voices_volumes", data) + prefs.putString("voices_volumes", data) } private fun loadVoiceVolumes() { - val data = prefs.get("voices_volumes", "127,127,127,127") + val data = prefs.getString("voices_volumes", "127,127,127,127") val volumesArray = data.split(",") if (volumesArray.size == 4) {