diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/HarmonicAnalyser.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/HarmonicAnalyser.kt new file mode 100644 index 0000000..3aa3a25 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/HarmonicAnalyser.kt @@ -0,0 +1,261 @@ +package mg.dot.feufaro.solfa + + +object HarmonicAnalyzer { + private val solfaToSemitones = mapOf( + "d" to 0, "D" to 1, + "r" to 2, "R" to 3, + "m" to 4, + "f" to 5, "F" to 6, + "s" to 7, "S" to 8, + "l" to 9, "T" to 10, + "t" to 11 + ) + + private val semitoneToDegree = mapOf( + 0 to "I", 1 to "bII", + 2 to "II", 3 to "bIII", + 4 to "III", + 5 to "IV", 6 to "#IV", + 7 to "V", 8 to "bVI", + 9 to "VI", 10 to "bVII", + 11 to "VII" + ) + + private val noteNames = listOf( + "C", "C#", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B" + ) + + private val keyOffsets = mapOf( + "C" to 0, "C#" to 1, "Db" to 1, "D" to 2, "Eb" to 3, + "E" to 4, "F" to 5, "F#" to 6, "Gb" to 6, "G" to 7, + "Ab" to 8, "A" to 9, "Bb" to 10,"B" to 11 + ) + + private val chordDefinitions = listOf( + ChordDef("maj7", setOf(0, 4, 7, 11), setOf(4, 11), 12), + ChordDef("7", setOf(0, 4, 7, 10), setOf(4, 10), 12), + ChordDef("m7", setOf(0, 3, 7, 10), setOf(3, 10), 12), + ChordDef("dim7", setOf(0, 3, 6, 9), setOf(3, 6, 9), 12), + ChordDef("m7b5", setOf(0, 3, 6, 10), setOf(3, 6, 10), 12), + ChordDef("7sus4", setOf(0, 5, 7, 10), setOf(5, 10), 12), + ChordDef("maj7sus2", setOf(0, 2, 7, 11), setOf(2, 11), 12), + ChordDef("maj7#11", setOf(0, 4, 6, 7), setOf(4, 6), 12), + ChordDef("add9", setOf(0, 2, 4, 7), setOf(2, 4), 11), + ChordDef("madd9", setOf(0, 2, 3, 7), setOf(2, 3), 11), + ChordDef("add11", setOf(0, 4, 5, 7), setOf(4, 5), 11), + + ChordDef("", setOf(0, 4, 7), setOf(4), 8), + ChordDef("m", setOf(0, 3, 7), setOf(3), 8), + ChordDef("dim", setOf(0, 3, 6), setOf(3, 6), 8), + ChordDef("aug", setOf(0, 4, 8), setOf(4, 8), 8), + ChordDef("sus2", setOf(0, 2, 7), setOf(2), 8), + ChordDef("sus4", setOf(0, 5, 7), setOf(5), 8), + + ChordDef("5", setOf(0, 7), setOf(7), 5), + ChordDef("", setOf(0, 4), setOf(4), 5), + ChordDef("m", setOf(0, 3), setOf(3), 5), + + ChordDef("", setOf(0), setOf(), 3) + ) + + private val MULTI_NOTE_SEPARATORS = Regex("""[•/|+ ,]+""") + + + fun splitSlices(notes: Map): List> { + val splitRaw: Map> = notes.mapValues { (_, raw) -> + val parts = raw.split(MULTI_NOTE_SEPARATORS).map { it.trim() }.filter { it.isNotEmpty() } + if (parts.isEmpty()) listOf("") else parts + } + + val sliceCount = splitRaw.values.maxOfOrNull { it.size } ?: 1 + + return (0 until sliceCount).map { idx -> + splitRaw.mapValues { (_, parts) -> + parts.getOrElse(idx) { parts.last() } + } + } + } + + fun analyzeDegree(notes: Map): String { + val slices = splitSlices(notes) + val isMulti = slices.size > 1 + + val degrees = slices.map { slice -> + val cleaned = cleanNotes(slice) + if (!isMulti) printTable(cleaned) + val match = findChordMatch(cleaned) ?: return@map "" + val rootDegree = semitoneToDegree[match.root] ?: "" + buildSlashNotation("$rootDegree${match.quality}", match, ::semitoneToDegreeStr) + } + + if (isMulti) { + printTableMulti(notes, slices) + } + + val degreeLine = degrees.filter { it.isNotEmpty() }.joinToString(" ") + + /*println("├──────────────────┤") + println("│ DEGREE: $degreeLine") + println("└──────────────────┘")*/ + + return degreeLine + } + + fun analyzeChordName(notes: Map, songKey: String = "C"): String { + val keyOffset = keyOffsets[songKey] ?: 0 + 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 chordName = noteNames[absoluteRoot] + match.quality + if (match.bass != -1 && match.bass != match.root) { + val absoluteBass = (keyOffset + match.bass) % 12 + "$chordName/${noteNames[absoluteBass]}" + } else { + chordName + } + } + + return names.filter { it.isNotEmpty() }.joinToString(" ") + } + + private fun cleanOneNote(raw: String): String { + var clean = raw + .replace(Regex("[()₁₂₃₄¹²³⁴]"), "") + .replace("'", "").replace(",", "") + .replace("z", "").replace("w", "").replace("@", "") + .replace("―", "") + .replace("—", "") + .trim() + + clean = when { + clean.contains("di") -> "D" + clean.contains("ri") -> "R" + clean.contains("fi") -> "F" + clean.contains("si") -> "S" + clean.contains("ta") -> "T" + else -> clean + } + return clean + } + + private fun cleanNotes(notes: Map): Map = + notes.mapValues { (_, raw) -> cleanOneNote(raw) } + + private fun printTable(cleanedNotes: Map) { + val voiceNames = mapOf(1 to "S", 2 to "A", 3 to "T", 4 to "B") +// println("┌──────┬───────────┐") +// println("│ Voix │ Note │") +// println("├──────┼───────────┤") + (1..4).forEach { i -> + //println("│ ${voiceNames[i]} │ ${(cleanedNotes[i] ?: "").padEnd(9)} │") + } + } + + private fun printTableMulti( + raw: Map, + slices: List> + ) { + val voiceNames = mapOf(1 to "S", 2 to "A", 3 to "T", 4 to "B") + val headers = (1..slices.size).joinToString(" │ ") { "Note${it} " } +// println("┌──────┬───────────┐") +// println("│ Voix │ $headers│") +// println("├──────┼───────────┤") + (1..4).forEach { voiceIdx -> + val cols = slices.mapIndexed { _, slice -> + (slice[voiceIdx] ?: "").padEnd(5) + }.joinToString(" • ") + //println("│ ${voiceNames[voiceIdx]} │ $cols │") + } + } + + private fun findChordMatch(notes: Map): FullMatch? { + val activeNotes = notes.values + .filter { it.isNotEmpty() } + .mapNotNull { solfaToSemitones[it] } + if (activeNotes.isEmpty()) return null + + val uniqueSemitones = activeNotes.toSet() + val bassRaw = notes[4]?.trim() ?: "" + val bassSemitone = solfaToSemitones[bassRaw] ?: -1 + var bestMatch: FullMatch? = null + var bestScore = -1 + + if (bassSemitone != -1) { + val intervals = uniqueSemitones.map { (it - bassSemitone + 12) % 12 }.toSet() + val result = detectQuality(intervals) + if (result != null && result.weight >= 8) { + bestMatch = FullMatch(bassSemitone, result.quality, bassSemitone) + bestScore = result.weight + } + } + + for (rootCandidate in uniqueSemitones) { + val intervals = uniqueSemitones.map { (it - rootCandidate + 12) % 12 }.toSet() + val result = detectQuality(intervals) ?: continue + val bassBonus = if (rootCandidate == bassSemitone) 2 else 0 + val diatonicBonus = if (rootCandidate in setOf(0,2,4,5,7,9,11)) 1 else 0 + val score = result.weight + bassBonus + diatonicBonus + + if (score > bestScore) { + bestScore = score + bestMatch = FullMatch(rootCandidate, result.quality, bassSemitone) + } + } + + return bestMatch + } + + private fun detectQuality(intervals: Set): QualityResult? { + if (!intervals.contains(0)) return null + + var bestDef: ChordDef? = null + var bestScore = -1 + + for (def in chordDefinitions) { + if (!intervals.containsAll(def.mandatory)) continue + + val matchingNotes = intervals.count { it in def.structure } + val foreignNotes = intervals.count { it !in def.structure } + + val maxForeign = if (def.structure.size <= 2) 0 else 1 + if (foreignNotes > maxForeign) continue + + val score = def.baseWeight + matchingNotes - (foreignNotes * 3) + if (score > bestScore) { + bestScore = score + bestDef = def + } + } + + return if (bestDef != null) QualityResult(bestDef.name, bestScore) else null + } + + private fun semitoneToDegreeStr(semitone: Int): String = + semitoneToDegree[semitone] ?: "" + + private fun buildSlashNotation( + chordName: String, + match: FullMatch, + toStr: (Int) -> String + ): String { + return if (match.bass != -1 && match.bass != match.root) { + "$chordName/${toStr(match.bass)}" + } else { + chordName + } + } + + data class ChordDef( + val name: String, + val structure: Set, + val mandatory: Set, + val baseWeight: Int + ) + + private data class FullMatch(val root: Int, val quality: String, val bass: Int) + private data class QualityResult(val quality: String, val weight: Int) +} \ No newline at end of file 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 88fb595..caf0371 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TimeUnitObject.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TimeUnitObject.kt @@ -587,6 +587,8 @@ fun LazyVerticalGridTUO( } val editMode by sharedScreenModel.modeEditor.collectAsState() + val showFullChord by sharedScreenModel.harmonyView.collectAsState() + var toggleWithDegreeAndChord by remember { mutableStateOf (false) } val focusManager = LocalFocusManager.current Column( @@ -777,6 +779,63 @@ fun LazyVerticalGridTUO( } } } + if(showFullChord) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + measureTUOs.forEachIndexed { indexInMeasure, tuo -> + val chordMap = (1..4).mapNotNull { voice -> + val noteStr = tuo.tuNotes.getOrNull(voice)?.toString() + if (noteStr != null) voice to noteStr else null + }.toMap() + + 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 + } + + Column( + modifier = Modifier + .width(gridWidthDp / gridColumnCount) + ) { + val maxFontSize = 12.sp + val minFontSize = 10.sp + var currentFontSize by remember { mutableStateOf(maxFontSize) } + + TextButton( + onClick = { toggleWithDegreeAndChord = !toggleWithDegreeAndChord }, + contentPadding = PaddingValues(0.dp), + modifier = Modifier.height(24.dp).fillMaxWidth() + ) { + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val textToDisplay = if (toggleWithDegreeAndChord) (chordName ?: "") else (degreeName ?: "") + + Text( + text = textToDisplay, + onTextLayout = { textLayoutResult -> + if (textLayoutResult.hasVisualOverflow && currentFontSize > minFontSize) { + currentFontSize = (currentFontSize.value * 0.9f).sp + } + }, + softWrap = false, + maxLines = 1, + overflow = TextOverflow.Visible, + style = TextStyle( + color = if (!toggleWithDegreeAndChord) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary.copy(red = MaterialTheme.colorScheme.secondary.red * 0.4f, green = MaterialTheme.colorScheme.secondary.green * 0.4f, blue = MaterialTheme.colorScheme.secondary.blue * 0.4f ), + fontWeight = FontWeight.Bold, + fontSize = currentFontSize + ) + ) + } + } + } + } + } + } Row(modifier = Modifier.fillMaxWidth()) { measureTUOs.forEachIndexed { indexInMeasure, oneTUO -> val globalIndex = (measureIndex * gridColumnCount) + indexInMeasure diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/SimpleDrawerContent.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/SimpleDrawerContent.kt index 905df89..dc81696 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/SimpleDrawerContent.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/SimpleDrawerContent.kt @@ -62,7 +62,7 @@ fun SimpleDrawerContent( var playListExpanded by remember { mutableStateOf(false) } val editMode by sharedScreenModel.modeEditor.collectAsState() - var state1 by remember { mutableStateOf(false) } + val chordView by sharedScreenModel.harmonyView.collectAsState() var state2 by remember { mutableStateOf(false) } ModalDrawerSheet( modifier = Modifier.width(300.dp) @@ -85,14 +85,16 @@ fun SimpleDrawerContent( sharedScreenModel.toggleEditorMode(newState) }, thumbIcon = Icons.Default.Edit, - label = "Mode Edit" + label = "Edition" ) CustomSwitchItem( - checked = state1, - onCheckedChange = { state1 = it }, + checked = chordView, + onCheckedChange = { newStateMod -> + sharedScreenModel.toggleHarmonyMode(newStateMod) + }, thumbIcon = Icons.Default.MusicNote, - label = "Analyse chords", + label = "Analyse d\'accords" ) Column( modifier = Modifier.padding(5.dp), 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 2c0b5f2..fdafa83 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt @@ -226,6 +226,12 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode fun toggleEditorMode(enabled: Boolean) { _modeEditor.value = enabled } + private val _harmonyView = MutableStateFlow(false) + val harmonyView: StateFlow = _harmonyView + fun toggleHarmonyMode(enabled: Boolean) { + _harmonyView.value = enabled + } + private val _isFullScreen = MutableStateFlow(false) val isFullScreen = _isFullScreen.asStateFlow() @@ -658,6 +664,7 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode _expandedFAB.value = false _showSearchMenu.value = false _editModeState.value = false + _harmonyView.value = false setTranspositionInterval(0) _midiMarkersList.value = emptyList() _tuoTimestamps.value = emptyList()