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>
No files matched your search
@@ -0,0 +1 @@
|
||||
{"sessionId":"db412f81-3e15-4e3f-9010-4a026dced776","pid":79175,"procStart":"Tue Jul 21 20:39:41 2026","acquiredAt":1784666538953}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Gradle / Kotlin
|
||||
.gradle/
|
||||
build/
|
||||
**/build/
|
||||
.kotlin/
|
||||
local.properties
|
||||
|
||||
# IDE / OS
|
||||
.idea/
|
||||
*.iml
|
||||
.DS_Store
|
||||
|
||||
# Xcode project is generated from iosApp/project.yml via xcodegen
|
||||
iosApp/iosApp.xcodeproj/
|
||||
iosApp/build/
|
||||
|
||||
# Session/tooling artifacts
|
||||
.superpowers/
|
||||
.claude/settings.local.json
|
||||
|
||||
# Promo video: sources are versioned, deps and renders are not
|
||||
video/node_modules/
|
||||
video/out/
|
||||
|
||||
# Score library (256MB of .mxl, not source code — the curated bundled set
|
||||
# lives in shared/src/commonMain/composeResources/files/scores/)
|
||||
scores/
|
||||
|
||||
# Binary release artifacts
|
||||
*.apk
|
||||
*.apk.idsig
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/zsh
|
||||
cd "${0:A:h}"
|
||||
exec claude
|
||||
@@ -0,0 +1,162 @@
|
||||
# InTono — Guida Utente
|
||||
|
||||
Intonazione e pratica per musicisti di ogni livello.
|
||||
|
||||
---
|
||||
|
||||
## Strumenti
|
||||
|
||||
### Controllo Intonazione
|
||||
Rileva in tempo reale la nota che stai suonando tramite il microfono del dispositivo. Lo schermo mostra:
|
||||
- **Nome della nota** rilevata (notazione italiana: Do, Re, Mi...)
|
||||
- **Deviazione in cents** rispetto all'intonazione perfetta
|
||||
- **Indicatore visivo** verde (intonato) o rosso (stonato)
|
||||
|
||||
Ideale per strumenti a corda, a fiato o voce: verifica se stai suonando la nota giusta.
|
||||
|
||||
### Metronomo
|
||||
Un metronomo digitale con le seguenti funzioni:
|
||||
- **BPM regolabile** da 30 a 240 tramite slider
|
||||
- **Tempi disponibili**: 2/4, 3/4, 4/4, 6/8
|
||||
- **Indicatori visivi dei battiti** con accento sul primo battito
|
||||
- **Tap Tempo**: tocca ripetutamente il pulsante per impostare il BPM dal tuo ritmo
|
||||
- **Tuner integrato**: premi il pulsante microfono per attivare il rilevamento dell'intonazione mentre suoni a tempo. Mostra la nota rilevata, la deviazione in cents con indicatore visivo e la frequenza in Hz
|
||||
|
||||
Premi il pulsante Play per avviare, Stop per fermare.
|
||||
|
||||
### Nota di Riferimento
|
||||
Genera una nota di riferimento per accordare o esercitarsi a orecchio:
|
||||
- **Pulsante rapido LA 440 Hz** per l'accordatura standard
|
||||
- **Griglia di 12 note** per selezionare qualsiasi nota cromatica
|
||||
- **Ottava selezionabile** da 2 a 7
|
||||
- Mostra la frequenza esatta in Hz
|
||||
|
||||
Premi Play per ascoltare la nota, Stop per fermare.
|
||||
|
||||
---
|
||||
|
||||
## Pratica
|
||||
|
||||
### Modalita Pratica
|
||||
Il cuore dell'app. Carica una sequenza di note e suonale una per una:
|
||||
1. Scegli come inserire le note: **manuale**, **scansione foto** o **file MusicXML**
|
||||
2. Suona ogni nota mostrata sul pentagramma
|
||||
3. L'app rileva la nota dal microfono e avanza automaticamente quando e' corretta
|
||||
4. Al termine vedi le statistiche della sessione
|
||||
|
||||
**Modalita di visualizzazione del pentagramma:**
|
||||
- **Nota**: mostra solo la nota corrente
|
||||
- **Contesto**: mostra le note vicine
|
||||
- **Spartito**: mostra tutto il pentagramma
|
||||
- **Chiaro**: sfondo bianco per leggibilita
|
||||
- **Nascondi**: nasconde il nome della nota scritta, utile per esercitarsi a leggere dal pentagramma
|
||||
|
||||
**Metronomo integrato:** sotto i selettori di visualizzazione c'e' una barra metronomo con play/stop, BPM e slider per suonare a tempo durante la pratica.
|
||||
|
||||
Lo sfondo cambia colore: verde = nota corretta, rosso = nota sbagliata.
|
||||
|
||||
### Esercizi
|
||||
Esercizi pronti da suonare 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
|
||||
|
||||
Tocca un esercizio per iniziare subito la pratica.
|
||||
|
||||
### Scale e Arpeggi
|
||||
Genera scale e arpeggi in qualsiasi tonalita:
|
||||
|
||||
**Scale disponibili:**
|
||||
- Maggiore, Minore Naturale, Armonica, Melodica
|
||||
- Pentatonica Maggiore e Minore
|
||||
- Cromatica
|
||||
|
||||
**Arpeggi disponibili:**
|
||||
- Maggiore, Minore, Diminuito, Aumentato
|
||||
- Settima Dominante, Maggiore, Minore
|
||||
|
||||
**Come usare:**
|
||||
1. Seleziona la nota fondamentale (12 opzioni cromatiche)
|
||||
2. Scegli l'ottava (2-6)
|
||||
3. Scegli il tipo di scala o arpeggio
|
||||
4. Scegli la direzione: ascendente, discendente o entrambe
|
||||
5. Visualizza l'anteprima sul pentagramma
|
||||
6. Tocca **Pratica** per esercitarti con il riconoscimento automatico
|
||||
|
||||
### Spartiti
|
||||
Sfoglia la libreria di file MusicXML e pratica direttamente dai brani importati.
|
||||
|
||||
---
|
||||
|
||||
## Sfida
|
||||
|
||||
### Ear Training
|
||||
Allena il tuo orecchio musicale con due modalita:
|
||||
|
||||
**Riconosci Nota**: ascolta una nota e indovina quale nota e'.
|
||||
**Riconosci Intervallo**: ascolta due note e identifica l'intervallo (da unisono a ottava).
|
||||
|
||||
**Livelli di difficolta:**
|
||||
- **Facile**: poche note/intervalli semplici (unisono, terza, quinta, ottava)
|
||||
- **Medio**: piu' note e intervalli comuni
|
||||
- **Difficile**: tutte le 12 note e tutti i 13 intervalli
|
||||
|
||||
Ogni sessione ha 10 domande. Premi il pulsante Play per ascoltare, poi tocca la risposta corretta tra le 4 opzioni.
|
||||
|
||||
### Modalita Sfida
|
||||
Metti alla prova la tua velocita e precisione:
|
||||
1. Scegli la **sorgente**: esercizi, scale o note random
|
||||
2. Scegli il **tempo limite**: 30 secondi, 1 minuto o 2 minuti
|
||||
3. Suona il maggior numero di note corrette nel tempo!
|
||||
|
||||
Ogni nota da suonare e' mostrata sia sul **pentagramma** che con il nome e l'ottava.
|
||||
|
||||
**Sistema di punteggio:**
|
||||
- 100 punti base per ogni nota corretta
|
||||
- Bonus precisione: +50 punti se entro 5 cents, +25 se entro 15 cents
|
||||
- Bonus velocita: +30 punti se suoni in meno di 2 secondi
|
||||
- **Moltiplicatore combo**: suona note consecutive corrette per aumentare il punteggio fino a 2x
|
||||
|
||||
### Classifica
|
||||
Vedi i tuoi 20 migliori punteggi nelle sfide, ordinati dal piu' alto. Per ogni risultato sono indicati il punteggio, le note suonate, la combo massima e la data.
|
||||
|
||||
---
|
||||
|
||||
## Progressi
|
||||
|
||||
### Statistiche
|
||||
Panoramica dei tuoi progressi nel tempo:
|
||||
|
||||
**Riepilogo:**
|
||||
- Numero totale di sessioni
|
||||
- Sessioni completate
|
||||
- Tempo totale di pratica
|
||||
- Miglior precisione raggiunta
|
||||
|
||||
**Grafici giornalieri:**
|
||||
- **Precisione** (deviazione media in cents — piu' basso e' meglio)
|
||||
- **Primo tentativo** (percentuale di note suonate al primo colpo)
|
||||
- **Tempo di reazione** (velocita media nel suonare ogni nota)
|
||||
|
||||
### Obiettivi Giornalieri
|
||||
Mantieni la costanza nella pratica:
|
||||
- **Imposta un obiettivo**: 5, 10, 15 o 30 minuti al giorno
|
||||
- **Progresso circolare**: vedi quanto hai praticato oggi
|
||||
- **Serie consecutiva**: il contatore si incrementa ogni giorno che raggiungi l'obiettivo
|
||||
- **Calendario**: visualizza gli ultimi 30 giorni con i giorni di pratica colorati in verde
|
||||
|
||||
Il tempo viene conteggiato da tutte le attivita: pratica, sfide e ear training.
|
||||
|
||||
### Cronologia
|
||||
Rivedi tutte le sessioni passate in un'unica lista cronologica: pratica, ear training e sfide. Ogni tipo ha un badge colorato per distinguerlo a colpo d'occhio. Tocca una sessione di pratica per rieseguirla.
|
||||
|
||||
---
|
||||
|
||||
## Suggerimenti
|
||||
|
||||
- **Ambiente silenzioso**: l'app funziona meglio in un ambiente 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 — usa gli obiettivi giornalieri per restare motivato
|
||||
- **Inizia dalle scale**: padroneggiare le scale e' la base per suonare qualsiasi brano
|
||||
- **Ear training regolare**: anche 5 minuti di ear training al giorno migliorano notevolmente l'orecchio musicale
|
||||
@@ -0,0 +1,67 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>InTono — Privacy Policy</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.6; color: #333; }
|
||||
h1 { color: #1B5E20; }
|
||||
h2 { color: #2E7D32; margin-top: 2em; }
|
||||
.date { color: #666; font-style: italic; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>InTono — Privacy Policy</h1>
|
||||
<p class="date">Ultimo aggiornamento: 27 febbraio 2026</p>
|
||||
|
||||
<h2>1. Introduzione</h2>
|
||||
<p>InTono ("l'app") e' un'applicazione per l'accordatura, la pratica e l'allenamento musicale. Questa privacy policy descrive come l'app raccoglie, utilizza e protegge i tuoi dati.</p>
|
||||
|
||||
<h2>2. Dati raccolti</h2>
|
||||
<h3>2.1 Audio dal microfono</h3>
|
||||
<p>L'app accede al microfono del dispositivo per rilevare le note musicali suonate. L'audio viene elaborato localmente sul dispositivo in tempo reale e <strong>non viene registrato, salvato o trasmesso</strong> a server esterni.</p>
|
||||
|
||||
<h3>2.2 Fotocamera e libreria foto</h3>
|
||||
<p>L'app puo' utilizzare la fotocamera o la libreria foto per scansionare immagini di spartiti musicali. Le immagini acquisite possono essere inviate a servizi di intelligenza artificiale (OpenAI o Anthropic) per il riconoscimento ottico della musica (OMR). Le immagini vengono trasmesse solo al momento della scansione e non vengono conservate sui server di terze parti oltre il tempo necessario all'elaborazione.</p>
|
||||
|
||||
<h3>2.3 Dati di pratica</h3>
|
||||
<p>L'app memorizza localmente sul dispositivo le statistiche delle sessioni di pratica, i punteggi delle sfide e gli obiettivi giornalieri. Questi dati <strong>non vengono trasmessi</strong> a server esterni.</p>
|
||||
|
||||
<h3>2.4 Chiavi API</h3>
|
||||
<p>Se utilizzi la funzione di scansione spartiti, l'app ti chiede di inserire una chiave API per i servizi di riconoscimento (OpenAI o Anthropic). La chiave viene memorizzata localmente sul dispositivo e trasmessa direttamente ai server del fornitore scelto solo durante le richieste di scansione.</p>
|
||||
|
||||
<h2>3. Servizi di terze parti</h2>
|
||||
<p>L'app puo' comunicare con i seguenti servizi esterni esclusivamente per la funzione di scansione spartiti:</p>
|
||||
<ul>
|
||||
<li><strong>OpenAI API</strong> (api.openai.com) — per il riconoscimento ottico degli spartiti</li>
|
||||
<li><strong>Anthropic API</strong> (api.anthropic.com) — per il riconoscimento ottico degli spartiti</li>
|
||||
</ul>
|
||||
<p>Nessun altro dato personale viene condiviso con questi servizi. Per le loro privacy policy, visita i rispettivi siti web.</p>
|
||||
|
||||
<h2>4. Dati NON raccolti</h2>
|
||||
<p>L'app <strong>non</strong> raccoglie:</p>
|
||||
<ul>
|
||||
<li>Dati di identificazione personale (nome, email, telefono)</li>
|
||||
<li>Dati di localizzazione</li>
|
||||
<li>Identificatori del dispositivo per scopi di tracciamento</li>
|
||||
<li>Dati di utilizzo per analytics o pubblicita</li>
|
||||
</ul>
|
||||
<p>L'app <strong>non</strong> contiene pubblicita e <strong>non</strong> utilizza SDK di tracciamento o analytics.</p>
|
||||
|
||||
<h2>5. Conservazione dei dati</h2>
|
||||
<p>Tutti i dati dell'app (statistiche, punteggi, impostazioni) sono memorizzati esclusivamente sul tuo dispositivo. Disinstallando l'app, tutti i dati vengono eliminati.</p>
|
||||
|
||||
<h2>6. Sicurezza</h2>
|
||||
<p>Le comunicazioni con i servizi di terze parti avvengono esclusivamente tramite connessioni HTTPS crittografate. I dati locali sono protetti dai meccanismi di sicurezza del sistema operativo.</p>
|
||||
|
||||
<h2>7. Diritti dell'utente</h2>
|
||||
<p>Puoi eliminare tutti i dati dell'app in qualsiasi momento disinstallandola dal dispositivo. Non essendoci dati conservati su server esterni (ad eccezione delle elaborazioni temporanee per la scansione spartiti), non e' necessaria alcuna richiesta di cancellazione.</p>
|
||||
|
||||
<h2>8. Modifiche alla privacy policy</h2>
|
||||
<p>Eventuali modifiche a questa privacy policy saranno pubblicate in questa pagina con la data di aggiornamento. Ti consigliamo di consultarla periodicamente.</p>
|
||||
|
||||
<h2>9. Contatti</h2>
|
||||
<p>Per domande sulla privacy, contattaci a: <strong>info@lzinformatica.it</strong></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,100 @@
|
||||
# NoteMusicali
|
||||
|
||||
App multipiattaforma per l'apprendimento musicale, costruita con **Kotlin Multiplatform** e **Compose Multiplatform**.
|
||||
|
||||
## Funzionalita
|
||||
|
||||
- **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
|
||||
|
||||
## Piattaforme supportate
|
||||
|
||||
| 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 |
|
||||
|
||||
## Struttura progetto
|
||||
|
||||
```
|
||||
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)
|
||||
```
|
||||
|
||||
## Prerequisiti
|
||||
|
||||
- **JDK 17+**
|
||||
- **Android Studio** (per Android)
|
||||
- **Xcode 15+** (per iOS)
|
||||
- **XcodeGen** (per generare il progetto iOS): `brew install xcodegen`
|
||||
|
||||
## Build
|
||||
|
||||
### Android
|
||||
|
||||
```bash
|
||||
./gradlew :app:assembleDebug
|
||||
```
|
||||
|
||||
L'APK si trova in `app/build/outputs/apk/debug/`.
|
||||
|
||||
### iOS
|
||||
|
||||
```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)
|
||||
@@ -0,0 +1,70 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.notemusicali"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.notemusicali"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
val keystorePath = System.getenv("INTONO_KEYSTORE_PATH")
|
||||
if (keystorePath != null) {
|
||||
storeFile = file(keystorePath)
|
||||
storePassword = System.getenv("INTONO_KEYSTORE_PASSWORD")
|
||||
keyAlias = System.getenv("INTONO_KEY_ALIAS")
|
||||
keyPassword = System.getenv("INTONO_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
val keystorePath = System.getenv("INTONO_KEYSTORE_PATH")
|
||||
if (keystorePath != null) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":shared"))
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.ui)
|
||||
implementation(libs.androidx.material3)
|
||||
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# ProGuard rules for InTono (NoteMusicali)
|
||||
|
||||
# --- Kotlin ---
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes Signature
|
||||
-keepattributes InnerClasses
|
||||
-keepattributes EnclosingMethod
|
||||
-keep class kotlin.Metadata { *; }
|
||||
-dontwarn kotlin.**
|
||||
|
||||
# --- Kotlin Serialization ---
|
||||
-keepattributes RuntimeVisibleAnnotations
|
||||
-keep class kotlinx.serialization.** { *; }
|
||||
-keepclassmembers class * {
|
||||
@kotlinx.serialization.Serializable <fields>;
|
||||
}
|
||||
-keepclassmembers @kotlinx.serialization.Serializable class * {
|
||||
*** Companion;
|
||||
*** serializer(...);
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
-keepclasseswithmembers class * {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# --- Ktor ---
|
||||
-keep class io.ktor.** { *; }
|
||||
-dontwarn io.ktor.**
|
||||
-keep class io.ktor.client.engine.** { *; }
|
||||
-keepclassmembers class io.ktor.** { volatile <fields>; }
|
||||
|
||||
# --- Coroutines ---
|
||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||
-keepclassmembers class kotlinx.coroutines.** { volatile <fields>; }
|
||||
-dontwarn kotlinx.coroutines.**
|
||||
|
||||
# --- Compose ---
|
||||
-keep class androidx.compose.** { *; }
|
||||
-dontwarn androidx.compose.**
|
||||
|
||||
# --- Multiplatform Settings ---
|
||||
-keep class com.russhwolf.settings.** { *; }
|
||||
|
||||
# --- App classes ---
|
||||
-keep class com.notemusicali.** { *; }
|
||||
|
||||
# --- CameraX ---
|
||||
-keep class androidx.camera.** { *; }
|
||||
-dontwarn androidx.camera.**
|
||||
|
||||
# --- Standard Android ---
|
||||
-keepclassmembers class * implements android.os.Parcelable {
|
||||
public static final ** CREATOR;
|
||||
}
|
||||
-keepclassmembers class * implements java.io.Serializable {
|
||||
static final long serialVersionUID;
|
||||
private static final java.io.ObjectStreamField[] serialPersistentFields;
|
||||
!static !transient <fields>;
|
||||
private void writeObject(java.io.ObjectOutputStream);
|
||||
private void readObject(java.io.ObjectInputStream);
|
||||
java.lang.Object writeReplace();
|
||||
java.lang.Object readResolve();
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.NoteMusicali">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.NoteMusicali">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.notemusicali
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.notemusicali.navigation.AppNavigation
|
||||
import com.notemusicali.scores.ScoreProviderContext
|
||||
import com.notemusicali.scan.AppSettings
|
||||
import com.notemusicali.ui.theme.NoteMusicaliTheme
|
||||
import com.notemusicali.util.CrashHandler
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val requestPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { _ -> }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
CrashHandler.install()
|
||||
AppSettings.migrateIfNeeded()
|
||||
enableEdgeToEdge()
|
||||
ScoreProviderContext.init(this, getExternalFilesDir(null))
|
||||
|
||||
val permissionsToRequest = mutableListOf<String>()
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
permissionsToRequest.add(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
permissionsToRequest.add(Manifest.permission.CAMERA)
|
||||
}
|
||||
permissionsToRequest.forEach { requestPermissionLauncher.launch(it) }
|
||||
|
||||
setContent {
|
||||
NoteMusicaliTheme {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
AppNavigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:centerX="0.5"
|
||||
android:centerY="0.5"
|
||||
android:gradientRadius="60%p"
|
||||
android:startColor="#2D1B69"
|
||||
android:endColor="#0A0118" />
|
||||
</shape>
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- Subtle glow behind note -->
|
||||
<path
|
||||
android:fillColor="#33FFD700"
|
||||
android:pathData="M54,54 m-22,0 a22,22 0,1 1,44 0 a22,22 0,1 1,-44 0" />
|
||||
|
||||
<!-- Decorative ring -->
|
||||
<path
|
||||
android:strokeColor="#22FFD700"
|
||||
android:strokeWidth="0.5"
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M54,54 m-28,0 a28,28 0,1 1,56 0 a28,28 0,1 1,-56 0" />
|
||||
|
||||
<!-- Eighth note - gold -->
|
||||
<path
|
||||
android:fillColor="#FFD700"
|
||||
android:pathData="M52,34 L52,68 C51.5,67.2 50.5,66.6 49,66.4 C45.5,65.8 42,67.5 41.5,70.2 C41,72.9 43.5,75.2 47,75.8 C50.5,76.4 53.5,74.8 54,72.2 L54,72 L54,46 L66,42 L66,38 Z" />
|
||||
|
||||
<!-- Flag -->
|
||||
<path
|
||||
android:fillColor="#FFD700"
|
||||
android:pathData="M54,34 C58,36 62,40 60,46 L54,42 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">InTono</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.NoteMusicali" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,492 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>InTono — App Store Description</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #1a1a2e;
|
||||
--accent: #e94560;
|
||||
--accent-light: #fce4ec;
|
||||
--bg: #fafafa;
|
||||
--card-bg: #ffffff;
|
||||
--text: #2c2c2c;
|
||||
--text-light: #666666;
|
||||
--border: #e0e0e0;
|
||||
--green: #2e7d32;
|
||||
--blue: #1565c0;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 32px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
padding-bottom: 32px;
|
||||
border-bottom: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.header .app-icon-placeholder {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: linear-gradient(135deg, var(--accent), #ff6f61);
|
||||
border-radius: 18px;
|
||||
margin: 0 auto 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 4px 16px rgba(233, 69, 96, 0.3);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.header .subtitle {
|
||||
font-size: 16px;
|
||||
color: var(--text-light);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.header .developer {
|
||||
font-size: 14px;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header .developer a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.header .developer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Language Section */
|
||||
.lang-section {
|
||||
margin-bottom: 56px;
|
||||
}
|
||||
|
||||
.lang-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lang-flag {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
/* Metadata Card */
|
||||
.meta-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.meta-card-header {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meta-card-header .char-count {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
opacity: 0.7;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.meta-card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.meta-card-body.description {
|
||||
white-space: pre-wrap;
|
||||
font-size: 14.5px;
|
||||
line-height: 1.7;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.meta-card-body.promo {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.meta-card-body.keywords {
|
||||
font-size: 14px;
|
||||
color: var(--text-light);
|
||||
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.section-divider {
|
||||
border: none;
|
||||
border-top: 2px dashed var(--border);
|
||||
margin: 48px 0;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 48px;
|
||||
padding-top: 24px;
|
||||
border-top: 2px solid var(--border);
|
||||
color: var(--text-light);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.footer .brand {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer .contact {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.footer .date {
|
||||
margin-top: 12px;
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
body {
|
||||
background: white;
|
||||
font-size: 12px;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 100%;
|
||||
padding: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.header .app-icon-placeholder {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
font-size: 28px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.lang-section {
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.lang-title {
|
||||
font-size: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.meta-card {
|
||||
break-inside: avoid;
|
||||
margin-bottom: 14px;
|
||||
box-shadow: none;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.meta-card-header {
|
||||
padding: 8px 16px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.meta-card-body {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.meta-card-body.description {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.meta-card-body.promo {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.meta-card-body.keywords {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
margin: 32px 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 32px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.lang-section:last-of-type {
|
||||
break-before: page;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent) !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="app-icon-placeholder">♫</div>
|
||||
<h1>InTono</h1>
|
||||
<div class="subtitle">Accordatore, pratica e allenamento musicale</div>
|
||||
<div class="developer">
|
||||
by <strong>LuZa</strong> —
|
||||
<a href="https://djluza.com">djluza.com</a> ·
|
||||
<a href="mailto:info@djluza.com">info@djluza.com</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- ITALIAN VERSION -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="lang-section">
|
||||
<div class="lang-title">
|
||||
<span class="lang-flag">🇮🇹</span>
|
||||
Italiano — App Store Description
|
||||
</div>
|
||||
|
||||
<!-- Promotional Text -->
|
||||
<div class="meta-card">
|
||||
<div class="meta-card-header">
|
||||
Testo Promozionale
|
||||
<span class="char-count">max 170 caratteri</span>
|
||||
</div>
|
||||
<div class="meta-card-body promo">Accorda, pratica e migliora con InTono: accordatore in tempo reale, metronomo, ear training, sfide e statistiche. Per ogni strumento e ogni livello.</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="meta-card">
|
||||
<div class="meta-card-header">
|
||||
Descrizione
|
||||
<span class="char-count">max 4000 caratteri</span>
|
||||
</div>
|
||||
<div class="meta-card-body description">InTono e' il tuo compagno di pratica musicale: tutto cio' che serve per accordare, esercitarti e migliorare, in un'unica app. Pensata per violino, viola, violoncello, chitarra, pianoforte e voce, a qualsiasi livello.
|
||||
|
||||
ACCORDATORE IN TEMPO REALE
|
||||
Rileva la nota che stai suonando tramite il microfono. Visualizza il nome della nota, la deviazione in cents e un indicatore colorato verde/rosso per capire al volo se sei intonato.
|
||||
|
||||
METRONOMO
|
||||
BPM regolabile da 30 a 240, tempi 2/4, 3/4, 4/4 e 6/8, indicatori visivi dei battiti e Tap Tempo per impostare il ritmo al volo.
|
||||
|
||||
NOTA DI RIFERIMENTO
|
||||
Genera qualsiasi nota cromatica dall'ottava 2 alla 7. Pulsante rapido LA 440 Hz per l'accordatura standard.
|
||||
|
||||
PRATICA GUIDATA
|
||||
Carica una sequenza di note — inserimento manuale, scansione foto dello spartito con IA, oppure importazione MusicXML — e suonale una per una. L'app rileva ogni nota dal microfono e avanza automaticamente quando e' corretta. Quattro viste del pentagramma: Nota, Contesto, Spartito e Chiaro.
|
||||
|
||||
ESERCIZI PRONTI
|
||||
4 livelli di difficolta' progressiva, dalle note base a scale con alterazioni, arpeggi e salti.
|
||||
|
||||
SCALE E ARPEGGI
|
||||
Genera scale e arpeggi in qualsiasi tonalita':
|
||||
- Scale: Maggiore, Minore, Armonica, Melodica, Pentatonica, Cromatica
|
||||
- Arpeggi: Maggiore, Minore, Diminuito, Aumentato, Settima
|
||||
Scegli nota, ottava e direzione, poi esercitati con il riconoscimento automatico.
|
||||
|
||||
SPARTITI
|
||||
Sfoglia e pratica direttamente da file MusicXML importati nella libreria.
|
||||
|
||||
EAR TRAINING
|
||||
Allena l'orecchio musicale: riconosci note e intervalli con 3 livelli di difficolta'. Ogni sessione ha 10 domande con feedback immediato.
|
||||
|
||||
MODALITA' SFIDA
|
||||
Metti alla prova velocita' e precisione con sfide a tempo: 30 secondi, 1 o 2 minuti. Guadagna punti con bonus precisione, bonus velocita' e moltiplicatore combo.
|
||||
|
||||
CLASSIFICA E STATISTICHE
|
||||
Consulta i tuoi 20 migliori punteggi. Segui i tuoi progressi con grafici di precisione, velocita' e primo tentativo.
|
||||
|
||||
OBIETTIVI GIORNALIERI
|
||||
Imposta il tuo obiettivo: 5, 10, 15 o 30 minuti al giorno. Tieni traccia della serie consecutiva e visualizza gli ultimi 30 giorni sul calendario.
|
||||
|
||||
CRONOLOGIA
|
||||
Rivedi ogni sessione passata con le relative statistiche e rieseguila in qualsiasi momento.
|
||||
|
||||
SCANSIONA SPARTITO CON IA
|
||||
Scatta una foto al tuo spartito e lascia che l'intelligenza artificiale riconosca le note. Inizia subito a praticare.
|
||||
|
||||
PRIVACY AL PRIMO POSTO
|
||||
Tutto l'audio viene elaborato localmente sul dispositivo. Nessun tracciamento, nessuna pubblicita', nessun dato raccolto.
|
||||
|
||||
Scarica InTono e inizia a migliorare oggi.</div>
|
||||
</div>
|
||||
|
||||
<!-- Keywords -->
|
||||
<div class="meta-card">
|
||||
<div class="meta-card-header">
|
||||
Parole Chiave
|
||||
<span class="char-count">max 100 caratteri</span>
|
||||
</div>
|
||||
<div class="meta-card-body keywords">accordatore,intonazione,metronomo,ear training,scale,arpeggi,violino,chitarra,voce,pratica,musicale</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="section-divider">
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- ENGLISH VERSION -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="lang-section">
|
||||
<div class="lang-title">
|
||||
<span class="lang-flag">🇬🇧</span>
|
||||
English — App Store Description
|
||||
</div>
|
||||
|
||||
<!-- Promotional Text -->
|
||||
<div class="meta-card">
|
||||
<div class="meta-card-header">
|
||||
Promotional Text
|
||||
<span class="char-count">max 170 characters</span>
|
||||
</div>
|
||||
<div class="meta-card-body promo">Tune, practice and improve with InTono: real-time tuner, metronome, ear training, challenges and stats. For every instrument and every level.</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="meta-card">
|
||||
<div class="meta-card-header">
|
||||
Description
|
||||
<span class="char-count">max 4000 characters</span>
|
||||
</div>
|
||||
<div class="meta-card-body description">InTono is your music practice companion: everything you need to tune, practice and improve, all in one app. Designed for violin, viola, cello, guitar, piano and voice, at any level.
|
||||
|
||||
REAL-TIME TUNER
|
||||
Detects the note you are playing through the microphone. Shows the note name, cents deviation and a green/red colour indicator so you can instantly see if you are in tune.
|
||||
|
||||
METRONOME
|
||||
Adjustable BPM from 30 to 240, time signatures 2/4, 3/4, 4/4 and 6/8, visual beat indicators and Tap Tempo to set the pace on the fly.
|
||||
|
||||
REFERENCE NOTE
|
||||
Generate any chromatic note from octave 2 to 7. Quick A440 button for standard tuning.
|
||||
|
||||
GUIDED PRACTICE
|
||||
Load a note sequence — manual entry, photo scan of sheet music with AI, or MusicXML import — and play them one by one. The app detects each note from the microphone and advances automatically when correct. Four staff views: Note, Context, Score and Light.
|
||||
|
||||
READY-MADE EXERCISES
|
||||
4 progressive difficulty levels, from basic notes to scales with accidentals, arpeggios and leaps.
|
||||
|
||||
SCALES AND ARPEGGIOS
|
||||
Generate scales and arpeggios in any key:
|
||||
- Scales: Major, Minor, Harmonic, Melodic, Pentatonic, Chromatic
|
||||
- Arpeggios: Major, Minor, Diminished, Augmented, Seventh
|
||||
Choose root note, octave and direction, then practise with automatic recognition.
|
||||
|
||||
SHEET MUSIC
|
||||
Browse and practise directly from MusicXML files imported into the library.
|
||||
|
||||
EAR TRAINING
|
||||
Train your musical ear: recognise notes and intervals across 3 difficulty levels. Each session has 10 questions with instant feedback.
|
||||
|
||||
CHALLENGE MODE
|
||||
Test your speed and accuracy with timed challenges: 30 seconds, 1 or 2 minutes. Earn points with accuracy bonus, speed bonus and combo multiplier.
|
||||
|
||||
LEADERBOARD AND STATISTICS
|
||||
View your top 20 scores. Track your progress with charts for accuracy, speed and first-attempt rate.
|
||||
|
||||
DAILY GOALS
|
||||
Set your target: 5, 10, 15 or 30 minutes per day. Keep track of your streak and view the last 30 days on the calendar.
|
||||
|
||||
HISTORY
|
||||
Review every past session with its statistics and replay it at any time.
|
||||
|
||||
AI SHEET MUSIC SCANNER
|
||||
Take a photo of your sheet music and let artificial intelligence recognise the notes. Start practising right away.
|
||||
|
||||
PRIVACY FIRST
|
||||
All audio is processed locally on your device. No tracking, no ads, no data collected.
|
||||
|
||||
Download InTono and start improving today.</div>
|
||||
</div>
|
||||
|
||||
<!-- Keywords -->
|
||||
<div class="meta-card">
|
||||
<div class="meta-card-header">
|
||||
Keywords
|
||||
<span class="char-count">max 100 characters</span>
|
||||
</div>
|
||||
<div class="meta-card-body keywords">tuner,pitch,metronome,ear training,scales,arpeggios,violin,guitar,voice,practice,music,intonation</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<div class="brand">LuZa</div>
|
||||
<div><a href="https://djluza.com">djluza.com</a></div>
|
||||
<div class="contact"><a href="mailto:info@djluza.com">info@djluza.com</a></div>
|
||||
<div class="date">Prepared February 2026</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.android.library) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.multiplatform) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
alias(libs.plugins.compose.multiplatform) apply false
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.compose.multiplatform)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm("desktop") {
|
||||
compilations.all {
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
val desktopMain by getting {
|
||||
dependencies {
|
||||
implementation(project(":shared"))
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(compose.material3)
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.runtime)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "com.notemusicali.MainKt"
|
||||
|
||||
nativeDistributions {
|
||||
targetFormats(
|
||||
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg,
|
||||
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi,
|
||||
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb,
|
||||
)
|
||||
packageName = "InTono"
|
||||
packageVersion = "1.0.0"
|
||||
|
||||
macOS {
|
||||
infoPlist {
|
||||
extraKeysRawXml = """
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>InTono ha bisogno del microfono per rilevare le note musicali suonate</string>
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.notemusicali
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import androidx.compose.ui.window.rememberWindowState
|
||||
import com.notemusicali.navigation.AppNavigation
|
||||
import com.notemusicali.ui.theme.NoteMusicaliTheme
|
||||
|
||||
fun main() = application {
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "InTono",
|
||||
state = rememberWindowState(width = 1024.dp, height = 768.dp),
|
||||
) {
|
||||
NoteMusicaliTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
AppNavigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
# Staff Rendering Upgrade — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rendere il pentagramma con glifi raffinati, travature, stanghette di battuta con tempo, e spaziatura proporzionale alla durata, in tutte le viste Compose condivise.
|
||||
|
||||
**Architecture:** La geometria (unità di durata, gruppi di travatura, posizioni battute, pesi/posizioni orizzontali, direzione gambi) vive in `StaffLayout` (pura, TDD). Le primitive di disegno (`Path` per teste/code, parallelogrammi per travature, stanghette, cifre tempo, chiave) vivono nel nuovo `StaffRenderer.kt` e sostituiscono `NoteRenderer.kt`. Le tre viste diventano orchestratori sottili.
|
||||
|
||||
**Tech Stack:** Kotlin Multiplatform, Compose Multiplatform Canvas/DrawScope, kotlin-test. Spec: `docs/superpowers/specs/2026-07-22-staff-rendering-design.md`.
|
||||
|
||||
**Nota:** il progetto NON è un repository git — al posto dei commit ogni task termina eseguendo la suite (`export JAVA_HOME=$(/usr/libexec/java_home) && ./gradlew :shared:desktopTest`).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: StaffLayout.quarterUnits
|
||||
|
||||
**Files:**
|
||||
- Modify: `shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffLayout.kt`
|
||||
- Test: `shared/src/commonTest/kotlin/com/notemusicali/ui/components/StaffGeometryTest.kt` (create)
|
||||
|
||||
- [ ] **Step 1: test fallente**
|
||||
|
||||
```kotlin
|
||||
package com.notemusicali.ui.components
|
||||
|
||||
import com.notemusicali.music.MusicalNote
|
||||
import com.notemusicali.music.NoteDuration
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class StaffGeometryTest {
|
||||
@Test
|
||||
fun `quarter units follow the binary duration ladder`() {
|
||||
assertEquals(4.0, StaffLayout.quarterUnits(NoteDuration.WHOLE))
|
||||
assertEquals(2.0, StaffLayout.quarterUnits(NoteDuration.HALF))
|
||||
assertEquals(1.0, StaffLayout.quarterUnits(NoteDuration.QUARTER))
|
||||
assertEquals(0.5, StaffLayout.quarterUnits(NoteDuration.EIGHTH))
|
||||
assertEquals(0.25, StaffLayout.quarterUnits(NoteDuration.SIXTEENTH))
|
||||
assertEquals(0.125, StaffLayout.quarterUnits(NoteDuration.THIRTY_SECOND))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: verifica RED** — `./gradlew :shared:compileTestKotlinDesktop` → errore `Unresolved reference 'quarterUnits'`
|
||||
- [ ] **Step 3: implementazione minima** in `StaffLayout`:
|
||||
|
||||
```kotlin
|
||||
fun quarterUnits(duration: NoteDuration): Double = when (duration) {
|
||||
NoteDuration.WHOLE -> 4.0
|
||||
NoteDuration.HALF -> 2.0
|
||||
NoteDuration.QUARTER -> 1.0
|
||||
NoteDuration.EIGHTH -> 0.5
|
||||
NoteDuration.SIXTEENTH -> 0.25
|
||||
NoteDuration.THIRTY_SECOND -> 0.125
|
||||
}
|
||||
```
|
||||
|
||||
(aggiungere `import com.notemusicali.music.NoteDuration`)
|
||||
|
||||
- [ ] **Step 4: verifica GREEN** — `./gradlew :shared:desktopTest --tests "com.notemusicali.ui.components.StaffGeometryTest"` → PASS
|
||||
|
||||
### Task 2: StaffLayout.beamGroups + stemUpForGroup
|
||||
|
||||
**Files:** come Task 1.
|
||||
|
||||
- [ ] **Step 1: test fallenti** (aggiungere a `StaffGeometryTest`; helper `n(dur)` costruisce una nota DO4 con quella durata)
|
||||
|
||||
```kotlin
|
||||
private fun n(d: NoteDuration) = MusicalNote.fromNameAndOctave(0, 4, d)
|
||||
|
||||
@Test
|
||||
fun `consecutive eighths in the same beat are beamed together`() {
|
||||
val notes = listOf(n(NoteDuration.EIGHTH), n(NoteDuration.EIGHTH), n(NoteDuration.QUARTER))
|
||||
assertEquals(listOf(0..1), StaffLayout.beamGroups(notes, beats = 4, beatType = 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a lone eighth keeps its flag`() {
|
||||
val notes = listOf(n(NoteDuration.EIGHTH), n(NoteDuration.QUARTER), n(NoteDuration.EIGHTH))
|
||||
assertEquals(emptyList(), StaffLayout.beamGroups(notes, 4, 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `beat boundary splits a run of eighths in four four`() {
|
||||
// 4 crome: beat 1 = note 0-1, beat 2 = note 2-3
|
||||
val notes = List(4) { n(NoteDuration.EIGHTH) }
|
||||
assertEquals(listOf(0..1, 2..3), StaffLayout.beamGroups(notes, 4, 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `six eight groups eighths in threes`() {
|
||||
val notes = List(6) { n(NoteDuration.EIGHTH) }
|
||||
assertEquals(listOf(0..2, 3..5), StaffLayout.beamGroups(notes, 6, 8))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sixteenths beam with eighths inside the beat`() {
|
||||
val notes = listOf(n(NoteDuration.EIGHTH), n(NoteDuration.SIXTEENTH), n(NoteDuration.SIXTEENTH))
|
||||
assertEquals(listOf(0..2), StaffLayout.beamGroups(notes, 4, 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stem direction for a group follows the majority`() {
|
||||
assertTrue(StaffLayout.stemUpForGroup(listOf(0, 2, 6))) // 2 su 3 sotto la linea mediana
|
||||
assertTrue(!StaffLayout.stemUpForGroup(listOf(5, 6, 2))) // 2 su 3 sopra
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: verifica RED** — compile error su `beamGroups`
|
||||
- [ ] **Step 3: implementazione** in `StaffLayout`:
|
||||
|
||||
```kotlin
|
||||
/** Gruppi (indici) di note veloci consecutive che iniziano nello stesso beat. */
|
||||
fun beamGroups(notes: List<MusicalNote>, beats: Int, beatType: Int): List<IntRange> {
|
||||
val beatLen = if (beatType == 8 && beats % 3 == 0) 1.5 else 4.0 / beatType
|
||||
val groups = mutableListOf<IntRange>()
|
||||
var groupStart = -1
|
||||
var groupBeat = -1
|
||||
var pos = 0.0
|
||||
fun close(endExclusive: Int) {
|
||||
if (groupStart >= 0 && endExclusive - groupStart >= 2) groups.add(groupStart until endExclusive)
|
||||
groupStart = -1
|
||||
}
|
||||
notes.forEachIndexed { i, note ->
|
||||
val beamable = quarterUnits(note.duration) <= 0.5
|
||||
val beat = (pos / beatLen).toInt()
|
||||
if (beamable) {
|
||||
if (groupStart < 0) { groupStart = i; groupBeat = beat }
|
||||
else if (beat != groupBeat) { close(i); groupStart = i; groupBeat = beat }
|
||||
} else {
|
||||
close(i)
|
||||
}
|
||||
pos += quarterUnits(note.duration)
|
||||
}
|
||||
close(notes.size)
|
||||
return groups
|
||||
}
|
||||
|
||||
/** Gambi in su se la maggioranza delle note del gruppo sta sotto la linea mediana. */
|
||||
fun stemUpForGroup(relPositions: List<Int>): Boolean =
|
||||
relPositions.count { it < 4 } * 2 >= relPositions.size
|
||||
```
|
||||
|
||||
- [ ] **Step 4: verifica GREEN** — suite del file → PASS
|
||||
|
||||
### Task 3: StaffLayout.measurePositions
|
||||
|
||||
**Files:** come Task 1.
|
||||
|
||||
- [ ] **Step 1: test fallenti**
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `barlines fall every four quarters in four four`() {
|
||||
val notes = List(8) { n(NoteDuration.QUARTER) }
|
||||
assertEquals(listOf(3, 7), StaffLayout.measurePositions(notes, 4, 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `barlines fall every three quarters in three four`() {
|
||||
val notes = List(6) { n(NoteDuration.QUARTER) }
|
||||
assertEquals(listOf(2, 5), StaffLayout.measurePositions(notes, 3, 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `overshooting note closes the measure after itself`() {
|
||||
// 2/4: la semibreve (4 quarti) eccede la battuta da 2 → stanghetta dopo di lei
|
||||
val notes = listOf(n(NoteDuration.WHOLE), n(NoteDuration.QUARTER), n(NoteDuration.QUARTER))
|
||||
assertEquals(listOf(0, 2), StaffLayout.measurePositions(notes, 2, 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty sequence has no barlines`() {
|
||||
assertEquals(emptyList(), StaffLayout.measurePositions(emptyList(), 4, 4))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: verifica RED**
|
||||
- [ ] **Step 3: implementazione**
|
||||
|
||||
```kotlin
|
||||
/** Indici di nota DOPO i quali cade una stanghetta (inclusa l'eventuale finale). */
|
||||
fun measurePositions(notes: List<MusicalNote>, beats: Int, beatType: Int): List<Int> {
|
||||
val capacity = beats * (4.0 / beatType)
|
||||
if (capacity <= 0.0) return emptyList()
|
||||
val positions = mutableListOf<Int>()
|
||||
var acc = 0.0
|
||||
notes.forEachIndexed { i, note ->
|
||||
acc += quarterUnits(note.duration)
|
||||
if (acc >= capacity - 1e-9) {
|
||||
positions.add(i)
|
||||
acc = 0.0
|
||||
}
|
||||
}
|
||||
return positions
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: verifica GREEN**
|
||||
|
||||
### Task 4: StaffLayout.xPositions (spaziatura proporzionale)
|
||||
|
||||
**Files:** come Task 1.
|
||||
|
||||
- [ ] **Step 1: test fallenti**
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `x positions give longer notes more room`() {
|
||||
val notes = listOf(n(NoteDuration.HALF), n(NoteDuration.EIGHTH), n(NoteDuration.EIGHTH))
|
||||
val xs = StaffLayout.xPositions(notes, areaStart = 0f, areaWidth = 100f)
|
||||
assertEquals(3, xs.size)
|
||||
val gapLongToShort = xs[1] - xs[0]
|
||||
val gapShortToShort = xs[2] - xs[1]
|
||||
assertTrue(gapLongToShort > gapShortToShort, "la minima deve occupare più spazio: $xs")
|
||||
assertTrue(xs.all { it in 0f..100f })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single note is centered in the area`() {
|
||||
val xs = StaffLayout.xPositions(listOf(n(NoteDuration.QUARTER)), 10f, 100f)
|
||||
assertEquals(60f, xs.single(), absoluteTolerance = 0.5f)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: verifica RED**
|
||||
- [ ] **Step 3: implementazione** (peso = √durata; centro dello slot)
|
||||
|
||||
```kotlin
|
||||
/** 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()
|
||||
val weights = notes.map { kotlin.math.sqrt(quarterUnits(it.duration)).toFloat() }
|
||||
val total = weights.sum()
|
||||
var cursor = 0f
|
||||
return weights.map { w ->
|
||||
val center = areaStart + (cursor + w / 2f) / total * areaWidth
|
||||
cursor += w
|
||||
center
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: verifica GREEN**
|
||||
|
||||
### Task 5: NoteSequence.beats/beatType + parser `<time>`
|
||||
|
||||
**Files:**
|
||||
- Modify: `shared/src/commonMain/kotlin/com/notemusicali/music/NoteSequence.kt`
|
||||
- Modify: `shared/src/commonMain/kotlin/com/notemusicali/music/MusicXmlParser.kt`
|
||||
- Test: `shared/src/commonTest/kotlin/com/notemusicali/music/MusicXmlParserTest.kt`
|
||||
|
||||
- [ ] **Step 1: test fallenti** (in `MusicXmlParserTest`)
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun parseExtractsTimeSignature() {
|
||||
val xml = """
|
||||
<score-partwise><part id="P1"><measure number="1">
|
||||
<attributes><time><beats>3</beats><beat-type>4</beat-type></time></attributes>
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>4</duration></note>
|
||||
</measure></part></score-partwise>
|
||||
""".trimIndent()
|
||||
val sequence = MusicXmlParser.parse(xml)
|
||||
assertEquals(3, sequence.beats)
|
||||
assertEquals(4, sequence.beatType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseDefaultsToFourFourWithoutTimeSignature() {
|
||||
val xml = """
|
||||
<score-partwise><part id="P1"><measure number="1">
|
||||
<note><pitch><step>C</step><octave>4</octave></pitch><duration>4</duration></note>
|
||||
</measure></part></score-partwise>
|
||||
""".trimIndent()
|
||||
val sequence = MusicXmlParser.parse(xml)
|
||||
assertEquals(4, sequence.beats)
|
||||
assertEquals(4, sequence.beatType)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: verifica RED** — `Unresolved reference 'beats'`
|
||||
- [ ] **Step 3: implementazione**
|
||||
|
||||
`NoteSequence.kt`: aggiungere i campi con default
|
||||
|
||||
```kotlin
|
||||
data class NoteSequence(
|
||||
val name: String,
|
||||
val notes: List<MusicalNote>,
|
||||
val beats: Int = 4,
|
||||
val beatType: Int = 4,
|
||||
) { /* corpo esistente invariato */ }
|
||||
```
|
||||
|
||||
`MusicXmlParser.kt`: nuova regex + estrazione nel `parse`
|
||||
|
||||
```kotlin
|
||||
private val TIME_REGEX = Regex(
|
||||
"""<time\b[^>]*>.*?<beats>\s*(\d+)\s*</beats>.*?<beat-type>\s*(\d+)\s*</beat-type>.*?</time>""",
|
||||
RegexOption.DOT_MATCHES_ALL,
|
||||
)
|
||||
```
|
||||
|
||||
e nel `parse`, prima del `return`:
|
||||
|
||||
```kotlin
|
||||
val time = TIME_REGEX.find(partContent)
|
||||
val beats = time?.groupValues?.get(1)?.toIntOrNull() ?: 4
|
||||
val beatType = time?.groupValues?.get(2)?.toIntOrNull() ?: 4
|
||||
return NoteSequence(name = name, notes = notes, beats = beats, beatType = beatType)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: verifica GREEN** — suite completa desktop → PASS
|
||||
|
||||
### Task 6: StaffRenderer.kt — primitive di disegno
|
||||
|
||||
**Files:**
|
||||
- Create: `shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffRenderer.kt`
|
||||
|
||||
Non testabile a unità (DrawScope): la verifica è la compilazione + l'ispezione visiva del Task 10. Coordinate dei `Path` prese dai mockup approvati (unità `u = lineSpacing * 0.09f`, testa larga ~1.35·lineSpacing come oggi).
|
||||
|
||||
- [ ] **Step 1: creare il file** con questo contenuto:
|
||||
|
||||
```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 androidx.compose.ui.unit.sp
|
||||
import com.notemusicali.music.MusicalNote
|
||||
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" (dal mockup approvato): 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)
|
||||
quadraticBezierTo(cx - 5.4f * u, cy - 4.8f * u, cx + 2.4f * u, cy - 4.9f * u)
|
||||
quadraticBezierTo(cx + 7.6f * u, cy - 4.9f * u, cx + 7.4f * u, cy - 0.9f * u)
|
||||
quadraticBezierTo(cx + 6.8f * u, cy + 4.9f * u, cx - 1.6f * u, cy + 5.0f * u)
|
||||
quadraticBezierTo(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: disegna 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 = quartersOf(durations[i]) <= 0.25 && quartersOf(durations[i + 1]) <= 0.25
|
||||
if (bothShort) beamSegment(attachX[i], attachX[i + 1], secondOffset)
|
||||
}
|
||||
}
|
||||
|
||||
private fun quartersOf(d: NoteDuration): Double = StaffLayout.quarterUnits(d)
|
||||
|
||||
/** 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()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: compilare** — `./gradlew :shared:compileKotlinDesktop` → BUILD SUCCESSFUL
|
||||
|
||||
### Task 7: riscrittura StaffView.kt (StaffView + StaffPreview)
|
||||
|
||||
**Files:**
|
||||
- Modify: `shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffView.kt`
|
||||
|
||||
- [ ] **Step 1: StaffView (nota singola)** — sostituire il blocco head/stem/flags/accidental con:
|
||||
|
||||
```kotlin
|
||||
drawNoteHeadPath(noteX, noteY, lineSpacing, note.duration, noteColor)
|
||||
val stemUp = relPos < 4
|
||||
val tip = drawStem(noteX, noteY, lineSpacing, stemUp, note.duration, noteColor)
|
||||
tip?.let { drawCurvedFlags(it, stemUp, note.duration, lineSpacing, noteColor) }
|
||||
drawAccidentalGlyph(note, noteX - staffHeadHalfWidth(lineSpacing) - lineSpacing * 0.7f, noteY, lineSpacing * 0.45f, noteColor)
|
||||
```
|
||||
|
||||
e il blocco chiave con `drawClef(textMeasurer, useTreble, leftMargin + 2f, staffBottom, lineSpacing, clefColor)` (dove `clefColor` = colore note a piena opacità).
|
||||
|
||||
- [ ] **Step 2: StaffPreview** — sostituire il calcolo `noteStep` uniforme con `StaffLayout.xPositions(notes, noteAreaStart, noteAreaWidth)`; calcolare `val groups = StaffLayout.beamGroups(notes, 4, 4)` e disegnare le note nei gruppi con `drawBeamedGroup` (x/y/durate del gruppo, `stemUp = StaffLayout.stemUpForGroup(relPos del gruppo)`), le altre con `drawStem`+`drawCurvedFlags`. Le teste si disegnano per tutte con `drawNoteHeadPath`.
|
||||
- [ ] **Step 3: compilare + suite** — BUILD SUCCESSFUL, test verdi.
|
||||
|
||||
### Task 8: riscrittura StaffContextView.kt
|
||||
|
||||
**Files:**
|
||||
- Modify: `shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffContextView.kt`
|
||||
|
||||
- [ ] **Step 1:** firma: aggiungere parametri `beats: Int = 4, beatType: Int = 4` a `StaffContextView` (i chiamanti in PracticeScreen li passano dalla sequenza, con default invariati altrove).
|
||||
- [ ] **Step 2:** dentro il Canvas: `xs = StaffLayout.xPositions(visibleNotes, noteAreaStart, noteAreaWidth)`; `groups = StaffLayout.beamGroups(visibleNotes, beats, beatType)`; `bars = StaffLayout.measurePositions(notes, beats, beatType)` (calcolate sull'intera sequenza, disegnate se l'indice assoluto cade nella finestra: `x` a metà tra la nota e la successiva). Disegno note: teste `drawNoteHeadPath` sempre; per gli indici nei gruppi raccogliere x/y/durate e chiamare `drawBeamedGroup` una volta per gruppo con il colore della nota corrente se il gruppo la contiene, altrimenti dim; per gli altri `drawStem`+`drawCurvedFlags`. Chiave con `drawClef`. Evidenziazioni (cerchio oro, etichetta) invariate.
|
||||
- [ ] **Step 3:** aggiornare il call-site in `PracticeScreen` (passare `beats`/`beatType` della sequenza corrente).
|
||||
- [ ] **Step 4:** compilare + suite.
|
||||
|
||||
### Task 9: riscrittura StaffFullView.kt
|
||||
|
||||
**Files:**
|
||||
- Modify: `shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffFullView.kt`
|
||||
|
||||
- [ ] **Step 1:** firma: aggiungere `beats: Int = 4, beatType: Int = 4`; call-site in PracticeScreen aggiornato.
|
||||
- [ ] **Step 2:** wrapping per battute: pre-calcolare `bars = StaffLayout.measurePositions(notes, beats, beatType)` e spezzare le righe sul confine di battuta più vicino alla capienza (`notesPerLine` diventa il tetto: la riga termina all'ultima stanghetta che ci sta; se una battuta eccede da sola la riga si spezza comunque a `notesPerLine`). Ogni riga usa `StaffLayout.xPositions` sul proprio segmento, `beamGroups` calcolati per segmento di riga, tempo (4/4) disegnato con `drawTimeSignature` solo sulla prima riga dopo la chiave, `drawBarline` alle stanghette interne e a fine riga se coincide con una battuta.
|
||||
- [ ] **Step 3:** compilare + suite; auto-scroll esistente invariato (usa `currentLine` = riga contenente `currentIndex`, ora derivata dalla mappa nota→riga costruita nello step 2).
|
||||
|
||||
### Task 10: pulizia + verifica completa
|
||||
|
||||
- [ ] **Step 1:** eliminare `NoteRenderer.kt` (drawNoteHead/drawNoteStem/drawNoteFlags non più referenziati — verificare con grep prima di cancellare).
|
||||
- [ ] **Step 2:** suite completa: `./gradlew :shared:desktopTest :shared:iosSimulatorArm64Test :app:assembleDebug` → tutto verde.
|
||||
- [ ] **Step 3:** lancio app desktop (`./gradlew :desktopApp:run`), ispezione visiva di Esercizi (StaffPreview), Pratica in modalità Contesto e Spartito, e Scale; passata di tuning sulle costanti (`u`, spessori, pendenza) se qualcosa stona.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Design: rendering avanzato del pentagramma (InTono)
|
||||
|
||||
Data: 2026-07-22 — Approccio approvato: **disegno raffinato su Canvas** (nessun font musicale embedded).
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Migliorare la resa delle note sul pentagramma nelle viste Compose condivise con quattro upgrade approvati dall'utente:
|
||||
|
||||
1. **Glifi raffinati** — teste "a pera" (`Path`), code curve riempite, ♯/♭ ben proporzionati, chiave a piena opacità e dimensione maggiore.
|
||||
2. **Travature (beam)** — crome/semicrome consecutive nello stesso beat unite da parallelogrammi inclinati.
|
||||
3. **Battute** — stanghette e cifre del tempo (es. 4/4) dopo la chiave.
|
||||
4. **Spaziatura proporzionale** — larghezza orizzontale ∝ √(durata in quarti).
|
||||
|
||||
Non-obiettivi: font SMuFL/Bravura (rimandabile senza rework: la geometria resta valida), pause, legature di portamento, chiavi miste nello stesso rigo, gambi condivisi tra righi.
|
||||
|
||||
## Architettura
|
||||
|
||||
### 1. `StaffLayout` (esteso) — geometria pura, `commonMain`, testabile
|
||||
|
||||
- `quarterUnits(duration: NoteDuration): Double` — WHOLE=4, HALF=2, QUARTER=1, EIGHTH=0.5, SIXTEENTH=0.25, THIRTY_SECOND=0.125.
|
||||
- `beamGroups(notes: List<MusicalNote>, beats: Int, beatType: Int): List<IntRange>` — gruppi di indici di note beamabili (durata ≤ EIGHTH) consecutive che iniziano nello stesso beat; gruppi di 1 elemento esclusi (tengono la coda). Il confine di beat spezza il gruppo.
|
||||
- `measurePositions(notes: List<MusicalNote>, beats: Int, beatType: Int): List<Int>` — indici *dopo* i quali cade una stanghetta (accumulo di quarti ≥ capacità battuta; best effort quando le durate non riempiono esattamente la battuta: la stanghetta cade al superamento, mai eccezioni).
|
||||
- `spacingWeights(notes: List<MusicalNote>): List<Float>` — pesi orizzontali `sqrt(quarterUnits)`, normalizzati dal chiamante sull'ampiezza disponibile.
|
||||
- `stemUp(relPos: Int): Boolean` (regola esistente `relPos < 4`); per un beam group la direzione è quella della maggioranza delle note del gruppo.
|
||||
|
||||
### 2. `StaffRenderer` (nuovo file, `ui/components`) — estensioni `DrawScope`
|
||||
|
||||
Primitive di disegno usate da tutte le viste:
|
||||
|
||||
- `drawNoteHeadPath` — testa a pera via `Path` (quadratiche, come il mockup approvato), piena per ≤ QUARTER, contorno per WHOLE/HALF; rotazione implicita nella forma.
|
||||
- `drawCurvedFlag` — coda a goccia riempita (`Path` cubica), 1–3 code per croma/semicroma/biscroma, specchiata per gambo in giù.
|
||||
- `drawBeam` — parallelogramma tra due estremità di gambo, spessore ~0.5·lineSpacing, pendenza limitata (±0.5·lineSpacing per nota); doppia barra per semicrome.
|
||||
- `drawSharpGlyph` / `drawFlatGlyph` — esistenti, raffinati (proporzioni del mockup).
|
||||
- `drawBarline`, `drawTimeSignature(beats, beatType)` — stanghetta sottile a tutta altezza rigo; cifre con `TextMeasurer` (bold, serif di sistema).
|
||||
- `drawClef` — glifo Unicode esistente ma a piena opacità/colore nota e dimensione maggiore.
|
||||
|
||||
Le funzioni esistenti in `NoteRenderer.kt` (drawNoteHead/drawNoteStem/drawNoteFlags) vengono sostituite dalle nuove primitive; il file viene aggiornato o assorbito da StaffRenderer.
|
||||
|
||||
### 3. Modello e parser
|
||||
|
||||
- `NoteSequence` guadagna `beats: Int = 4`, `beatType: Int = 4`.
|
||||
- `MusicXmlParser` estrae la prima occorrenza di `<time><beats>N</beats><beat-type>M</beat-type></time>` nella prima parte; assente → 4/4.
|
||||
- Inserimento manuale, scale, esercizi, scansione AI: default 4/4.
|
||||
|
||||
### 4. Applicazione per vista
|
||||
|
||||
| Vista | Glifi | Travature | Battute+Tempo | Spaziatura |
|
||||
|---|---|---|---|---|
|
||||
| StaffFullView | ✅ | ✅ | ✅ | ✅ (per rigo) |
|
||||
| StaffContextView | ✅ | ✅ | ✅ (se nella finestra) | ✅ |
|
||||
| StaffPreview | ✅ | ✅ | — | ✅ |
|
||||
| StaffView (nota singola) | ✅ | — | — | — |
|
||||
|
||||
L'evidenziazione (nota corrente oro, suonate sbiadite) resta identica. In StaffFullView il wrapping per riga passa da "N note fisse" a "capienza per pesi", con le battute che preferibilmente non si spezzano a fine riga quando possibile (best effort: si spezza sul confine di battuta più vicino che sta nella riga; se una battuta è più larga della riga, si spezza comunque).
|
||||
|
||||
## Gestione errori
|
||||
|
||||
- Sequenze senza time signature → 4/4.
|
||||
- Durate che non riempiono la battuta (anacrusi, parser lossy) → stanghetta al superamento della soglia; nessuna eccezione.
|
||||
- Beam group con note su registri lontani → pendenza della travatura clampata.
|
||||
- Sequenze vuote → nessun disegno (comportamento attuale).
|
||||
|
||||
## Test
|
||||
|
||||
- **TDD (commonTest)**: `quarterUnits` per tutte le durate; `beamGroups` (coppie di crome, croma singola, confine di beat in 4/4 e 6/8, misto crome/semicrome, interruzione su nota lunga); `measurePositions` (4/4 esatto, 3/4, durate eccedenti, sequenza vuota); `spacingWeights` (monotonia, rapporto √); parser `<time>` (presente, assente, 6/8).
|
||||
- **Verifica visiva**: lancio dell'app desktop e ispezione di Esercizi/Spartiti/Pratica (screenshot).
|
||||
|
||||
## Rischi
|
||||
|
||||
- La resa estetica di path disegnati a mano richiede iterazione visiva: prevista una passata di tuning dopo la prima implementazione, con l'app desktop come banco di prova.
|
||||
- StaffFullView cambia il layout di wrapping: verificare l'auto-scroll alla riga corrente (già esistente) con le nuove larghezze.
|
||||
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
@@ -0,0 +1,59 @@
|
||||
[versions]
|
||||
agp = "8.7.3"
|
||||
kotlin = "2.1.0"
|
||||
compose-multiplatform = "1.7.3"
|
||||
coreKtx = "1.15.0"
|
||||
lifecycleRuntimeKtx = "2.8.7"
|
||||
activityCompose = "1.9.3"
|
||||
composeBom = "2024.12.01"
|
||||
navigationCompose = "2.8.5"
|
||||
coroutines = "1.9.0"
|
||||
camerax = "1.4.1"
|
||||
ktor = "3.0.3"
|
||||
kotlinx-serialization = "1.7.3"
|
||||
multiplatform-settings = "1.2.0"
|
||||
junit = "4.13.2"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
|
||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "coroutines" }
|
||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
|
||||
androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" }
|
||||
androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" }
|
||||
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
|
||||
androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
|
||||
ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" }
|
||||
ktor-client-darwin = { group = "io.ktor", name = "ktor-client-darwin", version.ref = "ktor" }
|
||||
ktor-client-java = { group = "io.ktor", name = "ktor-client-java", version.ref = "ktor" }
|
||||
ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
|
||||
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||
multiplatform-settings = { group = "com.russhwolf", name = "multiplatform-settings-no-arg", version.ref = "multiplatform-settings" }
|
||||
multiplatform-settings-test = { group = "com.russhwolf", name = "multiplatform-settings-test", version.ref = "multiplatform-settings" }
|
||||
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version = "1.1.0-alpha06" }
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
|
||||
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS=""
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn ( ) {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die ( ) {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin, switch paths to Windows format before running java
|
||||
if $cygwin ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=$((i+1))
|
||||
done
|
||||
case $i in
|
||||
(0) set -- ;;
|
||||
(1) set -- "$args0" ;;
|
||||
(2) set -- "$args0" "$args1" ;;
|
||||
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
|
||||
function splitJvmOpts() {
|
||||
JVM_OPTS=("$@")
|
||||
}
|
||||
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
|
||||
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
|
||||
|
||||
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
|
||||
@@ -0,0 +1,90 @@
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windowz variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
if "%@eval[2+2]" == "4" goto 4NT_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
goto execute
|
||||
|
||||
:4NT_args
|
||||
@rem Get arguments from the 4NT Shell from JP Software
|
||||
set CMD_LINE_ARGS=%$
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon_40.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_60.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_58.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_87.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_80.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_120.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_120.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_180.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_20.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_40.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_29.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_58.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_40.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_80.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_76.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_152.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_167.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "83.5x83.5"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_1024.png",
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 659 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"red" : "0.071",
|
||||
"green" : "0.071",
|
||||
"blue" : "0.071",
|
||||
"alpha" : "1.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon_1x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 230 B |
|
After Width: | Height: | Size: 415 B |
|
After Width: | Height: | Size: 669 B |
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>InTono necessita del microfono per rilevare le note suonate</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>InTono necessita della fotocamera per scansionare gli spartiti</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>InTono necessita dell'accesso alle foto per importare immagini di spartiti</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string>LaunchBackground</string>
|
||||
<key>UIImageName</key>
|
||||
<string>LaunchIcon</string>
|
||||
</dict>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeAudioData</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypePhotosOrVideos</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,19 @@
|
||||
import SwiftUI
|
||||
import shared
|
||||
|
||||
@main
|
||||
struct iOSApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView: UIViewControllerRepresentable {
|
||||
func makeUIViewController(context: Context) -> UIViewController {
|
||||
MainViewControllerKt.MainViewController()
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
name: iosApp
|
||||
options:
|
||||
bundleIdPrefix: com.notemusicali
|
||||
deploymentTarget:
|
||||
iOS: "16.0"
|
||||
xcodeVersion: "15.0"
|
||||
|
||||
settings:
|
||||
base:
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: "NO"
|
||||
|
||||
targets:
|
||||
iosApp:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: iosApp
|
||||
settings:
|
||||
base:
|
||||
INFOPLIST_FILE: iosApp/Info.plist
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.notemusicali.iosApp
|
||||
PRODUCT_NAME: InTono
|
||||
DEVELOPMENT_TEAM: X4PCY86YL9
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
FRAMEWORK_SEARCH_PATHS:
|
||||
- "$(inherited)"
|
||||
- "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"
|
||||
OTHER_LINKER_FLAGS:
|
||||
- "$(inherited)"
|
||||
- "-ObjC"
|
||||
- "-framework"
|
||||
- "shared"
|
||||
SWIFT_VERSION: "5.0"
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
IPHONEOS_DEPLOYMENT_TARGET: "16.0"
|
||||
preBuildScripts:
|
||||
- name: "Compile Kotlin Framework"
|
||||
script: |
|
||||
cd "$SRCROOT/.."
|
||||
./gradlew :shared:embedAndSignAppleFrameworkForXcode
|
||||
basedOnDependencyAnalysis: false
|
||||
@@ -0,0 +1,79 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>InTono - Privacy Policy</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px;
|
||||
background: #fafafa;
|
||||
}
|
||||
h1 { font-size: 28px; margin-bottom: 8px; color: #1a1a1a; }
|
||||
h2 { font-size: 20px; margin-top: 28px; margin-bottom: 12px; color: #1a1a1a; }
|
||||
p, li { margin-bottom: 10px; font-size: 15px; }
|
||||
ul { padding-left: 24px; }
|
||||
.subtitle { color: #666; font-size: 14px; margin-bottom: 24px; }
|
||||
.updated { color: #999; font-size: 13px; margin-top: 32px; border-top: 1px solid #ddd; padding-top: 16px; }
|
||||
a { color: #1976D2; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Privacy Policy</h1>
|
||||
<p class="subtitle">InTono - Accordatore e pratica per musicisti</p>
|
||||
|
||||
<h2>Introduzione</h2>
|
||||
<p>La presente informativa descrive come InTono ("l'App"), sviluppata da LuZa, gestisce i dati dell'utente. La tua privacy è importante per noi e ci impegniamo a proteggerla.</p>
|
||||
|
||||
<h2>Dati raccolti</h2>
|
||||
<p>InTono raccoglie e utilizza i seguenti dati esclusivamente sul tuo dispositivo:</p>
|
||||
<ul>
|
||||
<li><strong>Audio dal microfono:</strong> l'App accede al microfono del dispositivo per rilevare le note musicali in tempo reale (accordatore, pratica, sfida). L'audio viene elaborato localmente e <strong>non viene mai registrato, salvato o trasmesso</strong> a server esterni.</li>
|
||||
<li><strong>Fotocamera:</strong> l'App può accedere alla fotocamera per scansionare spartiti musicali tramite riconoscimento AI. Le immagini vengono inviate al servizio API selezionato (Anthropic o OpenAI) esclusivamente per il riconoscimento delle note e non vengono conservate.</li>
|
||||
<li><strong>Dati di pratica:</strong> statistiche delle sessioni (precisione, tempo, punteggi) vengono salvate localmente sul dispositivo per mostrare progressi e cronologia.</li>
|
||||
<li><strong>Preferenze:</strong> impostazioni dell'app (obiettivi giornalieri, cartella spartiti, chiave API) vengono salvate localmente sul dispositivo.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Chiavi API</h2>
|
||||
<p>Per la funzione di scansione spartiti, l'utente può inserire una propria chiave API (Anthropic o OpenAI). Questa chiave viene salvata localmente sul dispositivo in forma offuscata e viene utilizzata esclusivamente per le richieste di riconoscimento delle note. Non viene mai condivisa con terze parti.</p>
|
||||
|
||||
<h2>Servizi di terze parti</h2>
|
||||
<p>L'App utilizza servizi di terze parti solo quando l'utente attiva la funzione di scansione spartiti:</p>
|
||||
<ul>
|
||||
<li><strong>Anthropic API</strong> o <strong>OpenAI API:</strong> per il riconoscimento delle note da immagini di spartiti. Le immagini vengono inviate tramite connessione crittografata (HTTPS). Consulta le rispettive privacy policy: <a href="https://www.anthropic.com/privacy">Anthropic</a>, <a href="https://openai.com/privacy">OpenAI</a>.</li>
|
||||
</ul>
|
||||
<p>L'App non utilizza servizi di analytics, tracciamento, pubblicità o altri servizi di terze parti.</p>
|
||||
|
||||
<h2>Archiviazione dei dati</h2>
|
||||
<p>Tutti i dati dell'App (statistiche, preferenze, punteggi) sono salvati esclusivamente sul tuo dispositivo. Non vengono trasmessi a server esterni. La disinstallazione dell'App rimuove tutti i dati.</p>
|
||||
|
||||
<h2>Permessi richiesti</h2>
|
||||
<ul>
|
||||
<li><strong>Microfono:</strong> necessario per il rilevamento delle note (accordatore, pratica, sfida, ear training).</li>
|
||||
<li><strong>Fotocamera:</strong> necessaria per la scansione degli spartiti.</li>
|
||||
<li><strong>Accesso ai file:</strong> necessario per importare spartiti MusicXML dal dispositivo.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Sicurezza</h2>
|
||||
<p>L'elaborazione audio avviene interamente sul dispositivo. Le comunicazioni con servizi esterni (solo per la scansione spartiti) avvengono tramite connessione crittografata HTTPS.</p>
|
||||
|
||||
<h2>Minori</h2>
|
||||
<p>L'App non raccoglie consapevolmente dati personali da minori di 13 anni. L'App non richiede la creazione di account e non raccoglie dati identificativi.</p>
|
||||
|
||||
<h2>Modifiche alla privacy policy</h2>
|
||||
<p>Eventuali modifiche a questa privacy policy verranno pubblicate su questa pagina con la data di aggiornamento.</p>
|
||||
|
||||
<h2>Contatti</h2>
|
||||
<p>Per domande sulla privacy o sull'App, contattaci a: <a href="mailto:info@djluza.com">info@djluza.com</a></p>
|
||||
|
||||
<p class="updated">Ultimo aggiornamento: 27 febbraio 2026</p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google {
|
||||
content {
|
||||
includeGroupByRegex("com\\.android.*")
|
||||
includeGroupByRegex("com\\.google.*")
|
||||
includeGroupByRegex("androidx.*")
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
@Suppress("UnstableApiUsage")
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "NoteMusicali"
|
||||
include(":app")
|
||||
include(":shared")
|
||||
include(":desktopApp")
|
||||
@@ -0,0 +1,103 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.compose.multiplatform)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
|
||||
compilerOptions {
|
||||
freeCompilerArgs.add("-Xexpect-actual-classes")
|
||||
}
|
||||
|
||||
applyDefaultHierarchyTemplate()
|
||||
|
||||
androidTarget {
|
||||
compilations.all {
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
jvm("desktop") {
|
||||
compilations.all {
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
listOf(
|
||||
iosX64(),
|
||||
iosArm64(),
|
||||
iosSimulatorArm64(),
|
||||
).forEach { target ->
|
||||
target.binaries.framework {
|
||||
baseName = "shared"
|
||||
isStatic = true
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(compose.runtime)
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.material3)
|
||||
implementation(compose.materialIconsExtended)
|
||||
implementation(compose.ui)
|
||||
implementation(compose.components.resources)
|
||||
implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4")
|
||||
implementation("org.jetbrains.androidx.navigation:navigation-compose:2.8.0-alpha10")
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
implementation(libs.multiplatform.settings)
|
||||
}
|
||||
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
implementation(libs.multiplatform.settings.test)
|
||||
}
|
||||
|
||||
androidMain.dependencies {
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.androidx.security.crypto)
|
||||
implementation(libs.ktor.client.cio)
|
||||
implementation(libs.androidx.camera.core)
|
||||
implementation(libs.androidx.camera.camera2)
|
||||
implementation(libs.androidx.camera.lifecycle)
|
||||
implementation(libs.androidx.camera.view)
|
||||
}
|
||||
|
||||
val desktopMain by getting {
|
||||
dependencies {
|
||||
implementation(libs.ktor.client.java)
|
||||
implementation(libs.kotlinx.coroutines.swing)
|
||||
}
|
||||
}
|
||||
|
||||
iosMain.dependencies {
|
||||
implementation(libs.ktor.client.darwin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.notemusicali.shared"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.notemusicali.audio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
private class AndroidAudioSource(
|
||||
private val sampleRate: Int,
|
||||
private val bufferSize: Int,
|
||||
) : AudioSource {
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun audioFlow(): Flow<FloatArray> = callbackFlow {
|
||||
val minBufferSize = AudioRecord.getMinBufferSize(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_FLOAT,
|
||||
)
|
||||
val recordBufferSize = maxOf(minBufferSize, bufferSize * 4)
|
||||
|
||||
val audioRecord = AudioRecord(
|
||||
MediaRecorder.AudioSource.MIC,
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_FLOAT,
|
||||
recordBufferSize,
|
||||
)
|
||||
|
||||
if (audioRecord.state != AudioRecord.STATE_INITIALIZED) {
|
||||
audioRecord.release()
|
||||
close(IllegalStateException("AudioRecord failed to initialize"))
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
audioRecord.startRecording()
|
||||
|
||||
val buffer = FloatArray(bufferSize)
|
||||
|
||||
try {
|
||||
while (isActive) {
|
||||
val read = audioRecord.read(buffer, 0, bufferSize, AudioRecord.READ_BLOCKING)
|
||||
if (read > 0) {
|
||||
trySend(buffer.copyOf(read))
|
||||
} else if (read < 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
audioRecord.stop()
|
||||
audioRecord.release()
|
||||
}
|
||||
|
||||
awaitClose()
|
||||
}
|
||||
}
|
||||
|
||||
actual fun createAudioSource(sampleRate: Int, bufferSize: Int): AudioSource =
|
||||
AndroidAudioSource(sampleRate, bufferSize)
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.notemusicali.audio
|
||||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioTrack
|
||||
|
||||
private class AndroidToneGenerator(
|
||||
private val sampleRate: Int,
|
||||
) : ToneGenerator {
|
||||
|
||||
private var audioTrack: AudioTrack? = null
|
||||
|
||||
override fun playTone(frequencyHz: Float, durationMs: Long) {
|
||||
stop()
|
||||
val floatSamples = WaveGenerator.generateToneSamples(frequencyHz, durationMs, sampleRate)
|
||||
playFloatSamples(floatSamples)
|
||||
}
|
||||
|
||||
override fun playClick(accent: Boolean) {
|
||||
stop()
|
||||
val floatSamples = WaveGenerator.generateClickSamples(accent, sampleRate)
|
||||
playFloatSamples(floatSamples)
|
||||
}
|
||||
|
||||
private fun playFloatSamples(floatSamples: FloatArray) {
|
||||
val buffer = ShortArray(floatSamples.size) { i ->
|
||||
(floatSamples[i] * Short.MAX_VALUE).toInt().coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort()
|
||||
}
|
||||
|
||||
val track = AudioTrack.Builder()
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
.build()
|
||||
)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.build()
|
||||
)
|
||||
.setBufferSizeInBytes(buffer.size * 2)
|
||||
.setTransferMode(AudioTrack.MODE_STATIC)
|
||||
.build()
|
||||
|
||||
track.write(buffer, 0, buffer.size)
|
||||
track.play()
|
||||
audioTrack = track
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
audioTrack?.let { track ->
|
||||
try {
|
||||
track.stop()
|
||||
track.release()
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
audioTrack = null
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
actual fun createToneGenerator(sampleRate: Int): ToneGenerator =
|
||||
AndroidToneGenerator(sampleRate)
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.notemusicali.scan
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Base64
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
actual typealias PlatformImage = Bitmap
|
||||
|
||||
actual fun PlatformImage.toBase64Jpeg(maxDim: Int, quality: Int): String {
|
||||
val scaled = if (width > maxDim || height > maxDim) {
|
||||
val scale = maxDim.toFloat() / maxOf(width, height)
|
||||
Bitmap.createScaledBitmap(
|
||||
this,
|
||||
(width * scale).toInt(),
|
||||
(height * scale).toInt(),
|
||||
true,
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
val stream = ByteArrayOutputStream()
|
||||
scaled.compress(Bitmap.CompressFormat.JPEG, quality, stream)
|
||||
return Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.notemusicali.scan
|
||||
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.notemusicali.scores.ScoreProviderContext
|
||||
import com.russhwolf.settings.SharedPreferencesSettings
|
||||
import com.russhwolf.settings.Settings
|
||||
|
||||
internal actual fun createSecureSettings(): Settings? {
|
||||
val context = ScoreProviderContext.appContext ?: return null
|
||||
return try {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
val prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"intono_secure_prefs",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
SharedPreferencesSettings(prefs)
|
||||
} catch (_: Exception) {
|
||||
// Keystore unavailable (rare): fall back to the obfuscated store
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.notemusicali.ui.components
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.notemusicali.scores.extractMusicXmlFromBytes
|
||||
|
||||
@Composable
|
||||
actual fun rememberFilePickerLauncher(
|
||||
mimeTypes: List<String>,
|
||||
onFileContent: (String) -> Unit,
|
||||
): () -> Unit {
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument()
|
||||
) { uri: Uri? ->
|
||||
uri?.let {
|
||||
try {
|
||||
context.contentResolver.openInputStream(it)?.use { stream ->
|
||||
val bytes = stream.readBytes()
|
||||
val content = extractMusicXmlFromBytes(bytes)
|
||||
if (content != null) {
|
||||
onFileContent(content)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// File read failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { launcher.launch(mimeTypes.toTypedArray()) }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.notemusicali.ui.components
|
||||
|
||||
import android.content.Intent
|
||||
import android.provider.DocumentsContract
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@Composable
|
||||
actual fun rememberFolderPickerLauncher(
|
||||
onFolderSelected: (uri: String, displayName: String) -> Unit,
|
||||
): () -> Unit {
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree()
|
||||
) { uri ->
|
||||
uri?.let {
|
||||
try {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
it, Intent.FLAG_GRANT_READ_URI_PERMISSION,
|
||||
)
|
||||
} catch (_: Exception) {}
|
||||
val docId = DocumentsContract.getTreeDocumentId(it)
|
||||
val name = docId
|
||||
.substringAfterLast(":")
|
||||
.substringAfterLast("/")
|
||||
.ifEmpty { "Cartella" }
|
||||
onFolderSelected(it.toString(), name)
|
||||
}
|
||||
}
|
||||
return { launcher.launch(null) }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.notemusicali.ui.components
|
||||
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
|
||||
@Composable
|
||||
actual fun KeepScreenOn() {
|
||||
val view = LocalView.current
|
||||
DisposableEffect(Unit) {
|
||||
val window = (view.context as? android.app.Activity)?.window
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
onDispose {
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.notemusicali.ui.scan
|
||||
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageCapture
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.notemusicali.scan.PlatformImage
|
||||
|
||||
@Composable
|
||||
actual fun CameraCapture(
|
||||
onImageCaptured: (PlatformImage) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val imageCapture = remember { ImageCapture.Builder().build() }
|
||||
|
||||
Box(modifier = modifier) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PreviewView(ctx).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
||||
cameraProviderFuture.addListener({
|
||||
val cameraProvider = cameraProviderFuture.get()
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.surfaceProvider = surfaceProvider
|
||||
}
|
||||
|
||||
try {
|
||||
cameraProvider.unbindAll()
|
||||
cameraProvider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||
preview,
|
||||
imageCapture,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("CameraCapture", "Camera bind failed", e)
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(ctx))
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
||||
// Capture button
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 48.dp)
|
||||
.size(72.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White)
|
||||
.border(4.dp, Color.White.copy(alpha = 0.5f), CircleShape)
|
||||
.clickable {
|
||||
imageCapture.takePicture(
|
||||
ContextCompat.getMainExecutor(context),
|
||||
object : ImageCapture.OnImageCapturedCallback() {
|
||||
override fun onCaptureSuccess(image: ImageProxy) {
|
||||
val bitmap = image.toBitmap()
|
||||
image.close()
|
||||
onImageCaptured(bitmap)
|
||||
}
|
||||
|
||||
override fun onError(exception: androidx.camera.core.ImageCaptureException) {
|
||||
Log.e("CameraCapture", "Capture failed", exception)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(60.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White)
|
||||
.border(2.dp, Color.LightGray, CircleShape),
|
||||
)
|
||||
}
|
||||
|
||||
// Hint text
|
||||
Text(
|
||||
text = "Inquadra lo spartito e scatta",
|
||||
fontSize = 14.sp,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 132.dp)
|
||||
.background(Color.Black.copy(alpha = 0.4f), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.notemusicali.util
|
||||
|
||||
import android.util.Log
|
||||
|
||||
actual object CrashHandler {
|
||||
private const val TAG = "InTono"
|
||||
|
||||
actual fun install() {
|
||||
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
Log.e(TAG, "Uncaught exception in ${thread.name}", throwable)
|
||||
defaultHandler?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.notemusicali.util
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
|
||||
|
||||
actual fun formatEpochDate(epochMillis: Long): String {
|
||||
val sdf = SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.getDefault())
|
||||
return sdf.format(Date(epochMillis))
|
||||
}
|
||||
|
||||
actual fun currentDateString(): String {
|
||||
// Machine-readable format parsed by DailyGoalManager: must stay ASCII in every locale
|
||||
val sdf = SimpleDateFormat("yyyyMMdd", Locale.US)
|
||||
return sdf.format(Date())
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<resources>
|
||||
<!-- App -->
|
||||
<string name="app_name">InTono</string>
|
||||
<string name="app_subtitle">Pitch check and practice for musicians</string>
|
||||
|
||||
<!-- Home sections -->
|
||||
<string name="section_tools">Tools</string>
|
||||
<string name="section_practice">Practice</string>
|
||||
<string name="section_challenge">Challenge</string>
|
||||
<string name="section_progress">Progress</string>
|
||||
|
||||
<!-- 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="metronome_title">Metronome</string>
|
||||
<string name="metronome_desc">Adjustable tempo with accents and tap tempo</string>
|
||||
<string name="reference_title">Reference Note</string>
|
||||
<string name="reference_desc">Generate any note for tuning or ear training</string>
|
||||
<string name="practice_title">Practice Mode</string>
|
||||
<string name="practice_desc">Note sequences with visual guidance and automatic progression</string>
|
||||
<string name="exercises_title">Exercises</string>
|
||||
<string name="exercises_desc">Guided exercises with progressive difficulty</string>
|
||||
<string name="scales_title">Scales & Arpeggios</string>
|
||||
<string name="scales_desc">Generate scales and arpeggios in any key</string>
|
||||
<string name="scores_title">Scores</string>
|
||||
<string name="scores_desc">Browse MusicXML scores and practice</string>
|
||||
<string name="ear_training_title">Ear Training</string>
|
||||
<string name="ear_training_desc">Recognize notes and intervals by ear</string>
|
||||
<string name="challenge_title">Challenge Mode</string>
|
||||
<string name="challenge_desc">Play as many correct notes as you can before time runs out!</string>
|
||||
<string name="leaderboard_title">Leaderboard</string>
|
||||
<string name="leaderboard_desc">Your best challenge scores</string>
|
||||
<string name="stats_title">Statistics</string>
|
||||
<string name="stats_desc">Charts for accuracy, speed and progress</string>
|
||||
<string name="goals_title">Daily Goals</string>
|
||||
<string name="goals_desc">Set goals and keep your streak going</string>
|
||||
<string name="history_title">History</string>
|
||||
<string name="history_desc">Review past practice sessions</string>
|
||||
<string name="guide_title">Guide</string>
|
||||
<string name="guide_desc">How to use the app and tips</string>
|
||||
|
||||
<!-- Navigation -->
|
||||
<string name="back">Back</string>
|
||||
|
||||
<!-- Common -->
|
||||
<string name="octave">Octave</string>
|
||||
<string name="octave_n">Octave %1$s</string>
|
||||
<string name="start">Start</string>
|
||||
<string name="finish">Finish</string>
|
||||
<string name="retry">Retry</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="error">Error</string>
|
||||
<string name="play_note">Play:</string>
|
||||
<string name="detected">Detected:</string>
|
||||
<string name="notes">Notes</string>
|
||||
<string name="instrument">Instrument</string>
|
||||
<string name="duration">Duration</string>
|
||||
<string name="score">Score</string>
|
||||
<string name="combo">Combo</string>
|
||||
<string name="points">points</string>
|
||||
<string name="partial">Partial</string>
|
||||
<string name="n_notes">%1$d notes</string>
|
||||
<string name="note_n_of_total">Note %1$d of %2$d</string>
|
||||
<string name="select_note">Select note</string>
|
||||
<string name="direction">Direction</string>
|
||||
|
||||
<!-- Tuner -->
|
||||
<string name="tuner_silence">Silence</string>
|
||||
|
||||
<!-- Metronome -->
|
||||
<string name="bpm">BPM</string>
|
||||
<string name="time_signature">Time</string>
|
||||
<string name="tap_tempo">Tap Tempo</string>
|
||||
|
||||
<!-- Reference Note -->
|
||||
<string name="la_440">A 440 Hz</string>
|
||||
<string name="octave_freq">Octave %1$d • %2$s Hz</string>
|
||||
|
||||
<!-- Practice -->
|
||||
<string name="practice_choose">Choose how to enter notes</string>
|
||||
<string name="manual_input_title">Manual Input</string>
|
||||
<string name="manual_input_desc">Select notes one by one using the grid</string>
|
||||
<string name="import_musicxml_title">Import MusicXML</string>
|
||||
<string name="import_musicxml_desc">Load a score from MuseScore, Finale or Sibelius</string>
|
||||
<string name="scan_title">Scan Score</string>
|
||||
<string name="scan_desc">Take a photo of the score and recognize notes with AI</string>
|
||||
<string name="view_note">Note</string>
|
||||
<string name="view_context">Context</string>
|
||||
<string name="view_score">Score</string>
|
||||
<string name="view_light">Light</string>
|
||||
<string name="hide_note_name">Hide</string>
|
||||
<string name="completed">Completed!</string>
|
||||
<string name="all_notes_correct">All notes played correctly</string>
|
||||
<string name="avg_deviation">Average deviation</string>
|
||||
<string name="perfect_notes">Perfect notes</string>
|
||||
<string name="first_attempt">First attempt</string>
|
||||
<string name="repeat">Repeat</string>
|
||||
|
||||
<!-- Manual Input -->
|
||||
<string name="note_input_title">Note Input</string>
|
||||
<string name="selected_notes">Selected notes (%1$d)</string>
|
||||
<string name="undo_last">Undo Last</string>
|
||||
<string name="clear_all">Clear All</string>
|
||||
<string name="start_practice_n">Start Practice (%1$d notes)</string>
|
||||
<string name="manual_sequence_name">Manual Sequence</string>
|
||||
|
||||
<!-- Exercises -->
|
||||
<string name="level_n">Level %1$d</string>
|
||||
|
||||
<!-- Scales -->
|
||||
<string name="tab_scales">Scales</string>
|
||||
<string name="tab_arpeggios">Arpeggios</string>
|
||||
<string name="root_note">Root note</string>
|
||||
<string name="scale_type">Scale type</string>
|
||||
<string name="arpeggio_type">Arpeggio type</string>
|
||||
<string name="start_practice">Practice</string>
|
||||
|
||||
<!-- Scores -->
|
||||
<string name="folder_app">App folder</string>
|
||||
<string name="folder_named">Folder: %1$s</string>
|
||||
<string name="change_folder">Change folder</string>
|
||||
<string name="change">Change</string>
|
||||
<string name="no_scores_found">No scores found.</string>
|
||||
<string name="folder">Folder</string>
|
||||
<string name="score_file">Score</string>
|
||||
|
||||
<!-- Ear Training -->
|
||||
<string name="difficulty">Difficulty</string>
|
||||
<string name="question_n">Question %1$d/%2$d</string>
|
||||
<string name="correct_count">%1$d correct</string>
|
||||
<string name="play_sound">Play</string>
|
||||
<string name="listening">Listening...</string>
|
||||
<string name="press_to_listen">Press to listen</string>
|
||||
<string name="session_completed">Session Completed!</string>
|
||||
<string name="correct_answers">correct answers</string>
|
||||
<string name="result">Result</string>
|
||||
|
||||
<!-- Challenge -->
|
||||
<string name="challenge_subtitle">Play as many correct notes before time runs out!</string>
|
||||
<string name="challenge_completed">Challenge Completed!</string>
|
||||
<string name="notes_played">Notes played</string>
|
||||
<string name="max_combo">Max combo</string>
|
||||
<string name="time">Time</string>
|
||||
<string name="start_challenge">Start Challenge!</string>
|
||||
<string name="challenge_random">Random Challenge</string>
|
||||
<string name="source_exercises">Exercises</string>
|
||||
<string name="source_scales">Scales</string>
|
||||
<string name="source_random">Random</string>
|
||||
<string name="sequence">Sequence</string>
|
||||
<string name="time_limit">Time limit</string>
|
||||
|
||||
<!-- Leaderboard -->
|
||||
<string name="no_results">No results yet. Play a challenge!</string>
|
||||
|
||||
<!-- Stats -->
|
||||
<string name="summary">Summary</string>
|
||||
<string name="sessions">Sessions</string>
|
||||
<string name="sessions_completed">Completed</string>
|
||||
<string name="practice_time">Practice time</string>
|
||||
<string name="best_accuracy">Best accuracy</string>
|
||||
<string name="accuracy_cents">Accuracy (cents)</string>
|
||||
<string name="cents">cents</string>
|
||||
<string name="first_attempt_pct">First attempt (%)</string>
|
||||
<string name="reaction_time_ms">Reaction time (ms)</string>
|
||||
<string name="ms">ms</string>
|
||||
<string name="pct">%</string>
|
||||
<string name="no_stats_yet">Complete some practice sessions to see statistics!</string>
|
||||
|
||||
<!-- Goals -->
|
||||
<string name="consecutive_day_one">consecutive day</string>
|
||||
<string name="consecutive_days">consecutive days</string>
|
||||
<string name="start_streak">Start your streak!</string>
|
||||
<string name="of_target">of %1$dm</string>
|
||||
<string name="goal_reached">Goal reached!</string>
|
||||
<string name="daily_goal">Daily goal</string>
|
||||
<string name="last_30_days">Last 30 days</string>
|
||||
<string name="no_recent_activity">No recent activity</string>
|
||||
|
||||
<!-- History -->
|
||||
<string name="no_sessions">No sessions saved.\nComplete an activity to see it here.</string>
|
||||
<string name="history_type_practice">Practice</string>
|
||||
<string name="history_type_ear_training">Ear Training</string>
|
||||
<string name="history_type_challenge">Challenge</string>
|
||||
<string name="history_ear_note">Notes</string>
|
||||
<string name="history_ear_interval">Intervals</string>
|
||||
<string name="history_score">Score</string>
|
||||
<string name="history_combo">combo</string>
|
||||
<string name="history_correct">correct</string>
|
||||
|
||||
<!-- Scan -->
|
||||
<string name="analyzing">Analyzing...</string>
|
||||
<string name="analyzing_desc">Recognizing notes from the score</string>
|
||||
<string name="notes_detected">%1$d notes detected</string>
|
||||
<string name="tap_to_remove">Tap a note to remove it</string>
|
||||
<string name="take_again">Take again</string>
|
||||
<string name="change_api_key">Change API Key</string>
|
||||
<string name="api_key">API Key</string>
|
||||
<string name="enter_anthropic_key">Enter your Anthropic API key</string>
|
||||
<string name="enter_openai_key">Enter your OpenAI API key</string>
|
||||
<string name="get_anthropic_key">Get your key at console.anthropic.com</string>
|
||||
<string name="get_openai_key">Get your key at platform.openai.com</string>
|
||||
|
||||
<!-- 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_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_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>
|
||||
<string name="guide_ear_training_desc">Train your musical ear: Recognize Note (guess the note you hear) and Recognize Interval (identify the interval between two notes). Three difficulty levels with 10 questions per session.</string>
|
||||
<string name="guide_challenge_desc">Test your speed and accuracy: choose source and time limit (30s, 1min, 2min) and play as many correct notes as possible. Each note is shown on the staff. Scoring system with accuracy bonus, speed bonus and combo multiplier.</string>
|
||||
<string name="guide_leaderboard_desc">Your top 20 challenge scores, with score, notes played, max combo and date.</string>
|
||||
<string name="guide_stats_desc">Overview of your progress: total sessions, practice time, best accuracy. Daily charts for accuracy, first attempt and reaction time.</string>
|
||||
<string name="guide_goals_desc">Set a goal (5, 10, 15 or 30 minutes per day), follow the circular progress, maintain your streak and view the calendar of the last 30 days. Time is tracked from all activities: practice, challenges and ear training.</string>
|
||||
<string name="guide_history_desc">Review all past sessions in a single chronological list: practice, ear training and challenges. Each type has a colored badge for easy identification. Tap a practice session to replay it.</string>
|
||||
<string name="guide_tip_quiet">Quiet environment: the app works best with little background noise.</string>
|
||||
<string name="guide_tip_distance">Microphone distance: keep the device 30-50 cm from your instrument.</string>
|
||||
<string name="guide_tip_daily">Daily practice: even 10 minutes a day makes a difference.</string>
|
||||
<string name="guide_tip_scales">Start with scales: mastering scales is the foundation for playing any piece.</string>
|
||||
<string name="guide_tip_ear">Regular ear training: even 5 minutes a day greatly improves your musical ear.</string>
|
||||
<string name="guide_section_support">Support</string>
|
||||
<string name="guide_support_desc">Need help, want to report a bug, or suggest an improvement? Write to us!</string>
|
||||
<string name="guide_support_button">Contact us via email</string>
|
||||
<string name="guide_support_email">info@djluza.com</string>
|
||||
|
||||
<!-- Info -->
|
||||
<string name="guide_section_info">About</string>
|
||||
<string name="info_version">Version %1$s</string>
|
||||
<string name="info_developer">Developed by LuZa</string>
|
||||
<string name="info_website">djluza.com</string>
|
||||
<string name="info_privacy_policy">Privacy Policy</string>
|
||||
<string name="info_privacy_url">https://djluza.com/intono/privacy</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,237 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<resources>
|
||||
<!-- App -->
|
||||
<string name="app_name">InTono</string>
|
||||
<string name="app_subtitle">Intonazione e pratica per musicisti</string>
|
||||
|
||||
<!-- Home sections -->
|
||||
<string name="section_tools">Strumenti</string>
|
||||
<string name="section_practice">Pratica</string>
|
||||
<string name="section_challenge">Sfida</string>
|
||||
<string name="section_progress">Progressi</string>
|
||||
|
||||
<!-- Home cards -->
|
||||
<string name="tuner_title">Controllo Intonazione</string>
|
||||
<string name="tuner_desc">Verifica in tempo reale se stai suonando la nota corretta</string>
|
||||
<string name="metronome_title">Metronomo</string>
|
||||
<string name="metronome_desc">Tempo regolabile con accenti e tap tempo</string>
|
||||
<string name="reference_title">Nota di Riferimento</string>
|
||||
<string name="reference_desc">Genera qualsiasi nota per accordare o esercitarsi</string>
|
||||
<string name="practice_title">Modalita Pratica</string>
|
||||
<string name="practice_desc">Sequenze di note con guida visiva e avanzamento automatico</string>
|
||||
<string name="exercises_title">Esercizi</string>
|
||||
<string name="exercises_desc">Esercizi guidati con difficolta progressiva</string>
|
||||
<string name="scales_title">Scale e Arpeggi</string>
|
||||
<string name="scales_desc">Genera scale e arpeggi in qualsiasi tonalita</string>
|
||||
<string name="scores_title">Spartiti</string>
|
||||
<string name="scores_desc">Sfoglia spartiti MusicXML e pratica</string>
|
||||
<string name="ear_training_title">Ear Training</string>
|
||||
<string name="ear_training_desc">Riconosci note e intervalli ad orecchio</string>
|
||||
<string name="challenge_title">Modalita Sfida</string>
|
||||
<string name="challenge_desc">Suona piu note possibili nel tempo limite!</string>
|
||||
<string name="leaderboard_title">Classifica</string>
|
||||
<string name="leaderboard_desc">I tuoi migliori punteggi nelle sfide</string>
|
||||
<string name="stats_title">Statistiche</string>
|
||||
<string name="stats_desc">Grafici di precisione, velocita e progressi</string>
|
||||
<string name="goals_title">Obiettivi Giornalieri</string>
|
||||
<string name="goals_desc">Imposta obiettivi e mantieni la serie</string>
|
||||
<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>
|
||||
|
||||
<!-- Navigation -->
|
||||
<string name="back">Indietro</string>
|
||||
|
||||
<!-- Common -->
|
||||
<string name="octave">Ottava</string>
|
||||
<string name="octave_n">Ottava %1$s</string>
|
||||
<string name="start">Inizia</string>
|
||||
<string name="finish">Fine</string>
|
||||
<string name="retry">Riprova</string>
|
||||
<string name="cancel">Annulla</string>
|
||||
<string name="save">Salva</string>
|
||||
<string name="error">Errore</string>
|
||||
<string name="play_note">Suona:</string>
|
||||
<string name="detected">Rilevato:</string>
|
||||
<string name="notes">Note</string>
|
||||
<string name="instrument">Strumento</string>
|
||||
<string name="duration">Durata</string>
|
||||
<string name="score">Punteggio</string>
|
||||
<string name="combo">Combo</string>
|
||||
<string name="points">punti</string>
|
||||
<string name="partial">Parziale</string>
|
||||
<string name="n_notes">%1$d note</string>
|
||||
<string name="note_n_of_total">Nota %1$d di %2$d</string>
|
||||
<string name="select_note">Seleziona nota</string>
|
||||
<string name="direction">Direzione</string>
|
||||
|
||||
<!-- Tuner -->
|
||||
<string name="tuner_silence">Silenzio</string>
|
||||
|
||||
<!-- Metronome -->
|
||||
<string name="bpm">BPM</string>
|
||||
<string name="time_signature">Tempo</string>
|
||||
<string name="tap_tempo">Tap Tempo</string>
|
||||
|
||||
<!-- Reference Note -->
|
||||
<string name="la_440">LA 440 Hz</string>
|
||||
<string name="octave_freq">Ottava %1$d • %2$s Hz</string>
|
||||
|
||||
<!-- Practice -->
|
||||
<string name="practice_choose">Scegli come inserire le note</string>
|
||||
<string name="manual_input_title">Inserimento Manuale</string>
|
||||
<string name="manual_input_desc">Seleziona le note una per una usando la griglia</string>
|
||||
<string name="import_musicxml_title">Importa MusicXML</string>
|
||||
<string name="import_musicxml_desc">Carica uno spartito da MuseScore, Finale o Sibelius</string>
|
||||
<string name="scan_title">Scansiona Spartito</string>
|
||||
<string name="scan_desc">Scatta una foto allo spartito e riconosci le note con AI</string>
|
||||
<string name="view_note">Nota</string>
|
||||
<string name="view_context">Contesto</string>
|
||||
<string name="view_score">Spartito</string>
|
||||
<string name="view_light">Chiaro</string>
|
||||
<string name="hide_note_name">Nascondi</string>
|
||||
<string name="completed">Completato!</string>
|
||||
<string name="all_notes_correct">Tutte le note suonate correttamente</string>
|
||||
<string name="avg_deviation">Deviazione media</string>
|
||||
<string name="perfect_notes">Note perfette</string>
|
||||
<string name="first_attempt">Primo tentativo</string>
|
||||
<string name="repeat">Ripeti</string>
|
||||
|
||||
<!-- Manual Input -->
|
||||
<string name="note_input_title">Inserimento Note</string>
|
||||
<string name="selected_notes">Note selezionate (%1$d)</string>
|
||||
<string name="undo_last">Annulla Ultima</string>
|
||||
<string name="clear_all">Cancella Tutto</string>
|
||||
<string name="start_practice_n">Inizia Pratica (%1$d note)</string>
|
||||
<string name="manual_sequence_name">Sequenza Manuale</string>
|
||||
|
||||
<!-- Exercises -->
|
||||
<string name="level_n">Livello %1$d</string>
|
||||
|
||||
<!-- Scales -->
|
||||
<string name="tab_scales">Scale</string>
|
||||
<string name="tab_arpeggios">Arpeggi</string>
|
||||
<string name="root_note">Nota fondamentale</string>
|
||||
<string name="scale_type">Tipo di scala</string>
|
||||
<string name="arpeggio_type">Tipo di arpeggio</string>
|
||||
<string name="start_practice">Pratica</string>
|
||||
|
||||
<!-- Scores -->
|
||||
<string name="folder_app">Cartella app</string>
|
||||
<string name="folder_named">Cartella: %1$s</string>
|
||||
<string name="change_folder">Cambia cartella</string>
|
||||
<string name="change">Cambia</string>
|
||||
<string name="no_scores_found">Nessuno spartito trovato.</string>
|
||||
<string name="folder">Cartella</string>
|
||||
<string name="score_file">Spartito</string>
|
||||
|
||||
<!-- Ear Training -->
|
||||
<string name="difficulty">Difficolta</string>
|
||||
<string name="question_n">Domanda %1$d/%2$d</string>
|
||||
<string name="correct_count">%1$d corrette</string>
|
||||
<string name="play_sound">Riproduci</string>
|
||||
<string name="listening">Ascolta...</string>
|
||||
<string name="press_to_listen">Premi per ascoltare</string>
|
||||
<string name="session_completed">Sessione Completata!</string>
|
||||
<string name="correct_answers">risposte corrette</string>
|
||||
<string name="result">Risultato</string>
|
||||
|
||||
<!-- Challenge -->
|
||||
<string name="challenge_subtitle">Suona piu note possibili nel tempo limite!</string>
|
||||
<string name="challenge_completed">Sfida Completata!</string>
|
||||
<string name="notes_played">Note suonate</string>
|
||||
<string name="max_combo">Combo massima</string>
|
||||
<string name="time">Tempo</string>
|
||||
<string name="start_challenge">Inizia Sfida!</string>
|
||||
<string name="challenge_random">Sfida Random</string>
|
||||
<string name="source_exercises">Esercizi</string>
|
||||
<string name="source_scales">Scale</string>
|
||||
<string name="source_random">Random</string>
|
||||
<string name="sequence">Sequenza</string>
|
||||
<string name="time_limit">Tempo limite</string>
|
||||
|
||||
<!-- Leaderboard -->
|
||||
<string name="no_results">Nessun risultato ancora. Gioca una sfida!</string>
|
||||
|
||||
<!-- Stats -->
|
||||
<string name="summary">Riepilogo</string>
|
||||
<string name="sessions">Sessioni</string>
|
||||
<string name="sessions_completed">Completate</string>
|
||||
<string name="practice_time">Tempo pratica</string>
|
||||
<string name="best_accuracy">Miglior precisione</string>
|
||||
<string name="accuracy_cents">Precisione (cents)</string>
|
||||
<string name="cents">cents</string>
|
||||
<string name="first_attempt_pct">Primo tentativo (%)</string>
|
||||
<string name="reaction_time_ms">Tempo reazione (ms)</string>
|
||||
<string name="ms">ms</string>
|
||||
<string name="pct">%</string>
|
||||
<string name="no_stats_yet">Completa qualche sessione di pratica per vedere le statistiche!</string>
|
||||
|
||||
<!-- Goals -->
|
||||
<string name="consecutive_day_one">giorno consecutivo</string>
|
||||
<string name="consecutive_days">giorni consecutivi</string>
|
||||
<string name="start_streak">Inizia la tua serie!</string>
|
||||
<string name="of_target">di %1$dm</string>
|
||||
<string name="goal_reached">Obiettivo raggiunto!</string>
|
||||
<string name="daily_goal">Obiettivo giornaliero</string>
|
||||
<string name="last_30_days">Ultimi 30 giorni</string>
|
||||
<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="history_type_practice">Pratica</string>
|
||||
<string name="history_type_ear_training">Ear Training</string>
|
||||
<string name="history_type_challenge">Sfida</string>
|
||||
<string name="history_ear_note">Note</string>
|
||||
<string name="history_ear_interval">Intervalli</string>
|
||||
<string name="history_score">Punteggio</string>
|
||||
<string name="history_combo">combo</string>
|
||||
<string name="history_correct">corrette</string>
|
||||
|
||||
<!-- Scan -->
|
||||
<string name="analyzing">Analisi in corso...</string>
|
||||
<string name="analyzing_desc">Riconoscimento delle note dallo spartito</string>
|
||||
<string name="notes_detected">%1$d note rilevate</string>
|
||||
<string name="tap_to_remove">Tocca una nota per rimuoverla</string>
|
||||
<string name="take_again">Scatta di nuovo</string>
|
||||
<string name="change_api_key">Cambia API Key</string>
|
||||
<string name="api_key">API Key</string>
|
||||
<string name="enter_anthropic_key">Inserisci la tua API key Anthropic</string>
|
||||
<string name="enter_openai_key">Inserisci la tua API key OpenAI</string>
|
||||
<string name="get_anthropic_key">Ottieni la key su console.anthropic.com</string>
|
||||
<string name="get_openai_key">Ottieni la key su platform.openai.com</string>
|
||||
|
||||
<!-- 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_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_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_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_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_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>
|
||||
<string name="guide_support_email">info@djluza.com</string>
|
||||
|
||||
<!-- Info -->
|
||||
<string name="guide_section_info">Informazioni</string>
|
||||
<string name="info_version">Versione %1$s</string>
|
||||
<string name="info_developer">Sviluppato da LuZa</string>
|
||||
<string name="info_website">djluza.com</string>
|
||||
<string name="info_privacy_policy">Privacy Policy</string>
|
||||
<string name="info_privacy_url">https://djluza.com/intono/privacy</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.notemusicali.audio
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Orchestrates the audio pipeline: AudioSource → PitchDetector → NoteConverter.
|
||||
* Exposes processed results as StateFlow.
|
||||
*/
|
||||
class AudioProcessor(
|
||||
preset: InstrumentPreset = InstrumentPreset.VIOLINO,
|
||||
) {
|
||||
private var audioSource: AudioSource = createAudioSource(bufferSize = preset.bufferSize)
|
||||
private var pitchDetector: PitchDetector = PitchDetector(
|
||||
bufferSize = preset.bufferSize,
|
||||
threshold = preset.yinThreshold,
|
||||
minFrequency = preset.minFrequency,
|
||||
maxFrequency = preset.maxFrequency,
|
||||
)
|
||||
|
||||
fun updatePreset(preset: InstrumentPreset) {
|
||||
stop()
|
||||
audioSource = createAudioSource(bufferSize = preset.bufferSize)
|
||||
pitchDetector = PitchDetector(
|
||||
bufferSize = preset.bufferSize,
|
||||
threshold = preset.yinThreshold,
|
||||
minFrequency = preset.minFrequency,
|
||||
maxFrequency = preset.maxFrequency,
|
||||
)
|
||||
}
|
||||
|
||||
sealed class ProcessedResult {
|
||||
data object Silence : ProcessedResult()
|
||||
|
||||
data class Detected(
|
||||
val noteResult: NoteConverter.NoteResult,
|
||||
val confidence: Float,
|
||||
val rmsAmplitude: Float,
|
||||
) : ProcessedResult()
|
||||
|
||||
data class NoNote(
|
||||
val rmsAmplitude: Float,
|
||||
) : ProcessedResult()
|
||||
}
|
||||
|
||||
private val _result = MutableStateFlow<ProcessedResult>(ProcessedResult.Silence)
|
||||
val result: StateFlow<ProcessedResult> = _result.asStateFlow()
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
fun start(scope: CoroutineScope) {
|
||||
stop()
|
||||
pitchDetector.reset()
|
||||
job = scope.launch {
|
||||
audioSource.audioFlow()
|
||||
.flowOn(Dispatchers.Default)
|
||||
.collect { buffer ->
|
||||
val pitchResult = pitchDetector.detect(buffer)
|
||||
_result.value = when {
|
||||
pitchResult.rmsAmplitude < 0.01f -> ProcessedResult.Silence
|
||||
pitchResult.isValid -> {
|
||||
val noteResult = NoteConverter.frequencyToNote(pitchResult.frequency)
|
||||
ProcessedResult.Detected(
|
||||
noteResult = noteResult,
|
||||
confidence = pitchResult.confidence,
|
||||
rmsAmplitude = pitchResult.rmsAmplitude,
|
||||
)
|
||||
}
|
||||
else -> ProcessedResult.NoNote(pitchResult.rmsAmplitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
_result.value = ProcessedResult.Silence
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.notemusicali.audio
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Platform-agnostic audio source that provides audio buffers as a Flow.
|
||||
*/
|
||||
interface AudioSource {
|
||||
fun audioFlow(): Flow<FloatArray>
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a platform-specific audio source.
|
||||
*/
|
||||
expect fun createAudioSource(sampleRate: Int = 44100, bufferSize: Int = 2048): AudioSource
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.notemusicali.audio
|
||||
|
||||
enum class InstrumentPreset(
|
||||
val displayName: String,
|
||||
val minFrequency: Float,
|
||||
val maxFrequency: Float,
|
||||
val yinThreshold: Float,
|
||||
val bufferSize: Int,
|
||||
val minOctave: Int,
|
||||
val maxOctave: Int,
|
||||
val description: String,
|
||||
) {
|
||||
VIOLINO(
|
||||
displayName = "Violino",
|
||||
minFrequency = 180f,
|
||||
maxFrequency = 2700f,
|
||||
yinThreshold = 0.15f,
|
||||
bufferSize = 2048,
|
||||
minOctave = 3,
|
||||
maxOctave = 7,
|
||||
description = "G3-E7 \u2022 Ottimizzato per archi",
|
||||
),
|
||||
VIOLA(
|
||||
displayName = "Viola",
|
||||
minFrequency = 120f,
|
||||
maxFrequency = 1800f,
|
||||
yinThreshold = 0.15f,
|
||||
bufferSize = 2048,
|
||||
minOctave = 3,
|
||||
maxOctave = 6,
|
||||
description = "C3-A6 \u2022 Ottimizzato per archi",
|
||||
),
|
||||
VIOLONCELLO(
|
||||
displayName = "Violoncello",
|
||||
minFrequency = 60f,
|
||||
maxFrequency = 1100f,
|
||||
yinThreshold = 0.15f,
|
||||
bufferSize = 4096,
|
||||
minOctave = 2,
|
||||
maxOctave = 6,
|
||||
description = "C2-C6 \u2022 Buffer ampio per note basse",
|
||||
),
|
||||
PIANOFORTE(
|
||||
displayName = "Pianoforte",
|
||||
minFrequency = 27f,
|
||||
maxFrequency = 4200f,
|
||||
yinThreshold = 0.10f,
|
||||
bufferSize = 4096,
|
||||
minOctave = 0,
|
||||
maxOctave = 8,
|
||||
description = "A0-C8 \u2022 Soglia bassa, segnale pulito",
|
||||
),
|
||||
CHITARRA(
|
||||
displayName = "Chitarra",
|
||||
minFrequency = 78f,
|
||||
maxFrequency = 1400f,
|
||||
yinThreshold = 0.12f,
|
||||
bufferSize = 2048,
|
||||
minOctave = 2,
|
||||
maxOctave = 6,
|
||||
description = "E2-E6 \u2022 Corde pizzicate",
|
||||
),
|
||||
VOCE(
|
||||
displayName = "Voce",
|
||||
minFrequency = 75f,
|
||||
maxFrequency = 1200f,
|
||||
yinThreshold = 0.15f,
|
||||
bufferSize = 2048,
|
||||
minOctave = 2,
|
||||
maxOctave = 6,
|
||||
description = "D2-D6 \u2022 Range vocale esteso",
|
||||
),
|
||||
|
||||
;
|
||||
|
||||
val octaveRange: IntRange get() = minOctave..maxOctave
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.notemusicali.audio
|
||||
|
||||
import com.notemusicali.music.ItalianNotation
|
||||
import com.notemusicali.music.MusicalNote
|
||||
import kotlin.math.log2
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Converts frequency (Hz) to musical note information.
|
||||
* Uses A4 = 440Hz as reference pitch.
|
||||
*/
|
||||
object NoteConverter {
|
||||
|
||||
private const val A4_FREQUENCY = 440.0
|
||||
private const val A4_MIDI = 69
|
||||
|
||||
data class NoteResult(
|
||||
val note: MusicalNote,
|
||||
val centsDeviation: Float,
|
||||
val frequency: Float,
|
||||
)
|
||||
|
||||
fun frequencyToNote(frequency: Float): NoteResult {
|
||||
val midiNumber = frequencyToMidi(frequency)
|
||||
val roundedMidi = midiNumber.roundToInt()
|
||||
val centsDeviation = ((midiNumber - roundedMidi) * 100).toFloat()
|
||||
|
||||
val noteIndex = ((roundedMidi % 12) + 12) % 12
|
||||
val octave = (roundedMidi / 12) - 1
|
||||
|
||||
val italianName = ItalianNotation.fromNoteIndex(noteIndex)
|
||||
|
||||
val note = MusicalNote(
|
||||
noteIndex = noteIndex,
|
||||
octave = octave,
|
||||
italianName = italianName,
|
||||
midiNumber = roundedMidi,
|
||||
)
|
||||
|
||||
return NoteResult(
|
||||
note = note,
|
||||
centsDeviation = centsDeviation,
|
||||
frequency = frequency,
|
||||
)
|
||||
}
|
||||
|
||||
private fun frequencyToMidi(frequency: Float): Double {
|
||||
return A4_MIDI + 12.0 * log2(frequency.toDouble() / A4_FREQUENCY)
|
||||
}
|
||||
|
||||
fun midiToFrequency(midiNumber: Int): Float {
|
||||
return (A4_FREQUENCY * 2.0.pow((midiNumber - A4_MIDI).toDouble() / 12.0)).toFloat()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.notemusicali.audio
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* YIN algorithm implementation for pitch detection.
|
||||
* Optimized for violin range (180Hz-2700Hz, G3-E7).
|
||||
*
|
||||
* The algorithm follows 5 steps:
|
||||
* 1. Difference function
|
||||
* 2. Cumulative mean normalized difference (CMND)
|
||||
* 3. Absolute threshold
|
||||
* 4. Parabolic interpolation
|
||||
* 5. Hz conversion
|
||||
*/
|
||||
class PitchDetector(
|
||||
private val sampleRate: Int = 44100,
|
||||
private val bufferSize: Int = 2048,
|
||||
private val threshold: Float = 0.15f,
|
||||
private val minFrequency: Float = 180f,
|
||||
private val maxFrequency: Float = 2700f,
|
||||
private val confidenceThreshold: Float = 0.7f,
|
||||
) {
|
||||
private val maxLag = (sampleRate / minFrequency).toInt().coerceAtMost(bufferSize / 2)
|
||||
private val minLag = (sampleRate / maxFrequency).toInt().coerceAtLeast(2)
|
||||
|
||||
private val recentPitches = ArrayDeque<Float>(3)
|
||||
|
||||
data class PitchResult(
|
||||
val frequency: Float,
|
||||
val confidence: Float,
|
||||
val rmsAmplitude: Float,
|
||||
) {
|
||||
// detect() only emits frequency > 0 after applying confidenceThreshold,
|
||||
// so validity must not re-check confidence against a hardcoded value
|
||||
val isValid: Boolean get() = frequency > 0f
|
||||
}
|
||||
|
||||
fun detect(audioBuffer: FloatArray): PitchResult {
|
||||
val rms = computeRms(audioBuffer)
|
||||
if (rms < 0.01f) {
|
||||
return PitchResult(0f, 0f, rms)
|
||||
}
|
||||
|
||||
// Step 1: Difference function
|
||||
val diff = differenceFunction(audioBuffer)
|
||||
|
||||
// Step 2: Cumulative mean normalized difference
|
||||
val cmnd = cumulativeMeanNormalizedDifference(diff)
|
||||
|
||||
// Step 3: Absolute threshold
|
||||
val lag = absoluteThreshold(cmnd) ?: return PitchResult(0f, 0f, rms)
|
||||
|
||||
// Step 4: Parabolic interpolation
|
||||
val refinedLag = parabolicInterpolation(cmnd, lag)
|
||||
|
||||
// Step 5: Hz conversion
|
||||
val frequency = sampleRate.toFloat() / refinedLag
|
||||
val confidence = 1f - cmnd[lag]
|
||||
|
||||
if (frequency < minFrequency || frequency > maxFrequency || confidence < confidenceThreshold) {
|
||||
return PitchResult(0f, confidence, rms)
|
||||
}
|
||||
|
||||
// Median filter on last 3 detections
|
||||
val filteredFrequency = medianFilter(frequency)
|
||||
|
||||
return PitchResult(filteredFrequency, confidence, rms)
|
||||
}
|
||||
|
||||
private fun computeRms(buffer: FloatArray): Float {
|
||||
var sum = 0f
|
||||
for (sample in buffer) {
|
||||
sum += sample * sample
|
||||
}
|
||||
return kotlin.math.sqrt(sum / buffer.size)
|
||||
}
|
||||
|
||||
private fun differenceFunction(buffer: FloatArray): FloatArray {
|
||||
val diff = FloatArray(maxLag + 1)
|
||||
for (tau in 1..maxLag) {
|
||||
var sum = 0f
|
||||
for (i in 0 until bufferSize - tau) {
|
||||
val delta = buffer[i] - buffer[i + tau]
|
||||
sum += delta * delta
|
||||
}
|
||||
diff[tau] = sum
|
||||
}
|
||||
return diff
|
||||
}
|
||||
|
||||
private fun cumulativeMeanNormalizedDifference(diff: FloatArray): FloatArray {
|
||||
val cmnd = FloatArray(diff.size)
|
||||
cmnd[0] = 1f
|
||||
var runningSum = 0f
|
||||
for (tau in 1 until diff.size) {
|
||||
runningSum += diff[tau]
|
||||
cmnd[tau] = if (runningSum != 0f) {
|
||||
diff[tau] * tau / runningSum
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
}
|
||||
return cmnd
|
||||
}
|
||||
|
||||
private fun absoluteThreshold(cmnd: FloatArray): Int? {
|
||||
// Find first dip below threshold after minLag
|
||||
var tau = minLag
|
||||
while (tau < cmnd.size) {
|
||||
if (cmnd[tau] < threshold) {
|
||||
// Walk to the minimum of this dip
|
||||
while (tau + 1 < cmnd.size && cmnd[tau + 1] < cmnd[tau]) {
|
||||
tau++
|
||||
}
|
||||
return tau
|
||||
}
|
||||
tau++
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun parabolicInterpolation(cmnd: FloatArray, tau: Int): Float {
|
||||
if (tau < 1 || tau >= cmnd.size - 1) return tau.toFloat()
|
||||
|
||||
val s0 = cmnd[tau - 1]
|
||||
val s1 = cmnd[tau]
|
||||
val s2 = cmnd[tau + 1]
|
||||
|
||||
val adjustment = (s2 - s0) / (2f * (2f * s1 - s2 - s0))
|
||||
|
||||
return if (abs(adjustment) < 1f) tau + adjustment else tau.toFloat()
|
||||
}
|
||||
|
||||
private fun medianFilter(frequency: Float): Float {
|
||||
recentPitches.addLast(frequency)
|
||||
if (recentPitches.size > 3) {
|
||||
recentPitches.removeFirst()
|
||||
}
|
||||
|
||||
if (recentPitches.size < 3) return frequency
|
||||
|
||||
val sorted = recentPitches.toList().sorted()
|
||||
return sorted[1]
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
recentPitches.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.notemusicali.audio
|
||||
|
||||
/**
|
||||
* Platform-agnostic tone generator for producing audio output.
|
||||
* Used by metronome, ear training, and reference note features.
|
||||
*/
|
||||
interface ToneGenerator {
|
||||
/** Play a sine wave tone at the given frequency for the specified duration in milliseconds. */
|
||||
fun playTone(frequencyHz: Float, durationMs: Long)
|
||||
|
||||
/** Play a short click sound. If accent is true, play a louder/higher click. */
|
||||
fun playClick(accent: Boolean = false)
|
||||
|
||||
/** Stop any currently playing sound. */
|
||||
fun stop()
|
||||
|
||||
/** Release all audio resources. Call when done using the generator. */
|
||||
fun release()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a platform-specific ToneGenerator.
|
||||
*/
|
||||
expect fun createToneGenerator(sampleRate: Int = 44100): ToneGenerator
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.notemusicali.audio
|
||||
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* Shared waveform generator with additive synthesis and ADSR envelopes.
|
||||
* Produces piano-like tones and realistic metronome clicks.
|
||||
*/
|
||||
object WaveGenerator {
|
||||
|
||||
// Piano-like harmonic series: (multiplier, relative amplitude)
|
||||
// Mimics the overtone structure of a struck string
|
||||
private val pianoHarmonics = floatArrayOf(
|
||||
1f, 1.0f,
|
||||
2f, 0.50f,
|
||||
3f, 0.28f,
|
||||
4f, 0.14f,
|
||||
5f, 0.09f,
|
||||
6f, 0.05f,
|
||||
7f, 0.03f,
|
||||
8f, 0.015f,
|
||||
)
|
||||
private const val NUM_HARMONICS = 8
|
||||
private const val HARMONIC_SUM = 2.085f // sum of amplitudes for normalization
|
||||
|
||||
private const val TWO_PI = 2.0 * PI
|
||||
|
||||
/**
|
||||
* Generate a piano-like tone with additive synthesis and ADSR envelope.
|
||||
*/
|
||||
fun generateToneSamples(
|
||||
frequencyHz: Float,
|
||||
durationMs: Long,
|
||||
sampleRate: Int,
|
||||
): FloatArray {
|
||||
val totalSamples = (sampleRate * durationMs / 1000).toInt()
|
||||
val samples = FloatArray(totalSamples)
|
||||
|
||||
// ADSR envelope (in samples)
|
||||
val attackLen = (sampleRate * 0.008).toInt() // 8ms attack
|
||||
val decayLen = (sampleRate * 0.12).toInt() // 120ms decay
|
||||
val sustainLevel = 0.50
|
||||
val releaseLen = (sampleRate * 0.06).toInt() // 60ms release
|
||||
val sustainEnd = totalSamples - releaseLen
|
||||
|
||||
val amplitude = 0.70
|
||||
|
||||
// Per-harmonic: progressively faster decay for higher harmonics (natural behavior)
|
||||
for (i in 0 until totalSamples) {
|
||||
// ADSR envelope
|
||||
val env = when {
|
||||
i < attackLen -> i.toDouble() / attackLen
|
||||
i < attackLen + decayLen -> {
|
||||
val progress = (i - attackLen).toDouble() / decayLen
|
||||
1.0 - (1.0 - sustainLevel) * progress
|
||||
}
|
||||
i >= sustainEnd -> {
|
||||
val progress = (i - sustainEnd).toDouble() / releaseLen
|
||||
sustainLevel * (1.0 - progress)
|
||||
}
|
||||
else -> sustainLevel
|
||||
}
|
||||
|
||||
// Additive synthesis
|
||||
var sample = 0.0
|
||||
var idx = 0
|
||||
while (idx < NUM_HARMONICS) {
|
||||
val mult = pianoHarmonics[idx * 2]
|
||||
val amp = pianoHarmonics[idx * 2 + 1]
|
||||
val harmonicFreq = frequencyHz * mult
|
||||
// Anti-aliasing: skip harmonics above Nyquist frequency
|
||||
if (harmonicFreq > sampleRate / 2) break
|
||||
// Higher harmonics decay faster over time
|
||||
val harmonicDecay = exp(-i.toDouble() / sampleRate * mult * 1.2)
|
||||
sample += amp * harmonicDecay * sin(TWO_PI * harmonicFreq * i / sampleRate)
|
||||
idx++
|
||||
}
|
||||
|
||||
// Normalize and apply envelope
|
||||
samples[i] = (sample / HARMONIC_SUM * env * amplitude).toFloat()
|
||||
}
|
||||
|
||||
return samples
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a realistic metronome click with tonal + noise components.
|
||||
* Uses inharmonic overtones for a wood-block-like sound.
|
||||
*/
|
||||
fun generateClickSamples(
|
||||
accent: Boolean,
|
||||
sampleRate: Int,
|
||||
): FloatArray {
|
||||
val durationMs = if (accent) 40 else 30
|
||||
val totalSamples = sampleRate * durationMs / 1000
|
||||
val samples = FloatArray(totalSamples)
|
||||
|
||||
val toneFreq = if (accent) 1800.0 else 1200.0
|
||||
val amplitude = if (accent) 0.80 else 0.60
|
||||
val noiseAmount = 0.25
|
||||
|
||||
// Simple deterministic pseudo-random for noise (reproducible across platforms)
|
||||
var noiseState = 48271L
|
||||
|
||||
for (i in 0 until totalSamples) {
|
||||
val t = i.toDouble() / sampleRate
|
||||
|
||||
// Sharp exponential decay with initial transient
|
||||
val progress = i.toDouble() / totalSamples
|
||||
val decay = exp(-progress * 8.0)
|
||||
|
||||
// Tonal component: inharmonic overtones for wood-like timbre
|
||||
val tone = sin(TWO_PI * toneFreq * t) +
|
||||
0.45 * sin(TWO_PI * toneFreq * 2.4 * t) +
|
||||
0.20 * sin(TWO_PI * toneFreq * 4.1 * t) +
|
||||
0.10 * sin(TWO_PI * toneFreq * 5.7 * t)
|
||||
|
||||
// Noise component (linear congruential generator)
|
||||
noiseState = (noiseState * 1103515245L + 12345L) and 0x7FFFFFFFL
|
||||
val noise = noiseState.toDouble() / 0x7FFFFFFFL * 2.0 - 1.0
|
||||
|
||||
// Mix tone and noise, then apply decay
|
||||
val mixed = (1.0 - noiseAmount) * tone / 1.75 + noiseAmount * noise
|
||||
samples[i] = (mixed * decay * amplitude).toFloat()
|
||||
}
|
||||
|
||||
return samples
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.notemusicali.challenge
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
|
||||
@Serializable
|
||||
data class ChallengeResult(
|
||||
val id: String,
|
||||
val dateMillis: Long,
|
||||
val sequenceName: String,
|
||||
val timeLimitSec: Int,
|
||||
val totalScore: Int,
|
||||
val notesPlayed: Int,
|
||||
val maxCombo: Int,
|
||||
val accuracy: Float,
|
||||
)
|
||||
|
||||
enum class ChallengeTimeLimit(val seconds: Int, val displayName: String) {
|
||||
SHORT(30, "30s"),
|
||||
MEDIUM(60, "1 min"),
|
||||
LONG(120, "2 min"),
|
||||
}
|
||||
|
||||
object ChallengeScoring {
|
||||
private const val BASE_POINTS = 100
|
||||
private const val PERFECT_BONUS = 50 // ≤5 cents
|
||||
private const val GOOD_BONUS = 25 // ≤15 cents
|
||||
private const val SPEED_BONUS = 30 // <2s reaction
|
||||
|
||||
/**
|
||||
* Score a single note. Returns points earned.
|
||||
* @param centsDeviation absolute cents deviation
|
||||
* @param reactionTimeMs time to play the note
|
||||
* @param comboStreak current combo streak (0-based)
|
||||
* @param firstAttempt true if played correctly on first try
|
||||
*/
|
||||
fun scoreNote(
|
||||
centsDeviation: Float,
|
||||
reactionTimeMs: Long,
|
||||
comboStreak: Int,
|
||||
firstAttempt: Boolean,
|
||||
): Int {
|
||||
if (!firstAttempt) return 0
|
||||
|
||||
val absCents = abs(centsDeviation)
|
||||
var points = BASE_POINTS
|
||||
|
||||
// Accuracy bonus
|
||||
when {
|
||||
absCents <= 5f -> points += PERFECT_BONUS
|
||||
absCents <= 15f -> points += GOOD_BONUS
|
||||
}
|
||||
|
||||
// Speed bonus
|
||||
if (reactionTimeMs < 2000) {
|
||||
points += SPEED_BONUS
|
||||
}
|
||||
|
||||
// Combo multiplier: 1.0x base, +0.1x per streak note (max 2.0x)
|
||||
val multiplier = 1.0f + (comboStreak * 0.1f).coerceAtMost(1.0f)
|
||||
return (points * multiplier).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* Session accuracy: fraction of played notes that were correct on the first attempt.
|
||||
*/
|
||||
fun accuracy(notesPlayed: Int, firstAttemptNotes: Int): Float =
|
||||
if (notesPlayed > 0) (firstAttemptNotes.toFloat() / notesPlayed).coerceAtMost(1f) else 0f
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.notemusicali.challenge
|
||||
|
||||
import com.notemusicali.scan.AppSettings
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object ChallengeRepository {
|
||||
private const val KEY = "challenge_results"
|
||||
private const val MAX_RESULTS = 20
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun load(): List<ChallengeResult> {
|
||||
val raw = AppSettings.getString(KEY) ?: return emptyList()
|
||||
return try {
|
||||
json.decodeFromString<List<ChallengeResult>>(raw)
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun save(result: ChallengeResult) {
|
||||
val current = load().toMutableList()
|
||||
current.add(result)
|
||||
current.sortByDescending { it.totalScore }
|
||||
if (current.size > MAX_RESULTS) {
|
||||
current.subList(MAX_RESULTS, current.size).clear()
|
||||
}
|
||||
AppSettings.putString(KEY, json.encodeToString(current))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.notemusicali.eartraining
|
||||
|
||||
import com.notemusicali.audio.NoteConverter
|
||||
import com.notemusicali.music.ItalianNotation
|
||||
import kotlin.random.Random
|
||||
|
||||
enum class IntervalType(val semitones: Int, val displayName: String) {
|
||||
UNISON(0, "Unisono"),
|
||||
MINOR_2ND(1, "Seconda min."),
|
||||
MAJOR_2ND(2, "Seconda magg."),
|
||||
MINOR_3RD(3, "Terza min."),
|
||||
MAJOR_3RD(4, "Terza magg."),
|
||||
PERFECT_4TH(5, "Quarta giusta"),
|
||||
TRITONE(6, "Tritono"),
|
||||
PERFECT_5TH(7, "Quinta giusta"),
|
||||
MINOR_6TH(8, "Sesta min."),
|
||||
MAJOR_6TH(9, "Sesta magg."),
|
||||
MINOR_7TH(10, "Settima min."),
|
||||
MAJOR_7TH(11, "Settima magg."),
|
||||
OCTAVE(12, "Ottava"),
|
||||
}
|
||||
|
||||
enum class EarTrainingMode(val displayName: String) {
|
||||
NOTE("Riconosci Nota"),
|
||||
INTERVAL("Riconosci Intervallo"),
|
||||
}
|
||||
|
||||
enum class EarTrainingDifficulty(val displayName: String) {
|
||||
EASY("Facile"),
|
||||
MEDIUM("Medio"),
|
||||
HARD("Difficile"),
|
||||
}
|
||||
|
||||
data class EarTrainingQuestion(
|
||||
val mode: EarTrainingMode,
|
||||
val referenceMidi: Int,
|
||||
val targetMidi: Int,
|
||||
val correctAnswer: String,
|
||||
val choices: List<String>,
|
||||
val interval: IntervalType? = null,
|
||||
) {
|
||||
val referenceFrequency: Float get() = NoteConverter.midiToFrequency(referenceMidi)
|
||||
val targetFrequency: Float get() = NoteConverter.midiToFrequency(targetMidi)
|
||||
}
|
||||
|
||||
object EarTrainingEngine {
|
||||
|
||||
private val NOTE_NAMES = (0..11).map { ItalianNotation.fromNoteIndex(it).display }
|
||||
|
||||
fun generateNoteQuestion(difficulty: EarTrainingDifficulty): EarTrainingQuestion {
|
||||
val midiRange = when (difficulty) {
|
||||
EarTrainingDifficulty.EASY -> 60..72 // C4-C5 (one octave)
|
||||
EarTrainingDifficulty.MEDIUM -> 55..79 // G3-G5
|
||||
EarTrainingDifficulty.HARD -> 48..84 // C3-C6
|
||||
}
|
||||
|
||||
val targetMidi = Random.nextInt(midiRange.first, midiRange.last + 1)
|
||||
val targetNoteIndex = ((targetMidi % 12) + 12) % 12
|
||||
val correctName = NOTE_NAMES[targetNoteIndex]
|
||||
|
||||
// Generate 3 wrong choices
|
||||
val wrongChoices = mutableSetOf<String>()
|
||||
while (wrongChoices.size < 3) {
|
||||
val wrong = NOTE_NAMES[Random.nextInt(12)]
|
||||
if (wrong != correctName) wrongChoices.add(wrong)
|
||||
}
|
||||
|
||||
val choices = (wrongChoices.toList() + correctName).shuffled()
|
||||
|
||||
return EarTrainingQuestion(
|
||||
mode = EarTrainingMode.NOTE,
|
||||
referenceMidi = targetMidi,
|
||||
targetMidi = targetMidi,
|
||||
correctAnswer = correctName,
|
||||
choices = choices,
|
||||
)
|
||||
}
|
||||
|
||||
fun generateIntervalQuestion(difficulty: EarTrainingDifficulty): EarTrainingQuestion {
|
||||
val availableIntervals = when (difficulty) {
|
||||
EarTrainingDifficulty.EASY -> listOf(
|
||||
IntervalType.UNISON, IntervalType.MAJOR_3RD,
|
||||
IntervalType.PERFECT_5TH, IntervalType.OCTAVE,
|
||||
)
|
||||
EarTrainingDifficulty.MEDIUM -> listOf(
|
||||
IntervalType.MAJOR_2ND, IntervalType.MINOR_3RD, IntervalType.MAJOR_3RD,
|
||||
IntervalType.PERFECT_4TH, IntervalType.PERFECT_5TH,
|
||||
IntervalType.MINOR_7TH, IntervalType.OCTAVE,
|
||||
)
|
||||
EarTrainingDifficulty.HARD -> IntervalType.entries
|
||||
}
|
||||
|
||||
val interval = availableIntervals.random()
|
||||
val baseMidi = Random.nextInt(60, 73) // C4-C5
|
||||
val targetMidi = baseMidi + interval.semitones
|
||||
|
||||
// Generate wrong choices
|
||||
val wrongIntervals = availableIntervals.filter { it != interval }.shuffled().take(3)
|
||||
|
||||
val choices = (wrongIntervals.map { it.displayName } + interval.displayName).shuffled()
|
||||
|
||||
return EarTrainingQuestion(
|
||||
mode = EarTrainingMode.INTERVAL,
|
||||
referenceMidi = baseMidi,
|
||||
targetMidi = targetMidi,
|
||||
correctAnswer = interval.displayName,
|
||||
choices = choices,
|
||||
interval = interval,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.notemusicali.eartraining
|
||||
|
||||
import com.notemusicali.scan.AppSettings
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class EarTrainingSession(
|
||||
val id: String,
|
||||
val dateMillis: Long,
|
||||
val mode: String,
|
||||
val difficulty: String,
|
||||
val totalQuestions: Int,
|
||||
val correctAnswers: Int,
|
||||
) {
|
||||
val accuracy: Float get() = if (totalQuestions == 0) 0f
|
||||
else correctAnswers.toFloat() / totalQuestions
|
||||
}
|
||||
|
||||
object EarTrainingStatsRepository {
|
||||
private const val KEY = "ear_training_sessions"
|
||||
private const val MAX_SESSIONS = 50
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun load(): List<EarTrainingSession> {
|
||||
val raw = AppSettings.getString(KEY) ?: return emptyList()
|
||||
return try {
|
||||
json.decodeFromString<List<EarTrainingSession>>(raw)
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun save(session: EarTrainingSession) {
|
||||
val current = load().toMutableList()
|
||||
current.add(0, session)
|
||||
if (current.size > MAX_SESSIONS) {
|
||||
current.subList(MAX_SESSIONS, current.size).clear()
|
||||
}
|
||||
AppSettings.putString(KEY, json.encodeToString(current))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.notemusicali.exercises
|
||||
|
||||
import com.notemusicali.music.MusicalNote
|
||||
import com.notemusicali.music.NoteSequence
|
||||
|
||||
data class Exercise(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val level: Int,
|
||||
val sequence: NoteSequence,
|
||||
)
|
||||
|
||||
object ExerciseRepository {
|
||||
|
||||
fun getAllExercises(): List<NoteSequence> = all.map { it.sequence }
|
||||
|
||||
val all: List<Exercise> = listOf(
|
||||
Exercise(
|
||||
id = 1,
|
||||
title = "Prime Note",
|
||||
description = "5 note naturali base",
|
||||
level = 1,
|
||||
sequence = NoteSequence(
|
||||
name = "Prime Note",
|
||||
notes = listOf(
|
||||
MusicalNote.fromNameAndOctave(0, 4), // DO4
|
||||
MusicalNote.fromNameAndOctave(2, 4), // RE4
|
||||
MusicalNote.fromNameAndOctave(4, 4), // MI4
|
||||
MusicalNote.fromNameAndOctave(5, 4), // FA4
|
||||
MusicalNote.fromNameAndOctave(7, 4), // SOL4
|
||||
),
|
||||
),
|
||||
),
|
||||
Exercise(
|
||||
id = 2,
|
||||
title = "Scala di DO Maggiore",
|
||||
description = "Scala completa su un'ottava",
|
||||
level = 2,
|
||||
sequence = NoteSequence(
|
||||
name = "Scala di DO Maggiore",
|
||||
notes = listOf(
|
||||
MusicalNote.fromNameAndOctave(0, 4), // DO4
|
||||
MusicalNote.fromNameAndOctave(2, 4), // RE4
|
||||
MusicalNote.fromNameAndOctave(4, 4), // MI4
|
||||
MusicalNote.fromNameAndOctave(5, 4), // FA4
|
||||
MusicalNote.fromNameAndOctave(7, 4), // SOL4
|
||||
MusicalNote.fromNameAndOctave(9, 4), // LA4
|
||||
MusicalNote.fromNameAndOctave(11, 4), // SI4
|
||||
MusicalNote.fromNameAndOctave(0, 5), // DO5
|
||||
),
|
||||
),
|
||||
),
|
||||
Exercise(
|
||||
id = 3,
|
||||
title = "Scala di SOL Maggiore",
|
||||
description = "Introduce FA# e cambio ottava",
|
||||
level = 3,
|
||||
sequence = NoteSequence(
|
||||
name = "Scala di SOL Maggiore",
|
||||
notes = listOf(
|
||||
MusicalNote.fromNameAndOctave(7, 3), // SOL3
|
||||
MusicalNote.fromNameAndOctave(9, 3), // LA3
|
||||
MusicalNote.fromNameAndOctave(11, 3), // SI3
|
||||
MusicalNote.fromNameAndOctave(0, 4), // DO4
|
||||
MusicalNote.fromNameAndOctave(2, 4), // RE4
|
||||
MusicalNote.fromNameAndOctave(4, 4), // MI4
|
||||
MusicalNote.fromNameAndOctave(6, 4), // FA#4
|
||||
MusicalNote.fromNameAndOctave(7, 4), // SOL4
|
||||
),
|
||||
),
|
||||
),
|
||||
Exercise(
|
||||
id = 4,
|
||||
title = "Arpeggi Maggiori",
|
||||
description = "Salti di terza e quinta",
|
||||
level = 4,
|
||||
sequence = NoteSequence(
|
||||
name = "Arpeggi Maggiori",
|
||||
notes = listOf(
|
||||
MusicalNote.fromNameAndOctave(0, 4), // DO4
|
||||
MusicalNote.fromNameAndOctave(4, 4), // MI4
|
||||
MusicalNote.fromNameAndOctave(7, 4), // SOL4
|
||||
MusicalNote.fromNameAndOctave(0, 5), // DO5
|
||||
MusicalNote.fromNameAndOctave(7, 3), // SOL3
|
||||
MusicalNote.fromNameAndOctave(11, 3), // SI3
|
||||
MusicalNote.fromNameAndOctave(2, 4), // RE4
|
||||
MusicalNote.fromNameAndOctave(7, 4), // SOL4
|
||||
),
|
||||
),
|
||||
),
|
||||
Exercise(
|
||||
id = 5,
|
||||
title = "Ave Maria — Schubert",
|
||||
description = "Melodia principale (Sib maggiore)",
|
||||
level = 3,
|
||||
sequence = NoteSequence(
|
||||
name = "Ave Maria — Schubert",
|
||||
notes = listOf(
|
||||
MusicalNote.fromMidi(70), // Sib4
|
||||
MusicalNote.fromMidi(69), // La4
|
||||
MusicalNote.fromMidi(70), // Sib4
|
||||
MusicalNote.fromMidi(74), // Re5
|
||||
MusicalNote.fromMidi(72), // Do5
|
||||
MusicalNote.fromMidi(70), // Sib4
|
||||
MusicalNote.fromMidi(72), // Do5
|
||||
MusicalNote.fromMidi(74), // Re5
|
||||
MusicalNote.fromMidi(72), // Do5
|
||||
MusicalNote.fromMidi(70), // Sib4
|
||||
MusicalNote.fromMidi(69), // La4
|
||||
MusicalNote.fromMidi(67), // Sol4
|
||||
MusicalNote.fromMidi(69), // La4
|
||||
MusicalNote.fromMidi(70), // Sib4
|
||||
MusicalNote.fromMidi(74), // Re5
|
||||
MusicalNote.fromMidi(74), // Re5
|
||||
MusicalNote.fromMidi(72), // Do5
|
||||
MusicalNote.fromMidi(70), // Sib4
|
||||
MusicalNote.fromMidi(69), // La4
|
||||
MusicalNote.fromMidi(67), // Sol4
|
||||
MusicalNote.fromMidi(74), // Re5
|
||||
MusicalNote.fromMidi(76), // Mi5
|
||||
MusicalNote.fromMidi(74), // Re5
|
||||
MusicalNote.fromMidi(73), // Do#5
|
||||
MusicalNote.fromMidi(69), // La4
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.notemusicali.goals
|
||||
|
||||
import com.notemusicali.scan.AppSettings
|
||||
import com.notemusicali.util.currentDateString
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class DailyGoalSettings(
|
||||
val targetMinutes: Int = 15,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DailyGoalState(
|
||||
val streak: Int = 0,
|
||||
val lastGoalMetDate: String = "",
|
||||
val todayMs: Long = 0L,
|
||||
val todayDate: String = "",
|
||||
val history: Map<String, Long> = emptyMap(), // date -> ms practiced
|
||||
)
|
||||
|
||||
object DailyGoalManager {
|
||||
|
||||
private const val SETTINGS_KEY = "daily_goal_settings"
|
||||
private const val STATE_KEY = "daily_goal_state"
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun getSettings(): DailyGoalSettings {
|
||||
val raw = AppSettings.getString(SETTINGS_KEY) ?: return DailyGoalSettings()
|
||||
return try {
|
||||
json.decodeFromString<DailyGoalSettings>(raw)
|
||||
} catch (_: Exception) { DailyGoalSettings() }
|
||||
}
|
||||
|
||||
fun saveSettings(settings: DailyGoalSettings) {
|
||||
AppSettings.putString(SETTINGS_KEY, json.encodeToString(settings))
|
||||
}
|
||||
|
||||
fun getState(): DailyGoalState {
|
||||
val raw = AppSettings.getString(STATE_KEY) ?: return DailyGoalState()
|
||||
return try {
|
||||
val state = json.decodeFromString<DailyGoalState>(raw)
|
||||
val today = currentDateString()
|
||||
if (state.todayDate != today) {
|
||||
// New day — reset today counter, keep history
|
||||
state.copy(todayMs = 0L, todayDate = today)
|
||||
} else state
|
||||
} catch (_: Exception) { DailyGoalState() }
|
||||
}
|
||||
|
||||
fun recordPractice(durationMs: Long) {
|
||||
val today = currentDateString()
|
||||
val settings = getSettings()
|
||||
var state = getState()
|
||||
|
||||
// Ensure todayDate is current
|
||||
if (state.todayDate != today) {
|
||||
state = state.copy(todayMs = 0L, todayDate = today)
|
||||
}
|
||||
|
||||
val newTodayMs = state.todayMs + durationMs
|
||||
val updatedHistory = state.history.toMutableMap()
|
||||
updatedHistory[today] = newTodayMs
|
||||
|
||||
// Keep only last 60 days of history
|
||||
val sortedKeys = updatedHistory.keys.sorted()
|
||||
if (sortedKeys.size > 60) {
|
||||
sortedKeys.take(sortedKeys.size - 60).forEach { updatedHistory.remove(it) }
|
||||
}
|
||||
|
||||
val targetMs = settings.targetMinutes * 60_000L
|
||||
val goalMetToday = newTodayMs >= targetMs
|
||||
val wasMetBefore = state.todayMs >= targetMs
|
||||
|
||||
var streak = state.streak
|
||||
if (goalMetToday && !wasMetBefore) {
|
||||
// Calculate streak: check if yesterday was the last goal-met date
|
||||
val yesterday = previousDateString(today)
|
||||
streak = if (state.lastGoalMetDate == yesterday || state.lastGoalMetDate == today) {
|
||||
state.streak + 1
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
val newState = state.copy(
|
||||
todayMs = newTodayMs,
|
||||
todayDate = today,
|
||||
streak = streak,
|
||||
lastGoalMetDate = if (goalMetToday) today else state.lastGoalMetDate,
|
||||
history = updatedHistory,
|
||||
)
|
||||
|
||||
AppSettings.putString(STATE_KEY, json.encodeToString(newState))
|
||||
}
|
||||
|
||||
fun isTodayGoalMet(): Boolean {
|
||||
val settings = getSettings()
|
||||
val state = getState()
|
||||
val today = currentDateString()
|
||||
if (state.todayDate != today) return false
|
||||
return state.todayMs >= settings.targetMinutes * 60_000L
|
||||
}
|
||||
|
||||
fun todayProgressFraction(): Float {
|
||||
val settings = getSettings()
|
||||
val state = getState()
|
||||
val today = currentDateString()
|
||||
if (state.todayDate != today) return 0f
|
||||
val target = settings.targetMinutes * 60_000L
|
||||
if (target <= 0) return 1f
|
||||
return (state.todayMs.toFloat() / target).coerceAtMost(1f)
|
||||
}
|
||||
|
||||
/** Get last N days of practice history (date -> minutes). */
|
||||
fun recentHistory(days: Int = 30): List<Pair<String, Int>> {
|
||||
val state = getState()
|
||||
val allDates = state.history.entries
|
||||
.sortedByDescending { it.key }
|
||||
.take(days)
|
||||
.reversed()
|
||||
return allDates.map { (date, ms) -> date to (ms / 60_000L).toInt() }
|
||||
}
|
||||
|
||||
private fun previousDateString(yyyymmdd: String): String {
|
||||
if (yyyymmdd.length != 8) return ""
|
||||
val y = yyyymmdd.substring(0, 4).toIntOrNull() ?: return ""
|
||||
val m = yyyymmdd.substring(4, 6).toIntOrNull() ?: return ""
|
||||
val d = yyyymmdd.substring(6, 8).toIntOrNull() ?: return ""
|
||||
|
||||
return if (d > 1) {
|
||||
"$y${m.toString().padStart(2, '0')}${(d - 1).toString().padStart(2, '0')}"
|
||||
} else if (m > 1) {
|
||||
val prevMonth = m - 1
|
||||
val daysInPrevMonth = daysInMonth(prevMonth, y)
|
||||
"$y${prevMonth.toString().padStart(2, '0')}${daysInPrevMonth.toString().padStart(2, '0')}"
|
||||
} else {
|
||||
"${y - 1}1231"
|
||||
}
|
||||
}
|
||||
|
||||
private fun daysInMonth(month: Int, year: Int): Int = when (month) {
|
||||
1 -> 31; 2 -> if (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) 29 else 28
|
||||
3 -> 31; 4 -> 30; 5 -> 31; 6 -> 30; 7 -> 31; 8 -> 31
|
||||
9 -> 30; 10 -> 31; 11 -> 30; 12 -> 31; else -> 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.notemusicali.history
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class NoteResult(
|
||||
val noteName: String,
|
||||
val midiNumber: Int,
|
||||
val centsDeviation: Float,
|
||||
val reactionTimeMs: Long,
|
||||
val firstAttemptSuccess: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PracticeSession(
|
||||
val id: String,
|
||||
val sequenceName: String,
|
||||
val instrumentName: String,
|
||||
val dateMillis: Long,
|
||||
val totalTimeMs: Long,
|
||||
val noteResults: List<NoteResult>,
|
||||
val noteMidiNumbers: List<Int>,
|
||||
val completed: Boolean = true,
|
||||
) {
|
||||
val averageCents: Float
|
||||
get() = if (noteResults.isEmpty()) 0f
|
||||
else noteResults.map { kotlin.math.abs(it.centsDeviation) }.average().toFloat()
|
||||
|
||||
val perfectNotes: Int
|
||||
get() = noteResults.count { kotlin.math.abs(it.centsDeviation) <= 10f }
|
||||
|
||||
val firstAttemptRate: Float
|
||||
get() = if (noteResults.isEmpty()) 0f
|
||||
else noteResults.count { it.firstAttemptSuccess }.toFloat() / noteResults.size
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.notemusicali.history
|
||||
|
||||
import com.notemusicali.scan.AppSettings
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object SessionRepository {
|
||||
private const val KEY = "practice_sessions"
|
||||
private const val MAX_SESSIONS = 50
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun load(): List<PracticeSession> {
|
||||
val raw = AppSettings.getString(KEY) ?: return emptyList()
|
||||
return try {
|
||||
json.decodeFromString<List<PracticeSession>>(raw)
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun save(session: PracticeSession) {
|
||||
val current = load().toMutableList()
|
||||
current.add(0, session)
|
||||
if (current.size > MAX_SESSIONS) {
|
||||
current.subList(MAX_SESSIONS, current.size).clear()
|
||||
}
|
||||
AppSettings.putString(KEY, json.encodeToString(current))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.notemusicali.metronome
|
||||
|
||||
import com.notemusicali.audio.ToneGenerator
|
||||
import com.notemusicali.audio.createToneGenerator
|
||||
import com.notemusicali.util.currentTimeMillis
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class TimeSignature(val displayName: String, val beatsPerMeasure: Int) {
|
||||
TWO_FOUR("2/4", 2),
|
||||
THREE_FOUR("3/4", 3),
|
||||
FOUR_FOUR("4/4", 4),
|
||||
SIX_EIGHT("6/8", 6),
|
||||
}
|
||||
|
||||
class MetronomeEngine(
|
||||
private val toneGenerator: ToneGenerator = createToneGenerator(),
|
||||
private val nowMs: () -> Long = { currentTimeMillis() },
|
||||
) {
|
||||
private val _currentBeat = MutableStateFlow(0)
|
||||
val currentBeat: StateFlow<Int> = _currentBeat.asStateFlow()
|
||||
|
||||
private val _isPlaying = MutableStateFlow(false)
|
||||
val isPlaying: StateFlow<Boolean> = _isPlaying.asStateFlow()
|
||||
|
||||
var bpm: Int = 120
|
||||
var timeSignature: TimeSignature = TimeSignature.FOUR_FOUR
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
fun start(scope: CoroutineScope) {
|
||||
if (_isPlaying.value) return
|
||||
_isPlaying.value = true
|
||||
_currentBeat.value = 0
|
||||
|
||||
job = scope.launch {
|
||||
var beat = 0
|
||||
// Absolute-time grid: each tick is scheduled from the accumulated ideal
|
||||
// time, so click/scheduler latency never accumulates as drift.
|
||||
var nextTickMs = nowMs().toDouble()
|
||||
while (isActive) {
|
||||
val isAccent = beat == 0
|
||||
_currentBeat.value = beat
|
||||
toneGenerator.playClick(accent = isAccent)
|
||||
|
||||
nextTickMs += 60_000.0 / bpm
|
||||
delay((nextTickMs - nowMs()).toLong().coerceAtLeast(0L))
|
||||
|
||||
beat = (beat + 1) % timeSignature.beatsPerMeasure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
_isPlaying.value = false
|
||||
_currentBeat.value = 0
|
||||
toneGenerator.stop()
|
||||
}
|
||||
|
||||
fun release() {
|
||||
stop()
|
||||
toneGenerator.release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.notemusicali.music
|
||||
|
||||
/**
|
||||
* Maps note indices (0-11) to Italian notation names.
|
||||
* 0=DO(C), 1=DO#, 2=RE(D), ... 11=SI(B)
|
||||
*/
|
||||
object ItalianNotation {
|
||||
|
||||
data class NoteName(
|
||||
val sharp: String,
|
||||
val flat: String?,
|
||||
) {
|
||||
val display: String get() = sharp
|
||||
}
|
||||
|
||||
private val NOTE_NAMES = arrayOf(
|
||||
NoteName("DO", null), // 0 - C
|
||||
NoteName("DO#", "REb"), // 1 - C#/Db
|
||||
NoteName("RE", null), // 2 - D
|
||||
NoteName("RE#", "MIb"), // 3 - D#/Eb
|
||||
NoteName("MI", null), // 4 - E
|
||||
NoteName("FA", null), // 5 - F
|
||||
NoteName("FA#", "SOLb"), // 6 - F#/Gb
|
||||
NoteName("SOL", null), // 7 - G
|
||||
NoteName("SOL#", "LAb"), // 8 - G#/Ab
|
||||
NoteName("LA", null), // 9 - A
|
||||
NoteName("LA#", "SIb"), // 10 - A#/Bb
|
||||
NoteName("SI", null), // 11 - B
|
||||
)
|
||||
|
||||
fun fromNoteIndex(index: Int): NoteName {
|
||||
return NOTE_NAMES[index % 12]
|
||||
}
|
||||
|
||||
fun allNotes(): List<NoteName> = NOTE_NAMES.toList()
|
||||
|
||||
/**
|
||||
* Parse an Italian note name to its index (0-11).
|
||||
* Accepts both sharp and flat variants.
|
||||
*/
|
||||
fun toNoteIndex(name: String): Int? {
|
||||
val upper = name.uppercase().trim()
|
||||
return NOTE_NAMES.indexOfFirst { noteName ->
|
||||
noteName.sharp.uppercase() == upper ||
|
||||
noteName.flat?.uppercase() == upper
|
||||
}.takeIf { it >= 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Map MusicXML pitch step (C,D,E,F,G,A,B) + alter to note index.
|
||||
*/
|
||||
fun fromMusicXmlStep(step: String, alter: Int = 0): Int? {
|
||||
val baseIndex = when (step.uppercase()) {
|
||||
"C" -> 0
|
||||
"D" -> 2
|
||||
"E" -> 4
|
||||
"F" -> 5
|
||||
"G" -> 7
|
||||
"A" -> 9
|
||||
"B" -> 11
|
||||
else -> return null
|
||||
}
|
||||
return ((baseIndex + alter) + 12) % 12
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.notemusicali.music
|
||||
|
||||
/**
|
||||
* Parses MusicXML content to extract a monophonic note sequence suitable for
|
||||
* guided practice:
|
||||
* - only the first <part> is read (typically the melody / right hand)
|
||||
* - only the first voice of that part is kept
|
||||
* - chord continuation notes (<chord/>), grace notes and tied-note
|
||||
* continuations (<tie type="stop">) are skipped
|
||||
* - rests are skipped
|
||||
* - only the first time signature is used; mid-piece meter changes are ignored
|
||||
*/
|
||||
object MusicXmlParser {
|
||||
|
||||
// <part\s...> or <part>, but not <part-list>/<part-name>
|
||||
private val PART_START_REGEX = Regex("""<part(\s[^>]*)?>""")
|
||||
private val NOTE_BLOCK_REGEX = Regex("""<note\b[^>]*>(.*?)</note>""", RegexOption.DOT_MATCHES_ALL)
|
||||
private val REST_REGEX = Regex("""<rest\b""")
|
||||
private val CHORD_REGEX = Regex("""<chord\b""")
|
||||
private val GRACE_REGEX = Regex("""<grace\b""")
|
||||
private val TIE_STOP_REGEX = Regex("""<tie\b[^>]*type\s*=\s*["']stop["']""")
|
||||
private val VOICE_REGEX = Regex("""<voice>\s*(\d+)\s*</voice>""")
|
||||
private val DIVISIONS_REGEX = Regex("""<divisions>\s*(\d+)\s*</divisions>""")
|
||||
private val DURATION_REGEX = Regex("""<duration>\s*(\d+)\s*</duration>""")
|
||||
private val STEP_REGEX = Regex("""<step>([A-Ga-g])</step>""")
|
||||
private val ALTER_REGEX = Regex("""<alter>(-?\d+)</alter>""")
|
||||
private val OCTAVE_REGEX = Regex("""<octave>(\d+)</octave>""")
|
||||
private val TYPE_REGEX = Regex("""<type>(\w+)</type>""")
|
||||
private val TIME_REGEX = Regex(
|
||||
"""<time(?:\s[^>]*)?>.*?<beats>\s*(\d+)\s*</beats>.*?<beat-type>\s*(\d+)\s*</beat-type>.*?</time>""",
|
||||
RegexOption.DOT_MATCHES_ALL,
|
||||
)
|
||||
|
||||
fun parse(xmlContent: String, name: String = "MusicXML"): NoteSequence {
|
||||
val partContent = firstPartContent(xmlContent)
|
||||
val notes = mutableListOf<MusicalNote>()
|
||||
var firstVoice: String? = null
|
||||
|
||||
// <divisions> (in <attributes>) defines how many duration units make a
|
||||
// quarter note; it can change mid-part, so track each occurrence's position
|
||||
val divisionsByOffset = DIVISIONS_REGEX.findAll(partContent)
|
||||
.mapNotNull { m -> m.groupValues[1].toIntOrNull()?.let { m.range.first to it } }
|
||||
.toList()
|
||||
|
||||
NOTE_BLOCK_REGEX.findAll(partContent).forEach { noteMatch ->
|
||||
val noteBlock = noteMatch.groupValues[1]
|
||||
|
||||
if (REST_REGEX.containsMatchIn(noteBlock)) return@forEach
|
||||
if (CHORD_REGEX.containsMatchIn(noteBlock)) return@forEach
|
||||
if (GRACE_REGEX.containsMatchIn(noteBlock)) return@forEach
|
||||
if (TIE_STOP_REGEX.containsMatchIn(noteBlock)) return@forEach
|
||||
|
||||
val voice = VOICE_REGEX.find(noteBlock)?.groupValues?.get(1)
|
||||
if (voice != null) {
|
||||
if (firstVoice == null) firstVoice = voice
|
||||
else if (voice != firstVoice) return@forEach
|
||||
}
|
||||
|
||||
val step = STEP_REGEX.find(noteBlock)?.groupValues?.get(1) ?: return@forEach
|
||||
val alter = ALTER_REGEX.find(noteBlock)?.groupValues?.get(1)?.toIntOrNull() ?: 0
|
||||
val octave = OCTAVE_REGEX.find(noteBlock)?.groupValues?.get(1)?.toIntOrNull() ?: 4
|
||||
|
||||
val noteIndex = ItalianNotation.fromMusicXmlStep(step.uppercase(), alter) ?: return@forEach
|
||||
val type = TYPE_REGEX.find(noteBlock)?.groupValues?.get(1)
|
||||
val duration = when {
|
||||
type != null -> NoteDuration.fromMusicXml(type)
|
||||
else -> durationFromRatio(noteBlock, noteMatch.range.first, divisionsByOffset)
|
||||
}
|
||||
notes.add(MusicalNote.fromNameAndOctave(noteIndex, octave, duration, preferFlat = alter < 0))
|
||||
}
|
||||
|
||||
val time = TIME_REGEX.find(partContent)
|
||||
val beats = time?.groupValues?.get(1)?.toIntOrNull() ?: 4
|
||||
val beatType = time?.groupValues?.get(2)?.toIntOrNull() ?: 4
|
||||
return NoteSequence(name = name, notes = notes, beats = beats, beatType = beatType)
|
||||
}
|
||||
|
||||
/** Derives the note value from <duration>/<divisions> when <type> is absent. */
|
||||
private fun durationFromRatio(
|
||||
noteBlock: String,
|
||||
noteOffset: Int,
|
||||
divisionsByOffset: List<Pair<Int, Int>>,
|
||||
): NoteDuration {
|
||||
val durationUnits = DURATION_REGEX.find(noteBlock)?.groupValues?.get(1)?.toIntOrNull()
|
||||
?: return NoteDuration.QUARTER
|
||||
val divisions = divisionsByOffset.lastOrNull { it.first < noteOffset }?.second ?: 1
|
||||
if (divisions <= 0) return NoteDuration.QUARTER
|
||||
val quarters = durationUnits.toDouble() / divisions
|
||||
return when {
|
||||
quarters >= 4.0 -> NoteDuration.WHOLE
|
||||
quarters >= 2.0 -> NoteDuration.HALF
|
||||
quarters >= 1.0 -> NoteDuration.QUARTER
|
||||
quarters >= 0.5 -> NoteDuration.EIGHTH
|
||||
quarters >= 0.25 -> NoteDuration.SIXTEENTH
|
||||
else -> NoteDuration.THIRTY_SECOND
|
||||
}
|
||||
}
|
||||
|
||||
/** Content of the first <part> element, or the whole document if none is found. */
|
||||
private fun firstPartContent(xml: String): String {
|
||||
val start = PART_START_REGEX.find(xml) ?: return xml
|
||||
val contentStart = start.range.last + 1
|
||||
val end = xml.indexOf("</part>", contentStart)
|
||||
return if (end >= 0) xml.substring(contentStart, end) else xml.substring(contentStart)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.notemusicali.music
|
||||
|
||||
enum class NoteDuration(val holdMs: Long) {
|
||||
WHOLE(2500),
|
||||
HALF(1500),
|
||||
QUARTER(1000),
|
||||
EIGHTH(600),
|
||||
SIXTEENTH(400),
|
||||
THIRTY_SECOND(300),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromMusicXml(type: String): NoteDuration = when (type) {
|
||||
"whole" -> WHOLE
|
||||
"half" -> HALF
|
||||
"quarter" -> QUARTER
|
||||
"eighth" -> EIGHTH
|
||||
"16th" -> SIXTEENTH
|
||||
"32nd" -> THIRTY_SECOND
|
||||
else -> QUARTER
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a musical note with its properties.
|
||||
*/
|
||||
data class MusicalNote(
|
||||
val noteIndex: Int,
|
||||
val octave: Int,
|
||||
val italianName: ItalianNotation.NoteName,
|
||||
val midiNumber: Int,
|
||||
val duration: NoteDuration = NoteDuration.QUARTER,
|
||||
// Enharmonic preference: a black key can be spelled as sharp (default) or flat
|
||||
val preferFlat: Boolean = false,
|
||||
) {
|
||||
val displayName: String
|
||||
get() = if (preferFlat) italianName.flat ?: italianName.sharp else italianName.sharp
|
||||
val fullName: String get() = "$displayName$octave"
|
||||
|
||||
companion object {
|
||||
fun fromMidi(
|
||||
midiNumber: Int,
|
||||
duration: NoteDuration = NoteDuration.QUARTER,
|
||||
preferFlat: Boolean = false,
|
||||
): MusicalNote {
|
||||
val noteIndex = ((midiNumber % 12) + 12) % 12
|
||||
val octave = (midiNumber / 12) - 1
|
||||
return MusicalNote(
|
||||
noteIndex = noteIndex,
|
||||
octave = octave,
|
||||
italianName = ItalianNotation.fromNoteIndex(noteIndex),
|
||||
midiNumber = midiNumber,
|
||||
duration = duration,
|
||||
preferFlat = preferFlat,
|
||||
)
|
||||
}
|
||||
|
||||
fun fromNameAndOctave(
|
||||
noteIndex: Int,
|
||||
octave: Int,
|
||||
duration: NoteDuration = NoteDuration.QUARTER,
|
||||
preferFlat: Boolean = false,
|
||||
): MusicalNote {
|
||||
val midiNumber = (octave + 1) * 12 + noteIndex
|
||||
return MusicalNote(
|
||||
noteIndex = noteIndex,
|
||||
octave = octave,
|
||||
italianName = ItalianNotation.fromNoteIndex(noteIndex),
|
||||
midiNumber = midiNumber,
|
||||
duration = duration,
|
||||
preferFlat = preferFlat,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.notemusicali.music
|
||||
|
||||
/**
|
||||
* Ordered list of notes for practice mode.
|
||||
*/
|
||||
data class NoteSequence(
|
||||
val name: String,
|
||||
val notes: List<MusicalNote>,
|
||||
val beats: Int = 4,
|
||||
val beatType: Int = 4,
|
||||
) {
|
||||
val size: Int get() = notes.size
|
||||
|
||||
fun noteAt(index: Int): MusicalNote? = notes.getOrNull(index)
|
||||
|
||||
companion object {
|
||||
fun empty(name: String = "Nuova Sequenza"): NoteSequence {
|
||||
return NoteSequence(name = name, notes = emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.notemusicali.music
|
||||
|
||||
enum class ScaleType(val displayName: String, val intervals: List<Int>) {
|
||||
MAJOR("Maggiore", listOf(0, 2, 4, 5, 7, 9, 11, 12)),
|
||||
NATURAL_MINOR("Minore Naturale", listOf(0, 2, 3, 5, 7, 8, 10, 12)),
|
||||
HARMONIC_MINOR("Minore Armonica", listOf(0, 2, 3, 5, 7, 8, 11, 12)),
|
||||
MELODIC_MINOR("Minore Melodica", listOf(0, 2, 3, 5, 7, 9, 11, 12)),
|
||||
PENTATONIC_MAJOR("Pentatonica Maggiore", listOf(0, 2, 4, 7, 9, 12)),
|
||||
PENTATONIC_MINOR("Pentatonica Minore", listOf(0, 3, 5, 7, 10, 12)),
|
||||
CHROMATIC("Cromatica", listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)),
|
||||
}
|
||||
|
||||
enum class ArpeggioType(val displayName: String, val intervals: List<Int>) {
|
||||
MAJOR("Maggiore", listOf(0, 4, 7, 12)),
|
||||
MINOR("Minore", listOf(0, 3, 7, 12)),
|
||||
DIMINISHED("Diminuito", listOf(0, 3, 6, 12)),
|
||||
AUGMENTED("Aumentato", listOf(0, 4, 8, 12)),
|
||||
DOMINANT_7TH("Settima Dom.", listOf(0, 4, 7, 10, 12)),
|
||||
MAJOR_7TH("Settima Magg.", listOf(0, 4, 7, 11, 12)),
|
||||
MINOR_7TH("Settima Min.", listOf(0, 3, 7, 10, 12)),
|
||||
}
|
||||
|
||||
enum class ScaleDirection(val displayName: String) {
|
||||
ASCENDING("Ascendente"),
|
||||
DESCENDING("Discendente"),
|
||||
BOTH("Asc. + Disc."),
|
||||
}
|
||||
|
||||
object ScaleGenerator {
|
||||
|
||||
fun generateScale(
|
||||
rootNoteIndex: Int,
|
||||
octave: Int,
|
||||
scaleType: ScaleType,
|
||||
direction: ScaleDirection = ScaleDirection.ASCENDING,
|
||||
): NoteSequence {
|
||||
val rootMidi = (octave + 1) * 12 + rootNoteIndex
|
||||
val rootName = ItalianNotation.fromNoteIndex(rootNoteIndex).display
|
||||
val name = "Scala $rootName ${scaleType.displayName}"
|
||||
|
||||
val ascending = scaleType.intervals.map { interval ->
|
||||
MusicalNote.fromMidi(rootMidi + interval)
|
||||
}
|
||||
|
||||
val notes = when (direction) {
|
||||
ScaleDirection.ASCENDING -> ascending
|
||||
ScaleDirection.DESCENDING -> ascending.reversed()
|
||||
ScaleDirection.BOTH -> ascending + ascending.dropLast(1).reversed()
|
||||
}
|
||||
|
||||
return NoteSequence(name = name, notes = notes)
|
||||
}
|
||||
|
||||
fun generateArpeggio(
|
||||
rootNoteIndex: Int,
|
||||
octave: Int,
|
||||
arpeggioType: ArpeggioType,
|
||||
direction: ScaleDirection = ScaleDirection.ASCENDING,
|
||||
): NoteSequence {
|
||||
val rootMidi = (octave + 1) * 12 + rootNoteIndex
|
||||
val rootName = ItalianNotation.fromNoteIndex(rootNoteIndex).display
|
||||
val name = "Arpeggio $rootName ${arpeggioType.displayName}"
|
||||
|
||||
val ascending = arpeggioType.intervals.map { interval ->
|
||||
MusicalNote.fromMidi(rootMidi + interval)
|
||||
}
|
||||
|
||||
val notes = when (direction) {
|
||||
ScaleDirection.ASCENDING -> ascending
|
||||
ScaleDirection.DESCENDING -> ascending.reversed()
|
||||
ScaleDirection.BOTH -> ascending + ascending.dropLast(1).reversed()
|
||||
}
|
||||
|
||||
return NoteSequence(name = name, notes = notes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package com.notemusicali.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.notemusicali.audio.InstrumentPreset
|
||||
import com.notemusicali.challenge.ChallengeTimeLimit
|
||||
import com.notemusicali.music.MusicalNote
|
||||
import com.notemusicali.music.NoteSequence
|
||||
import com.notemusicali.ui.challenge.ChallengeScreen
|
||||
import com.notemusicali.ui.challenge.ChallengeSetupScreen
|
||||
import com.notemusicali.ui.challenge.ChallengeViewModel
|
||||
import com.notemusicali.ui.challenge.LeaderboardScreen
|
||||
import com.notemusicali.ui.eartraining.EarTrainingScreen
|
||||
import com.notemusicali.ui.exercises.ExercisesScreen
|
||||
import com.notemusicali.ui.goals.DailyGoalScreen
|
||||
import com.notemusicali.ui.guide.GuideScreen
|
||||
import com.notemusicali.ui.history.HistoryScreen
|
||||
import com.notemusicali.ui.home.HomeScreen
|
||||
import com.notemusicali.ui.metronome.MetronomeScreen
|
||||
import com.notemusicali.ui.practice.ManualInputScreen
|
||||
import com.notemusicali.ui.practice.PracticeScreen
|
||||
import com.notemusicali.ui.practice.PracticeSetupScreen
|
||||
import com.notemusicali.ui.practice.PracticeViewModel
|
||||
import com.notemusicali.ui.reference.ReferenceNoteScreen
|
||||
import com.notemusicali.ui.scales.ScaleGeneratorScreen
|
||||
import com.notemusicali.ui.scan.ScanScreen
|
||||
import com.notemusicali.ui.scan.ScanViewModel
|
||||
import com.notemusicali.ui.scores.ScoresScreen
|
||||
import com.notemusicali.ui.stats.StatsScreen
|
||||
import com.notemusicali.ui.tuner.TunerScreen
|
||||
|
||||
object Routes {
|
||||
const val HOME = "home"
|
||||
const val TUNER = "tuner"
|
||||
const val PRACTICE_SETUP = "practice_setup"
|
||||
const val MANUAL_INPUT = "manual_input"
|
||||
const val SCAN = "scan"
|
||||
const val PRACTICE = "practice"
|
||||
const val EXERCISES = "exercises"
|
||||
const val HISTORY = "history"
|
||||
const val SCORES = "scores"
|
||||
const val METRONOME = "metronome"
|
||||
const val REFERENCE_NOTE = "reference_note"
|
||||
const val SCALES = "scales"
|
||||
const val EAR_TRAINING = "ear_training"
|
||||
const val CHALLENGE_SETUP = "challenge_setup"
|
||||
const val CHALLENGE = "challenge"
|
||||
const val LEADERBOARD = "leaderboard"
|
||||
const val STATS = "stats"
|
||||
const val DAILY_GOALS = "daily_goals"
|
||||
const val GUIDE = "guide"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppNavigation() {
|
||||
val navController = rememberNavController()
|
||||
|
||||
var currentSequence by remember { mutableStateOf<NoteSequence?>(null) }
|
||||
var currentInstrument by remember { mutableStateOf(InstrumentPreset.VIOLINO) }
|
||||
var challengeTimeLimit by remember { mutableStateOf(ChallengeTimeLimit.MEDIUM) }
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Routes.HOME,
|
||||
) {
|
||||
composable(Routes.HOME) {
|
||||
HomeScreen(
|
||||
onNavigateToTuner = { navController.navigate(Routes.TUNER) },
|
||||
onNavigateToPractice = { navController.navigate(Routes.PRACTICE_SETUP) },
|
||||
onNavigateToExercises = { navController.navigate(Routes.EXERCISES) },
|
||||
onNavigateToHistory = { navController.navigate(Routes.HISTORY) },
|
||||
onNavigateToScores = { navController.navigate(Routes.SCORES) },
|
||||
onNavigateToMetronome = { navController.navigate(Routes.METRONOME) },
|
||||
onNavigateToReference = { navController.navigate(Routes.REFERENCE_NOTE) },
|
||||
onNavigateToScales = { navController.navigate(Routes.SCALES) },
|
||||
onNavigateToEarTraining = { navController.navigate(Routes.EAR_TRAINING) },
|
||||
onNavigateToChallenge = { navController.navigate(Routes.CHALLENGE_SETUP) },
|
||||
onNavigateToLeaderboard = { navController.navigate(Routes.LEADERBOARD) },
|
||||
onNavigateToStats = { navController.navigate(Routes.STATS) },
|
||||
onNavigateToDailyGoals = { navController.navigate(Routes.DAILY_GOALS) },
|
||||
onNavigateToGuide = { navController.navigate(Routes.GUIDE) },
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.TUNER) {
|
||||
TunerScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
composable(Routes.METRONOME) {
|
||||
MetronomeScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
composable(Routes.REFERENCE_NOTE) {
|
||||
ReferenceNoteScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
composable(Routes.SCALES) {
|
||||
ScaleGeneratorScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onPractice = { sequence ->
|
||||
currentSequence = sequence
|
||||
currentInstrument = InstrumentPreset.VIOLINO
|
||||
navController.navigate(Routes.PRACTICE) {
|
||||
popUpTo(Routes.SCALES)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.EAR_TRAINING) {
|
||||
EarTrainingScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
composable(Routes.CHALLENGE_SETUP) {
|
||||
ChallengeSetupScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onStart = { sequence, timeLimit ->
|
||||
currentSequence = sequence
|
||||
challengeTimeLimit = timeLimit
|
||||
navController.navigate(Routes.CHALLENGE) {
|
||||
popUpTo(Routes.CHALLENGE_SETUP)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.CHALLENGE) {
|
||||
val challengeViewModel: ChallengeViewModel = viewModel { ChallengeViewModel() }
|
||||
LaunchedEffect(currentSequence, challengeTimeLimit) {
|
||||
currentSequence?.let { seq ->
|
||||
challengeViewModel.setSequence(seq)
|
||||
challengeViewModel.setTimeLimit(challengeTimeLimit)
|
||||
challengeViewModel.start()
|
||||
}
|
||||
}
|
||||
ChallengeScreen(
|
||||
onBack = { navController.popBackStack(Routes.HOME, inclusive = false) },
|
||||
viewModel = challengeViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.LEADERBOARD) {
|
||||
LeaderboardScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
composable(Routes.STATS) {
|
||||
StatsScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
composable(Routes.DAILY_GOALS) {
|
||||
DailyGoalScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
composable(Routes.GUIDE) {
|
||||
GuideScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
composable(Routes.PRACTICE_SETUP) {
|
||||
PracticeSetupScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onManualInput = { navController.navigate(Routes.MANUAL_INPUT) },
|
||||
onScan = { navController.navigate(Routes.SCAN) },
|
||||
onMusicXmlLoaded = { sequence ->
|
||||
currentSequence = sequence
|
||||
navController.navigate(Routes.PRACTICE)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.MANUAL_INPUT) {
|
||||
ManualInputScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onSequenceReady = { sequence, instrument ->
|
||||
currentSequence = sequence
|
||||
currentInstrument = instrument
|
||||
navController.navigate(Routes.PRACTICE)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.SCAN) {
|
||||
val scanViewModel: ScanViewModel = viewModel { ScanViewModel() }
|
||||
ScanScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onSequenceCaptured = { sequence, instrument ->
|
||||
currentSequence = sequence
|
||||
currentInstrument = instrument
|
||||
navController.navigate(Routes.PRACTICE) {
|
||||
popUpTo(Routes.PRACTICE_SETUP)
|
||||
}
|
||||
},
|
||||
viewModel = scanViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.EXERCISES) {
|
||||
ExercisesScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onExerciseSelected = { sequence ->
|
||||
currentSequence = sequence
|
||||
currentInstrument = InstrumentPreset.VIOLINO
|
||||
navController.navigate(Routes.PRACTICE) {
|
||||
popUpTo(Routes.EXERCISES)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.SCORES) {
|
||||
ScoresScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onScoreSelected = { sequence ->
|
||||
currentSequence = sequence
|
||||
currentInstrument = InstrumentPreset.PIANOFORTE
|
||||
navController.navigate(Routes.PRACTICE) {
|
||||
popUpTo(Routes.SCORES)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.HISTORY) {
|
||||
HistoryScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onReplay = { session ->
|
||||
val notes = session.noteMidiNumbers.map { MusicalNote.fromMidi(it) }
|
||||
currentSequence = NoteSequence(
|
||||
name = session.sequenceName,
|
||||
notes = notes,
|
||||
)
|
||||
currentInstrument = InstrumentPreset.entries.firstOrNull {
|
||||
it.displayName == session.instrumentName
|
||||
} ?: InstrumentPreset.VIOLINO
|
||||
navController.navigate(Routes.PRACTICE) {
|
||||
popUpTo(Routes.HISTORY)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.PRACTICE) {
|
||||
val practiceViewModel: PracticeViewModel = viewModel { PracticeViewModel() }
|
||||
LaunchedEffect(currentSequence, currentInstrument) {
|
||||
practiceViewModel.selectInstrument(currentInstrument)
|
||||
currentSequence?.let { seq ->
|
||||
practiceViewModel.setSequence(seq)
|
||||
}
|
||||
practiceViewModel.startListening()
|
||||
}
|
||||
PracticeScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
viewModel = practiceViewModel,
|
||||
onFinished = {
|
||||
navController.popBackStack(Routes.HOME, inclusive = false)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.notemusicali.scan
|
||||
|
||||
import com.russhwolf.settings.Settings
|
||||
|
||||
/**
|
||||
* Platform-backed secure key-value store (iOS Keychain, Android
|
||||
* EncryptedSharedPreferences). Null when the platform has none — values then
|
||||
* stay in the regular store with obfuscation.
|
||||
*/
|
||||
internal expect fun createSecureSettings(): Settings?
|
||||
|
||||
/**
|
||||
* Routes sensitive keys to the secure store when one exists, transparently
|
||||
* migrating legacy values out of the plain store. Everything else stays in
|
||||
* the plain store (obfuscated, to avoid trivial plaintext inspection).
|
||||
*/
|
||||
internal class ApiKeyStore(
|
||||
private val plain: Settings,
|
||||
private val secure: Settings?,
|
||||
) {
|
||||
private val secureKeys = setOf("anthropic_api_key", "openai_api_key")
|
||||
private val obfuscationKey = 0x4D // 'M' for music
|
||||
private val hexChars = "0123456789abcdef"
|
||||
|
||||
fun getString(key: String): String? {
|
||||
if (key in secureKeys && secure != null) {
|
||||
secure.getStringOrNull(key)?.let { return it }
|
||||
// One-time migration of a value stored before the secure store existed
|
||||
val legacy = plain.getStringOrNull(key)?.let(::deobfuscate) ?: return null
|
||||
secure.putString(key, legacy)
|
||||
plain.remove(key)
|
||||
return legacy
|
||||
}
|
||||
return plain.getStringOrNull(key)?.let(::deobfuscate)
|
||||
}
|
||||
|
||||
fun putString(key: String, value: String) {
|
||||
if (key in secureKeys && secure != null) {
|
||||
secure.putString(key, value)
|
||||
plain.remove(key)
|
||||
} else {
|
||||
plain.putString(key, obfuscate(value))
|
||||
}
|
||||
}
|
||||
|
||||
fun migratePlaintextIfNeeded(keys: List<String>) {
|
||||
for (key in keys) {
|
||||
val raw = plain.getStringOrNull(key) ?: continue
|
||||
if (!raw.startsWith("OBF:")) {
|
||||
plain.putString(key, obfuscate(raw))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun obfuscate(value: String): String {
|
||||
val bytes = value.encodeToByteArray()
|
||||
val sb = StringBuilder("OBF:")
|
||||
for (b in bytes) {
|
||||
val xored = (b.toInt() xor obfuscationKey) and 0xFF
|
||||
sb.append(hexChars[xored shr 4])
|
||||
sb.append(hexChars[xored and 0x0F])
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun deobfuscate(stored: String): String {
|
||||
if (!stored.startsWith("OBF:")) return stored // legacy plaintext
|
||||
val hex = stored.removePrefix("OBF:")
|
||||
val bytes = ByteArray(hex.length / 2) { i ->
|
||||
val hi = hex[i * 2].digitToInt(16)
|
||||
val lo = hex[i * 2 + 1].digitToInt(16)
|
||||
((hi shl 4 or lo) xor obfuscationKey).toByte()
|
||||
}
|
||||
return bytes.decodeToString()
|
||||
}
|
||||
}
|
||||