feufaro/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt

723 lines
No EOL
28 KiB
Kotlin

package mg.dot.feufaro.midi
import SharedScreenModel
import kotlinx.coroutines.*
import mg.dot.feufaro.getConfigDirectoryPath
import mg.dot.feufaro.viewmodel.MidiMarkers
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.sampled.AudioSystem
import javax.sound.sampled.FloatControl
actual class FMediaPlayer actual constructor(
private val filename: String,
private val onFinished: () -> Unit
) {
private var sequencer: Sequencer? = try {
MidiSystem.getSequencer(false)
} catch (e: Exception){
println("Erreur impossible obtenir ${e.message}")
null
}
private val prefs = Preferences.userRoot().node("mg.dot.feufaro")
private var synthetizer = MidiSystem.getSynthesizer() as Synthesizer?
private var pointA: Long = -1L
private var pointB: Long = -1L
private var isLoopingAB: Boolean = false
private val voiceVolumes = FloatArray(4) { 127f }
private var currentGlobalVolume: Float = 0.8f
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 val playerScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var abJob: Job? = null
private var navigationJob: Job? = null
private data class NavigationStep(
val marker: String,
val gridIndex: Int, // L'ancre absolue
val targetGrid: Int = 0,
val isHold: Boolean = false, // point d'orgue
var alreadyDone: Boolean = false, // done
var beatInDC: Int = 1, // temps note sur le marker
val dynamic: Dynamic?= null, // velocité
// hairpin
val hairPin: Char? = null,
val hairPinEndGrid: Int = -1,
val hairPinFromFactor: Float = 1.0f,
val hairPinToFactor: Float = 1.0f,
)
private val navigationSteps = mutableListOf<NavigationStep>()
private var boundModel: SharedScreenModel? = null
init {
try {
sequencer?.open()
synthetizer?.open()
val transmitter = sequencer?.transmitter
val synthReceiver = synthetizer?.receiver
transmitter?.receiver = synthReceiver
} catch (e: Exception) {
e.printStackTrace()
}
val file = File(filename)
if (file.exists()) {
val mySequence = MidiSystem.getSequence(file)
sequencer?.sequence = mySequence
stripTempoEvents(mySequence)
sequencer?.tempoFactor = 1.0f
loadVoiceVolumes()
applyVoiceStates()
sequencer?.addMetaEventListener { meta ->
if(meta.type == 47){
val pendingDc = getPendingDcStep()
if (pendingDc != null) {
println("onFinished : DC pending → saut direct vers ${pendingDc.targetGrid}")
pendingDc.alreadyDone = true
seekToGrid(pendingDc.targetGrid)
play()
} else {
onFinished()
}
}
}
} else {
// Créeons une fichier vide au 1er lancement de l'application, après MidiWriterKotlin l'écrasera
val f0file = File("${getConfigDirectoryPath()}whawyd3.mid")
f0file.createNewFile()
}
}
private fun stripTempoEvents(sequence: Sequence) {
for (track in sequence.tracks) {
val eventsToRemove = mutableListOf<javax.sound.midi.MidiEvent>()
for (i in 0 until track.size()) {
val event = track.get(i)
val message = event.message
if (message is MetaMessage && message.type == 0x51) { // 0x51 = Set Tempo
eventsToRemove.add(event)
}
}
eventsToRemove.forEach { track.remove(it) }
}
}
private fun applyBpm() {
sequencer?.tempoInBPM = targetBpm
sequencer?.tempoFactor = 1.0f
}
private fun forceTempo(bpm: Double) {
sequencer?.tempoInBPM = bpm.toFloat()
sequencer?.tempoFactor = 1.0f
}
private fun resetNavigationFlags() {
navigationSteps.forEach { it.alreadyDone = false }
}
private var dynamicJob: Job? = null
private fun applyDynamicSmooth(targetFactor: Float, durationMs: Long = 300L) {
dynamicJob?.cancel()
dynamicJob = playerScope.launch {
val startFactor = currentDynamicFactor
val steps = 30
val stepMs = durationMs / steps
for (i in 1..steps) {
val progress = i.toFloat() / steps
val smooth = progress * progress * (3f - 2f * progress)
currentDynamicFactor = startFactor + (targetFactor - startFactor) * smooth
applyVoiceStates()
delay(stepMs)
}
currentDynamicFactor = targetFactor
applyVoiceStates()
}
}
private fun applyDynamic(dynamic: Dynamic) {
val targetFactor = when (dynamic) {
Dynamic.MF -> 1.0f
else -> dynamic.factor
}
println("applyDynamic → ${dynamic.label} | ${currentDynamicFactor}$targetFactor")
applyDynamicSmooth(targetFactor, durationMs = 300L)
}
private fun extractDynamic(marker: String): Dynamic? {
val clean = marker.trim().removeSuffix("=").trim()
return Dynamic.entries.firstOrNull { it.label == clean }
}
fun factorToDynamic(factor: Float): Dynamic = Dynamic.entries.minByOrNull { kotlin.math.abs(it.factor - factor) } ?: Dynamic.MF
fun nextDynamic(currentFactor: Float, symbol: Char): Float {
val current = factorToDynamic(currentFactor)
val index = Dynamic.entries.indexOf(current)
return if (symbol == '<') {
Dynamic.entries.getOrElse(index + 1) { Dynamic.FFF }.factor
} else {
Dynamic.entries.getOrElse(index - 1) { Dynamic.PPP }.factor
}
}
private fun applyCrescendo(fromFactor: Float, toFactor: Float, durationMs: Long) {
dynamicJob?.cancel()
dynamicJob = playerScope.launch {
val steps = 40
val stepMs = (durationMs / steps).coerceAtLeast(10L)
for (i in 1..steps) {
val progress = i.toFloat() / steps
val smooth = progress * progress * (3f - 2f * progress) // ease in-out
currentDynamicFactor = fromFactor + (toFactor - fromFactor) * smooth
applyVoiceStates()
delay(stepMs)
}
currentDynamicFactor = toFactor
applyVoiceStates()
println("applyCrescendo terminé → factor=$currentDynamicFactor")
}
}
private fun prepareNavigation(sharedScreenModel: SharedScreenModel) {
val metadataList = sharedScreenModel.getFullMarkers()
navigationSteps.clear()
var lastSegno = 0
var lastFactor = Dynamic.MF.factor
metadataList.forEach { (timestamp, gridIndex, template, lastCallerMarker, marker, noteBefore, separat, note) ->
val currentIndex = gridIndex ?: 0
val dsRegex = Regex("""D\.?S\.?""")
val dcRegex = Regex("""D\.?C\.?""")
val dsGPattern = Regex("""D\.?S\.?_GROUP_PART""")
val dcGPattern = Regex("""D\.?C\.?_GROUP_PART""")
val last_grid = sharedScreenModel.getTotalGridCount()
val hairPins = sharedScreenModel.getHairPins()
when {
// segno
marker.contains("$") -> {
lastSegno = currentIndex
println("Cible ($) mémorisée au $lastSegno")
}
// Point d'orgue
marker.contains("\uD834\uDD10") -> {
val beat = if(note.contains('•')) 2 else 1 // demi-ton sur .) ou non
navigationSteps.add(
FMediaPlayer.NavigationStep(
marker,
currentIndex,
isHold = true,
beatInDC = beat
)
)
println("Point d'orgue (\uD834\uDD10) mémorisée au grille n° $gridIndex")
}
// DS
dsGPattern.matches(marker.trim())-> {
val target = if (lastSegno > 0) lastSegno else 0
var indx = if((last_grid - currentIndex) <= 0) {
currentIndex - 1
} else currentIndex
navigationSteps.add(
NavigationStep(
marker,
gridIndex = indx,
targetGrid = target,
)
)
println("Lien créé : $marker à grille n° $indx vers cible $target ........")
}
dsRegex.matches(marker.trim()) || marker == "DSFin" -> {
val target = if (lastSegno > 0) lastSegno else 0
navigationSteps.add(
NavigationStep(
marker,
currentIndex,
targetGrid = target
))
println("Lien DS créé : Saut immédiat à $gridIndex vers Segno $target")
}
// DC
dcGPattern.matches(marker.trim()) -> {
val indx = if((last_grid - currentIndex) <= 0) {
currentIndex - 1
} else currentIndex
// println("monn index est $indx car dernier est $last_grid et curr $currentIndex")
navigationSteps.add(
NavigationStep(
marker,
indx,
targetGrid = 0
)
)
println("Lien créé : $marker à $indx vers cible 0 ")
}
(dcRegex.matches(marker.trim()) && !marker.contains("DC_GROUP_PART")) -> {
println("dernier grille $last_grid")
var indx = if((last_grid - currentIndex) <= 0) {
currentIndex /*- 1*/
} else currentIndex
navigationSteps.add(
NavigationStep(
marker,
indx,
targetGrid = 0
)
)
println("Lien DC créé : $marker à $indx vers le début")
}
// velocité
extractDynamic(marker) != null && marker.trim() != "=" -> {
val dyn = extractDynamic(marker) ?: return@forEach
lastFactor = when (dyn) {
Dynamic.MF -> 1.0f
else -> dyn.factor
}
navigationSteps.add(
NavigationStep(
marker = marker,
gridIndex = currentIndex,
dynamic = dyn
)
)
println("Dynamique '${dyn.label}' (vel=${dyn.velocity}) mémorisée à la grille $currentIndex")
}
// Soufflet
marker.trim() == "<" || marker.trim() == ">" ||
marker.trim().contains("cres", ignoreCase = true) ||
marker.trim().contains("dim", ignoreCase = true) -> {
val symbol = when {
marker.trim() == "<" -> '<'
marker.trim() == ">" -> '>'
marker.trim().contains("cres", ignoreCase = true) -> '<'
marker.trim().contains("dim", ignoreCase = true) -> '>'
else -> return@forEach
}
val pair = hairPins.find { it.startGrid == currentIndex } ?: return@forEach
val dynBefore = navigationSteps
.filter { it.dynamic != null && it.gridIndex <= currentIndex }
.maxByOrNull { it.gridIndex }?.dynamic ?: Dynamic.MF
val dynAfter = metadataList
.filter { (_, gi, _, _, mk, _, _, _) ->
(gi ?: 0) >= pair.endGrid && Dynamic.entries.any { d -> d.label == mk.trim() }
}
.minByOrNull { it.gridIndex ?: 0 }
?.let { m -> Dynamic.entries.find { d -> d.label == m.marker.trim() } }
val explicitDynAfter = metadataList
.filter { (_, gi, _, _, mk, _, _, _) ->
(gi ?: 0) >= pair.endGrid &&
extractDynamic(mk) != null
}
.minByOrNull { it.gridIndex ?: 0 }
?.let { m -> extractDynamic(m.marker) }
val fromFactor = lastFactor
val toFactor = when {
explicitDynAfter != null && symbol == '<' && explicitDynAfter.factor > fromFactor -> {
explicitDynAfter.factor
}
explicitDynAfter != null && symbol == '>' && explicitDynAfter.factor < fromFactor -> {
explicitDynAfter.factor
}
else -> nextDynamic(fromFactor, symbol)
}
lastFactor = toFactor
navigationSteps.add(
NavigationStep(
marker = marker.trim(),
gridIndex = currentIndex,
hairPin = symbol,
hairPinEndGrid = pair.endGrid,
hairPinFromFactor = fromFactor,
hairPinToFactor = toFactor
)
)
println("NavigationStep HairPin '$symbol' : $currentIndex${pair.endGrid} | ${fromFactor})→${toFactor})")
}
}
}
}
private fun getPendingDcStep(): NavigationStep? {
return navigationSteps.firstOrNull { step ->
!step.alreadyDone &&
step.hairPin == null &&
step.dynamic == null &&
!step.isHold
}
}
private fun startNavigationMonitor(sharedScreenModel: SharedScreenModel) {
var dcDone = sharedScreenModel.getDcDone()
val dsDone = sharedScreenModel.getDsDone()
navigationJob?.cancel()
navigationJob = playerScope.launch(Dispatchers.Default) {
sharedScreenModel.activeIndex.collect { currentIndex ->
val availableIndices = navigationSteps.map { it.gridIndex }
// println("bpm:$targetBpm _ ${sequencer?.tempoInBPM}| Index en mémoire : $availableIndices")
// println("i $currentIndex ")
if (sequencer?.isRunning == true) {
if (Math.abs(sequencer!!.tempoInBPM - targetBpm) > 0.1) {
forceTempo(targetBpm.toDouble())
}
if (currentIndex < 0) return@collect
val step = navigationSteps.find {
it.gridIndex == currentIndex &&
!it.alreadyDone
}
if (step != null) {
when {
// Point d'orgue
step.isHold -> {
val currentBpm = sequencer?.tempoInBPM ?: targetBpm
val beatMs = (60_000 / currentBpm).toLong()
val holdDuration = if (step.beatInDC == 2) beatMs / 2 else beatMs * 2
println("POINT D'ORGUE sur Grille $currentIndex | Durée: ${holdDuration}ms")
synthetizer?.channels?.forEach { it?.controlChange(64, 127) }
val previousFactor = sequencer?.tempoFactor ?: 1.0f
sequencer?.tempoFactor = 0.0001f
delay(holdDuration)
sequencer?.tempoFactor = previousFactor
synthetizer?.channels?.forEach { it?.controlChange(64, 0) }
}
//Soufflet
step.hairPin != null -> {
val resolution = sequencer?.sequence?.resolution?.toLong() ?: 480L
val gridDistance = (step.hairPinEndGrid - step.gridIndex).coerceAtLeast(1)
val beatDurationMs = (60_000L / targetBpm).toLong()
val durationMs = (gridDistance * beatDurationMs).coerceIn(200L, 5000L)
println("HairPin '${step.hairPin}' grille ${step.gridIndex}${step.hairPinEndGrid} | ${step.hairPinFromFactor}${step.hairPinToFactor} | durée=${durationMs}ms")
applyCrescendo(
fromFactor = step.hairPinFromFactor,
toFactor = step.hairPinToFactor,
durationMs = durationMs
)
}
//velocity
step.dynamic != null -> {
applyDynamic(step.dynamic)
}
else -> {
// $ dc ds ...
step.alreadyDone = true
val currentBpm = targetBpm
val beatDuration = (60_000 / currentBpm).toLong()
println("avant de sauter bpm=$targetBpm")
synthetizer?.channels?.forEach { it?.controlChange(64, 127) }
sequencer?.tempoFactor = 0.0001f
delay(beatDuration)
// println("et là je saut vers ${step.targetGrid}")
seekToGrid(step.targetGrid)
synthetizer?.channels?.forEach { it?.controlChange(64, 0) }
sequencer?.tempoFactor = 1f
if (sequencer?.isRunning == false) {
sequencer?.tempoInBPM = targetBpm
sequencer?.start()
}
// println("Après de sauter bpm=$targetBpm")
}
}
}
}
}
}
}
private var syncJob: Job? = null
private fun startSyncLoop(sharedScreenModel: SharedScreenModel) {
syncJob?.cancel()
syncJob = playerScope.launch(Dispatchers.Default) {
while (isActive) {
if (sequencer?.isRunning == true) {
val resolution = sequencer?.sequence?.resolution?.toLong() ?: 480L
val gridIndex = (sequencer!!.tickPosition / resolution).toInt()
sharedScreenModel.updateActiveIndexByIndex(gridIndex)
}
delay(20)
}
}
}
actual fun seekToGrid(gridIndex: Int) {
val resolution = sequencer?.sequence?.resolution?.toDouble() ?: 480.0
val tick = (gridIndex.toDouble() * resolution).toLong()
sequencer?.tickPosition = tick
applyBpm()
// Voir les velocity avant
val lastDynamic = navigationSteps
.filter { it.dynamic != null && it.gridIndex <= gridIndex }
.maxByOrNull { it.gridIndex }
?.dynamic ?: Dynamic.MF
currentDynamicFactor = lastDynamic.factor
applyVoiceStates()
}
actual fun play(){
if (sequencer?.isOpen == true){
applyBpm()
sequencer?.start()
println("La sequence vient d etre lancé ${sequencer?.isRunning}")
// sequencer!!.open()
}
}
actual fun pause(){
sequencer?.stop()
}
actual fun requestSync(sharedScreenModel: SharedScreenModel) {
val seq = sequencer?.sequence
if (seq != null) {
syncTuoWithMidi(seq, sharedScreenModel)
}
}
actual fun syncNavigationMonitor(sharedScreenModel: SharedScreenModel) {
this.boundModel = sharedScreenModel
prepareNavigation(sharedScreenModel)
startNavigationMonitor(sharedScreenModel)
startSyncLoop(sharedScreenModel)
}
fun syncTuoWithMidi(sequence: Sequence, sharedScreenModel: SharedScreenModel) {
val timestamps = mutableListOf<Long>()
val resolution = sequence.resolution.toDouble()
val bpm = targetBpm/*sequencer?.tempoInBPM?.toDouble() ?: 120.0*/
val usPerTick = (60_000_000.0 / bpm) / resolution
// détecter par 60 ticks càd par un temp (noir)
val totalTicks = sequence.tickLength
val step = 60L
for (tick in 0..totalTicks step step) {
val microsecond = (tick * usPerTick).toLong()
timestamps.add(microsecond)
// println("Note detectée au grille: ${tick/60} -> Temps: ${microsecond / 1000} ms")
}
// Détection de tous les note y compris les /2 /4 temps
/*val processedTicks = mutableSetOf<Long>()
for (track in sequence.tracks) {
for (i in 0 until track.size()) {
val event = track.get(i)
val message = event.message
if (message is ShortMessage *//*&& message.command == ShortMessage.NOTE_ON *//*&& message.data2 > 0) {
if (!processedTicks.contains(event.tick)) {
val microsecond = (event.tick * usPerTick).toLong()
timestamps.add(microsecond)
processedTicks.add(event.tick)
println("Note detectée au Tick: ${event.tick} -> Temps: ${microsecond / 1000} ms")
}
}
}
}*/
val sortedList = timestamps.sorted()
sharedScreenModel.updateTimestamps(sortedList)
// println("SUR tempo $targetBpm Synchronization terminer et mis à jour")
}
actual fun stop(){
// sequencer?.stop()
sequencer?.microsecondPosition = 0
navigationJob?.cancel()
resetNavigationFlags()
navigationSteps.clear()
clearLoop()
currentDynamicFactor = Dynamic.MF.factor
// release()
}
actual fun getDuration(): Long {
return (sequencer?.microsecondLength ?: 0L) / 1000
}
actual fun getCurrentPosition(): Long {
return (sequencer?.microsecondPosition ?: 0L) / 1000
}
actual fun seekTo(position: Long) {
sequencer?.microsecondPosition = position * 1000
applyBpm()
}
actual fun release() {
sequencer?.close()
synthetizer?.close()
playerScope.cancel()
}
actual fun setVolume(level: Float) {
try {
this.currentGlobalVolume = level
applyVoiceStates()
/*val volumeInt = (level * 127).toInt().coerceIn(0, 127)
synthetizer?.channels?.forEachIndexed { index, channel ->
if (index < 4) {
val vol = if (voiceVolumes[index] != 0f) volumeInt else 0
channel?.controlChange(7, vol)
} else {
channel?.controlChange(7, volumeInt)
}
}*/
val mixer = AudioSystem.getMixer(null)
val lines = mixer.sourceLines
for (line in lines) {
if(line.isControlSupported(FloatControl.Type.MASTER_GAIN)) {
val gainControl = line.getControl(FloatControl.Type.MASTER_GAIN) as FloatControl
val dB = (Math.log10(level.toDouble().coerceAtLeast(0.0001)) * 20).toFloat()
gainControl.value = dB.coerceIn(gainControl.minimum, gainControl.maximum)
}
}
} catch (e: Exception){
e.printStackTrace()
}
// println("la volume $level")
}
actual fun setPointA() {
pointA = sequencer?.tickPosition ?: 0L
}
actual fun setPointB() {
pointB = sequencer?.tickPosition ?: 0L
if (pointB > pointA && pointA != -1L) {
isLoopingAB = true
startABMonitor()
}
}
actual fun clearLoop() {
isLoopingAB = false
pointA = -1L
pointB = -1L
}
private fun startABMonitor() {
abJob?.cancel()
abJob = playerScope.launch {
while(isLoopingAB) {
val currentTick = sequencer?.tickPosition?: 0L
if (currentTick >= pointB) {
sequencer?.tickPosition = pointA
}
delay(50)
}
}
}
actual fun getLoopState() = Triple(pointA, pointB, isLoopingAB)
actual fun toggleVoice(index: Int) {
saveVoicesVolumes()
applyVoiceStates()
}
actual fun updateVoiceVolume(voiceIndex: Int, newVolume: Float) {
if (voiceIndex in 0..3) {
voiceVolumes[voiceIndex] = newVolume
saveVoicesVolumes()
applyVoiceStates()
}
}
private fun applyVoiceStates() {
try {
synthetizer?.channels?.let { channels ->
for (i in 0 until 4) {
if (i < channels.size) {
val userVol = voiceVolumes[i] / 127f
val globalVol = currentGlobalVolume
val finalVol = (127f * userVol * globalVol * currentDynamicFactor).toInt().coerceIn(0, 127)
// println("Voice[$i] user=${voiceVolumes[i]} global=$currentGlobalVolume dynFactor=$currentDynamicFactor → finalVol=$finalVol")
channels[i].controlChange(7, finalVol)
channels[i].controlChange(11, finalVol)
if (finalVol == 0) channels[i].controlChange(123, 0)
/*val targetVolume = voiceVolumes[i].toInt()
channels[i].controlChange(7, targetVolume)
channels[i].controlChange(11, targetVolume)
if (targetVolume == 0) {
channels[i].controlChange(123, 0)
}*/
}
// println("SATB $i Volumes: ${voiceVolumes[i]}")
}
}
} catch (e: Exception) { e.printStackTrace() }
}
actual fun getVoiceVolumes(): List<Float> = voiceVolumes.toList()
actual fun changeInstru(noInstru: Int) {
val pgm = noInstru
synthetizer?.channels?.let {
channels ->
for (i in 0 until 4) {
channels[i].programChange(pgm)
}
}
}
actual fun getCurrentBPM(): Float {
return targetBpm
}
actual fun setTempo(bpm: Float){
this.targetBpm = bpm
sequencer?.tempoInBPM = bpm
boundModel?.let { modele ->
val seq = sequencer?.sequence
if (seq != null) {
syncTuoWithMidi(seq, modele)
}
prepareNavigation(modele)
}
println("Tempo réglé à : $bpm BPM")
}
private fun saveVoicesVolumes() {
val data = voiceVolumes.joinToString(",")
prefs.put("voices_volumes", data)
}
private fun loadVoiceVolumes() {
val data = prefs.get("voices_volumes", "127,127,127,127")
val volumesArray = data.split(",")
if (volumesArray.size == 4) {
for (i in 0 until 4) {
voiceVolumes[i] = volumesArray[i].toFloatOrNull() ?: 127f
}
}
}
}