From d5d8068c24fd24b1a62b29c8746f0c260efa5a79 Mon Sep 17 00:00:00 2001 From: Hasinjato Date: Thu, 15 Oct 2026 11:03:24 +0300 Subject: [PATCH] Attach lyrics on midi export; stz loop midi --- .../mg/dot/feufaro/midi/MidiWriterKotlin.kt | 2 +- .../dot/feufaro/midi/MidiWriterforEXport.kt | 127 +++++++++++++++--- .../kotlin/mg/dot/feufaro/solfa/Solfa.kt | 126 ++++++++++++++++- .../kotlin/mg/dot/feufaro/midi/MidiPlayer.kt | 15 ++- 4 files changed, 246 insertions(+), 24 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiWriterKotlin.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiWriterKotlin.kt index 5bc94de..9054d54 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiWriterKotlin.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiWriterKotlin.kt @@ -53,7 +53,7 @@ class MidiWriterKotlin constructor(private val fileRepository: FileRepository) tick = 0 pitches.forEach { if (it.metaType > 0) { - val metaDataString = String(it.metaBytes, Charsets.ISO_8859_1) + val metaDataString = String(it.metaBytes, Charsets.UTF_8) addMetaMessage(it.metaType, it.tick, it.metaByteSize, metaDataString) } else if (it.pitch != "") { addNote(it.voiceNumber, it.pitch.toInt(), 100, it.tick.toLong()) diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiWriterforEXport.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiWriterforEXport.kt index 80c105e..c48ed1b 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiWriterforEXport.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiWriterforEXport.kt @@ -68,6 +68,106 @@ class MidiWriterforEXport constructor(private val fileRepository: FileRepositor sequence.addControlChange(channel, ctrl, valByte, tickPosition) } +//val dcGridIndex = (dcMarker.gridIndex ?: 0)+1 + //val endRepeatGridIndex = (fineMarker?.gridIndex ?: dcGridIndex)+1 + /** + * Déplie séquentiellement tous les sauts (D.C. et D.S. / DSFin) dans l'ordre chronologique. + */ + fun expandDaCapoAndSegno( + initialPitches: List, + initialMarkers: List + ): Pair, List> { + val isDC = Regex("""D\.?C\.?""", RegexOption.IGNORE_CASE) + val isDS = Regex("""D\.?S\.?""", RegexOption.IGNORE_CASE) + val isFine = Regex("""\b(fine|fin|farany|end)\b""", RegexOption.IGNORE_CASE) + + var currentPitches = initialPitches + var currentMarkers = initialMarkers + + var maxIterations = 20 + + while (maxIterations > 0) { + maxIterations-- + + val sortedMarkers = currentMarkers.sortedBy { it.gridIndex ?: 0 } + val candidateJumpMarker = sortedMarkers.firstOrNull { m -> + m.marker.any { text -> + val clean = text.trim() + isDC.containsMatchIn(clean) || isDS.containsMatchIn(clean) + } + } ?: break + + val candidateGridIdx = candidateJumpMarker.gridIndex ?: 0 + val jumpGridIndex = candidateGridIdx + 1 + val jumpRawTick = jumpGridIndex * 480L + val isDsJump = candidateJumpMarker.marker.any { text -> isDS.containsMatchIn(text.trim()) } + + currentMarkers = currentMarkers.map { m -> + if (m === candidateJumpMarker || (m.gridIndex == candidateGridIdx && m.marker == candidateJumpMarker.marker)) { + m.copy(marker = m.marker.map { text -> + text.replace(Regex("""D\.?C\.?""", RegexOption.IGNORE_CASE), "") + .replace(Regex("""D\.?S\.?""", RegexOption.IGNORE_CASE), "") + }) + } else { + m + } + } + + val startRepeatGridIndex = if (isDsJump) { + val segnoMarker = currentMarkers.lastOrNull { m -> + (m.gridIndex ?: 0) <= jumpGridIndex && m.marker.any { it.contains("$") } + } + segnoMarker?.gridIndex ?: 0 + } else { + 0 + } + val startRepeatRawTick = startRepeatGridIndex * 480L + val fineMarker = currentMarkers.firstOrNull { m -> + val gIdx = m.gridIndex ?: 0 + gIdx >= startRepeatGridIndex && gIdx <= jumpGridIndex && m.marker.any { text -> isFine.containsMatchIn(text.trim()) } + } + + val hasFine = fineMarker != null + val endRepeatGridIndex = (fineMarker?.gridIndex ?: candidateGridIdx) + val endRepeatRawTick = endRepeatGridIndex * 480L + val shiftGridIndex = jumpGridIndex - startRepeatGridIndex + val shiftInPitchTick: Int = shiftGridIndex * 60 + val pitchesBeforeJump = currentPitches.filter { (it.tick * 8L) <= jumpRawTick } + + val pitchesToRepeat = currentPitches.filter { + val raw = it.tick * 8L + raw >= startRepeatRawTick && raw <= endRepeatRawTick + } + val duplicatedPitches = pitchesToRepeat.map { pitch -> + pitch.copy(tick = pitch.tick + shiftInPitchTick) + } + + val repeatDurationGridIndex = endRepeatGridIndex - startRepeatGridIndex + val durationShiftInPitchTick: Int = repeatDurationGridIndex * 60 + val shiftedPitchesAfterJump = currentPitches.filter { (it.tick * 8L) > jumpRawTick }.map { pitch -> + pitch.copy(tick = pitch.tick + durationShiftInPitchTick) + } + + currentPitches = pitchesBeforeJump + duplicatedPitches + shiftedPitchesAfterJump + + val markersBeforeJump = currentMarkers.filter { (it.gridIndex ?: 0) <= jumpGridIndex } + val markersToRepeat = currentMarkers.filter { + val gIdx = it.gridIndex ?: 0 + gIdx >= startRepeatGridIndex && gIdx <= endRepeatGridIndex + } + val duplicatedMarkers = markersToRepeat.map { m -> + m.copy(gridIndex = (m.gridIndex ?: 0) + shiftGridIndex) + } + val shiftedMarkersAfterJump = currentMarkers.filter { (it.gridIndex ?: 0) > jumpGridIndex }.map { m -> + m.copy(gridIndex = (m.gridIndex ?: 0) + repeatDurationGridIndex) + } + + currentMarkers = markersBeforeJump + duplicatedMarkers + shiftedMarkersAfterJump + } + + return Pair(currentPitches, currentMarkers) + } + fun process( pitches: List, markers: List = emptyList(), @@ -75,6 +175,7 @@ class MidiWriterforEXport constructor(private val fileRepository: FileRepositor voiceInstruments: Map = mapOf(1 to 0, 2 to 0, 3 to 0, 4 to 0), initialKey: String = "C" ) { + val (effectivePitches, effectiveMarkers) = expandDaCapoAndSegno(pitches, markers) val lastTick = 0 nextTick.clear() // addMetaMessage(0x59, 4, 2, 2,0) @@ -96,7 +197,7 @@ class MidiWriterforEXport constructor(private val fileRepository: FileRepositor 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("""^(?:♩|P)\s*[:=]\s*(\d+)""", RegexOption.IGNORE_CASE) - markers.forEach { midiMarker -> + effectiveMarkers.forEach { midiMarker -> val gridIdx = (midiMarker.gridIndex ?: 0) * 480L midiMarker.marker.forEach { text -> val cleanText = text.trim() @@ -168,7 +269,7 @@ class MidiWriterforEXport constructor(private val fileRepository: FileRepositor val stepDelta = 20 val processedHairpins = mutableSetOf() - val pitchesByTick = pitches.groupBy { (it.tick) * 8L }.toSortedMap() + val pitchesByTick = effectivePitches.groupBy { (it.tick) * 8L }.toSortedMap() pitchesByTick.forEach { (rawTick, eventsAtTick) -> /* Fermata CC*/ @@ -202,8 +303,7 @@ class MidiWriterforEXport constructor(private val fileRepository: FileRepositor baseVelocity = nuanceMap[rawTick] ?: Dynamic.MF.velocity } - val adjustedTick = rawTick/* + timeOffset*/ - + val adjustedTick = rawTick + timeOffset // hairpin @@ -243,40 +343,35 @@ class MidiWriterforEXport constructor(private val fileRepository: FileRepositor } } - - // Tempo Meta 0x51 val pendingTempoTicks = tempoChangeMap.keys.filter { it <= rawTick }.sorted() pendingTempoTicks.forEach { tempoRawTick -> val newBpm = tempoChangeMap[tempoRawTick] ?: initialBpm - val tempoAdjustedTick = tempoRawTick/* + timeOffset*/ + 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 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 + val keyBytes = byteArrayOf(sf.toByte(), 0.toByte()) 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*/ + val textAdjustedTick = textRawTick + timeOffset textMetaMap[textRawTick]?.forEach { annotationText -> addTextMeta(text = annotationText, tickPosition = textAdjustedTick, metaType = 0x06) @@ -285,7 +380,6 @@ class MidiWriterforEXport constructor(private val fileRepository: FileRepositor } eventsAtTick.forEach { noteEvent -> - // Note + transpose val currentKeyIdx = Transpose.keyToNumber.indexOf(currentKey).coerceAtLeast(0) val transpositionSemitones = currentKeyIdx - baseKeyIdx @@ -303,7 +397,7 @@ class MidiWriterforEXport constructor(private val fileRepository: FileRepositor } tempoChangeMap.forEach { (tempoRawTick, bpm) -> - val tempoAdjustedTick = tempoRawTick/* + timeOffset*/ + val tempoAdjustedTick = tempoRawTick + timeOffset setTempoMeta(bpm, tempoAdjustedTick.toInt()) } tempoChangeMap.clear() @@ -322,9 +416,6 @@ class MidiWriterforEXport constructor(private val fileRepository: FileRepositor } 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( diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt index 39d5d5a..fc5e4c9 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt @@ -11,6 +11,7 @@ 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.MidiMarkers import mg.dot.feufaro.viewmodel.PartitionMetadata import java.io.File import kotlin.math.min @@ -261,8 +262,8 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository unparsedNote.forEach { preloadN(it) } + val localAccumulator = mutableListOf() try { - val localAccumulator = mutableListOf() withContext(Dispatchers.Main) { sharedScreenModel.updateFullTUOList(emptyList()) } @@ -270,6 +271,7 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository } catch (e: Exception) { println("Erreur parseScope Solfa:150 : ${e.message} iter: ${TimeUnitObject.nbBlock}") } + attachLyricsToPitches(localAccumulator) val pitches = pitches.sortedWith(compareBy({ it.tick }, { it.voiceNumber })) val midiWriter = MidiWriterKotlin(fileRepository) midiWriter.process(pitches) @@ -289,8 +291,79 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository fun generateMidiFile(bpm: Float, outputPath: String = "whawyd3.mid", transpI: Int = 0) { val parseScope = CoroutineScope(Dispatchers.Default) parseScope.launch { - val pitchesSorted = pitches.sortedWith(compareBy({ it.tick }, { it.voiceNumber })) + val nbStanzas = sharedScreenModel.nbStanzas.value.takeIf { it > 0 } ?: 1 + val currentTuoList = sharedScreenModel.tuoList.value + val basePitches = pitches.filter { it.metaType != 0x05 }.map { it.copy() } + val baseMarkers = sharedScreenModel.getFullMarkers() + + val loopedPitches = mutableListOf() + val loopedMarkers = mutableListOf() + val midiWriter = MidiWriterforEXport(fileRepository) + val totalGridsInStanza = currentTuoList.size + + var currentTickOffset = 0L + var currentGridOffset = 0 + + for (stanza in 1..nbStanzas) { + attachLyricsToPitches(tuoList = currentTuoList, stanzaNumber = stanza) + val stanzaLyrics = pitches.filter { it.metaType == 0x05 }.map { it.copy() } + + val stanzaPitches = basePitches.map { it.copy() } + stanzaLyrics + + val (expandedPitches, expandedMarkers) = midiWriter.expandDaCapoAndSegno(stanzaPitches, baseMarkers) + + expandedPitches.forEach { pitch -> + loopedPitches.add(pitch.copy().apply { + tick += currentTickOffset.toInt() + metaBytes = metaBytes.copyOf() + }) + } + + expandedMarkers.forEach { marker -> + loopedMarkers.add( + marker.copy( + timestamp = marker.timestamp + currentTickOffset, + gridIndex = (marker.gridIndex ?: 0) + currentGridOffset + ) + ) + } + + val lastNoteGrid = expandedPitches + .filter { it.metaType <= 0 && it.pitch.isNotEmpty() } + .maxOfOrNull { it.tick / 60 } + + val maxGridFromMarkers = expandedMarkers.maxOfOrNull { it.gridIndex ?: 0 } + val maxGridFromPitches = expandedPitches.maxOfOrNull { it.tick / 60 } + val maxStanzaGrid = maxOf(maxGridFromMarkers ?: 0, maxGridFromPitches ?: 0, totalGridsInStanza - 1) + val stanzaGridCount = maxStanzaGrid + 1 + + val totalGridWithGap = if (stanza < nbStanzas && lastNoteGrid != null) { + val measureInt = sharedScreenModel.measureInt.value.takeIf { it > 0 } ?: 4 + val gapNoires = when (measureInt) { + 2, 4, 8, 10 -> 4 + 3, 6, 9, 12 -> 3 + else -> 4 + } + lastNoteGrid + gapNoires + } else { + stanzaGridCount + } + + val stanzaTickDuration = totalGridWithGap * 60L + + currentGridOffset += totalGridWithGap + currentTickOffset += stanzaTickDuration + } + + pitches.clear() + pitches.addAll(basePitches) + attachLyricsToPitches( + tuoList = currentTuoList, + stanzaNumber = sharedScreenModel.stanza.value.takeIf { it > 0 } ?: 1 + ) + + val pitchesSorted = loopedPitches.sortedWith(compareBy({ it.tick }, { it.voiceNumber })) val instruments = mapOf( 1 to prefs.getInt("voice_instrument", 0), @@ -301,9 +374,10 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository val originalKey = sharedScreenModel.songKey.value val transposedKey = Transpose.transposeKey(baseKey = originalKey, interval = transpI) val initialSongBpm = meta["p"]?.toFloatOrNull() ?: bpm + midiWriter.process( pitchesSorted, - sharedScreenModel.getFullMarkers(), + loopedMarkers, initialBpm = initialSongBpm, instruments, initialKey = transposedKey @@ -311,6 +385,7 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository midiWriter.save(outputPath) } } + fun justBuild(sourceFile: String, sourceContent: String) { currentFile = sourceFile val parseScope = CoroutineScope(Dispatchers.Default) @@ -349,8 +424,8 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository unparsedNote.forEach { preloadN(it) } + val localAccumulator = mutableListOf() try { - val localAccumulator = mutableListOf() withContext(Dispatchers.Main) { sharedScreenModel.updateFullTUOList(emptyList()) } @@ -358,6 +433,7 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository } catch (e: Exception) { println("Erreur parseScope Solfa:150 : ${e.message} iter: ${TimeUnitObject.nbBlock}") } + attachLyricsToPitches(localAccumulator) val pitches = pitches.sortedWith(compareBy({ it.tick }, { it.voiceNumber })) val midiWriter = MidiWriterKotlin(fileRepository) midiWriter.process(pitches) @@ -2112,6 +2188,48 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository sharedScreenModel.setBpmFlow(bpm) } + private fun attachLyricsToPitches( + tuoList: List = sharedScreenModel.tuoList.value, + stanzaNumber: Int = sharedScreenModel.stanza.value.takeIf { it > 0 } ?: 1, + stepTicks: Int = 60, + ) { + pitches.removeAll { it.metaType == 0x05 } + + val cleanRegex = Regex("(\\d+\\.)+") + val validTuos = if (tuoList.size > 1 && tuoList.first().pTemplate.template.isEmpty()) { + tuoList.drop(1) + } else { + tuoList + } + var lyricsAttachedCount = 0 + + validTuos.forEachIndexed { i, tuo -> + val calculatedTick = i * stepTicks + + val rawLines = tuo.getSingleSyllable(stanzaNumber) + val cleanText = rawLines.firstOrNull() + ?.replace(cleanRegex, "") + ?.trim() ?: "" + +// println("Voici ${tuo.lyricsAsMultiString(stanzaNumber)}") + if (cleanText.isNotBlank()) { + val bytes = cleanText.toByteArray(Charsets.UTF_8) + val lyricMeta = MidiPitch( + voiceNumber = 1, + tick = calculatedTick, + metaType = 0x05, + metaBytes = bytes, + metaByteSize = bytes.size + ) + pitches.add(lyricMeta) + lyricsAttachedCount++ + +// println(String.format("TUO #%-3d | Tick: %-5d | Syllable: [%s] -> ATTACHÉ", +// i, calculatedTick, cleanText)) + } + } + } + private fun loadN(voiceNumber: Int, line: String, templateComments: MutableList) { var midiDuration = 0 var nextDuration = 0 diff --git a/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt b/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt index 8b7f301..40c38e0 100644 --- a/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt +++ b/composeApp/src/desktopMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt @@ -532,7 +532,20 @@ actual class FMediaPlayer actual constructor( navigationJob = playerScope.launch(Dispatchers.Default) { sharedScreenModel.activeIndex.collect { currentIndex -> // println("bpm:$targetBpm _ ${sequencer?.tempoInBPM}| Index en mémoire : $availableIndices") -// println("i $currentIndex ") + //println("i $currentIndex ${sequencer!!.tickPosition}") + if (currentIndex < 0) return@collect + val tick = currentIndex * 60L + val tuoList = sharedScreenModel.tuoList.value + val validTuos = if (tuoList.size > 1 && tuoList.first().pTemplate.template.isEmpty()) { + tuoList.drop(1) + } else { + tuoList + } + val currentTuo = validTuos.getOrNull(currentIndex) + val currentStanza = sharedScreenModel.stanza.value.coerceAtLeast(1) + val lyrics = currentTuo?.getSingleSyllable(currentStanza)?.joinToString(" ") ?: "" + + println("Grille #%-3d | Tick: %-5d | Parole: [%s]".format(currentIndex, tick, lyrics)) if (sequencer?.isRunning == true) { if (Math.abs(sequencer!!.tempoInBPM - targetBpm) > 0.1) {