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

+56 -81
View File
@@ -1,100 +1,75 @@
# NoteMusicali # InTono 🎻
App multipiattaforma per l'apprendimento musicale, costruita con **Kotlin Multiplatform** e **Compose Multiplatform**. [![Build](https://github.com/luzadev/intono/actions/workflows/build.yml/badge.svg)](https://github.com/luzadev/intono/actions/workflows/build.yml)
[![Release](https://img.shields.io/github/v/release/luzadev/intono)](https://github.com/luzadev/intono/releases/latest)
## Funzionalita **Intonazione e pratica per musicisti di ogni livello.** App multipiattaforma per l'apprendimento musicale costruita con **Kotlin Multiplatform** e **Compose Multiplatform**: rileva in tempo reale la nota che suoni (algoritmo YIN) e ti guida nella pratica su un pentagramma con notazione professionale.
- **Controllo Intonazione** — rileva in tempo reale la nota suonata tramite microfono (algoritmo YIN) | | |
- **Metronomo** — BPM regolabile, time signature, tap tempo e tuner integrato per suonare a tempo verificando l'intonazione |---|---|
- **Nota di Riferimento** — genera qualsiasi nota cromatica con frequenza esatta per accordatura | ![Home](docs/screenshots/home.png) | ![Esercizi](docs/screenshots/exercises.png) |
- **Pratica guidata** — segui sequenze di note con feedback visivo e sonoro, metronomo integrato, modalita "Nascondi" per lettura dal pentagramma | ![Pratica — vista spartito](docs/screenshots/practice.png) | ![Metronomo](docs/screenshots/metronome.png) |
- **Sfida** — suona il maggior numero di note nel tempo con punteggio, combo e pentagramma
- **Ear Training** — riconoscimento di note e intervalli con difficolta progressiva
- **Esercizi predefiniti** — scale, arpeggi e sequenze ordinate per difficolta
- **Inserimento manuale** — componi sequenze personalizzate con notazione italiana e durate musicali
- **Scansione spartiti** — riconosci note da foto di spartiti tramite AI (OpenAI / Gemini)
- **Pentagramma interattivo** — visualizzazione grafica delle note sul rigo musicale
- **Obiettivi giornalieri** — traccia il tempo di pratica da tutte le attivita con serie consecutiva e calendario
- **Cronologia unificata** — tutte le sessioni (pratica, ear training, sfide) in un'unica lista
## Piattaforme supportate ## Funzionalità
| Piattaforma | Stato | Note | - **Controllo intonazione** — rilevamento in tempo reale della nota al microfono (YIN, preset per violino, viola, violoncello, pianoforte, chitarra e voce) con deviazione in cents
|-------------|-------|------| - **Pratica guidata** — segui sequenze di note con avanzamento automatico; pentagramma in modalità Nota / Contesto / Spartito, tema chiaro e modalità "Nascondi" per la lettura
| Android | Funzionante | Richiede API 26+ (Android 8.0) | - **Pentagramma professionale** — glifi tipografici disegnati su Canvas, travature per crome e semicrome (anche in 6/8), stanghette di battuta con indicazione del tempo, diesis **e bemolle**, spaziatura proporzionale alla durata
| iOS | Funzionante | Richiede iOS 16+, audio via AVAudioEngine | - **Spartiti** — 12 brani celebri inclusi (Bach, Beethoven, Chopin, Pachelbel…), import MusicXML/MXL e scansione di spartiti da foto tramite AI (Claude / GPT-4o)
| macOS | Funzionante | Desktop app via Compose Desktop | - **Metronomo** — 30–240 BPM su griglia temporale assoluta (niente drift), tempi 2/4–6/8, tap tempo e accordatore integrato
- **Ear training** — riconoscimento di note e intervalli con difficoltà progressiva
- **Sfida** — quante note corrette riesci a suonare nel tempo limite? Punteggio con bonus precisione/velocità e combo
- **Progressi** — obiettivi giornalieri con streak, statistiche con grafici e cronologia unificata ri-eseguibile
## Struttura progetto ## Piattaforme
| Piattaforma | Stato | Requisiti |
|-------------|-------|-----------|
| Android | ✅ | API 26+ (Android 8.0) |
| iOS | ✅ | iOS 16+, Xcode 15+ |
| macOS/Desktop | ✅ | JVM 17+ (Compose Desktop) |
## Download
L'APK Android firmato è allegato a ogni [release](https://github.com/luzadev/intono/releases/latest). Gli APK di sviluppo sono disponibili come artifact di ogni [build CI](https://github.com/luzadev/intono/actions/workflows/build.yml).
## Architettura
``` ```
NoteMusicali/ NoteMusicali/
app/ # Android app module app/ # Entry point Android
shared/ # Kotlin Multiplatform shared code desktopApp/ # Entry point Desktop (JVM)
commonMain/ # Logica condivisa (UI, audio, pitch detection) iosApp/ # Entry point iOS (SwiftUI + XcodeGen)
androidMain/ # Implementazioni Android (AudioRecord) shared/ # Kotlin Multiplatform
iosMain/ # Implementazioni iOS (AVAudioEngine) commonMain/ # UI Compose, DSP (YIN), notazione, parser MusicXML,
desktopMain/ # Implementazioni Desktop (javax.sound) # geometria e rendering del pentagramma, motori di gioco
iosApp/ # Xcode project (SwiftUI entry point) androidMain/ # AudioRecord, CameraX, EncryptedSharedPreferences, SAF
desktopApp/ # Desktop app module (JVM) iosMain/ # AVAudioEngine, UIImagePickerController, Keychain, zlib
desktopMain/ # javax.sound, Swing file picker
``` ```
## Prerequisiti Tutta la logica (pitch detection, notazione, layout del pentagramma, motori di pratica/sfida) vive in `commonMain` ed è coperta da test (`commonTest`, ~110 test eseguiti su JVM e simulatore iOS). Le piattaforme forniscono solo I/O: audio, camera, storage sicuro, file system.
- **JDK 17+**
- **Android Studio** (per Android)
- **Xcode 15+** (per iOS)
- **XcodeGen** (per generare il progetto iOS): `brew install xcodegen`
## Build ## Build
### Android
```bash ```bash
./gradlew :app:assembleDebug # Test
./gradlew :shared:desktopTest # suite su JVM
./gradlew :shared:iosSimulatorArm64Test # suite su simulatore iOS
# Android
./gradlew :app:assembleDebug # APK in app/build/outputs/apk/debug/
# Desktop
./gradlew :desktopApp:run # esegui
./gradlew :desktopApp:packageDmg # DMG per macOS
# iOS
cd iosApp && xcodegen generate && open iosApp.xcodeproj
``` ```
L'APK si trova in `app/build/outputs/apk/debug/`. Per l'APK di release firmato servono le variabili d'ambiente `INTONO_KEYSTORE_PATH`, `INTONO_KEYSTORE_PASSWORD`, `INTONO_KEY_ALIAS`, `INTONO_KEY_PASSWORD` (in CI arrivano dai secrets della repo: il workflow [Release](.github/workflows/release.yml) parte a ogni tag `v*`).
### iOS ## Stack
```bash Kotlin 2.1 · Compose Multiplatform 1.7 · Ktor 3 · kotlinx-serialization · multiplatform-settings · CameraX · AVAudioEngine / AudioRecord / javax.sound
# Genera il progetto Xcode
cd iosApp && xcodegen generate
# Compila il framework Kotlin
./gradlew :shared:compileKotlinIosArm64
# Apri in Xcode, seleziona il device e Build & Run
open iosApp/iosApp.xcodeproj
```
Il pre-build script in Xcode esegue automaticamente `embedAndSignAppleFrameworkForXcode`.
### macOS (Desktop)
```bash
./gradlew :desktopApp:run # Esegui
./gradlew :desktopApp:packageDmg # Genera DMG
```
## Strumenti presets
L'app supporta preset ottimizzati per diversi strumenti:
| Strumento | Range | Buffer |
|-------------|-------------|--------|
| Violino | G3 - E7 | 2048 |
| Viola | C3 - A6 | 2048 |
| Violoncello | C2 - C6 | 4096 |
| Pianoforte | A0 - C8 | 4096 |
| Chitarra | E2 - E6 | 2048 |
| Voce | D2 - D6 | 2048 |
## Stack tecnologico
- **Kotlin 2.1** + **Compose Multiplatform 1.7**
- **Ktor** per le chiamate API (scansione spartiti)
- **multiplatform-settings** per preferenze persistenti
- **YIN algorithm** per pitch detection in tempo reale
- **AVAudioEngine** (iOS) / **AudioRecord** (Android) / **javax.sound** (Desktop)
Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

@@ -12,7 +12,7 @@
<!-- Home cards --> <!-- Home cards -->
<string name="tuner_title">Pitch Check</string> <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_title">Metronome</string>
<string name="metronome_desc">Adjustable tempo with accents and tap tempo</string> <string name="metronome_desc">Adjustable tempo with accents and tap tempo</string>
<string name="reference_title">Reference Note</string> <string name="reference_title">Reference Note</string>
@@ -204,10 +204,10 @@
<!-- Guide --> <!-- Guide -->
<string name="guide_section_tips">Tips</string> <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_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_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_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_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> <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_title">Cronologia</string>
<string name="history_desc">Rivedi le sessioni di pratica passate</string> <string name="history_desc">Rivedi le sessioni di pratica passate</string>
<string name="guide_title">Guida</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 --> <!-- Navigation -->
<string name="back">Indietro</string> <string name="back">Indietro</string>
@@ -179,7 +179,7 @@
<string name="no_recent_activity">Nessuna attivita recente</string> <string name="no_recent_activity">Nessuna attivita recente</string>
<!-- History --> <!-- 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_practice">Pratica</string>
<string name="history_type_ear_training">Ear Training</string> <string name="history_type_ear_training">Ear Training</string>
<string name="history_type_challenge">Sfida</string> <string name="history_type_challenge">Sfida</string>
@@ -205,23 +205,23 @@
<!-- Guide --> <!-- Guide -->
<string name="guide_section_tips">Suggerimenti</string> <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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_ear">Ear training regolare: anche 5 minuti al giorno migliorano l'orecchio musicale.</string>
<string name="guide_section_support">Supporto</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_desc">Hai bisogno di aiuto, vuoi segnalare un problema o suggerire un miglioramento? Scrivici!</string>
<string name="guide_support_button">Contattaci via email</string> <string name="guide_support_button">Contattaci via email</string>
@@ -21,6 +21,8 @@ data class PracticeSession(
val noteResults: List<NoteResult>, val noteResults: List<NoteResult>,
val noteMidiNumbers: List<Int>, val noteMidiNumbers: List<Int>,
val completed: Boolean = true, val completed: Boolean = true,
val beats: Int = 4,
val beatType: Int = 4,
) { ) {
val averageCents: Float val averageCents: Float
get() = if (noteResults.isEmpty()) 0f get() = if (noteResults.isEmpty()) 0f
@@ -235,6 +235,8 @@ fun AppNavigation() {
currentSequence = NoteSequence( currentSequence = NoteSequence(
name = session.sequenceName, name = session.sequenceName,
notes = notes, notes = notes,
beats = session.beats,
beatType = session.beatType,
) )
currentInstrument = InstrumentPreset.entries.firstOrNull { currentInstrument = InstrumentPreset.entries.firstOrNull {
it.displayName == session.instrumentName it.displayName == session.instrumentName
@@ -44,24 +44,12 @@ fun StaffFullView(
BoxWithConstraints(modifier = modifier.fillMaxWidth()) { BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
val availableWidthDp = maxWidth val availableWidthDp = maxWidth
val noteAreaWidthDp = availableWidthDp - clefWidthDp - 8.dp 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) val bars = StaffLayout.measurePositions(notes, beats, beatType)
val allGroups = StaffLayout.beamGroups(notes, beats, beatType)
// Righe: tetto notesPerLine; si chiude preferibilmente all'ultima stanghetta che ci sta val lines = StaffLayout.lineBreaks(notes, beats, beatType, capacityWeight)
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 lineCount = lines.size val lineCount = lines.size
val totalHeightDp = systemHeightDp * lineCount + systemGapDp * (lineCount - 1).coerceAtLeast(0) 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 lineNotes = notes.subList(r.first, r.last + 1)
val xs = StaffLayout.xPositions(lineNotes, cursor + 10f, size.width - cursor - 18f) 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 grouped = groups.flatMap { it }.toSet()
val currentInLine = currentIndex - r.first val currentInLine = currentIndex - r.first
@@ -120,6 +120,36 @@ object StaffLayout {
return positions 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. */ /** Centri orizzontali proporzionali a sqrt(durata): la nota lunga respira. */
fun xPositions(notes: List<MusicalNote>, areaStart: Float, areaWidth: Float): List<Float> { fun xPositions(notes: List<MusicalNote>, areaStart: Float, areaWidth: Float): List<Float> {
if (notes.isEmpty()) return emptyList() if (notes.isEmpty()) return emptyList()
@@ -175,9 +175,11 @@ internal fun DrawScope.drawTimeSignature(
) )
val top = textMeasurer.measure(beats.toString(), style) val top = textMeasurer.measure(beats.toString(), style)
val bottom = textMeasurer.measure(beatType.toString(), style) val bottom = textMeasurer.measure(beatType.toString(), style)
drawText(top, topLeft = Offset(x, staffBottom - 4f * lineSpacing + (2f * lineSpacing - top.size.height) / 2f)) // Cifre centrate su un asse comune (conta con metri a larghezza mista, es. 12/8)
drawText(bottom, topLeft = Offset(x, staffBottom - 2f * lineSpacing + (2f * lineSpacing - bottom.size.height) / 2f)) val width = maxOf(top.size.width, bottom.size.width).toFloat()
return 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. */ /** Chiave a piena opacità. Ritorna la larghezza occupata. */
@@ -183,6 +183,8 @@ class PracticeViewModel : ViewModel() {
noteResults = collectedResults.toList(), noteResults = collectedResults.toList(),
noteMidiNumbers = state.sequence.notes.map { it.midiNumber }, noteMidiNumbers = state.sequence.notes.map { it.midiNumber },
completed = completed, 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) val xs = StaffLayout.xPositions(listOf(n(NoteDuration.QUARTER)), 10f, 100f)
assertEquals(60f, xs.single(), absoluteTolerance = 0.5f) 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))
}
} }