Compare commits
2 commits
c06053c5dc
...
4940608a9b
| Author | SHA1 | Date | |
|---|---|---|---|
| 4940608a9b | |||
| ffb6a4c080 |
5 changed files with 441 additions and 9 deletions
|
|
@ -1,16 +1,119 @@
|
|||
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.runtime.Composable
|
||||
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.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
|
||||
){
|
||||
content()
|
||||
) {
|
||||
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))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<Boolean> = _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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue