Implements Chords analysis to show degree&chords(click degree) on partition

This commit is contained in:
Hasinjato 2026-06-23 11:54:55 +03:00
parent ffb6a4c080
commit 4940608a9b
4 changed files with 334 additions and 5 deletions

View file

@ -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<Int, String>): List<Map<Int, String>> {
val splitRaw: Map<Int, List<String>> = 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<Int, String>): 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<Int, String>, 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<Int, String>): Map<Int, String> =
notes.mapValues { (_, raw) -> cleanOneNote(raw) }
private fun printTable(cleanedNotes: Map<Int, String>) {
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<Int, String>,
slices: List<Map<Int, String>>
) {
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<Int, String>): 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<Int>): 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<Int>,
val mandatory: Set<Int>,
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)
}

View file

@ -587,6 +587,8 @@ fun LazyVerticalGridTUO(
} }
val editMode by sharedScreenModel.modeEditor.collectAsState() val editMode by sharedScreenModel.modeEditor.collectAsState()
val showFullChord by sharedScreenModel.harmonyView.collectAsState()
var toggleWithDegreeAndChord by remember { mutableStateOf (false) }
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
Column( 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()) { Row(modifier = Modifier.fillMaxWidth()) {
measureTUOs.forEachIndexed { indexInMeasure, oneTUO -> measureTUOs.forEachIndexed { indexInMeasure, oneTUO ->
val globalIndex = (measureIndex * gridColumnCount) + indexInMeasure val globalIndex = (measureIndex * gridColumnCount) + indexInMeasure

View file

@ -62,7 +62,7 @@ fun SimpleDrawerContent(
var playListExpanded by remember { mutableStateOf(false) } var playListExpanded by remember { mutableStateOf(false) }
val editMode by sharedScreenModel.modeEditor.collectAsState() val editMode by sharedScreenModel.modeEditor.collectAsState()
var state1 by remember { mutableStateOf(false) } val chordView by sharedScreenModel.harmonyView.collectAsState()
var state2 by remember { mutableStateOf(false) } var state2 by remember { mutableStateOf(false) }
ModalDrawerSheet( ModalDrawerSheet(
modifier = Modifier.width(300.dp) modifier = Modifier.width(300.dp)
@ -85,14 +85,16 @@ fun SimpleDrawerContent(
sharedScreenModel.toggleEditorMode(newState) sharedScreenModel.toggleEditorMode(newState)
}, },
thumbIcon = Icons.Default.Edit, thumbIcon = Icons.Default.Edit,
label = "Mode Edit" label = "Edition"
) )
CustomSwitchItem( CustomSwitchItem(
checked = state1, checked = chordView,
onCheckedChange = { state1 = it }, onCheckedChange = { newStateMod ->
sharedScreenModel.toggleHarmonyMode(newStateMod)
},
thumbIcon = Icons.Default.MusicNote, thumbIcon = Icons.Default.MusicNote,
label = "Analyse chords", label = "Analyse d\'accords"
) )
Column( Column(
modifier = Modifier.padding(5.dp), modifier = Modifier.padding(5.dp),

View file

@ -226,6 +226,12 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
fun toggleEditorMode(enabled: Boolean) { fun toggleEditorMode(enabled: Boolean) {
_modeEditor.value = enabled _modeEditor.value = enabled
} }
private val _harmonyView = MutableStateFlow(false)
val harmonyView: StateFlow<Boolean> = _harmonyView
fun toggleHarmonyMode(enabled: Boolean) {
_harmonyView.value = enabled
}
private val _isFullScreen = MutableStateFlow(false) private val _isFullScreen = MutableStateFlow(false)
val isFullScreen = _isFullScreen.asStateFlow() val isFullScreen = _isFullScreen.asStateFlow()
@ -658,6 +664,7 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
_expandedFAB.value = false _expandedFAB.value = false
_showSearchMenu.value = false _showSearchMenu.value = false
_editModeState.value = false _editModeState.value = false
_harmonyView.value = false
setTranspositionInterval(0) setTranspositionInterval(0)
_midiMarkersList.value = emptyList() _midiMarkersList.value = emptyList()
_tuoTimestamps.value = emptyList() _tuoTimestamps.value = emptyList()