Settings: countIn control; key&time signature on midifile; MidiPlayer: support change tempo , modulation . Support minor key

This commit is contained in:
Hasinjato 2026-08-07 11:37:49 +03:00
parent 340f72855f
commit b38bfebfe6
18 changed files with 854 additions and 115 deletions

View file

@ -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()

View file

@ -3,6 +3,7 @@ package mg.dot.feufaro.midi
import SharedScreenModel import SharedScreenModel
import com.russhwolf.settings.Settings import com.russhwolf.settings.Settings
import kotlinx.coroutines.* import kotlinx.coroutines.*
import mg.dot.feufaro.provideSettings
import org.billthefarmer.mididriver.MidiDriver import org.billthefarmer.mididriver.MidiDriver
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
@ -76,7 +77,16 @@ actual class FMediaPlayer actual constructor(
val mt=readByte(); val mt=readByte();
val len=readVarLen().toInt(); val len=readVarLen().toInt();
val md=ByteArray(len){data[pos++]}; 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()) { status == 0xF0 || status == 0xF7 -> repeat(readVarLen().toInt()) {
pos++ pos++
@ -164,16 +174,25 @@ actual class FMediaPlayer actual constructor(
val targetTempoMultiplier: Float = 0.6f val targetTempoMultiplier: Float = 0.6f
) )
private val navigationSteps = mutableListOf<NavigationStep>() private val navigationSteps = mutableListOf<NavigationStep>()
private val prefs: Settings = provideSettings()
init { init {
midiDriver.start() midiDriver.start()
loadVoiceVolumes() loadVoiceVolumes()
loadSavedInstrumentsToPlayer(this)
val file = File(filename) val file = File(filename)
if (file.exists()) { if (file.exists()) {
try { try {
sequence = MidiParser.parse(file) sequence = MidiParser.parse(file)
resolution = sequence!!.resolution 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 usPerTick = (60_000_000.0 / targetBpm) / resolution
} catch (e: Exception) { e.printStackTrace() } } catch (e: Exception) { e.printStackTrace() }
} }
@ -277,28 +296,37 @@ actual class FMediaPlayer actual constructor(
} }
private fun startPlaybackLoop() { private fun startPlaybackLoop() {
val seq = sequence ?: return val seq = sequence ?: run {
return
}
isRunning = true isRunning = true
needsClockSync = true
playJob?.cancel() playJob?.cancel()
playJob = playerScope.launch(Dispatchers.Default) { playJob = playerScope.launch(Dispatchers.Default) {
val events = seq.events val events = seq.events
var idx = events.indexOfFirst { it.tickAbsolute >= currentTickPos }
.takeIf { it >= 0 } ?: events.size
var clockNano = System.nanoTime() var clockNano = System.nanoTime()
var clockTick = currentTickPos var clockTick = currentTickPos
var idx = events.indexOfFirst { it.tickAbsolute >= currentTickPos }
.takeIf { it >= 0 } ?: events.size
while (isActive && isRunning) { while (isActive && isRunning) {
if (needsClockSync) { if (needsClockSync) {
clockNano = System.nanoTime() clockNano = System.nanoTime()
clockTick = currentTickPos clockTick = currentTickPos
idx = events.indexOfFirst { it.tickAbsolute >= currentTickPos }
val targetIdx = events.indexOfFirst { it.tickAbsolute >= currentTickPos }
.takeIf { it >= 0 } ?: events.size .takeIf { it >= 0 } ?: events.size
if (targetIdx > idx) {
idx = targetIdx
}
needsClockSync = false needsClockSync = false
} }
if (isHolding) { if (isHolding) {
yield() yield()
// delay(10)
clockNano = System.nanoTime() clockNano = System.nanoTime()
clockTick = currentTickPos clockTick = currentTickPos
continue continue
@ -309,7 +337,8 @@ actual class FMediaPlayer actual constructor(
if (dc != null) { if (dc != null) {
allNotesOff() allNotesOff()
seekToGrid(dc.targetGrid) seekToGrid(dc.targetGrid)
clockNano = System.nanoTime(); clockTick = currentTickPos clockNano = System.nanoTime()
clockTick = currentTickPos
idx = events.indexOfFirst { it.tickAbsolute >= currentTickPos } idx = events.indexOfFirst { it.tickAbsolute >= currentTickPos }
.takeIf { it >= 0 } ?: events.size .takeIf { it >= 0 } ?: events.size
continue continue
@ -322,16 +351,11 @@ actual class FMediaPlayer actual constructor(
} }
val ev = events[idx] 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 targetNano = clockNano + ((ev.tickAbsolute - clockTick) * usPerTick * 1000).toLong()
val now = System.nanoTime() val now = System.nanoTime()
val waitNano = targetNano - now val waitNano = targetNano - now
// if (waitNano > 0) delay((waitNano / 1_000_000).coerceAtLeast(0L)) if (waitNano > 0 && waitNano < 3_000_000_000L) {
if (waitNano > 0) {
// Thread.sleep est beaucoup plus précis que delay() pour les micro-délais MIDI
val ms = waitNano / 1_000_000 val ms = waitNano / 1_000_000
val ns = (waitNano % 1_000_000).toInt() val ns = (waitNano % 1_000_000).toInt()
try { try {
@ -339,19 +363,23 @@ actual class FMediaPlayer actual constructor(
} catch (e: Exception) { } catch (e: Exception) {
yield() yield()
} }
} } else if (waitNano < -100_000_000L) {
if (needsClockSync) { clockNano = System.nanoTime()
continue clockTick = ev.tickAbsolute
} else {
yield()
} }
currentTickPos = ev.tickAbsolute currentTickPos = ev.tickAbsolute
lastEventNano = System.nanoTime() lastEventNano = System.nanoTime()
// A-B loop // A-B loop
if (isLoopingAB && pointA >= 0 && pointB > pointA && if (isLoopingAB && pointA >= 0 && pointB > pointA &&
ticksToMs(currentTickPos) >= pointB) { ticksToMs(currentTickPos) >= pointB) {
allNotesOff(); currentTickPos = msToTicks(pointA) allNotesOff()
clockNano = System.nanoTime(); clockTick = currentTickPos currentTickPos = msToTicks(pointA)
clockNano = System.nanoTime()
clockTick = currentTickPos
idx = events.indexOfFirst { it.tickAbsolute >= currentTickPos } idx = events.indexOfFirst { it.tickAbsolute >= currentTickPos }
.takeIf { it >= 0 } ?: events.size .takeIf { it >= 0 } ?: events.size
continue continue
@ -364,9 +392,21 @@ actual class FMediaPlayer actual constructor(
0xB0 -> if (ev.data1 != 7 && ev.data1 != 11) 0xB0 -> if (ev.data1 != 7 && ev.data1 != 11)
send(0xB0 or ev.channel, ev.data1, ev.data2) send(0xB0 or ev.channel, ev.data1, ev.data2)
0xC0 -> send(0xC0 or ev.channel, ev.data1) 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()) { if (boundModel?.activeIndex?.value != currentGrid.toInt()) {
boundModel?.updateActiveIndex(currentGrid) boundModel?.updateActiveIndex(currentGrid)
} }
@ -704,7 +744,10 @@ actual class FMediaPlayer actual constructor(
} }
actual fun play() { actual fun play() {
if (sequence == null) return 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() playCountIn()
} else { } else {
applyVoiceStates() applyVoiceStates()
@ -778,6 +821,7 @@ actual class FMediaPlayer actual constructor(
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
applyVoiceStates() applyVoiceStates()
needsClockSync = true
startPlaybackLoop() startPlaybackLoop()
} }
} }
@ -812,9 +856,11 @@ actual class FMediaPlayer actual constructor(
applyVoiceStates() applyVoiceStates()
} }
actual fun setTempo(bpm: Float) { 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) } boundModel?.let { prepareNavigation(it) }
println("Tempo → $bpm BPM") needsClockSync = true
} }
actual fun getCurrentBPM(): Float = targetBpm actual fun getCurrentBPM(): Float = targetBpm
actual fun requestSync(sharedScreenModel: SharedScreenModel) { 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) for (ch in 0 until 4) send(0xC0 or ch, noInstru)
} }
actual fun getAvalaibleInstruments(): List<MidiInstrument> { actual fun getAvalaibleInstruments(): List<MidiInstrument> {
val gmInstruments = listOf( 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")
"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( MidiInstrument(
icon = icon,
program = index, program = index,
name = name name = name
) )
@ -885,9 +933,4 @@ actual class FMediaPlayer actual constructor(
val defaultProgram = 0 val defaultProgram = 0
return settings.getInt("voice_instrument", defaultProgram) return settings.getInt("voice_instrument", defaultProgram)
} }
fun loadSavedInstrumentsToPlayer(mediaPlayer: FMediaPlayer) {
val savedProgram = getVoiceInstrument()
mediaPlayer.changeInstru(savedProgram)
}
} }

View file

@ -0,0 +1,5 @@
package mg.dot.feufaro
import com.russhwolf.settings.Settings
expect fun provideSettings(): Settings

View file

@ -1,5 +1,7 @@
package mg.dot.feufaro.midi package mg.dot.feufaro.midi
import com.russhwolf.settings.Settings
import mg.dot.feufaro.provideSettings
import java.util.prefs.Preferences import java.util.prefs.Preferences
enum class Dynamic(val velocity: Int, val label: String, val defaultFactor: Float) { 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), MF (80, "mf", 1.00f),
F (96, "f", 1.15f), F (96, "f", 1.15f),
FF (112, "ff", 1.35f), FF (112, "ff", 1.35f),
FFF(126, "fff", 1.60f); FFF(127, "fff", 1.60f);
private val prefs = Preferences.userRoot().node("mg.dot.feufaro") private val prefs: Settings = provideSettings()
var savedFactor: Float = prefs.getFloat("dynamic_${name.lowercase()}", defaultFactor) var savedFactor: Float = prefs.getFloat("dynamic_${name.lowercase()}", defaultFactor)
set(value) { set(value) {
field = 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 get() = if (isGloballyEnabled) savedFactor else 1.00f
companion object { companion object {
private val globalPrefs = Preferences.userRoot().node("mg.dot.feufaro") private val prefs: Settings = provideSettings()
var isGloballyEnabled: Boolean = globalPrefs.getBoolean("dynamics_enabled", true) var isGloballyEnabled: Boolean = prefs.getBoolean("dynamics_enabled", true)
set(value) { set(value) {
field = value field = value
globalPrefs.putBoolean("dynamics_enabled", value) prefs.putBoolean("dynamics_enabled", value)
} }
fun fromVelocity(v: Int): Dynamic = fun fromVelocity(v: Int): Dynamic =
@ -35,5 +37,9 @@ enum class Dynamic(val velocity: Int, val label: String, val defaultFactor: Floa
fun resetToDefaults() { fun resetToDefaults() {
entries.forEach { it.savedFactor = it.defaultFactor } entries.forEach { it.savedFactor = it.defaultFactor }
} }
fun fromLabel(label: String): Dynamic? {
val cleanedLabel = label.trim().lowercase()
return entries.find { it.label == cleanedLabel }
}
} }
} }

View file

@ -12,7 +12,7 @@ data class MidiPitch (
var duration: Int = 0, var duration: Int = 0,
var markers: List<String> = listOf(), var markers: List<String> = listOf(),
var tick : Int = 0, var tick : Int = 0,
var metaBytes: String = "", var metaBytes: ByteArray = byteArrayOf(),
var metaByteSize: Int = 0, var metaByteSize: Int = 0,
var metaType : Int = -1 var metaType : Int = -1
) { ) {
@ -22,8 +22,11 @@ data class MidiPitch (
var curVoiceNumber: Int = 0 var curVoiceNumber: Int = 0
var nextTick : MutableList<Int> = mutableListOf() var nextTick : MutableList<Int> = mutableListOf()
} }
fun setMeta(type: Int, tickMeta: Int, nb: Int, data0: Int, data1: Int = 0, data2: Int = 0, data3: Int = 0, data4: Int = 0) { fun setMeta(type: Int, tickMeta: Int, nb: Int, vararg data: Int) {
metaBytes = ""+ data0.toChar() + data1.toChar() + data2.toChar() + data3.toChar() + data4.toChar() val bytes = ByteArray(nb) { i ->
(data.getOrElse(i) { 0 } and 0xFF).toByte()
}
metaBytes = bytes
tick = tickMeta tick = tickMeta
metaByteSize = nb metaByteSize = nb
metaType = type metaType = type
@ -55,29 +58,32 @@ data class MidiPitch (
nextTick.add(0) nextTick.add(0)
} }
} }
fun initKey(theKey: String) { fun initKey(theKey: String, isMinor: Boolean = false) {
key = theKey key = theKey
val armature = Transpose.toArmature(key) val armature = Transpose.toArmature(theKey)
val tickMeta = nextTick.getOrNull(0) ?: 0 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 { fun initMeasure(theMeasure: String): Boolean {
val numerator = theMeasure.replace(Regex("/.*"), "").toIntOrNull() val parts = theMeasure.split("/")
val denominator = theMeasure.replace(Regex("^[^/]*/(\\d+).*$"), "$1").toIntOrNull() ?: 4 val numerator = parts.getOrNull(0)?.toIntOrNull() ?: return false
val data1 = when (denominator) { val denominator = parts.getOrNull(1)?.toIntOrNull() ?: 4
val denomPower = when (denominator) {
1 -> 0 1 -> 0
2 -> 1 2 -> 1
4 -> 2 4 -> 2
8 -> 3 8 -> 3
16 -> 4 16 -> 4
else -> 5 32 -> 5
else -> 2
} }
val blankDuration = (4 * (numerator ?: 4) - ParseULine.blankDuration) * 15 val tickMeta = nextTick.getOrNull(0) ?: 0
if (numerator != null) { setMeta(0x58, tickMeta, 4, numerator, denomPower, 24, 8)
setMeta(0x58, blankDuration, 4, numerator, data1, 60, 20)
return true return true
}
return false
} }
fun reset() { fun reset() {
tick = 0 tick = 0

View file

@ -31,4 +31,4 @@ expect class FMediaPlayer(filename: String, sharedScreenModel: SharedScreenModel
fun getVoiceInstrument(): Int fun getVoiceInstrument(): Int
} }
data class MidiInstrument(val program: Int, val name: String) data class MidiInstrument(val icon: String = "\uD83C\uDFB9", val program: Int, val name: String)

View file

@ -36,7 +36,26 @@ class MidiSequence(val resolution: Int = 60) {
fun addSequence(channel: Int, pitch: Int, currentTick: Long, type: Int = 80, finalVelocity: Int = 100) { fun addSequence(channel: Int, pitch: Int, currentTick: Long, type: Int = 80, finalVelocity: Int = 100) {
val myTrack = tracks[0] 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<Byte>.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<Byte>.addNote(channel: Int, pitch: Int, currentTick: Long, type: Int = 80, finalVelocity: Int = 100) { fun MutableList<Byte>.addNote(channel: Int, pitch: Int, currentTick: Long, type: Int = 80, finalVelocity: Int = 100) {
// Calcul du Delta-Time // Calcul du Delta-Time
@ -127,4 +146,20 @@ class MidiSequence(val resolution: Int = 60) {
outTrack.addAll(data.toList().take(nbData)) outTrack.addAll(data.toList().take(nbData))
} }
fun addMetaFe(type: Int, tick: Int, nbData: Int, metaByte: ByteArray) {
if (tracks.isEmpty()) {
tracks.add(mutableListOf<Byte>(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)
}
}
} }

View file

@ -53,7 +53,8 @@ class MidiWriterKotlin constructor(private val fileRepository: FileRepository)
tick = 0 tick = 0
pitches.forEach { pitches.forEach {
if (it.metaType > 0) { 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 != "") { } else if (it.pitch != "") {
addNote(it.voiceNumber, it.pitch.toInt(), 100, it.tick.toLong()) addNote(it.voiceNumber, it.pitch.toInt(), 100, it.tick.toLong())
} }

View file

@ -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<MidiPitch> = mutableListOf()
private val lastPitch : MutableList<Int> = 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<MidiPitch>,
markers: List<MidiMarkers> = emptyList(),
initialBpm: Float = 120.0f,
voiceInstruments: Map<Int, Int> = 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<Long, Int>()
val fermataTicks = mutableSetOf<Long>()
val textMetaMap = mutableMapOf<Long, MutableList<String>>()
val keyChangeMap = mutableMapOf<Long, String>()
val tempoChangeMap = mutableMapOf<Long, Float>()
val hairpinMap = mutableMapOf<Long, Pair<String, Long>>()
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<Long>()
val pendingSustainOff = mutableListOf<Pair<Long, Int>>()
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<Long>()
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
)
}
}

View file

@ -1,16 +1,23 @@
package mg.dot.feufaro.solfa package mg.dot.feufaro.solfa
import SharedScreenModel import SharedScreenModel
import com.russhwolf.settings.Settings
import kotlinx.coroutines.* import kotlinx.coroutines.*
import mg.dot.feufaro.FileRepository import mg.dot.feufaro.FileRepository
import mg.dot.feufaro.SaveSettings import mg.dot.feufaro.SaveSettings
import mg.dot.feufaro.getGlobalTemplate import mg.dot.feufaro.getGlobalTemplate
import mg.dot.feufaro.getPlatform
import mg.dot.feufaro.launchFilePicker import mg.dot.feufaro.launchFilePicker
import mg.dot.feufaro.midi.MidiPitch import mg.dot.feufaro.midi.MidiPitch
import mg.dot.feufaro.midi.MidiWriterKotlin 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.viewmodel.PartitionMetadata
import mg.dot.feufaro.transformLyricsInput import mg.dot.feufaro.transformLyricsInput
import org.koin.core.component.inject
import java.io.File import java.io.File
import java.util.prefs.Preferences
import kotlin.getValue
import kotlin.math.min import kotlin.math.min
//@todo: split voices (ffpm19/ews22) ${S:mfs} in N4:, idem ffpm-212 //@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) 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) { fun justBuild(sourceFile: String, sourceContent: String) {
currentFile = sourceFile currentFile = sourceFile
val parseScope = CoroutineScope(Dispatchers.Default) val parseScope = CoroutineScope(Dispatchers.Default)
@ -1987,12 +2013,17 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
val z = midiPitch.copy() val z = midiPitch.copy()
if (z.tick != 0) { if (z.tick != 0) {
val tickMeta = midiPitch.tick val tickMeta = midiPitch.tick
val numerator = midiPitch.metaBytes.toByteArray()[0].toInt() val numerator = (midiPitch.metaBytes.getOrNull(0)?.toInt() ?: 4) and 0xFF
val denominatorPower = midiPitch.metaBytes.toByteArray()[1].toInt() val denominatorPower = (midiPitch.metaBytes.getOrNull(1)?.toInt() ?: 2) and 0xFF
val denominator = 1 shl denominatorPower val denominator = 1 shl denominatorPower
z.tick = 0 z.tick = 0
val newNumerator = tickMeta / 15 / denominator 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) pitches.add(z)
} }
@ -2005,7 +2036,7 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
} else if (typeBlock == "meta") { } else if (typeBlock == "meta") {
pitches.add(midiPitch.copy()) pitches.add(midiPitch.copy())
} }
midiPitch.metaBytes = "" midiPitch.metaBytes = byteArrayOf()
midiPitch.metaType = -1 midiPitch.metaType = -1
midiPitch.duration = 0 midiPitch.duration = 0
lastNoteString = "" lastNoteString = ""
@ -2015,7 +2046,7 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
midiPitch.currentVoiceNumber(voiceNumber) midiPitch.currentVoiceNumber(voiceNumber)
if (voiceNumber == 1) { if (voiceNumber == 1) {
midiPitch.initKey(meta["C"] ?: "C") midiPitch.initKey(meta["C"] ?: "C", meta["C"]?.endsWith("m") ?: false)
pushMidi("meta") pushMidi("meta")
if (midiPitch.initMeasure(meta["m"] ?: "4/4")) { if (midiPitch.initMeasure(meta["m"] ?: "4/4")) {
pushMidi("measure") pushMidi("measure")

View file

@ -24,7 +24,7 @@ class Transpose {
val octaveSigns = listOf("", "", "", "", "", "¹", "²", "³", "") val octaveSigns = listOf("", "", "", "", "", "¹", "²", "³", "")
val noteToNumber = listOf("d", "di", "r", "ri", "m", "f", "fi", "s", "si", "l", "ta", "t") 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 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 { fun transposeText(text: String, interval: Int): String {
if (text.isEmpty()) return text if (text.isEmpty()) return text
@ -100,9 +100,51 @@ class Transpose {
} }
} }
fun toArmature(note: String): Int { fun toArmature(note: String): Int {
val index = keyToNumber.indexOf(note) val cleanKey = note.trim()
return keyToArmature.getOrNull(index) ?: 0 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<String, Int> { fun transposeMidi(note: String, fromKey: String) : Pair<String, Int> {
return Pair(note, 4) return Pair(note, 4)
} }

View file

@ -254,6 +254,7 @@ fun MainScreenWithDrawer(
if (showSettingsDialog) { if (showSettingsDialog) {
Settings( Settings(
sharedScreenModel = sharedScreenModel, sharedScreenModel = sharedScreenModel,
solfaScreenModel,
onDismissRequest = { onDismissRequest = {
showSettingsDialog = false showSettingsDialog = false
}, },
@ -448,8 +449,10 @@ fun MainScreenWithDrawer(
}, actions = { }, actions = {
var tempInterval by remember(fileContent) { mutableStateOf(0) } var tempInterval by remember(fileContent) { mutableStateOf(0) }
var isEyeVisible by remember { mutableStateOf(false) } 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 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 rawKeyIndex = (songKeyIndex + tempInterval) % 12
val tempUiKey = keysOrder[if (rawKeyIndex < 0) rawKeyIndex + 12 else rawKeyIndex] val tempUiKey = keysOrder[if (rawKeyIndex < 0) rawKeyIndex + 12 else rawKeyIndex]
val appliedInterval by sharedScreenModel.transpositionInterval.collectAsState() val appliedInterval by sharedScreenModel.transpositionInterval.collectAsState()
@ -465,7 +468,7 @@ fun MainScreenWithDrawer(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = tempUiKey, text = if(isMinor) "${tempUiKey}m" else tempUiKey,
style = MaterialTheme.typography.displaySmall, style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.Black, fontWeight = FontWeight.Black,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@ -477,7 +480,7 @@ fun MainScreenWithDrawer(
) { ) {
isEyeVisible = !isEyeVisible isEyeVisible = !isEyeVisible
} }
.width(45.dp) .width(65.dp)
) )
} }
@ -809,7 +812,8 @@ fun MainScreenWithDrawer(
}, },
onVoiceVolumeChange = { index, volume -> onVoiceVolumeChange = { index, volume ->
player?.updateVoiceVolume(index, volume) player?.updateVoiceVolume(index, volume)
} },
sharedScreenModel = sharedScreenModel
) )
} else { } else {
Text("Sélectionner un morceau") Text("Sélectionner un morceau")

View file

@ -1,5 +1,6 @@
package mg.dot.feufaro.ui package mg.dot.feufaro.ui
import SharedScreenModel
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.russhwolf.settings.Settings
import feufaro.composeapp.generated.resources.Res import feufaro.composeapp.generated.resources.Res
import feufaro.composeapp.generated.resources.ic_mixer_satb import feufaro.composeapp.generated.resources.ic_mixer_satb
import feufaro.composeapp.generated.resources.ic_organ import feufaro.composeapp.generated.resources.ic_organ
@ -40,6 +42,7 @@ import feufaro.composeapp.generated.resources.mixer_fader
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import mg.dot.feufaro.getPlatform import mg.dot.feufaro.getPlatform
import mg.dot.feufaro.midi.FMediaPlayer import mg.dot.feufaro.midi.FMediaPlayer
import mg.dot.feufaro.provideSettings
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -55,6 +58,7 @@ fun MidiControlPanel(
onVolumeChange: (Float) -> Unit, onVolumeChange: (Float) -> Unit,
onVoiceVolumeChange: (voiceIndex: Int, newVolume: Float) -> Unit, onVoiceVolumeChange: (voiceIndex: Int, newVolume: Float) -> Unit,
mediaPlayer: FMediaPlayer, mediaPlayer: FMediaPlayer,
sharedScreenModel: SharedScreenModel,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val momo = duration.toInt() - currentPos.toInt() val momo = duration.toInt() - currentPos.toInt()
@ -71,8 +75,14 @@ fun MidiControlPanel(
} }
val labels = listOf("S", "A", "T", "B") val labels = listOf("S", "A", "T", "B")
listOf("Soprano", "Alto", "Ténor", "Basse") 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 currentBpm by remember { mutableStateOf(mediaPlayer.getCurrentBPM()) }
var isPianoSelected by remember { mutableStateOf(true) } var isPianoSelected by remember { mutableStateOf(true) }
@ -82,6 +92,7 @@ fun MidiControlPanel(
val platform = getPlatform().name val platform = getPlatform().name
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val prefs: Settings = provideSettings()
LaunchedEffect(currentPos, duration, solfaScrollState.viewportSize, solfaScrollState.maxValue) { LaunchedEffect(currentPos, duration, solfaScrollState.viewportSize, solfaScrollState.maxValue) {
if (duration > 0f && solfaScrollState.maxValue > 0) { if (duration > 0f && solfaScrollState.maxValue > 0) {
val progress = (currentPos / duration).coerceIn(0f, 1f) val progress = (currentPos / duration).coerceIn(0f, 1f)
@ -99,9 +110,9 @@ fun MidiControlPanel(
) )
} }
} }
LaunchedEffect(tempo) { /*LaunchedEffect(tempo) {
currentBpm = mediaPlayer.getCurrentBPM() currentBpm = mediaPlayer.getCurrentBPM()
} }*/
fun updateTempoToBpm(newBpm: Int) { fun updateTempoToBpm(newBpm: Int) {
tempo = newBpm.toFloat() tempo = newBpm.toFloat()
mediaPlayer?.setTempo(tempo) mediaPlayer?.setTempo(tempo)
@ -319,7 +330,7 @@ fun MidiControlPanel(
} }
} }
if (tempo <= 160) { // limite 160BPM if (tempo <= 320) {
IconButton( IconButton(
modifier = Modifier.background(Color(0XFF2C3130)), modifier = Modifier.background(Color(0XFF2C3130)),
onClick = { updateTempoByBpmStep(10) }) { onClick = { updateTempoByBpmStep(10) }) {
@ -436,11 +447,12 @@ fun MidiControlPanel(
} }
} }
val selectedInstru = prefs.getInt("voice_instrument", 1)
val instrumentButton = @Composable { val instrumentButton = @Composable {
IconButton( IconButton(
onClick = { onClick = {
isPianoSelected = !isPianoSelected isPianoSelected = !isPianoSelected
mediaPlayer?.changeInstru(if (isPianoSelected) 1 else 20) mediaPlayer?.changeInstru(if (isPianoSelected) selectedInstru else 16)
} }
) { ) {
if (isPianoSelected) { if (isPianoSelected) {

View file

@ -28,6 +28,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import com.russhwolf.settings.Settings
import feufaro.composeapp.generated.resources.Emmentaler import feufaro.composeapp.generated.resources.Emmentaler
import feufaro.composeapp.generated.resources.Res import feufaro.composeapp.generated.resources.Res
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -36,18 +37,22 @@ import mg.dot.feufaro.DisplayConfigManager
import mg.dot.feufaro.getPlatform import mg.dot.feufaro.getPlatform
import mg.dot.feufaro.midi.Dynamic import mg.dot.feufaro.midi.Dynamic
import mg.dot.feufaro.midi.MidiInstrument 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.Font
import org.jetbrains.compose.resources.FontResource
import org.koin.compose.koinInject import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun Settings( fun Settings(
sharedScreenModel: SharedScreenModel, sharedScreenModel: SharedScreenModel,
solfaScreenModel: SolfaScreenModel,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
expandedDropdownN: Boolean, expandedDropdownN: Boolean,
isAndroid: Boolean isAndroid: Boolean
) { ) {
val prefs: Settings = provideSettings()
var hasCountIn by remember { mutableStateOf(prefs.getBoolean("isCountIn", true)) }
var isDynamicEnabled by remember { mutableStateOf(Dynamic.isGloballyEnabled) } var isDynamicEnabled by remember { mutableStateOf(Dynamic.isGloballyEnabled) }
var refreshTrigger by remember { mutableStateOf(0) } var refreshTrigger by remember { mutableStateOf(0) }
val isFullScreenEnabled by sharedScreenModel.isFullScreen.collectAsState() val isFullScreenEnabled by sharedScreenModel.isFullScreen.collectAsState()
@ -86,7 +91,7 @@ fun Settings(
mutableStateOf(player?.getVoiceInstrument() ?: 1) mutableStateOf(player?.getVoiceInstrument() ?: 1)
} }
val selectedInstrument = instruments.find { it.program == globalInstrumentProgram } val selectedInstrument = instruments.find { it.program == globalInstrumentProgram }
?: MidiInstrument(globalInstrumentProgram, "Instrument $globalInstrumentProgram") ?: MidiInstrument(program = globalInstrumentProgram, name = "Instrument $globalInstrumentProgram")
val menuScrollState = rememberScrollState() val menuScrollState = rememberScrollState()
var expandedDropdown by remember { mutableStateOf(false) } var expandedDropdown by remember { mutableStateOf(false) }
@ -369,24 +374,40 @@ fun Settings(
Column(modifier = Modifier.padding(top = 8.dp), Column(modifier = Modifier.padding(top = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
Row( Text(
modifier = Modifier.fillMaxWidth(), text = "Réglage de la nuance",
horizontalArrangement = Arrangement.End, style = MaterialTheme.typography.titleMedium,
verticalAlignment = Alignment.CenterVertically fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = Modifier.fillMaxWidth()
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()
horizontalArrangement = Arrangement.spacedBy(10.dp) .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) Row(
Switch( verticalAlignment = Alignment.CenterVertically,
checked = isDynamicEnabled, horizontalArrangement = Arrangement.spacedBy(5.dp)
onCheckedChange = { enabled -> ) {
isDynamicEnabled = enabled Switch(
Dynamic.isGloballyEnabled = enabled checked = isDynamicEnabled,
refreshTrigger++ onCheckedChange = { enabled ->
} isDynamicEnabled = enabled
) Dynamic.isGloballyEnabled = enabled
refreshTrigger++
}
)
Text(
if (isDynamicEnabled) "Désactiver" else "Activer",
fontSize = 15.sp
)
}
OutlinedButton( OutlinedButton(
onClick = { onClick = {
@ -606,7 +627,7 @@ fun Settings(
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
OutlinedTextField( OutlinedTextField(
value = "${selectedInstrument.name}", value = "${selectedInstrument.icon} - ${selectedInstrument.name}",
onValueChange = {}, onValueChange = {},
readOnly = true, readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedDropdown) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedDropdown) },
@ -642,13 +663,13 @@ fun Settings(
}, },
onClick = { onClick = {
globalInstrumentProgram = instrument.program globalInstrumentProgram = instrument.program
sharedScreenModel.saveVoiceInstrument(instrument.program)
player?.changeInstru(instrument.program) solfaScreenModel.regenMidi(120.0f)
player?.saveVoiceInstrument(instrument.program) sharedScreenModel.loadNewSong("whawyd3.mid")
expandedDropdown = false expandedDropdown = false
}, },
leadingIcon = { leadingIcon = {
Text("🎹") Text(instrument.icon)
}, },
trailingIcon = { trailingIcon = {
if (isSelected) { 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)
)
}
}
}
} }
} }
} }

View file

@ -1,9 +1,11 @@
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
import com.russhwolf.settings.Settings
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -22,6 +24,7 @@ import mg.dot.feufaro.data.GridTUOData
import mg.dot.feufaro.data.getCombinedList import mg.dot.feufaro.data.getCombinedList
import mg.dot.feufaro.solfa.TimeUnitObject import mg.dot.feufaro.solfa.TimeUnitObject
import mg.dot.feufaro.midi.FMediaPlayer import mg.dot.feufaro.midi.FMediaPlayer
import mg.dot.feufaro.provideSettings
import mg.dot.feufaro.solfa.TUOEditState import mg.dot.feufaro.solfa.TUOEditState
import mg.dot.feufaro.solfa.getAllMarker import mg.dot.feufaro.solfa.getAllMarker
import mg.dot.feufaro.viewmodel.MidiMarkers import mg.dot.feufaro.viewmodel.MidiMarkers
@ -72,6 +75,17 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
private val _drawerItems = MutableStateFlow<List<DrawerItem>>(emptyList()) private val _drawerItems = MutableStateFlow<List<DrawerItem>>(emptyList())
val drawerItems: StateFlow<List<DrawerItem>> = _drawerItems.asStateFlow() val drawerItems: StateFlow<List<DrawerItem>> = _drawerItems.asStateFlow()
private val prefs: Settings = provideSettings()
private val _currentBpmFlow = MutableStateFlow(120f)
val currentBpmFlow: StateFlow<Float> = _currentBpmFlow.asStateFlow()
fun setBpmFlow(newBpm: Float) {
_currentBpmFlow.value = newBpm
}
fun getBpmFlow(): StateFlow<Float> {
return currentBpmFlow
}
val internalItems: StateFlow<List<DrawerItem>> = _drawerItems val internalItems: StateFlow<List<DrawerItem>> = _drawerItems
.map { list -> list.filter { it.path.startsWith("assets://") } } .map { list -> list.filter { it.path.startsWith("assets://") } }
.stateIn(screenModelScope, SharingStarted.Lazily, emptyList()) .stateIn(screenModelScope, SharingStarted.Lazily, emptyList())
@ -845,4 +859,11 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
return _playlist.value[playlistIndex] 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)
}
} }

View file

@ -55,4 +55,7 @@ class SolfaScreenModel(
solfa.createNewSolfa(metadata) solfa.createNewSolfa(metadata)
} }
} }
fun regenMidi(bpm: Float) {
solfa.generateMidiFile(bpm)
}
} }

View file

@ -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)
}

View file

@ -1,18 +1,12 @@
package mg.dot.feufaro.midi package mg.dot.feufaro.midi
import SharedScreenModel import SharedScreenModel
import com.russhwolf.settings.Settings
import kotlinx.coroutines.* import kotlinx.coroutines.*
import mg.dot.feufaro.getConfigDirectoryPath import mg.dot.feufaro.provideSettings
import mg.dot.feufaro.viewmodel.MidiMarkers import mg.dot.feufaro.solfa.Transpose
import java.io.File import java.io.File
import java.util.prefs.Preferences import javax.sound.midi.*
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.sampled.AudioSystem import javax.sound.sampled.AudioSystem
import javax.sound.sampled.FloatControl import javax.sound.sampled.FloatControl
@ -29,7 +23,7 @@ actual class FMediaPlayer actual constructor(
null null
} }
private val prefs = Preferences.userRoot().node("mg.dot.feufaro") private val prefs: Settings = provideSettings()
private var synthetizer = MidiSystem.getSynthesizer() as Synthesizer? private var synthetizer = MidiSystem.getSynthesizer() as Synthesizer?
private var pointA: Long = -1L private var pointA: Long = -1L
@ -41,8 +35,8 @@ actual class FMediaPlayer actual constructor(
private var currentDynamicVelocity: Int = Dynamic.MF.velocity private var currentDynamicVelocity: Int = Dynamic.MF.velocity
private var currentDynamicFactor: Float = Dynamic.MF.factor private var currentDynamicFactor: Float = Dynamic.MF.factor
private var currentTempo: Float = 1.0f
private var targetBpm: Float = 120f private var targetBpm: Float = 120f
private var initialBpm: Float = 120f
private val playerScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private val playerScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var abJob: Job? = null private var abJob: Job? = null
@ -69,7 +63,9 @@ actual class FMediaPlayer actual constructor(
val isTempoChange: Boolean = false, val isTempoChange: Boolean = false,
val tempoType: String = "", val tempoType: String = "",
val endGridForTempo: Int = -1, 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<NavigationStep>() private val navigationSteps = mutableListOf<NavigationStep>()
@ -254,6 +250,8 @@ actual class FMediaPlayer actual constructor(
var lastFactor = Dynamic.MF.factor var lastFactor = Dynamic.MF.factor
val ritRegex = Regex("""rit\.?|ritard\.?|ritenuto\.?|ritardando""", RegexOption.IGNORE_CASE) val ritRegex = Regex("""rit\.?|ritard\.?|ritenuto\.?|ritardando""", RegexOption.IGNORE_CASE)
val rallRegex = Regex("""rall\.?|rallent\.?|rallentando""", 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 -> metadataList.forEach { midiMarker ->
val currentIndex = midiMarker.gridIndex ?: 0 val currentIndex = midiMarker.gridIndex ?: 0
@ -278,6 +276,37 @@ actual class FMediaPlayer actual constructor(
val hasFin = finReg.containsMatchIn(mTrim) || finReg.containsMatchIn(nextTrim) val hasFin = finReg.containsMatchIn(mTrim) || finReg.containsMatchIn(nextTrim)
val isDcFin = hasDc && hasFin val isDcFin = hasDc && hasFin
val isDsFin = hasDs && 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 { when {
//DSFin //DSFin
isDsFin || isDcFin -> { isDsFin || isDcFin -> {
@ -529,11 +558,46 @@ actual class FMediaPlayer actual constructor(
val currentSteps = navigationSteps.filter { val currentSteps = navigationSteps.filter {
it.gridIndex == currentIndex && !it.alreadyDone it.gridIndex == currentIndex && !it.alreadyDone
} }
currentSteps.forEach { step -> for (step in currentSteps) {
if (Math.abs(sequencer!!.tempoInBPM - targetBpm) > 0.1 && !isInTempoChange) { if (Math.abs(sequencer!!.tempoInBPM - targetBpm) > 0.1 && !isInTempoChange) {
forceTempo(targetBpm.toDouble()) forceTempo(targetBpm.toDouble())
} }
when { 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 -> { step.isFin -> {
if(step.finActive) { if(step.finActive) {
step.alreadyDone = true step.alreadyDone = true
@ -658,13 +722,24 @@ actual class FMediaPlayer actual constructor(
val tick = (gridIndex.toDouble() * resolution).toLong() val tick = (gridIndex.toDouble() * resolution).toLong()
sequencer?.tickPosition = tick sequencer?.tickPosition = tick
val initialOrPreviousBpm = navigationSteps
.filter { it.newBpm != null && it.gridIndex <= gridIndex }
.maxByOrNull { it.gridIndex }?.newBpm ?: 120f
targetBpm = initialOrPreviousBpm
applyBpm() applyBpm()
sharedScreenModel.setBpmFlow(initialOrPreviousBpm)
// Voir les velocity avant // Voir les velocity avant
val lastDynamic = navigationSteps val lastDynamic = navigationSteps
.filter { it.dynamic != null && it.gridIndex <= gridIndex } .filter { it.dynamic != null && it.gridIndex <= gridIndex }
.maxByOrNull { it.gridIndex } .maxByOrNull { it.gridIndex }
?.dynamic ?: Dynamic.MF ?.dynamic ?: Dynamic.MF
currentDynamicFactor = lastDynamic.factor currentDynamicFactor = lastDynamic.factor
navigationSteps.forEach { step ->
if (step.gridIndex > gridIndex) {
step.alreadyDone = false
}
}
applyVoiceStates() applyVoiceStates()
} }
@ -676,6 +751,13 @@ actual class FMediaPlayer actual constructor(
if (sequencer?.isOpen == true) { if (sequencer?.isOpen == true) {
val currentTick = sequencer?.tickPosition ?: 0L val currentTick = sequencer?.tickPosition ?: 0L
if (currentTick == 0L) { if (currentTick == 0L) {
resetNavigationFlags()
targetBpm = initialBpm
forceTempo(initialBpm.toDouble())
boundModel?.setBpmFlow(initialBpm)
}
val countInEnable = prefs.getBoolean("isCountIn", true)
if (currentTick == 0L && countInEnable) {
playCountIn() playCountIn()
} else { } else {
startMidiPlayback() startMidiPlayback()
@ -805,6 +887,9 @@ actual class FMediaPlayer actual constructor(
navigationJob?.cancel() navigationJob?.cancel()
resetNavigationFlags() resetNavigationFlags()
navigationSteps.clear() navigationSteps.clear()
targetBpm = initialBpm
sequencer?.tempoInBPM = initialBpm
boundModel?.setBpmFlow(initialBpm)
clearLoop() clearLoop()
currentDynamicFactor = Dynamic.MF.factor currentDynamicFactor = Dynamic.MF.factor
resetTempoToNormal() resetTempoToNormal()
@ -929,12 +1014,14 @@ actual class FMediaPlayer actual constructor(
actual fun setTempo(bpm: Float){ actual fun setTempo(bpm: Float){
this.targetBpm = bpm this.targetBpm = bpm
this.initialBpm = bpm
sequencer?.tempoInBPM = bpm sequencer?.tempoInBPM = bpm
boundModel?.let { modele -> boundModel?.let { modele ->
val seq = sequencer?.sequence val seq = sequencer?.sequence
if (seq != null) { if (seq != null) {
syncTuoWithMidi(seq, modele) syncTuoWithMidi(seq, modele)
} }
modele.setBpmFlow(bpm)
prepareNavigation(modele) prepareNavigation(modele)
} }
println("Tempo réglé à : $bpm BPM") println("Tempo réglé à : $bpm BPM")
@ -947,18 +1034,18 @@ actual class FMediaPlayer actual constructor(
} }
synthesizer.availableInstruments.map { instrument -> synthesizer.availableInstruments.map { instrument ->
MidiInstrument( MidiInstrument(
icon = "\uD83C\uDFB9",
program = instrument.patch.program, program = instrument.patch.program,
name = instrument.name.trim() name = instrument.name.trim()
) )
}.sortedBy { it.program } }.sortedBy { it.program }
} catch (e: Exception) { } 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) { actual fun saveVoiceInstrument(program: Int) {
prefs.putInt("voice_instrument", program) prefs.putInt("voice_instrument", program)
try { prefs.flush() } catch (e: Exception) { e.printStackTrace() }
} }
actual fun getVoiceInstrument(): Int { actual fun getVoiceInstrument(): Int {
@ -976,11 +1063,11 @@ actual class FMediaPlayer actual constructor(
private fun saveVoicesVolumes() { private fun saveVoicesVolumes() {
val data = voiceVolumes.joinToString(",") val data = voiceVolumes.joinToString(",")
prefs.put("voices_volumes", data) prefs.putString("voices_volumes", data)
} }
private fun loadVoiceVolumes() { 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(",") val volumesArray = data.split(",")
if (volumesArray.size == 4) { if (volumesArray.size == 4) {