Compare commits

..

No commits in common. "4940608a9b003d8638dcac1f8b5061eaa2c955d3" and "c06053c5dc7a7cd7d7e8229de49b621654468964" have entirely different histories.

5 changed files with 9 additions and 441 deletions

View file

@ -1,119 +1,16 @@
package mg.dot.feufaro.ui
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
@Composable
actual fun MyVerticalScrollbar(
actual fun MyVerticalScrollbar (
modifier: Modifier,
scrollState: ScrollState?,
lazyListState: LazyListState?,
content: @Composable () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val density = LocalDensity.current
var containerHeight by remember { mutableStateOf(1f) }
val thumbHeight = 75.dp
val scrollPercentage by remember(scrollState, lazyListState) {
derivedStateOf {
if (scrollState != null && scrollState.maxValue > 0) {
scrollState.value.toFloat() / scrollState.maxValue
} else if (lazyListState != null) {
val layoutInfo = lazyListState.layoutInfo
val totalItems = layoutInfo.totalItemsCount
if (totalItems == 0) 0f else {
val firstVisibleItem = lazyListState.firstVisibleItemIndex
firstVisibleItem.toFloat() / totalItems
}
} else {
0f
}
}
}
Box(
modifier = modifier.onGloballyPositioned { coordinates ->
containerHeight = coordinates.size.height.toFloat()
}
) {
content()
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight()
.width(15.dp)
.background(Color.Transparent)
) {
val thumbHeightPx = with(density) { thumbHeight.toPx() }
val maxOffset = containerHeight - thumbHeightPx
val currentThumbOffset = (scrollPercentage * maxOffset).coerceIn(0f, maxOffset.coerceAtLeast(0f))
Box(
modifier = Modifier
.offset(y = with(density) { currentThumbOffset.toDp() })
.width(10.dp)
.height(thumbHeight)
.align(Alignment.TopEnd)
.clip(RoundedCornerShape(5.dp))
.background(
brush = Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.secondary.copy(alpha = 1f),
MaterialTheme.colorScheme.secondary.copy(alpha = 0.75f),
MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f)
)
)
)
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
coroutineScope.launch {
if (maxOffset > 0) {
val scrollRatio = delta / maxOffset
if (scrollState != null) {
scrollState.scrollBy(scrollRatio * scrollState.maxValue)
} else if (lazyListState != null) {
val totalItems = lazyListState.layoutInfo.totalItemsCount
lazyListState.scrollBy(scrollRatio * totalItems * 120f)
}
}
}
}
)
) {
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 4.dp)
.size(6.dp)
.blur(1.dp)
.background(MaterialTheme.colorScheme.background, RoundedCornerShape(3.dp))
)
}
}
}
){
content()
}

View file

@ -1,261 +0,0 @@
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,8 +587,6 @@ 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(
@ -779,63 +777,6 @@ 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

View file

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

View file

@ -226,12 +226,6 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
fun toggleEditorMode(enabled: Boolean) {
_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)
val isFullScreen = _isFullScreen.asStateFlow()
@ -664,7 +658,6 @@ 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()