From 78876d7ec0e72cd244813a9a98b9a4db7fc4c1c1 Mon Sep 17 00:00:00 2001 From: Hasinjato Date: Mon, 17 Aug 2026 15:38:29 +0300 Subject: [PATCH] '<' '>' to transpose audio & chords; "Transpose to C": to convert note to C scale --- .../kotlin/mg/dot/feufaro/midi/MidiPitch.kt | 2 +- .../mg/dot/feufaro/solfa/HarmonicAnalyser.kt | 8 +- .../kotlin/mg/dot/feufaro/solfa/Solfa.kt | 13 ++- .../mg/dot/feufaro/solfa/TimeUnitObject.kt | 8 +- .../kotlin/mg/dot/feufaro/solfa/Transpose.kt | 73 +++++++++++++++-- .../kotlin/mg/dot/feufaro/ui/DrawerUI.kt | 80 ++++++++++++++----- .../feufaro/viewmodel/SharedScreenModel.kt | 6 ++ .../dot/feufaro/viewmodel/SolfaScreenModel.kt | 4 +- 8 files changed, 156 insertions(+), 38 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiPitch.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiPitch.kt index acb9c5e..942a4c0 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiPitch.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/midi/MidiPitch.kt @@ -32,7 +32,7 @@ data class MidiPitch ( metaType = type } fun setNote(note: String, theDuration: Int) { - val midiPitch = Transpose.transposeToMidi(note, key, "C") + val midiPitch = Transpose.transposeToMidi(note, "C", key) velocity = if (midiPitch < 0) { 0 } else { diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/HarmonicAnalyser.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/HarmonicAnalyser.kt index a23ae6b..b11f254 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/HarmonicAnalyser.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/HarmonicAnalyser.kt @@ -102,17 +102,19 @@ object HarmonicAnalyzer { return degreeLine } - fun analyzeChordName(notes: Map, songKey: String = "C"): String { + fun analyzeChordName(notes: Map, songKey: String = "C", transposeInt: Int = 0): String { val keyOffset = keyOffsets[songKey] ?: 0 + val totalOffset = ((keyOffset + transposeInt) % 12 + 12) % 12 val slices = splitSlices(notes) val names = slices.map { slice -> val cleaned = cleanNotes(slice) val match = findChordMatch(cleaned) ?: return@map "" - val absoluteRoot = (keyOffset + match.root) % 12 + + val absoluteRoot = ((totalOffset + match.root) % 12 + 12) % 12 val chordName = noteNames[absoluteRoot] + match.quality if (match.bass != -1 && match.bass != match.root) { - val absoluteBass = (keyOffset + match.bass) % 12 + val absoluteBass = ((totalOffset + match.bass) % 12 + 12) % 12 "$chordName/${noteNames[absoluteBass]}" } else { chordName 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 0913b90..b1aafde 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt @@ -264,7 +264,7 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository private val prefs: Settings = provideSettings() - fun generateMidiFile(bpm: Float, outputPath: String = "whawyd3.mid") { + 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 })) @@ -276,12 +276,14 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository 3 to prefs.getInt("voice_instrument", 0), 4 to prefs.getInt("voice_instrument", 0), ) + val originalKey = sharedScreenModel.songKey.value + val transposedKey = Transpose.transposeKey(baseKey = originalKey, interval = transpI) midiWriter.process( pitchesSorted, sharedScreenModel.getFullMarkers(), initialBpm = bpm, instruments, - initialKey = sharedScreenModel.songKey.value + initialKey = transposedKey ) midiWriter.save(outputPath) } @@ -2086,7 +2088,12 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository midiPitch.currentVoiceNumber(voiceNumber) if (voiceNumber == 1) { - midiPitch.initKey(meta["C"] ?: "C", meta["C"]?.endsWith("m") ?: false) + val rawKey = sharedScreenModel.songKey.value.ifEmpty { meta["C"] ?: "C" } + val interval = sharedScreenModel.transpositionInterval.value + val isMinor = rawKey.endsWith("m", ignoreCase = true) + + val transposedKey = Transpose.transposeKey(rawKey, interval) + midiPitch.initKey(transposedKey, isMinor) pushMidi("meta") if (midiPitch.initMeasure(meta["m"] ?: "4/4")) { pushMidi("measure") diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TimeUnitObject.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TimeUnitObject.kt index f70d1b1..1c652ef 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TimeUnitObject.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TimeUnitObject.kt @@ -959,8 +959,10 @@ fun LazyVerticalGridTUO( val degreeName = remember(chordMap) { if (chordMap.isNotEmpty()) HarmonicAnalyzer.analyzeDegree(chordMap) else null } - val chordName = remember(chordMap) { - if (chordMap.isNotEmpty()) HarmonicAnalyzer.analyzeChordName(chordMap, sharedScreenModel.songKey.value) else null + val appliedInterval by sharedScreenModel.transpositionInterval.collectAsState() + val songKey = sharedScreenModel.songKey.value + val chordName = remember(chordMap, songKey, appliedInterval) { + if (chordMap.isNotEmpty()) HarmonicAnalyzer.analyzeChordName(chordMap, songKey, appliedInterval) else null } Column( @@ -1104,7 +1106,7 @@ fun LazyVerticalGridTUO( ) } ) { - val currentInterval by sharedScreenModel.transpositionInterval.collectAsState() + val currentInterval by sharedScreenModel.transpToCInt.collectAsState() TimeUnitComposable( tuo = oneTUO, stanzaNumber = currentStanza, diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Transpose.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Transpose.kt index 14c0ec5..e4440e8 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Transpose.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Transpose.kt @@ -200,22 +200,81 @@ class Transpose { return note } val noteNaked = regexFound.groupValues[1] + val originalOctaveStr = regexFound.groupValues[2] + val rest = regexFound.groupValues[3] val noteNum = noteToNumber.indexOf(noteNaked) val oldKey = keyToNumber.indexOf(fromKey) val newKey = keyToNumber.indexOf(toKey) - var newNoteNum = noteNum - newKey + oldKey + alter - var suffix = "" + if (oldKey == -1 || newKey == -1) return note + + var keyShift = newKey - oldKey + if (keyShift > 6) { + keyShift -= 12 + } else if (keyShift < -6) { + keyShift += 12 + } + + var newNoteNum = noteNum + keyShift + alter + var octaveShift = 0 val noteSize = noteToNumber.size + while (newNoteNum < 0) { - newNoteNum += noteSize - suffix += "," + newNoteNum += noteSize + octaveShift -= 1 } while (newNoteNum >= noteSize) { newNoteNum -= noteSize - suffix += "'" + octaveShift += 1 } - val newNote = noteToNumber[newNoteNum] + regexFound.groupValues[2] + suffix - return simplifyNote(newNote) + regexFound.groupValues[3] + + val originalOctaveVal = octaveToInt(originalOctaveStr) + val finalOctaveVal = originalOctaveVal + octaveShift + val finalOctaveStr = intToOctave(finalOctaveVal) + + val transposedNote = noteToNumber[newNoteNum] + return transposedNote + finalOctaveStr + rest + } + fun octaveToInt(octaveStr: String): Int { + var valOctave = 0 + for (char in octaveStr) { + when (char) { + '\'', '¹' -> valOctave += 1 + ',', '₁' -> valOctave -= 1 + '²' -> valOctave += 2 + '₂' -> valOctave -= 2 + '³' -> valOctave += 3 + '₃' -> valOctave -= 3 + '⁴' -> valOctave += 4 + '₄' -> valOctave -= 4 + } + } + return valOctave + } + + fun intToOctave(octaveVal: Int): String { + val index = octaveVal + 4 + return if (index in octaveSigns.indices) { + octaveSigns[index] + } else if (octaveVal > 0) { + "'".repeat(octaveVal) + } else if (octaveVal < 0) { + ",".repeat(-octaveVal) + } else { + "" + } + } + + fun transposeKey(baseKey: String, interval: Int): String { + if (baseKey.isBlank()) return "C" + + val isMinor = baseKey.endsWith("m", ignoreCase = true) + val rootKey = if (isMinor) baseKey.dropLast(1) else baseKey + val baseIndex = keyToNumber.indexOfFirst { it.equals(rootKey, ignoreCase = true) } + if (baseIndex == -1) return baseKey + val newIndex = (baseIndex + interval).mod(12) + val newRootKey = keyToNumber[newIndex] + + return if (isMinor) "${newRootKey}m" else newRootKey } @OptIn(ExperimentalMaterial3Api::class) // Ajoutez cette annotation pour utiliser ExposedDropdownMenuBox @Composable diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt index 4c3b6e2..68c0163 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt @@ -468,7 +468,8 @@ fun MainScreenWithDrawer( } } }, actions = { - var tempInterval by remember(fileContent) { mutableStateOf(0) } + val appliedInterval by sharedScreenModel.transpositionInterval.collectAsState() + var tempInterval by remember(fileContent, appliedInterval) { mutableStateOf(appliedInterval) } var isEyeVisible by remember { mutableStateOf(false) } val isMinor = songKey.endsWith("m", ignoreCase = true) val cleanSongKey = if (isMinor) songKey.dropLast(1) else songKey @@ -476,10 +477,9 @@ fun MainScreenWithDrawer( 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() val isPendingChange = tempInterval != appliedInterval val isCurrentlyTransposed = appliedInterval != 0 - + val formattedKey = if (isMinor) "${tempUiKey}m" else tempUiKey Box( modifier = Modifier.padding(end = 16.dp), contentAlignment = Alignment.TopCenter @@ -489,11 +489,11 @@ fun MainScreenWithDrawer( verticalAlignment = Alignment.CenterVertically ) { Text( - text = if(isMinor) "${tempUiKey}m" else tempUiKey, + text = formattedKey, style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Black, textAlign = TextAlign.Center, - color = if (isCurrentlyTransposed && !isPendingChange) Color(0xFFFFD700) else Color.White, + color = if (isCurrentlyTransposed || isPendingChange) MaterialTheme.colorScheme.tertiary else Color.White, modifier = Modifier .clickable( interactionSource = sharedInteractionSource, @@ -507,7 +507,10 @@ fun MainScreenWithDrawer( DropdownMenu( expanded = isEyeVisible, - onDismissRequest = { isEyeVisible = false }, + onDismissRequest = { + tempInterval = appliedInterval + isEyeVisible = false + }, containerColor = Color(0xFF2C3135).copy(alpha = 0.75f), shape = RoundedCornerShape(16.dp), modifier = Modifier.padding(12.dp) @@ -517,6 +520,8 @@ fun MainScreenWithDrawer( verticalArrangement = Arrangement.spacedBy(8.dp) ) { /* < = > */ + val isOriginalKeyC = cleanSongKey.equals("C", ignoreCase = true) + val isTransposedToC = tempUiKey.equals("C", ignoreCase = true) && !isOriginalKeyC Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly, @@ -549,11 +554,11 @@ fun MainScreenWithDrawer( } val centralIcon = - if (isPendingChange) Icons.Filled.Check else Icons.Filled.SwapHoriz + if (isPendingChange && !isTransposedToC) Icons.Filled.Check else Icons.Filled.SwapHoriz val centralTint = if (isPendingChange) MaterialTheme.colorScheme.tertiary else Color.White val tooltipText = - if (isPendingChange) "Transposer en $tempUiKey" else if (isCurrentlyTransposed) "Revenir en $songKey" else "Transposer" + if (isPendingChange) "Écoutez en $tempUiKey" else if (isCurrentlyTransposed) "Revenir en $songKey" else "" TooltipBox( positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), @@ -568,13 +573,18 @@ fun MainScreenWithDrawer( IconButton( modifier = Modifier.size(36.dp), onClick = { - if (!isPendingChange && isCurrentlyTransposed) { + if (isPendingChange) { + sharedScreenModel.setTranspositionInterval(tempInterval) + sharedScreenModel.stopMidi() + sharedScreenModel.seekTo(0f) + solfaScreenModel.loadFromFile(sharedScreenModel.activeFilePath.value) + } else if (isCurrentlyTransposed) { tempInterval = 0 sharedScreenModel.setTranspositionInterval(0) - } else { - sharedScreenModel.setTranspositionInterval(tempInterval) + sharedScreenModel.stopMidi() + sharedScreenModel.seekTo(0f) + solfaScreenModel.loadFromFile(sharedScreenModel.activeFilePath.value) } - solfaScreenModel.loadFromFile(sharedScreenModel.activeFilePath.value) isEyeVisible = false } ) { @@ -613,22 +623,35 @@ fun MainScreenWithDrawer( val cIndex = keysOrder.indexOf("C") val intervalToC = if (cIndex != -1) cIndex - songKeyIndex else 0 - + val buttonText = if (isTransposedToC) "Revenir en $songKey" else "Transposer en C" + val tooltipTextC = if (isTransposedToC) "Revenir en clé $songKey" else "Écrire les notes en Do (C)" TooltipBox( positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), tooltip = { PlainTooltip( containerColor = Color.DarkGray, contentColor = Color.White - ) { Text(text = "Transposer en Do (C)", fontSize = 12.sp) } + ) { Text(text = tooltipTextC, fontSize = 12.sp) } }, state = rememberTooltipState() ) { OutlinedButton( onClick = { - tempInterval = intervalToC - sharedScreenModel.setTranspositionInterval(intervalToC) - solfaScreenModel.loadFromFile(sharedScreenModel.activeFilePath.value) + if (isTransposedToC) { + tempInterval = 0 + sharedScreenModel.setTranspositionInterval(0) + sharedScreenModel.setTranspToC(0) + sharedScreenModel.stopMidi() + sharedScreenModel.seekTo(0f) + } else { + val currentKey = sharedScreenModel.songKey.value + val intervalC = getIntervalToC(currentKey) + + tempInterval = -intervalC + sharedScreenModel.setTranspToC(intervalC) + sharedScreenModel.stopMidi() + sharedScreenModel.seekTo(0f) + } isEyeVisible = false }, modifier = Modifier.fillMaxWidth().height(36.dp), @@ -642,7 +665,7 @@ fun MainScreenWithDrawer( ) ) { Text( - text = "Transposer en C", + text = buttonText, fontSize = 12.sp, fontWeight = FontWeight.Bold ) @@ -799,9 +822,10 @@ fun MainScreenWithDrawer( ) if (!savedPath.isNullOrEmpty()) { + val transpositionInterval = sharedScreenModel.transpositionInterval.value val bpm = sharedScreenModel.getBpmFlow().value ?: 120f - solfaScreenModel.generateMidiFile(bpm, savedPath) + solfaScreenModel.generateMidiFile(bpm, savedPath, transpositionInterval) println("Fichier MIDI enregistré avec succès sous : $savedPath") } } catch (e: Exception) { @@ -1138,6 +1162,24 @@ fun MainScreenWithDrawer( } }) } +private fun getIntervalToC(key: String): Int { + val cleanKey = key.removeSuffix("m").trim().uppercase() + return when (cleanKey) { + "C" -> 0 + "DB" -> 1 + "D" -> 2 + "EB" -> 3 + "E" -> 4 + "F" -> 5 + "GB" -> 6 + "G" -> 7 + "AB" -> 8 + "A" -> 9 + "BB" -> 10 + "B" -> 11 + else -> 0 + } +} @Composable private fun MyFAB( diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt index 1ce3df2..8c8dc73 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt @@ -290,6 +290,11 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode transpositionInterval.value = interval } + val transpToCInt = MutableStateFlow(0) + fun setTranspToC(count: Int) { + transpToCInt.value = count + } + private val _synchronizedSYllables = MutableStateFlow>(emptyList()) val synchronizedSyllables: StateFlow> = _synchronizedSYllables.asStateFlow() @@ -728,6 +733,7 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode _sourceModeState.value = false _harmonyView.value = false setTranspositionInterval(0) + setTranspToC(0) setSilentDurBefr(0) _midiMarkersList.value = emptyList() _tuoTimestamps.value = emptyList() diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SolfaScreenModel.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SolfaScreenModel.kt index 5c3723a..d6676e2 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SolfaScreenModel.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SolfaScreenModel.kt @@ -55,7 +55,7 @@ class SolfaScreenModel( solfa.createNewSolfa(metadata) } } - fun generateMidiFile(bpm: Float, path: String) { - solfa.generateMidiFile(bpm, path) + fun generateMidiFile(bpm: Float, path: String, transpI: Int) { + solfa.generateMidiFile(bpm, path, transpI) } } \ No newline at end of file