Initial commit: InTono — app KMP per l'apprendimento musicale

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>
This commit is contained in:
lucianoandClaude Fable 5 committed 2026-07-22 10:06:59 +02:00
commit 21ad969b10
245 files changed
+28963

No files matched your search

@@ -0,0 +1,199 @@
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()
}