docs: README con screenshot e badge; rifiniture pentagramma

- README riscritto: badge CI/release, screenshot reali (emulatore),
  funzionalità, architettura, istruzioni di build
- StaffLayout.lineBreaks: capienza riga in peso (√durata) con chiusura
  preferita alla stanghetta (5 test nuovi); usato da StaffFullView
- StaffFullView: travature globali clippate per riga (origine di beat
  corretta anche su righe spezzate a metà battuta)
- Cifre del tempo centrate su asse comune (metri tipo 12/8)
- PracticeSession persiste beats/beatType: il replay dalla cronologia
  conserva il metro (2 test, retrocompatibile con le sessioni salvate)
- fix: escaping Android-style (\' e \") reso letteralmente da compose
  resources in tutte le stringhe localizzate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Fable 5 committed 2026-07-22 11:38:02 +02:00
1 parent cf795e33d8
commit c00c27e606
15 files changed
+184 -114

No files matched your search

@@ -12,7 +12,7 @@
<!-- Home cards -->
<string name="tuner_title">Pitch Check</string>
<string name="tuner_desc">Check in real time if you\'re playing the correct note</string>
<string name="tuner_desc">Check in real time if you're playing the correct note</string>
<string name="metronome_title">Metronome</string>
<string name="metronome_desc">Adjustable tempo with accents and tap tempo</string>
<string name="reference_title">Reference Note</string>
@@ -204,10 +204,10 @@
<!-- Guide -->
<string name="guide_section_tips">Tips</string>
<string name="guide_tuner_desc">Detects the note you\'re playing in real time via the microphone. Shows the note name, cent deviation, and a visual indicator: green (in tune) or red (out of tune). Ideal for string, wind instruments or voice: check if you\'re playing the right note.</string>
<string name="guide_tuner_desc">Detects the note you're playing in real time via the microphone. Shows the note name, cent deviation, and a visual indicator: green (in tune) or red (out of tune). Ideal for string, wind instruments or voice: check if you're playing the right note.</string>
<string name="guide_metronome_desc">Digital metronome with BPM adjustable from 30 to 240, available time signatures (2/4, 3/4, 4/4, 6/8), visual beat indicators and Tap Tempo. Press the microphone button to activate the built-in tuner and check your intonation while playing in time.</string>
<string name="guide_reference_desc">Generate a reference note for tuning or ear training. Quick A 440 Hz button, grid of 12 chromatic notes, and selectable octave from 2 to 7.</string>
<string name="guide_practice_desc">The heart of the app. Load a note sequence (manual, photo scan, or MusicXML) and play them one by one. The app detects the note from the microphone and advances automatically. View the staff in Note, Context, Score, or Light mode. Use \"Hide\" to practice reading from the staff. Built-in metronome to play in time.</string>
<string name="guide_practice_desc">The heart of the app. Load a note sequence (manual, photo scan, or MusicXML) and play them one by one. The app detects the note from the microphone and advances automatically. View the staff in Note, Context, Score, or Light mode. Use "Hide" to practice reading from the staff. Built-in metronome to play in time.</string>
<string name="guide_exercises_desc">Ready-made exercises with progressive difficulty: Level 1 (basic notes C-G), Level 2 (complete scales), Level 3 (scales with accidentals and melodies), Level 4 (arpeggios and leaps).</string>
<string name="guide_scales_desc">Generate scales and arpeggios in any key. Scales: Major, Minor, Harmonic, Melodic, Pentatonic, Chromatic. Arpeggios: Major, Minor, Diminished, Augmented, Seventh. Choose root note, octave, type and direction.</string>
<string name="guide_scores_desc">Browse your MusicXML file library and practice directly from imported pieces.</string>
@@ -38,7 +38,7 @@
<string name="history_title">Cronologia</string>
<string name="history_desc">Rivedi le sessioni di pratica passate</string>
<string name="guide_title">Guida</string>
<string name="guide_desc">Come usare l\'app e suggerimenti</string>
<string name="guide_desc">Come usare l'app e suggerimenti</string>
<!-- Navigation -->
<string name="back">Indietro</string>
@@ -179,7 +179,7 @@
<string name="no_recent_activity">Nessuna attivita recente</string>
<!-- History -->
<string name="no_sessions">Nessuna sessione salvata.\nCompleta un\'attività per vederla qui.</string>
<string name="no_sessions">Nessuna sessione salvata.\nCompleta un'attività per vederla qui.</string>
<string name="history_type_practice">Pratica</string>
<string name="history_type_ear_training">Ear Training</string>
<string name="history_type_challenge">Sfida</string>
@@ -205,23 +205,23 @@
<!-- Guide -->
<string name="guide_section_tips">Suggerimenti</string>
<string name="guide_tuner_desc">Rileva in tempo reale la nota che stai suonando tramite il microfono. Mostra il nome della nota, la deviazione in cents e un indicatore visivo verde (intonato) o rosso (stonato). Ideale per strumenti a corda, a fiato o voce: verifica se stai suonando la nota giusta.</string>
<string name="guide_metronome_desc">Metronomo digitale con BPM regolabile da 30 a 240, tempi disponibili (2/4, 3/4, 4/4, 6/8), indicatori visivi dei battiti e funzione Tap Tempo. Premi il pulsante microfono per attivare il tuner integrato e verificare l\'intonazione mentre suoni a tempo.</string>
<string name="guide_metronome_desc">Metronomo digitale con BPM regolabile da 30 a 240, tempi disponibili (2/4, 3/4, 4/4, 6/8), indicatori visivi dei battiti e funzione Tap Tempo. Premi il pulsante microfono per attivare il tuner integrato e verificare l'intonazione mentre suoni a tempo.</string>
<string name="guide_reference_desc">Genera una nota di riferimento per accordare o esercitarsi a orecchio. Pulsante rapido LA 440 Hz, griglia di 12 note cromatiche e ottava selezionabile da 2 a 7.</string>
<string name="guide_practice_desc">Il cuore dell\'app. Carica una sequenza di note (manuale, scansione foto o MusicXML) e suonale una per una. L\'app rileva la nota dal microfono e avanza automaticamente. Visualizza il pentagramma in modalita Nota, Contesto, Spartito o Chiaro. Usa \"Nascondi\" per esercitarti a leggere dal pentagramma. Metronomo integrato per suonare a tempo.</string>
<string name="guide_practice_desc">Il cuore dell'app. Carica una sequenza di note (manuale, scansione foto o MusicXML) e suonale una per una. L'app rileva la nota dal microfono e avanza automaticamente. Visualizza il pentagramma in modalita Nota, Contesto, Spartito o Chiaro. Usa "Nascondi" per esercitarti a leggere dal pentagramma. Metronomo integrato per suonare a tempo.</string>
<string name="guide_exercises_desc">Esercizi pronti con difficolta progressiva: Livello 1 (note base Do-Sol), Livello 2 (scale complete), Livello 3 (scale con alterazioni e melodie), Livello 4 (arpeggi e salti).</string>
<string name="guide_scales_desc">Genera scale e arpeggi in qualsiasi tonalita. Scale: Maggiore, Minore, Armonica, Melodica, Pentatonica, Cromatica. Arpeggi: Maggiore, Minore, Diminuito, Aumentato, Settima. Scegli nota fondamentale, ottava, tipo e direzione.</string>
<string name="guide_scores_desc">Sfoglia la libreria di file MusicXML e pratica direttamente dai brani importati.</string>
<string name="guide_ear_training_desc">Allena l\'orecchio musicale: Riconosci Nota (indovina la nota ascoltata) e Riconosci Intervallo (identifica l\'intervallo tra due note). Tre livelli di difficolta con 10 domande per sessione.</string>
<string name="guide_challenge_desc">Metti alla prova velocita e precisione: scegli sorgente e tempo limite (30s, 1min, 2min) e suona il maggior numero di note corrette. Ogni nota e\' mostrata sul pentagramma. Sistema di punteggio con bonus precisione, velocita e moltiplicatore combo.</string>
<string name="guide_ear_training_desc">Allena l'orecchio musicale: Riconosci Nota (indovina la nota ascoltata) e Riconosci Intervallo (identifica l'intervallo tra due note). Tre livelli di difficolta con 10 domande per sessione.</string>
<string name="guide_challenge_desc">Metti alla prova velocita e precisione: scegli sorgente e tempo limite (30s, 1min, 2min) e suona il maggior numero di note corrette. Ogni nota e' mostrata sul pentagramma. Sistema di punteggio con bonus precisione, velocita e moltiplicatore combo.</string>
<string name="guide_leaderboard_desc">I tuoi 20 migliori punteggi nelle sfide, con punteggio, note suonate, combo massima e data.</string>
<string name="guide_stats_desc">Panoramica dei tuoi progressi: sessioni totali, tempo di pratica, miglior precisione. Grafici giornalieri di precisione, primo tentativo e tempo di reazione.</string>
<string name="guide_goals_desc">Imposta un obiettivo (5, 10, 15 o 30 minuti al giorno), segui il progresso circolare, mantieni la serie consecutiva e visualizza il calendario degli ultimi 30 giorni. Il tempo viene conteggiato da tutte le attivita: pratica, sfide e ear training.</string>
<string name="guide_history_desc">Rivedi tutte le sessioni passate in un\'unica lista cronologica: pratica, ear training e sfide. Ogni tipo ha un badge colorato per distinguerlo. Tocca una sessione di pratica per rieseguirla.</string>
<string name="guide_tip_quiet">Ambiente silenzioso: l\'app funziona meglio con poco rumore di fondo.</string>
<string name="guide_history_desc">Rivedi tutte le sessioni passate in un'unica lista cronologica: pratica, ear training e sfide. Ogni tipo ha un badge colorato per distinguerlo. Tocca una sessione di pratica per rieseguirla.</string>
<string name="guide_tip_quiet">Ambiente silenzioso: l'app funziona meglio con poco rumore di fondo.</string>
<string name="guide_tip_distance">Distanza dal microfono: tieni il dispositivo a 30-50 cm dallo strumento.</string>
<string name="guide_tip_daily">Pratica quotidiana: anche 10 minuti al giorno fanno la differenza.</string>
<string name="guide_tip_scales">Inizia dalle scale: padroneggiare le scale e\' la base per suonare qualsiasi brano.</string>
<string name="guide_tip_ear">Ear training regolare: anche 5 minuti al giorno migliorano l\'orecchio musicale.</string>
<string name="guide_tip_scales">Inizia dalle scale: padroneggiare le scale e' la base per suonare qualsiasi brano.</string>
<string name="guide_tip_ear">Ear training regolare: anche 5 minuti al giorno migliorano l'orecchio musicale.</string>
<string name="guide_section_support">Supporto</string>
<string name="guide_support_desc">Hai bisogno di aiuto, vuoi segnalare un problema o suggerire un miglioramento? Scrivici!</string>
<string name="guide_support_button">Contattaci via email</string>
@@ -21,6 +21,8 @@ data class PracticeSession(
val noteResults: List<NoteResult>,
val noteMidiNumbers: List<Int>,
val completed: Boolean = true,
val beats: Int = 4,
val beatType: Int = 4,
) {
val averageCents: Float
get() = if (noteResults.isEmpty()) 0f
@@ -235,6 +235,8 @@ fun AppNavigation() {
currentSequence = NoteSequence(
name = session.sequenceName,
notes = notes,
beats = session.beats,
beatType = session.beatType,
)
currentInstrument = InstrumentPreset.entries.firstOrNull {
it.displayName == session.instrumentName
@@ -44,24 +44,12 @@ fun StaffFullView(
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
val availableWidthDp = maxWidth
val noteAreaWidthDp = availableWidthDp - clefWidthDp - 8.dp
val notesPerLine = (noteAreaWidthDp / noteSpacingDp).toInt().coerceAtLeast(4)
// Capienza in peso: una unità di peso (semiminima) occupa ~noteSpacingDp
val capacityWeight = (noteAreaWidthDp / noteSpacingDp).coerceAtLeast(4f)
val bars = StaffLayout.measurePositions(notes, beats, beatType)
// Righe: tetto notesPerLine; si chiude preferibilmente all'ultima stanghetta che ci sta
val lines = mutableListOf<IntRange>()
var lineStart = 0
while (lineStart < notes.size) {
val cap = (lineStart + notesPerLine - 1).coerceAtMost(notes.size - 1)
val lastBarInLine = bars.lastOrNull { it in lineStart..cap }
val lineEnd = when {
cap == notes.size - 1 -> cap
lastBarInLine != null && lastBarInLine > lineStart -> lastBarInLine
else -> cap
}
lines.add(lineStart..lineEnd)
lineStart = lineEnd + 1
}
val allGroups = StaffLayout.beamGroups(notes, beats, beatType)
val lines = StaffLayout.lineBreaks(notes, beats, beatType, capacityWeight)
val lineCount = lines.size
val totalHeightDp = systemHeightDp * lineCount + systemGapDp * (lineCount - 1).coerceAtLeast(0)
@@ -121,7 +109,12 @@ fun StaffFullView(
val lineNotes = notes.subList(r.first, r.last + 1)
val xs = StaffLayout.xPositions(lineNotes, cursor + 10f, size.width - cursor - 18f)
val groups = StaffLayout.beamGroups(lineNotes, beats, beatType)
// Travature globali (origine di beat corretta) clippate alla riga
val groups = allGroups.mapNotNull { g ->
val from = maxOf(g.first, r.first)
val to = minOf(g.last, r.last)
if (to - from >= 1) (from - r.first)..(to - r.first) else null
}
val grouped = groups.flatMap { it }.toSet()
val currentInLine = currentIndex - r.first
@@ -120,6 +120,36 @@ object StaffLayout {
return positions
}
/**
* Spezza la sequenza in righe con capienza espressa in peso (sqrt della durata),
* chiudendo preferibilmente all'ultima stanghetta che entra nella riga.
*/
fun lineBreaks(notes: List<MusicalNote>, beats: Int, beatType: Int, capacityWeight: Float): List<IntRange> {
if (notes.isEmpty()) return emptyList()
val weights = notes.map { kotlin.math.sqrt(quarterUnits(it.duration)).toFloat() }
val bars = measurePositions(notes, beats, beatType).toSet()
val lines = mutableListOf<IntRange>()
var start = 0
while (start < notes.size) {
var acc = 0f
var cap = start
while (cap < notes.size && (cap == start || acc + weights[cap] <= capacityWeight)) {
acc += weights[cap]
cap++
}
cap--
val lastBar = ((start + 1)..cap).lastOrNull { it in bars }
val end = when {
cap >= notes.size - 1 -> notes.size - 1
lastBar != null -> lastBar
else -> cap
}
lines.add(start..end)
start = end + 1
}
return lines
}
/** Centri orizzontali proporzionali a sqrt(durata): la nota lunga respira. */
fun xPositions(notes: List<MusicalNote>, areaStart: Float, areaWidth: Float): List<Float> {
if (notes.isEmpty()) return emptyList()
@@ -175,9 +175,11 @@ internal fun DrawScope.drawTimeSignature(
)
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()
// Cifre centrate su un asse comune (conta con metri a larghezza mista, es. 12/8)
val width = maxOf(top.size.width, bottom.size.width).toFloat()
drawText(top, topLeft = Offset(x + (width - top.size.width) / 2f, staffBottom - 4f * lineSpacing + (2f * lineSpacing - top.size.height) / 2f))
drawText(bottom, topLeft = Offset(x + (width - bottom.size.width) / 2f, staffBottom - 2f * lineSpacing + (2f * lineSpacing - bottom.size.height) / 2f))
return width
}
/** Chiave a piena opacità. Ritorna la larghezza occupata. */
@@ -183,6 +183,8 @@ class PracticeViewModel : ViewModel() {
noteResults = collectedResults.toList(),
noteMidiNumbers = state.sequence.notes.map { it.midiNumber },
completed = completed,
beats = state.sequence.beats,
beatType = state.sequence.beatType,
)
}
@@ -0,0 +1,29 @@
package com.notemusicali.history
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
class PracticeSessionMeterTest {
@Test
fun `session carries its meter`() {
val session = PracticeSession(
id = "1", sequenceName = "Test", instrumentName = "Violino",
dateMillis = 0L, totalTimeMs = 0L,
noteResults = emptyList(), noteMidiNumbers = listOf(60),
beats = 3, beatType = 4,
)
assertEquals(3, session.beats)
assertEquals(4, session.beatType)
}
@Test
fun `legacy json without meter decodes with four four defaults`() {
val legacy = """{"id":"1","sequenceName":"Old","instrumentName":"Violino",""" +
""""dateMillis":0,"totalTimeMs":0,"noteResults":[],"noteMidiNumbers":[60]}"""
val session = Json { ignoreUnknownKeys = true }.decodeFromString<PracticeSession>(legacy)
assertEquals(4, session.beats)
assertEquals(4, session.beatType)
}
}
@@ -120,4 +120,39 @@ class StaffGeometryTest {
val xs = StaffLayout.xPositions(listOf(n(NoteDuration.QUARTER)), 10f, 100f)
assertEquals(60f, xs.single(), absoluteTolerance = 0.5f)
}
@Test
fun `line breaks prefer the last barline that fits the weight capacity`() {
// 8 semiminime in 4/4 (peso 1 ciascuna), capienza 5: la riga chiude alla stanghetta (indice 3)
val notes = List(8) { n(NoteDuration.QUARTER) }
assertEquals(listOf(0..3, 4..7), StaffLayout.lineBreaks(notes, 4, 4, capacityWeight = 5f))
}
@Test
fun `weight capacity lets short notes pack more per line`() {
// 16 semicrome (peso ~0.5): con capienza 4 ne entrano 8 per riga, non 4
val notes = List(16) { n(NoteDuration.SIXTEENTH) }
val lines = StaffLayout.lineBreaks(notes, 4, 4, capacityWeight = 4f)
assertEquals(2, lines.size)
assertEquals(0..7, lines[0])
}
@Test
fun `measure wider than the line is force split`() {
// 5 semiminime in 4/4 con capienza 2: nessuna stanghetta entra nella prima riga
val notes = List(5) { n(NoteDuration.QUARTER) }
assertEquals(listOf(0..1, 2..3, 4..4), StaffLayout.lineBreaks(notes, 4, 4, capacityWeight = 2f))
}
@Test
fun `line breaks cover every note exactly once`() {
val notes = List(11) { n(if (it % 3 == 0) NoteDuration.HALF else NoteDuration.EIGHTH) }
val lines = StaffLayout.lineBreaks(notes, 3, 4, capacityWeight = 3f)
assertEquals((0..10).toList(), lines.flatMap { it.toList() })
}
@Test
fun `empty sequence has no lines`() {
assertEquals(emptyList(), StaffLayout.lineBreaks(emptyList(), 4, 4, capacityWeight = 5f))
}
}