diff --git a/README.md b/README.md index 350cc67..206a26c 100644 --- a/README.md +++ b/README.md @@ -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 -- **Pratica guidata** — segui sequenze di note con feedback visivo e sonoro, metronomo integrato, modalita "Nascondi" per lettura dal pentagramma -- **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 +| | | +|---|---| +| ![Home](docs/screenshots/home.png) | ![Esercizi](docs/screenshots/exercises.png) | +| ![Pratica — vista spartito](docs/screenshots/practice.png) | ![Metronomo](docs/screenshots/metronome.png) | -## Piattaforme supportate +## Funzionalità -| Piattaforma | Stato | Note | -|-------------|-------|------| -| Android | Funzionante | Richiede API 26+ (Android 8.0) | -| iOS | Funzionante | Richiede iOS 16+, audio via AVAudioEngine | -| macOS | Funzionante | Desktop app via Compose Desktop | +- **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 +- **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 +- **Spartiti** — 12 brani celebri inclusi (Bach, Beethoven, Chopin, Pachelbel…), import MusicXML/MXL e scansione di spartiti da foto tramite AI (Claude / GPT-4o) +- **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/ - app/ # Android app module - shared/ # Kotlin Multiplatform shared code - commonMain/ # Logica condivisa (UI, audio, pitch detection) - androidMain/ # Implementazioni Android (AudioRecord) - iosMain/ # Implementazioni iOS (AVAudioEngine) - desktopMain/ # Implementazioni Desktop (javax.sound) - iosApp/ # Xcode project (SwiftUI entry point) - desktopApp/ # Desktop app module (JVM) + app/ # Entry point Android + desktopApp/ # Entry point Desktop (JVM) + iosApp/ # Entry point iOS (SwiftUI + XcodeGen) + shared/ # Kotlin Multiplatform + commonMain/ # UI Compose, DSP (YIN), notazione, parser MusicXML, + # geometria e rendering del pentagramma, motori di gioco + androidMain/ # AudioRecord, CameraX, EncryptedSharedPreferences, SAF + iosMain/ # AVAudioEngine, UIImagePickerController, Keychain, zlib + desktopMain/ # javax.sound, Swing file picker ``` -## Prerequisiti - -- **JDK 17+** -- **Android Studio** (per Android) -- **Xcode 15+** (per iOS) -- **XcodeGen** (per generare il progetto iOS): `brew install xcodegen` +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. ## Build -### Android - ```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 -# 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) +Kotlin 2.1 · Compose Multiplatform 1.7 · Ktor 3 · kotlinx-serialization · multiplatform-settings · CameraX · AVAudioEngine / AudioRecord / javax.sound diff --git a/docs/screenshots/exercises.png b/docs/screenshots/exercises.png new file mode 100644 index 0000000..f93f734 Binary files /dev/null and b/docs/screenshots/exercises.png differ diff --git a/docs/screenshots/home.png b/docs/screenshots/home.png new file mode 100644 index 0000000..1801345 Binary files /dev/null and b/docs/screenshots/home.png differ diff --git a/docs/screenshots/metronome.png b/docs/screenshots/metronome.png new file mode 100644 index 0000000..fffe9c9 Binary files /dev/null and b/docs/screenshots/metronome.png differ diff --git a/docs/screenshots/practice.png b/docs/screenshots/practice.png new file mode 100644 index 0000000..c130627 Binary files /dev/null and b/docs/screenshots/practice.png differ diff --git a/shared/src/commonMain/composeResources/values-en/strings.xml b/shared/src/commonMain/composeResources/values-en/strings.xml index 4073f9a..86c72f8 100644 --- a/shared/src/commonMain/composeResources/values-en/strings.xml +++ b/shared/src/commonMain/composeResources/values-en/strings.xml @@ -12,7 +12,7 @@ Pitch Check - Check in real time if you\'re playing the correct note + Check in real time if you're playing the correct note Metronome Adjustable tempo with accents and tap tempo Reference Note @@ -204,10 +204,10 @@ Tips - 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. + 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. 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. 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. - 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. + 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. 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). 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. Browse your MusicXML file library and practice directly from imported pieces. diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index c54a9e9..3a7dd1d 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -38,7 +38,7 @@ Cronologia Rivedi le sessioni di pratica passate Guida - Come usare l\'app e suggerimenti + Come usare l'app e suggerimenti Indietro @@ -179,7 +179,7 @@ Nessuna attivita recente - Nessuna sessione salvata.\nCompleta un\'attività per vederla qui. + Nessuna sessione salvata.\nCompleta un'attività per vederla qui. Pratica Ear Training Sfida @@ -205,23 +205,23 @@ Suggerimenti 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. - 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. + 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. 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. - 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. + 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. 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). 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. Sfoglia la libreria di file MusicXML e pratica direttamente dai brani importati. - 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. - 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. + 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. + 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. I tuoi 20 migliori punteggi nelle sfide, con punteggio, note suonate, combo massima e data. Panoramica dei tuoi progressi: sessioni totali, tempo di pratica, miglior precisione. Grafici giornalieri di precisione, primo tentativo e tempo di reazione. 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. - 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. - Ambiente silenzioso: l\'app funziona meglio con poco rumore di fondo. + 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. + Ambiente silenzioso: l'app funziona meglio con poco rumore di fondo. Distanza dal microfono: tieni il dispositivo a 30-50 cm dallo strumento. Pratica quotidiana: anche 10 minuti al giorno fanno la differenza. - Inizia dalle scale: padroneggiare le scale e\' la base per suonare qualsiasi brano. - Ear training regolare: anche 5 minuti al giorno migliorano l\'orecchio musicale. + Inizia dalle scale: padroneggiare le scale e' la base per suonare qualsiasi brano. + Ear training regolare: anche 5 minuti al giorno migliorano l'orecchio musicale. Supporto Hai bisogno di aiuto, vuoi segnalare un problema o suggerire un miglioramento? Scrivici! Contattaci via email diff --git a/shared/src/commonMain/kotlin/com/notemusicali/history/PracticeSession.kt b/shared/src/commonMain/kotlin/com/notemusicali/history/PracticeSession.kt index 5b497b5..d3f7ee1 100644 --- a/shared/src/commonMain/kotlin/com/notemusicali/history/PracticeSession.kt +++ b/shared/src/commonMain/kotlin/com/notemusicali/history/PracticeSession.kt @@ -21,6 +21,8 @@ data class PracticeSession( val noteResults: List, val noteMidiNumbers: List, val completed: Boolean = true, + val beats: Int = 4, + val beatType: Int = 4, ) { val averageCents: Float get() = if (noteResults.isEmpty()) 0f diff --git a/shared/src/commonMain/kotlin/com/notemusicali/navigation/AppNavigation.kt b/shared/src/commonMain/kotlin/com/notemusicali/navigation/AppNavigation.kt index 814817d..f6d91cb 100644 --- a/shared/src/commonMain/kotlin/com/notemusicali/navigation/AppNavigation.kt +++ b/shared/src/commonMain/kotlin/com/notemusicali/navigation/AppNavigation.kt @@ -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 diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffFullView.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffFullView.kt index d72ec3e..627ecd1 100644 --- a/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffFullView.kt +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffFullView.kt @@ -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() - 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 diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffLayout.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffLayout.kt index 31d53a0..7af9629 100644 --- a/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffLayout.kt +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffLayout.kt @@ -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, beats: Int, beatType: Int, capacityWeight: Float): List { + 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() + 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, areaStart: Float, areaWidth: Float): List { if (notes.isEmpty()) return emptyList() diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffRenderer.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffRenderer.kt index 9e39fbb..4762e32 100644 --- a/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffRenderer.kt +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffRenderer.kt @@ -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. */ diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/practice/PracticeViewModel.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/practice/PracticeViewModel.kt index 670ae28..cb27a10 100644 --- a/shared/src/commonMain/kotlin/com/notemusicali/ui/practice/PracticeViewModel.kt +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/practice/PracticeViewModel.kt @@ -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, ) } diff --git a/shared/src/commonTest/kotlin/com/notemusicali/history/PracticeSessionMeterTest.kt b/shared/src/commonTest/kotlin/com/notemusicali/history/PracticeSessionMeterTest.kt new file mode 100644 index 0000000..9c36822 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/notemusicali/history/PracticeSessionMeterTest.kt @@ -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(legacy) + assertEquals(4, session.beats) + assertEquals(4, session.beatType) + } +} diff --git a/shared/src/commonTest/kotlin/com/notemusicali/ui/components/StaffGeometryTest.kt b/shared/src/commonTest/kotlin/com/notemusicali/ui/components/StaffGeometryTest.kt index 7bd5282..6fcbac4 100644 --- a/shared/src/commonTest/kotlin/com/notemusicali/ui/components/StaffGeometryTest.kt +++ b/shared/src/commonTest/kotlin/com/notemusicali/ui/components/StaffGeometryTest.kt @@ -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)) + } }