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 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<NavigationStep>()
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,9 +363,11 @@ 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
@ -350,8 +376,10 @@ actual class FMediaPlayer actual constructor(
// 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<MidiInstrument> {
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)
}
}

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

View file

@ -12,7 +12,7 @@ data class MidiPitch (
var duration: Int = 0,
var markers: List<String> = 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<Int> = 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,30 +58,33 @@ 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)
val tickMeta = nextTick.getOrNull(0) ?: 0
setMeta(0x58, tickMeta, 4, numerator, denomPower, 24, 8)
return true
}
return false
}
fun reset() {
tick = 0
nextTick.clear()

View file

@ -31,4 +31,4 @@ expect class FMediaPlayer(filename: String, sharedScreenModel: SharedScreenModel
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,8 +36,27 @@ class MidiSequence(val resolution: Int = 60) {
fun addSequence(channel: Int, pitch: Int, currentTick: Long, type: Int = 80, finalVelocity: Int = 100) {
val myTrack = tracks[0]
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) {
// Calcul du Delta-Time
val delta = currentTick - lastTick
@ -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<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
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())
}

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
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")

View file

@ -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<String, Int> {
return Pair(note, 4)
}

View file

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

View file

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

View file

@ -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) }
@ -368,17 +373,28 @@ fun Settings(
) {
Column(modifier = Modifier.padding(top = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
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(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
horizontalArrangement = Arrangement.spacedBy(5.dp)
) {
Text(if(isDynamicEnabled) "Désactiver" else "Activer", fontWeight = FontWeight.Bold, fontSize = 15.sp)
Switch(
checked = isDynamicEnabled,
onCheckedChange = { enabled ->
@ -387,6 +403,11 @@ fun Settings(
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)
)
}
}
}
}
}
}

View file

@ -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<List<DrawerItem>>(emptyList())
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
.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)
}
}

View file

@ -55,4 +55,7 @@ class SolfaScreenModel(
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
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<NavigationStep>()
@ -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) {