App Kotlin Multiplatform (Android/iOS/Desktop) con Compose Multiplatform: controllo intonazione (YIN), pratica guidata, metronomo, ear training, sfide, scansione spartiti via AI, spartiti bundled e pentagramma con glifi tipografici, travature, battute e spaziatura proporzionale. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
200 lines
7.5 KiB
Kotlin
200 lines
7.5 KiB
Kotlin
package com.notemusicali.ui.components
|
|
|
|
import androidx.compose.ui.geometry.Offset
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.graphics.Path
|
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
|
import androidx.compose.ui.text.TextMeasurer
|
|
import androidx.compose.ui.text.TextStyle
|
|
import androidx.compose.ui.text.drawText
|
|
import androidx.compose.ui.text.font.FontFamily
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import com.notemusicali.music.NoteDuration
|
|
|
|
/** Primitive di disegno del pentagramma condivise da tutte le viste. */
|
|
|
|
private fun unit(lineSpacing: Float) = lineSpacing * 0.09f
|
|
|
|
internal fun staffHeadHalfWidth(lineSpacing: Float): Float = 7.5f * unit(lineSpacing)
|
|
|
|
/** Testa "a pera": piena per <= QUARTER, contorno per WHOLE/HALF. */
|
|
internal fun DrawScope.drawNoteHeadPath(
|
|
cx: Float, cy: Float,
|
|
lineSpacing: Float,
|
|
duration: NoteDuration,
|
|
color: Color,
|
|
) {
|
|
val u = unit(lineSpacing)
|
|
val path = Path().apply {
|
|
moveTo(cx - 7.4f * u, cy + 2.6f * u)
|
|
quadraticTo(cx - 5.4f * u, cy - 4.8f * u, cx + 2.4f * u, cy - 4.9f * u)
|
|
quadraticTo(cx + 7.6f * u, cy - 4.9f * u, cx + 7.4f * u, cy - 0.9f * u)
|
|
quadraticTo(cx + 6.8f * u, cy + 4.9f * u, cx - 1.6f * u, cy + 5.0f * u)
|
|
quadraticTo(cx - 7.4f * u, cy + 5.0f * u, cx - 7.4f * u, cy + 2.6f * u)
|
|
close()
|
|
}
|
|
val hollow = duration == NoteDuration.WHOLE || duration == NoteDuration.HALF
|
|
if (hollow) drawPath(path, color, style = Stroke(width = lineSpacing * 0.16f))
|
|
else drawPath(path, color)
|
|
}
|
|
|
|
/** Gambo attaccato al bordo della testa; ritorna l'estremità (null per la semibreve). */
|
|
internal fun DrawScope.drawStem(
|
|
cx: Float, cy: Float,
|
|
lineSpacing: Float,
|
|
stemUp: Boolean,
|
|
duration: NoteDuration,
|
|
color: Color,
|
|
tipY: Float? = null,
|
|
): Offset? {
|
|
if (duration == NoteDuration.WHOLE) return null
|
|
val u = unit(lineSpacing)
|
|
val stemH = lineSpacing * 3.2f
|
|
val x = if (stemUp) cx + 7.0f * u else cx - 7.0f * u
|
|
val endY = tipY ?: if (stemUp) cy - stemH else cy + stemH
|
|
drawLine(color, Offset(x, cy), Offset(x, endY), lineSpacing * 0.14f)
|
|
return Offset(x, endY)
|
|
}
|
|
|
|
/** Code a goccia riempite (1-3), specchiate per gambo in giù. */
|
|
internal fun DrawScope.drawCurvedFlags(
|
|
stemTip: Offset,
|
|
stemUp: Boolean,
|
|
duration: NoteDuration,
|
|
lineSpacing: Float,
|
|
color: Color,
|
|
) {
|
|
val count = when (duration) {
|
|
NoteDuration.EIGHTH -> 1
|
|
NoteDuration.SIXTEENTH -> 2
|
|
NoteDuration.THIRTY_SECOND -> 3
|
|
else -> return
|
|
}
|
|
val u = unit(lineSpacing)
|
|
val dir = if (stemUp) 1f else -1f
|
|
for (i in 0 until count) {
|
|
val oy = stemTip.y + dir * i * 5.5f * u
|
|
val path = Path().apply {
|
|
moveTo(stemTip.x, oy)
|
|
cubicTo(
|
|
stemTip.x + 7f * u, oy + dir * 4f * u,
|
|
stemTip.x + 10f * u, oy + dir * 11f * u,
|
|
stemTip.x + 6.5f * u, oy + dir * 21f * u,
|
|
)
|
|
cubicTo(
|
|
stemTip.x + 10.5f * u, oy + dir * 12f * u,
|
|
stemTip.x + 8.5f * u, oy + dir * 6f * u,
|
|
stemTip.x, oy + dir * 6f * u,
|
|
)
|
|
close()
|
|
}
|
|
drawPath(path, color)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gruppo travato: gambi allineati a una travatura inclinata (pendenza clampata
|
|
* a una lineSpacing) più la seconda barra tra coppie di semicrome.
|
|
*/
|
|
internal fun DrawScope.drawBeamedGroup(
|
|
xs: List<Float>, ys: List<Float>,
|
|
durations: List<NoteDuration>,
|
|
stemUp: Boolean,
|
|
lineSpacing: Float,
|
|
color: Color,
|
|
) {
|
|
if (xs.size < 2) return
|
|
val u = unit(lineSpacing)
|
|
val stemH = lineSpacing * 3.2f
|
|
val dir = if (stemUp) -1f else 1f
|
|
val attachX = xs.map { if (stemUp) it + 7.0f * u else it - 7.0f * u }
|
|
|
|
// Estremità ideali, poi pendenza clampata; la travatura deve superare tutte le teste
|
|
var y0 = ys.first() + dir * stemH
|
|
var y1 = ys.last() + dir * stemH
|
|
if (y1 - y0 > lineSpacing) y1 = y0 + lineSpacing
|
|
if (y0 - y1 > lineSpacing) y0 = y1 + lineSpacing
|
|
val minClearance = lineSpacing * 2.2f
|
|
ys.forEachIndexed { i, noteY ->
|
|
val t = (attachX[i] - attachX.first()) / (attachX.last() - attachX.first())
|
|
val beamY = y0 + (y1 - y0) * t
|
|
val clearance = (noteY - beamY) * -dir
|
|
if (clearance < minClearance) {
|
|
val shift = (minClearance - clearance) * dir
|
|
y0 += shift; y1 += shift
|
|
}
|
|
}
|
|
|
|
fun beamYAt(x: Float) = y0 + (y1 - y0) * (x - attachX.first()) / (attachX.last() - attachX.first())
|
|
|
|
// Gambi fino alla travatura
|
|
xs.indices.forEach { i ->
|
|
drawLine(color, Offset(attachX[i], ys[i]), Offset(attachX[i], beamYAt(attachX[i])), lineSpacing * 0.14f)
|
|
}
|
|
|
|
// La travatura si ispessisce VERSO le teste: in giù se i gambi sono in su
|
|
val thicken = lineSpacing * 0.45f * (if (stemUp) 1f else -1f)
|
|
fun beamSegment(fromX: Float, toX: Float, offset: Float) {
|
|
val path = Path().apply {
|
|
moveTo(fromX, beamYAt(fromX) + offset)
|
|
lineTo(toX, beamYAt(toX) + offset)
|
|
lineTo(toX, beamYAt(toX) + offset + thicken)
|
|
lineTo(fromX, beamYAt(fromX) + offset + thicken)
|
|
close()
|
|
}
|
|
drawPath(path, color)
|
|
}
|
|
// Barra primaria su tutto il gruppo
|
|
beamSegment(attachX.first(), attachX.last(), 0f)
|
|
// Barra secondaria tra coppie adiacenti di semicrome (o più corte)
|
|
val secondOffset = lineSpacing * 0.75f * (if (stemUp) 1f else -1f)
|
|
for (i in 0 until xs.size - 1) {
|
|
val bothShort = StaffLayout.quarterUnits(durations[i]) <= 0.25 && StaffLayout.quarterUnits(durations[i + 1]) <= 0.25
|
|
if (bothShort) beamSegment(attachX[i], attachX[i + 1], secondOffset)
|
|
}
|
|
}
|
|
|
|
/** Stanghetta di battuta. */
|
|
internal fun DrawScope.drawBarline(x: Float, staffBottom: Float, lineSpacing: Float, color: Color) {
|
|
drawLine(color, Offset(x, staffBottom - 4 * lineSpacing), Offset(x, staffBottom), lineSpacing * 0.1f)
|
|
}
|
|
|
|
/** Cifre del tempo (es. 4/4) dopo la chiave. Ritorna la larghezza occupata. */
|
|
internal fun DrawScope.drawTimeSignature(
|
|
textMeasurer: TextMeasurer,
|
|
beats: Int, beatType: Int,
|
|
x: Float, staffBottom: Float, lineSpacing: Float,
|
|
color: Color,
|
|
): Float {
|
|
val style = TextStyle(
|
|
color = color,
|
|
fontSize = (lineSpacing * 1.9f).toSp(),
|
|
fontWeight = FontWeight.Bold,
|
|
fontFamily = FontFamily.Serif,
|
|
)
|
|
val top = textMeasurer.measure(beats.toString(), style)
|
|
val bottom = textMeasurer.measure(beatType.toString(), style)
|
|
drawText(top, topLeft = Offset(x, staffBottom - 4f * lineSpacing + (2f * lineSpacing - top.size.height) / 2f))
|
|
drawText(bottom, topLeft = Offset(x, staffBottom - 2f * lineSpacing + (2f * lineSpacing - bottom.size.height) / 2f))
|
|
return maxOf(top.size.width, bottom.size.width).toFloat()
|
|
}
|
|
|
|
/** Chiave a piena opacità. Ritorna la larghezza occupata. */
|
|
internal fun DrawScope.drawClef(
|
|
textMeasurer: TextMeasurer,
|
|
useTreble: Boolean,
|
|
x: Float, staffBottom: Float, lineSpacing: Float,
|
|
color: Color,
|
|
): Float {
|
|
val style = TextStyle(color = color, fontSize = (lineSpacing * 3.4f).toSp(), fontFamily = FontFamily.Serif)
|
|
val result = textMeasurer.measure(StaffLayout.clefSymbol(useTreble), style)
|
|
val y = if (useTreble) {
|
|
(staffBottom - lineSpacing) - result.size.height * 0.42f
|
|
} else {
|
|
(staffBottom - 3 * lineSpacing) - result.size.height * 0.35f
|
|
}
|
|
drawText(result, topLeft = Offset(x, y))
|
|
return result.size.width.toFloat()
|
|
}
|