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

App Kotlin Multiplatform (Android/iOS/Desktop) con Compose Multiplatform:
controllo intonazione (YIN), pratica guidata, metronomo, ear training,
sfide, scansione spartiti via AI, spartiti bundled e pentagramma con
glifi tipografici, travature, battute e spaziatura proporzionale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Fable 5 committed 2026-07-22 10:06:59 +02:00
commit 21ad969b10
245 files changed
+28963

No files matched your search

+103
View File
@@ -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 &amp; 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()
}
}
@@ -0,0 +1,27 @@
package com.notemusicali.scan
import com.russhwolf.settings.Settings
/**
* Cross-platform settings accessor. API keys go to the platform secure store
* when available (iOS Keychain, Android EncryptedSharedPreferences); other
* values use multiplatform-settings with basic obfuscation for sensitive-ish
* content.
*/
object AppSettings {
private val store by lazy { ApiKeyStore(Settings(), createSecureSettings()) }
fun getString(key: String): String? = store.getString(key)
fun putString(key: String, value: String) = store.putString(key, value)
/**
* Migrate any existing plaintext values to obfuscated format.
* Call once at app startup.
*/
fun migrateIfNeeded() {
store.migratePlaintextIfNeeded(
listOf("api_key", "omr_api_key", "claude_api_key", "openai_api_key", "anthropic_api_key"),
)
}
}
@@ -0,0 +1,123 @@
package com.notemusicali.scan
import com.notemusicali.music.MusicalNote
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
object ClaudeOmrProcessor : OmrProcessor {
private const val API_URL = "https://api.anthropic.com/v1/messages"
private val PROMPT = """
Analizza questa immagine di spartito musicale.
Elenca SOLO le note in ordine di apparizione, una per riga, nel formato:
LETTERA OTTAVA
Usa la notazione anglosassone: C D E F G A B (con # o b per alterazioni).
Esempio:
C4
D#5
Bb3
E4
Ignora pause, dinamiche, legature e qualsiasi altro simbolo non-nota.
Se non riesci a identificare note, rispondi esattamente: NESSUNA_NOTA
""".trimIndent()
private val json = Json { ignoreUnknownKeys = true }
override suspend fun analyze(imageBase64: String, apiKey: String): Result<List<MusicalNote>> {
return try {
val client = OmrHttp.createClient()
try {
val requestBody = buildRequestBody(imageBase64)
val response = client.post(API_URL) {
header("x-api-key", apiKey)
header("anthropic-version", "2023-06-01")
contentType(ContentType.Application.Json)
setBody(requestBody.toString())
}
val responseBody = response.bodyAsText()
if (response.status.value !in 200..299) {
return Result.failure(Exception(OmrErrorParsing.errorMessage(responseBody, response.status.value)))
}
parseResponse(responseBody)
} finally {
client.close()
}
} catch (e: Exception) {
Result.failure(e)
}
}
internal fun parseResponse(responseBody: String): Result<List<MusicalNote>> {
val content = try {
val responseJson = json.parseToJsonElement(responseBody).jsonObject
val contentArray = responseJson["content"]?.jsonArray
?: return Result.failure(Exception("Nessun contenuto nella risposta"))
contentArray
.map { it.jsonObject }
.firstOrNull { it["type"]?.jsonPrimitive?.content == "text" }
?.get("text")?.jsonPrimitive?.content?.trim()
?: return Result.failure(Exception("Nessun testo nella risposta"))
} catch (e: Exception) {
return Result.failure(Exception("Risposta non valida dal server"))
}
if (content == "NESSUNA_NOTA") {
return Result.failure(Exception("Nessuna nota riconosciuta nell'immagine"))
}
val notes = NoteParser.parseNotes(content)
return if (notes.isEmpty()) {
Result.failure(Exception("Nessuna nota valida trovata nella risposta"))
} else {
Result.success(notes)
}
}
private fun buildRequestBody(base64Image: String): JsonObject {
return buildJsonObject {
// claude-sonnet-4-20250514 is deprecated (retires June 2026); claude-sonnet-5 is its replacement
put("model", "claude-sonnet-5")
put("max_tokens", 1024)
put("messages", buildJsonArray {
add(buildJsonObject {
put("role", "user")
put("content", buildJsonArray {
add(buildJsonObject {
put("type", "image")
put("source", buildJsonObject {
put("type", "base64")
put("media_type", "image/jpeg")
put("data", base64Image)
})
})
add(buildJsonObject {
put("type", "text")
put("text", PROMPT)
})
})
})
})
}
}
}
@@ -0,0 +1,32 @@
package com.notemusicali.scan
import com.notemusicali.music.ItalianNotation
import com.notemusicali.music.MusicalNote
object NoteParser {
private val NOTE_REGEX = Regex("""^([A-Ga-g][#b]?)(\d)$""")
fun parseNotes(content: String): List<MusicalNote> {
return content.lines()
.map { it.trim() }
.filter { it.isNotEmpty() }
.mapNotNull { line ->
val match = NOTE_REGEX.matchEntire(line) ?: return@mapNotNull null
val noteName = match.groupValues[1]
val octave = match.groupValues[2].toIntOrNull() ?: return@mapNotNull null
val step = noteName.first().uppercase()
val alter = when {
noteName.length > 1 && noteName[1] == '#' -> 1
noteName.length > 1 && noteName[1] == 'b' -> -1
else -> 0
}
val noteIndex = ItalianNotation.fromMusicXmlStep(step, alter)
?: return@mapNotNull null
MusicalNote.fromNameAndOctave(noteIndex, octave, preferFlat = alter < 0)
}
}
}
@@ -0,0 +1,32 @@
package com.notemusicali.scan
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeout
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/** Shared HTTP plumbing for the OMR providers. */
internal object OmrHttp {
fun createClient(): HttpClient = HttpClient {
install(HttpTimeout) {
// Vision requests on a large image can be slow, but must not hang forever
requestTimeoutMillis = 90_000
connectTimeoutMillis = 15_000
socketTimeoutMillis = 90_000
}
}
}
/** Extracts a human-readable message from a provider error body (Anthropic and OpenAI share the shape). */
object OmrErrorParsing {
private val json = Json { ignoreUnknownKeys = true }
fun errorMessage(body: String, statusCode: Int): String = try {
json.parseToJsonElement(body).jsonObject["error"]
?.jsonObject?.get("message")?.jsonPrimitive?.content
?: "Errore HTTP $statusCode"
} catch (_: Exception) {
"Errore HTTP $statusCode"
}
}
@@ -0,0 +1,10 @@
package com.notemusicali.scan
import com.notemusicali.music.MusicalNote
/**
* Interface for Optical Music Recognition processors.
*/
interface OmrProcessor {
suspend fun analyze(imageBase64: String, apiKey: String): Result<List<MusicalNote>>
}
@@ -0,0 +1,113 @@
package com.notemusicali.scan
import com.notemusicali.music.MusicalNote
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
object OpenAiOmrProcessor : OmrProcessor {
private const val API_URL = "https://api.openai.com/v1/chat/completions"
private val PROMPT = """
Analizza questa immagine di spartito musicale.
Elenca SOLO le note in ordine di apparizione, una per riga, nel formato:
LETTERA OTTAVA
Usa la notazione anglosassone: C D E F G A B (con # o b per alterazioni).
Esempio:
C4
D#5
Bb3
E4
Ignora pause, dinamiche, legature e qualsiasi altro simbolo non-nota.
Se non riesci a identificare note, rispondi esattamente: NESSUNA_NOTA
""".trimIndent()
private val json = Json { ignoreUnknownKeys = true }
override suspend fun analyze(imageBase64: String, apiKey: String): Result<List<MusicalNote>> {
return try {
val client = OmrHttp.createClient()
try {
val requestBody = buildRequestBody(imageBase64)
val response = client.post(API_URL) {
header("Authorization", "Bearer $apiKey")
contentType(ContentType.Application.Json)
setBody(requestBody.toString())
}
val responseBody = response.bodyAsText()
if (response.status.value !in 200..299) {
return Result.failure(Exception(OmrErrorParsing.errorMessage(responseBody, response.status.value)))
}
parseResponse(responseBody)
} finally {
client.close()
}
} catch (e: Exception) {
Result.failure(e)
}
}
internal fun parseResponse(responseBody: String): Result<List<MusicalNote>> {
val content = try {
json.parseToJsonElement(responseBody).jsonObject
.let { it["choices"]?.jsonArray }
?.firstOrNull()?.jsonObject
?.get("message")?.jsonObject
?.get("content")?.jsonPrimitive?.content?.trim()
?: return Result.failure(Exception("Risposta vuota dal server"))
} catch (e: Exception) {
return Result.failure(Exception("Risposta non valida dal server"))
}
if (content == "NESSUNA_NOTA") {
return Result.failure(Exception("Nessuna nota riconosciuta nell'immagine"))
}
val notes = NoteParser.parseNotes(content)
return if (notes.isEmpty()) {
Result.failure(Exception("Nessuna nota valida trovata nella risposta"))
} else {
Result.success(notes)
}
}
private fun buildRequestBody(base64Image: String) = buildJsonObject {
put("model", "gpt-4o")
put("max_tokens", 1000)
put("temperature", 0.1)
put("messages", buildJsonArray {
add(buildJsonObject {
put("role", "user")
put("content", buildJsonArray {
add(buildJsonObject {
put("type", "text")
put("text", PROMPT)
})
add(buildJsonObject {
put("type", "image_url")
put("image_url", buildJsonObject {
put("url", "data:image/jpeg;base64,$base64Image")
put("detail", "high")
})
})
})
})
})
}
}
@@ -0,0 +1,11 @@
package com.notemusicali.scan
/**
* Platform-agnostic wrapper for image data.
*/
expect class PlatformImage
/**
* Encode a platform image to Base64 JPEG string.
*/
expect fun PlatformImage.toBase64Jpeg(maxDim: Int = 1024, quality: Int = 85): String
@@ -0,0 +1,121 @@
package com.notemusicali.stats
import com.notemusicali.history.PracticeSession
import com.notemusicali.history.SessionRepository
import kotlin.math.abs
data class DailyStats(
val dateLabel: String,
val sessionCount: Int,
val totalTimeMs: Long,
val averageCents: Float,
val firstAttemptRate: Float,
val averageReactionMs: Long,
)
data class OverallStats(
val totalSessions: Int,
val totalPracticeTimeMs: Long,
val bestAccuracyCents: Float,
val averageCents: Float,
val averageFirstAttemptRate: Float,
val completedSessions: Int,
)
object StatsCalculator {
fun calculateOverall(sessions: List<PracticeSession> = SessionRepository.load()): OverallStats {
if (sessions.isEmpty()) return OverallStats(0, 0L, 0f, 0f, 0f, 0)
val totalTime = sessions.sumOf { it.totalTimeMs }
val withResults = sessions.filter { it.noteResults.isNotEmpty() }
val avgCents = if (withResults.isEmpty()) 0f
else withResults.map { it.averageCents }.average().toFloat()
val bestCents = if (withResults.isEmpty()) 0f
else withResults.minOf { it.averageCents }
val avgFirstAttempt = if (withResults.isEmpty()) 0f
else withResults.map { it.firstAttemptRate }.average().toFloat()
return OverallStats(
totalSessions = sessions.size,
totalPracticeTimeMs = totalTime,
bestAccuracyCents = bestCents,
averageCents = avgCents,
averageFirstAttemptRate = avgFirstAttempt,
completedSessions = sessions.count { it.completed },
)
}
fun calculateDaily(sessions: List<PracticeSession> = SessionRepository.load()): List<DailyStats> {
if (sessions.isEmpty()) return emptyList()
// Group by day (using dateMillis / MS_PER_DAY)
val grouped = sessions.groupBy { it.dateMillis / 86_400_000L }
return grouped.entries
.sortedBy { it.key }
.map { (dayKey, daySessions) ->
val withResults = daySessions.filter { it.noteResults.isNotEmpty() }
val avgCents = if (withResults.isEmpty()) 0f
else withResults.map { it.averageCents }.average().toFloat()
val avgFirstAttempt = if (withResults.isEmpty()) 0f
else withResults.map { it.firstAttemptRate }.average().toFloat()
val avgReaction = if (withResults.isEmpty()) 0L
else withResults.flatMap { it.noteResults }
.map { it.reactionTimeMs }
.average().toLong()
// Simple date label from epoch day
val totalDays = dayKey
val year = estimateYear(totalDays)
val dayOfYear = estimateDayOfYear(totalDays, year)
val (month, day) = dayOfYearToMonthDay(dayOfYear, isLeapYear(year))
val dateLabel = "${day.toString().padStart(2, '0')}/${month.toString().padStart(2, '0')}"
DailyStats(
dateLabel = dateLabel,
sessionCount = daySessions.size,
totalTimeMs = daySessions.sumOf { it.totalTimeMs },
averageCents = avgCents,
firstAttemptRate = avgFirstAttempt,
averageReactionMs = avgReaction,
)
}
}
// Simple epoch day → year/month/day helpers (no platform dependency)
private fun isLeapYear(year: Int): Boolean =
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
private fun estimateYear(epochDay: Long): Int {
var y = 1970
var remaining = epochDay
while (true) {
val daysInYear = if (isLeapYear(y)) 366L else 365L
if (remaining < daysInYear) return y
remaining -= daysInYear
y++
}
}
private fun estimateDayOfYear(epochDay: Long, year: Int): Int {
var remaining = epochDay
var y = 1970
while (y < year) {
remaining -= if (isLeapYear(y)) 366L else 365L
y++
}
return remaining.toInt() + 1
}
private fun dayOfYearToMonthDay(dayOfYear: Int, leap: Boolean): Pair<Int, Int> {
val daysInMonths = if (leap) intArrayOf(31,29,31,30,31,30,31,31,30,31,30,31)
else intArrayOf(31,28,31,30,31,30,31,31,30,31,30,31)
var remaining = dayOfYear
for (m in daysInMonths.indices) {
if (remaining <= daysInMonths[m]) return Pair(m + 1, remaining)
remaining -= daysInMonths[m]
}
return Pair(12, 31)
}
}
@@ -0,0 +1,292 @@
package com.notemusicali.ui.challenge
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.EmojiEvents
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.ui.components.BackTopBar
import com.notemusicali.ui.components.KeepScreenOn
import com.notemusicali.ui.components.StaffView
import com.notemusicali.ui.theme.TunerColors
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun ChallengeScreen(
onBack: () -> Unit,
viewModel: ChallengeViewModel,
) {
val state by viewModel.uiState.collectAsState()
KeepScreenOn()
DisposableEffect(Unit) {
onDispose { /* cleanup handled in ViewModel */ }
}
if (state.isComplete) {
ChallengeCompleteView(
state = state,
onBack = onBack,
)
return
}
val backgroundColor by animateColorAsState(
targetValue = if (state.isCorrect) TunerColors.inTuneBackground
else TunerColors.silenceBackground,
animationSpec = tween(200),
label = "bgColor",
)
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor),
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
modifier = Modifier.widthIn(max = 600.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
BackTopBar(title = stringResource(Res.string.challenge_title), onBack = onBack)
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Timer bar
val timeProgress = state.remainingMs.toFloat() / (state.timeLimit.seconds * 1000f)
LinearProgressIndicator(
progress = { timeProgress },
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(4.dp)),
color = when {
timeProgress > 0.5f -> Color(0xFF4CAF50)
timeProgress > 0.2f -> Color(0xFFFF9800)
else -> Color(0xFFF44336)
},
trackColor = Color.White.copy(alpha = 0.15f),
)
// Timer text
val sec = state.remainingMs / 1000
Text(
text = "${sec}s",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
modifier = Modifier.padding(top = 4.dp),
)
Spacer(modifier = Modifier.height(16.dp))
// Score + Combo
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(stringResource(Res.string.score), fontSize = 12.sp, color = Color.White.copy(alpha = 0.5f))
Text(
text = "${state.score}",
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(stringResource(Res.string.combo), fontSize = 12.sp, color = Color.White.copy(alpha = 0.5f))
Text(
text = "${state.combo}x",
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
color = if (state.combo > 0) Color(0xFFFF9800) else Color.White.copy(alpha = 0.4f),
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(stringResource(Res.string.notes), fontSize = 12.sp, color = Color.White.copy(alpha = 0.5f))
Text(
text = "${state.notesPlayed}",
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
)
}
}
Spacer(modifier = Modifier.weight(1f))
// Target note on staff
state.targetNote?.let { note ->
StaffView(
note = note,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
// Target note name
Text(
text = state.targetNote?.displayName ?: "",
fontSize = 72.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
)
Text(
text = stringResource(Res.string.octave_n, (state.targetNote?.octave ?: "").toString()),
fontSize = 16.sp,
color = Color.White.copy(alpha = 0.5f),
)
Spacer(modifier = Modifier.height(24.dp))
// Detected note
if (state.detectedNoteName.isNotEmpty()) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.06f))
.padding(horizontal = 24.dp, vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = state.detectedNoteName,
fontSize = 40.sp,
fontWeight = FontWeight.Medium,
color = if (state.isCorrect) TunerColors.inTune else TunerColors.outOfTune,
)
}
}
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
@Composable
private fun ChallengeCompleteView(
state: ChallengeUiState,
onBack: () -> Unit,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(Color(0xFF1A237E), Color(0xFF0D1B47)),
)
),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
imageVector = Icons.Outlined.EmojiEvents,
contentDescription = stringResource(Res.string.challenge_completed),
modifier = Modifier.size(64.dp),
tint = Color(0xFFFFD54F),
)
Text(
text = stringResource(Res.string.challenge_completed),
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
)
Text(
text = "${state.score}",
fontSize = 64.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFFFFD54F),
)
Text(
text = stringResource(Res.string.points),
fontSize = 18.sp,
color = Color.White.copy(alpha = 0.6f),
)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.1f))
.padding(horizontal = 24.dp, vertical = 16.dp),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(0.6f),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(stringResource(Res.string.notes_played), fontSize = 14.sp, color = Color.White.copy(alpha = 0.6f))
Text("${state.notesPlayed}", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color.White)
}
Row(
modifier = Modifier.fillMaxWidth(0.6f),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(stringResource(Res.string.max_combo), fontSize = 14.sp, color = Color.White.copy(alpha = 0.6f))
Text("${state.maxCombo}x", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color(0xFFFF9800))
}
Row(
modifier = Modifier.fillMaxWidth(0.6f),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(stringResource(Res.string.time), fontSize = 14.sp, color = Color.White.copy(alpha = 0.6f))
Text("${state.timeLimit.displayName}", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color.White)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onBack) {
Text(stringResource(Res.string.finish))
}
}
}
}
@@ -0,0 +1,164 @@
package com.notemusicali.ui.challenge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.challenge.ChallengeTimeLimit
import com.notemusicali.exercises.ExerciseRepository
import com.notemusicali.music.NoteSequence
import com.notemusicali.music.ScaleDirection
import com.notemusicali.music.ScaleGenerator
import com.notemusicali.music.ScaleType
import com.notemusicali.ui.components.BackTopBar
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
import kotlin.random.Random
private enum class SequenceSource {
EXERCISES,
SCALES,
RANDOM,
}
@Composable
fun ChallengeSetupScreen(
onBack: () -> Unit,
onStart: (NoteSequence, ChallengeTimeLimit) -> Unit,
) {
var selectedSource by remember { mutableStateOf(SequenceSource.EXERCISES) }
var selectedTimeLimit by remember { mutableStateOf(ChallengeTimeLimit.MEDIUM) }
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
modifier = Modifier.widthIn(max = 600.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
BackTopBar(title = stringResource(Res.string.challenge_title), onBack = onBack)
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(Res.string.section_challenge),
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
)
Text(
text = stringResource(Res.string.challenge_subtitle),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.6f),
)
Spacer(modifier = Modifier.height(40.dp))
// Sequence source
Text(stringResource(Res.string.sequence), fontSize = 16.sp, color = Color.White.copy(alpha = 0.6f))
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SequenceSource.entries.forEach { source ->
val sourceLabel = when (source) {
SequenceSource.EXERCISES -> stringResource(Res.string.source_exercises)
SequenceSource.SCALES -> stringResource(Res.string.source_scales)
SequenceSource.RANDOM -> stringResource(Res.string.source_random)
}
FilterChip(
selected = selectedSource == source,
onClick = { selectedSource = source },
label = { Text(sourceLabel) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
selectedLabelColor = Color.White,
),
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Time limit
Text(stringResource(Res.string.time_limit), fontSize = 16.sp, color = Color.White.copy(alpha = 0.6f))
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ChallengeTimeLimit.entries.forEach { limit ->
FilterChip(
selected = selectedTimeLimit == limit,
onClick = { selectedTimeLimit = limit },
label = { Text(limit.displayName) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
selectedLabelColor = Color.White,
),
)
}
}
Spacer(modifier = Modifier.height(48.dp))
val challengeRandomName = stringResource(Res.string.challenge_random)
Button(
onClick = {
val sequence = generateSequence(selectedSource, challengeRandomName)
onStart(sequence, selectedTimeLimit)
},
modifier = Modifier.fillMaxWidth(0.6f),
) {
Text(stringResource(Res.string.start_challenge), fontSize = 18.sp)
}
}
}
}
}
private fun generateSequence(source: SequenceSource, challengeRandomName: String): NoteSequence {
return when (source) {
SequenceSource.EXERCISES -> {
val exercises = ExerciseRepository.getAllExercises()
exercises.randomOrNull() ?: generateRandomSequence(challengeRandomName)
}
SequenceSource.SCALES -> {
val rootNote = Random.nextInt(12)
ScaleGenerator.generateScale(rootNote, 4, ScaleType.MAJOR, ScaleDirection.BOTH)
}
SequenceSource.RANDOM -> generateRandomSequence(challengeRandomName)
}
}
private fun generateRandomSequence(challengeRandomName: String): NoteSequence {
val notes = (1..12).map {
com.notemusicali.music.MusicalNote.fromMidi(Random.nextInt(60, 73))
}
return NoteSequence(name = challengeRandomName, notes = notes)
}
@@ -0,0 +1,220 @@
package com.notemusicali.ui.challenge
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.notemusicali.audio.AudioProcessor
import com.notemusicali.challenge.ChallengeRepository
import com.notemusicali.challenge.ChallengeResult
import com.notemusicali.challenge.ChallengeScoring
import com.notemusicali.challenge.ChallengeTimeLimit
import com.notemusicali.goals.DailyGoalManager
import com.notemusicali.music.MusicalNote
import com.notemusicali.music.NoteSequence
import com.notemusicali.util.currentTimeMillis
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
import kotlin.math.abs
data class ChallengeUiState(
val sequence: NoteSequence = NoteSequence.empty(),
val timeLimit: ChallengeTimeLimit = ChallengeTimeLimit.MEDIUM,
val currentIndex: Int = 0,
val targetNote: MusicalNote? = null,
val detectedNoteName: String = "",
val detectedCents: Float = 0f,
val isCorrect: Boolean = false,
val score: Int = 0,
val combo: Int = 0,
val maxCombo: Int = 0,
val remainingMs: Long = 60_000L,
val isRunning: Boolean = false,
val isComplete: Boolean = false,
val notesPlayed: Int = 0,
val lastResult: ChallengeResult? = null,
)
class ChallengeViewModel : ViewModel() {
private val audioProcessor = AudioProcessor()
private val _uiState = MutableStateFlow(ChallengeUiState())
val uiState: StateFlow<ChallengeUiState> = _uiState.asStateFlow()
private var timerJob: Job? = null
private var noteStartTime: Long = 0L
private var firstAttemptForNote: Boolean = true
private var firstAttemptNotes: Int = 0
fun setSequence(sequence: NoteSequence) {
_uiState.value = ChallengeUiState(
sequence = sequence,
targetNote = sequence.noteAt(0),
)
}
fun setTimeLimit(limit: ChallengeTimeLimit) {
_uiState.value = _uiState.value.copy(
timeLimit = limit,
remainingMs = limit.seconds * 1000L,
)
}
fun start() {
val state = _uiState.value
_uiState.value = state.copy(
isRunning = true,
remainingMs = state.timeLimit.seconds * 1000L,
currentIndex = 0,
targetNote = state.sequence.noteAt(0),
score = 0,
combo = 0,
maxCombo = 0,
notesPlayed = 0,
isComplete = false,
)
noteStartTime = currentTimeMillis()
firstAttemptForNote = true
firstAttemptNotes = 0
// Start audio
audioProcessor.start(viewModelScope)
// Start timer
timerJob = viewModelScope.launch {
val startTime = currentTimeMillis()
val totalMs = state.timeLimit.seconds * 1000L
while (isActive) {
val elapsed = currentTimeMillis() - startTime
val remaining = (totalMs - elapsed).coerceAtLeast(0L)
_uiState.value = _uiState.value.copy(remainingMs = remaining)
if (remaining <= 0) {
endChallenge()
break
}
delay(100)
}
}
// Listen for notes
viewModelScope.launch {
audioProcessor.result.collect { result ->
val s = _uiState.value
if (!s.isRunning || s.isComplete) return@collect
when (result) {
is AudioProcessor.ProcessedResult.Detected -> {
val detected = result.noteResult
val target = s.targetNote ?: return@collect
val isCorrect = detected.note.noteIndex == target.noteIndex &&
detected.note.octave == target.octave &&
abs(detected.centsDeviation) <= 30f
_uiState.value = s.copy(
detectedNoteName = detected.note.displayName,
detectedCents = detected.centsDeviation,
isCorrect = isCorrect,
)
if (isCorrect) {
val now = currentTimeMillis()
val reactionMs = now - noteStartTime
val combo = s.combo + 1
val points = ChallengeScoring.scoreNote(
centsDeviation = detected.centsDeviation,
reactionTimeMs = reactionMs,
comboStreak = s.combo,
firstAttempt = firstAttemptForNote,
)
if (firstAttemptForNote) firstAttemptNotes++
val nextIndex = s.currentIndex + 1
val maxCombo = maxOf(s.maxCombo, combo)
if (nextIndex >= s.sequence.size) {
// Loop back to start
noteStartTime = now
firstAttemptForNote = true
_uiState.value = s.copy(
currentIndex = 0,
targetNote = s.sequence.noteAt(0),
score = s.score + points,
combo = combo,
maxCombo = maxCombo,
notesPlayed = s.notesPlayed + 1,
isCorrect = false,
detectedNoteName = "",
)
} else {
noteStartTime = now
firstAttemptForNote = true
_uiState.value = s.copy(
currentIndex = nextIndex,
targetNote = s.sequence.noteAt(nextIndex),
score = s.score + points,
combo = combo,
maxCombo = maxCombo,
notesPlayed = s.notesPlayed + 1,
isCorrect = false,
detectedNoteName = "",
)
}
} else {
firstAttemptForNote = false
if (s.combo > 0) {
_uiState.value = s.copy(
combo = 0,
detectedNoteName = detected.note.displayName,
detectedCents = detected.centsDeviation,
)
}
}
}
else -> {
_uiState.value = s.copy(
detectedNoteName = "",
isCorrect = false,
)
}
}
}
}
}
private fun endChallenge() {
timerJob?.cancel()
audioProcessor.stop()
val state = _uiState.value
val result = ChallengeResult(
id = currentTimeMillis().toString(),
dateMillis = currentTimeMillis(),
sequenceName = state.sequence.name,
timeLimitSec = state.timeLimit.seconds,
totalScore = state.score,
notesPlayed = state.notesPlayed,
maxCombo = state.maxCombo,
accuracy = ChallengeScoring.accuracy(state.notesPlayed, firstAttemptNotes),
)
ChallengeRepository.save(result)
DailyGoalManager.recordPractice(state.timeLimit.seconds * 1000L)
_uiState.value = state.copy(
isRunning = false,
isComplete = true,
lastResult = result,
)
}
override fun onCleared() {
super.onCleared()
timerJob?.cancel()
audioProcessor.stop()
}
}
@@ -0,0 +1,120 @@
package com.notemusicali.ui.challenge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.challenge.ChallengeRepository
import com.notemusicali.ui.components.BackTopBar
import com.notemusicali.util.formatEpochDate
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun LeaderboardScreen(onBack: () -> Unit) {
val results = remember { ChallengeRepository.load() }
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(modifier = Modifier.widthIn(max = 600.dp)) {
BackTopBar(title = stringResource(Res.string.leaderboard_title), onBack = onBack)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
) {
if (results.isEmpty()) {
Spacer(modifier = Modifier.height(48.dp))
Text(
stringResource(Res.string.no_results),
fontSize = 16.sp,
color = Color.White.copy(alpha = 0.5f),
modifier = Modifier.align(Alignment.CenterHorizontally),
)
} else {
Spacer(modifier = Modifier.height(16.dp))
results.forEachIndexed { index, result ->
val rankColor = when (index) {
0 -> Color(0xFFFFD54F)
1 -> Color(0xFFB0BEC5)
2 -> Color(0xFFCD7F32)
else -> Color.White.copy(alpha = 0.6f)
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.06f))
.padding(16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "#${index + 1}",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = rankColor,
modifier = Modifier.widthIn(min = 48.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = result.sequenceName,
fontSize = 14.sp,
color = Color.White,
fontWeight = FontWeight.Medium,
)
Text(
text = "${result.notesPlayed} note • ${result.maxCombo}x combo • ${result.timeLimitSec}s",
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.4f),
)
Text(
text = formatEpochDate(result.dateMillis),
fontSize = 11.sp,
color = Color.White.copy(alpha = 0.3f),
)
}
Text(
text = "${result.totalScore}",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = rankColor,
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
@@ -0,0 +1,41 @@
package com.notemusicali.ui.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.back
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BackTopBar(
title: String,
onBack: () -> Unit,
containerColor: Color = Color.Transparent,
contentColor: Color = Color.White,
) {
TopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.back),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = containerColor,
titleContentColor = contentColor,
navigationIconContentColor = contentColor,
),
)
}
@@ -0,0 +1,14 @@
package com.notemusicali.ui.components
import androidx.compose.runtime.Composable
/**
* Platform-specific file picker launcher.
* Returns a lambda that, when invoked, opens the file picker.
* [onFileContent] is called with the file content as a String.
*/
@Composable
expect fun rememberFilePickerLauncher(
mimeTypes: List<String>,
onFileContent: (String) -> Unit,
): () -> Unit
@@ -0,0 +1,8 @@
package com.notemusicali.ui.components
import androidx.compose.runtime.Composable
@Composable
expect fun rememberFolderPickerLauncher(
onFolderSelected: (uri: String, displayName: String) -> Unit,
): () -> Unit
@@ -0,0 +1,56 @@
package com.notemusicali.ui.components
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.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun GradientCard(
gradient: Brush,
onClick: () -> Unit,
modifier: Modifier = Modifier,
accentColor: Color = Color.Transparent,
content: @Composable ColumnScope.() -> Unit,
) {
val shape = RoundedCornerShape(20.dp)
Box(modifier = modifier.clip(shape)) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(gradient)
.border(1.dp, Color.White.copy(alpha = 0.10f), shape)
.clickable(onClick = onClick)
.padding(20.dp),
content = content,
)
// Top accent line (like Remotion feature cards)
if (accentColor != Color.Transparent) {
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth(0.6f)
.height(2.dp)
.background(
Brush.horizontalGradient(
listOf(Color.Transparent, accentColor, Color.Transparent),
),
),
)
}
}
}
@@ -0,0 +1,6 @@
package com.notemusicali.ui.components
import androidx.compose.runtime.Composable
@Composable
expect fun KeepScreenOn()
@@ -0,0 +1,170 @@
package com.notemusicali.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.music.MusicalNote
/**
* Shows a window of notes around the current index, with the current note highlighted.
* [windowSize] controls how many notes are visible (odd number works best).
*/
@Composable
fun StaffContextView(
notes: List<MusicalNote>,
currentIndex: Int,
modifier: Modifier = Modifier,
windowSize: Int = 7,
lightTheme: Boolean = false,
beats: Int = 4,
beatType: Int = 4,
) {
if (notes.isEmpty()) return
val textMeasurer = rememberTextMeasurer()
Canvas(
modifier = modifier
.fillMaxWidth()
.height(140.dp),
) {
val lineColor = if (lightTheme) Color.Black else Color.White.copy(alpha = 0.4f)
val dimNoteColor = if (lightTheme) Color.Black else Color.White.copy(alpha = 0.35f)
val playedNoteColor = if (lightTheme) Color.Black.copy(alpha = 0.35f) else Color.White.copy(alpha = 0.2f)
val currentNoteColor = Color(0xFFFFD54F)
val clefColor = if (lightTheme) Color.Black else Color.White.copy(alpha = 0.8f)
val lineSpacing = size.height / 10f
val staffBottom = size.height / 2f + lineSpacing * 2f
val halfSpacing = lineSpacing / 2f
val leftMargin = size.width * 0.06f
val rightMargin = size.width * 0.02f
val useTreble = StaffLayout.useTrebleClef(notes)
// Staff lines
for (i in 0..4) {
val y = staffBottom - i * lineSpacing
drawLine(lineColor, Offset(leftMargin, y), Offset(size.width - rightMargin, y), 1.5f)
}
// Determine visible window
val half = windowSize / 2
val startIdx = (currentIndex - half).coerceAtLeast(0)
val endIdx = (startIdx + windowSize).coerceAtMost(notes.size)
val visibleNotes = notes.subList(startIdx, endIdx)
// Clef (+ time signature when the window includes the start of the piece)
var cursorX = leftMargin + 2f
cursorX += drawClef(textMeasurer, useTreble, cursorX, staffBottom, lineSpacing, clefColor)
if (startIdx == 0) {
cursorX += 6f
cursorX += drawTimeSignature(textMeasurer, beats, beatType, cursorX, staffBottom, lineSpacing, clefColor)
}
val noteAreaStart = cursorX + 10f
val noteAreaEnd = size.width - rightMargin - 8f
val noteAreaWidth = noteAreaEnd - noteAreaStart
val xs = StaffLayout.xPositions(visibleNotes, noteAreaStart, noteAreaWidth)
// Beam groups on the full sequence, clipped to the visible window (window-local indices)
val groups = StaffLayout.beamGroups(notes, beats, beatType)
.mapNotNull { g ->
val from = maxOf(g.first, startIdx)
val to = minOf(g.last, endIdx - 1)
if (to - from >= 1) (from - startIdx)..(to - startIdx) else null
}
val grouped = groups.flatMap { it }.toSet()
val bars = StaffLayout.measurePositions(notes, beats, beatType).toSet()
val headHalf = staffHeadHalfWidth(lineSpacing)
val ledgerHalf = lineSpacing * 0.8f
visibleNotes.forEachIndexed { vi, note ->
val actualIdx = startIdx + vi
val nx = xs[vi]
val isCurrent = actualIdx == currentIndex
val isPlayed = actualIdx < currentIndex
val color = when {
isCurrent -> currentNoteColor
isPlayed -> playedNoteColor
else -> dimNoteColor
}
val relPos = StaffLayout.relativePosition(note, useTreble)
val ny = staffBottom - relPos * halfSpacing
// Ledger lines
var lp = -2
while (lp >= relPos) {
val ly = staffBottom - lp * halfSpacing
drawLine(lineColor.copy(alpha = 0.3f), Offset(nx - ledgerHalf, ly), Offset(nx + ledgerHalf, ly), 1f)
lp -= 2
}
lp = 10
while (lp <= relPos) {
val ly = staffBottom - lp * halfSpacing
drawLine(lineColor.copy(alpha = 0.3f), Offset(nx - ledgerHalf, ly), Offset(nx + ledgerHalf, ly), 1f)
lp += 2
}
// Note head
drawNoteHeadPath(nx, ny, lineSpacing, note.duration, color)
// Stem + flags (only for notes not part of a beamed group)
if (vi !in grouped) {
val stemUp = relPos < 4
val stemTip = drawStem(nx, ny, lineSpacing, stemUp, note.duration, color)
stemTip?.let { drawCurvedFlags(it, stemUp, note.duration, lineSpacing, color) }
}
// Sharp
drawAccidentalGlyph(note, nx - headHalf - lineSpacing * 0.5f, ny, lineSpacing * 0.35f, color)
// Barline between this note and the next
if (actualIdx in bars && vi < visibleNotes.lastIndex) {
drawBarline((xs[vi] + xs[vi + 1]) / 2f, staffBottom, lineSpacing, lineColor)
}
// Current note highlight circle
if (isCurrent) {
drawCircle(currentNoteColor.copy(alpha = 0.15f), radius = lineSpacing * 2f, center = Offset(nx, ny))
}
// Note name under current
if (isCurrent) {
val labelStyle = TextStyle(color = currentNoteColor, fontSize = 12.sp)
val labelResult = textMeasurer.measure(note.fullName, labelStyle)
drawText(labelResult, topLeft = Offset(nx - labelResult.size.width / 2f, staffBottom + lineSpacing * 1.2f))
}
}
// Beamed groups
val currentVi = currentIndex - startIdx
groups.forEach { group ->
val gxs = group.map { xs[it] }
val relPositions = group.map { StaffLayout.relativePosition(visibleNotes[it], useTreble) }
val gys = relPositions.map { staffBottom - it * halfSpacing }
val gdurations = group.map { visibleNotes[it].duration }
val groupColor = if (currentVi in group) currentNoteColor else dimNoteColor
drawBeamedGroup(
xs = gxs,
ys = gys,
durations = gdurations,
stemUp = StaffLayout.stemUpForGroup(relPositions),
lineSpacing = lineSpacing,
color = groupColor,
)
}
}
}
@@ -0,0 +1,205 @@
package com.notemusicali.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import com.notemusicali.music.MusicalNote
/**
* Multi-line staff showing all notes with vertical scroll and auto-scroll to
* the current line. Lines wrap at measure boundaries when possible, notes are
* spaced proportionally to their duration, and fast notes are beamed.
*/
@Composable
fun StaffFullView(
notes: List<MusicalNote>,
currentIndex: Int,
modifier: Modifier = Modifier,
lightTheme: Boolean = false,
beats: Int = 4,
beatType: Int = 4,
) {
if (notes.isEmpty()) return
val textMeasurer = rememberTextMeasurer()
val density = LocalDensity.current
val scrollState = rememberScrollState()
val systemHeightDp = 100.dp
val systemGapDp = 16.dp
val clefWidthDp = 36.dp
val noteSpacingDp = 36.dp
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
val availableWidthDp = maxWidth
val noteAreaWidthDp = availableWidthDp - clefWidthDp - 8.dp
val notesPerLine = (noteAreaWidthDp / noteSpacingDp).toInt().coerceAtLeast(4)
val bars = StaffLayout.measurePositions(notes, beats, beatType)
// Righe: tetto notesPerLine; si chiude preferibilmente all'ultima stanghetta che ci sta
val lines = mutableListOf<IntRange>()
var lineStart = 0
while (lineStart < notes.size) {
val cap = (lineStart + notesPerLine - 1).coerceAtMost(notes.size - 1)
val lastBarInLine = bars.lastOrNull { it in lineStart..cap }
val lineEnd = when {
cap == notes.size - 1 -> cap
lastBarInLine != null && lastBarInLine > lineStart -> lastBarInLine
else -> cap
}
lines.add(lineStart..lineEnd)
lineStart = lineEnd + 1
}
val lineCount = lines.size
val totalHeightDp = systemHeightDp * lineCount + systemGapDp * (lineCount - 1).coerceAtLeast(0)
// Auto-scroll to current line
val lineOfNote = IntArray(notes.size)
lines.forEachIndexed { li, r -> r.forEach { lineOfNote[it] = li } }
val currentLine = lineOfNote.getOrElse(currentIndex) { 0 }
LaunchedEffect(currentLine) {
val systemHeightPx = with(density) { (systemHeightDp + systemGapDp).toPx() }
val viewportHeight = scrollState.viewportSize
val targetY = (systemHeightPx * currentLine - viewportHeight / 3f)
.toInt().coerceAtLeast(0)
scrollState.animateScrollTo(targetY)
}
Canvas(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState)
.height(totalHeightDp),
) {
val lineColor = if (lightTheme) Color.Black else Color.White.copy(alpha = 0.4f)
val dimColor = if (lightTheme) Color.Black else Color.White.copy(alpha = 0.35f)
val playedColor = if (lightTheme) Color.Black.copy(alpha = 0.35f) else Color.White.copy(alpha = 0.2f)
val currentColor = Color(0xFFFFD54F)
val clefColor = if (lightTheme) Color.Black else Color.White.copy(alpha = 0.8f)
val systemHeightPx = systemHeightDp.toPx()
val systemGapPx = systemGapDp.toPx()
val lineSpacing = systemHeightPx / 8f
val halfSpacing = lineSpacing / 2f
val staffRelBottom = systemHeightPx / 2f + lineSpacing * 2f
val useTreble = StaffLayout.useTrebleClef(notes)
val headHalf = staffHeadHalfWidth(lineSpacing)
val ledgerHalf = lineSpacing * 0.75f
lines.forEachIndexed { li, r ->
val systemTop = li * (systemHeightPx + systemGapPx)
val staffBottom = systemTop + staffRelBottom
// Staff lines
for (i in 0..4) {
val y = staffBottom - i * lineSpacing
drawLine(lineColor, Offset(0f, y), Offset(size.width, y), 1.2f)
}
// Clef (+ time signature on the first line only)
var cursor = 4f
cursor += drawClef(textMeasurer, useTreble, cursor, staffBottom, lineSpacing, clefColor)
if (li == 0) {
cursor += 6f
cursor += drawTimeSignature(textMeasurer, beats, beatType, cursor, staffBottom, lineSpacing, clefColor)
}
val lineNotes = notes.subList(r.first, r.last + 1)
val xs = StaffLayout.xPositions(lineNotes, cursor + 10f, size.width - cursor - 18f)
val groups = StaffLayout.beamGroups(lineNotes, beats, beatType)
val grouped = groups.flatMap { it }.toSet()
val currentInLine = currentIndex - r.first
lineNotes.forEachIndexed { vi, note ->
val actualIdx = r.first + vi
val nx = xs[vi]
val isCurrent = actualIdx == currentIndex
val isPlayed = actualIdx < currentIndex
val color = when {
isCurrent -> currentColor
isPlayed -> playedColor
else -> dimColor
}
val relPos = StaffLayout.relativePosition(note, useTreble)
val ny = staffBottom - relPos * halfSpacing
// Ledger lines
var lp = -2
while (lp >= relPos) {
val ly = staffBottom - lp * halfSpacing
drawLine(lineColor.copy(alpha = 0.3f), Offset(nx - ledgerHalf, ly), Offset(nx + ledgerHalf, ly), 1f)
lp -= 2
}
lp = 10
while (lp <= relPos) {
val ly = staffBottom - lp * halfSpacing
drawLine(lineColor.copy(alpha = 0.3f), Offset(nx - ledgerHalf, ly), Offset(nx + ledgerHalf, ly), 1f)
lp += 2
}
// Note head
drawNoteHeadPath(nx, ny, lineSpacing, note.duration, color)
// Stem + flags (only for notes not part of a beamed group)
if (vi !in grouped) {
val stemUp = relPos < 4
val stemTip = drawStem(nx, ny, lineSpacing, stemUp, note.duration, color)
stemTip?.let { drawCurvedFlags(it, stemUp, note.duration, lineSpacing, color) }
}
// Accidental
drawAccidentalGlyph(note, nx - headHalf - lineSpacing * 0.5f, ny, lineSpacing * 0.35f, color)
// Current note highlight circle
if (isCurrent) {
drawCircle(currentColor.copy(alpha = 0.15f), radius = lineSpacing * 1.8f, center = Offset(nx, ny))
}
}
// Beamed groups
groups.forEach { group ->
val gxs = group.map { xs[it] }
val relPositions = group.map { StaffLayout.relativePosition(lineNotes[it], useTreble) }
val gys = relPositions.map { staffBottom - it * halfSpacing }
val gdurations = group.map { lineNotes[it].duration }
val groupColor = if (currentInLine in group) currentColor else dimColor
drawBeamedGroup(
xs = gxs,
ys = gys,
durations = gdurations,
stemUp = StaffLayout.stemUpForGroup(relPositions),
lineSpacing = lineSpacing,
color = groupColor,
)
}
// Barlines
bars.forEach { b ->
if (b in r && b < r.last) {
drawBarline((xs[b - r.first] + xs[b - r.first + 1]) / 2f, staffBottom, lineSpacing, lineColor)
}
}
if (r.last in bars) {
drawBarline(size.width - 8f, staffBottom, lineSpacing, lineColor)
}
}
}
}
}
@@ -0,0 +1,171 @@
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 com.notemusicali.music.MusicalNote
import com.notemusicali.music.NoteDuration
/**
* Shared staff-layout math for every staff renderer (StaffView, StaffContextView,
* StaffFullView). Positions are expressed in half-line steps: each unit is half
* the distance between two staff lines.
*/
object StaffLayout {
// Chromatic index (0-11) to diatonic step (0-6): sharps sit on their natural's line
private val DIATONIC_MAP = intArrayOf(0, 0, 1, 1, 2, 3, 3, 4, 4, 5, 5, 6)
// Black-key chromatic indices, rendered with a sharp accidental
private val ACCIDENTAL_INDICES = setOf(1, 3, 6, 8, 10)
private const val TREBLE_CLEF = "𝄞" // 𝄞
private const val BASS_CLEF = "𝄢" // 𝄢
// Diatonic position of the staff's bottom line: E4 (treble) / G2 (bass)
private const val TREBLE_BOTTOM_LINE = 30
private const val BASS_BOTTOM_LINE = 18
enum class Accidental { SHARP, FLAT }
fun useTrebleClef(note: MusicalNote): Boolean = note.octave >= 3
fun useTrebleClef(notes: List<MusicalNote>): Boolean = notes.any { it.octave >= 3 }
fun diatonicPosition(note: MusicalNote): Int {
// A flat sits on the line of the natural above it (Eb on E's line),
// a sharp on the line of the natural below it (D# on D's line).
// Black keys are never at index 11, so +1 cannot wrap the octave.
val diatonicIndex = if (note.preferFlat && note.noteIndex in ACCIDENTAL_INDICES) {
DIATONIC_MAP[note.noteIndex + 1]
} else {
DIATONIC_MAP[note.noteIndex]
}
return note.octave * 7 + diatonicIndex
}
fun accidentalFor(note: MusicalNote): Accidental? = when {
note.noteIndex !in ACCIDENTAL_INDICES -> null
note.preferFlat -> Accidental.FLAT
else -> Accidental.SHARP
}
fun bottomLinePosition(useTreble: Boolean): Int =
if (useTreble) TREBLE_BOTTOM_LINE else BASS_BOTTOM_LINE
/** Half-line steps above the staff's bottom line (0 = on the bottom line). */
fun relativePosition(note: MusicalNote, useTreble: Boolean): Int =
diatonicPosition(note) - bottomLinePosition(useTreble)
fun needsSharp(note: MusicalNote): Boolean = note.noteIndex in ACCIDENTAL_INDICES
fun clefSymbol(useTreble: Boolean): String = if (useTreble) TREBLE_CLEF else BASS_CLEF
/** Durata in quarti (semiminima = 1). */
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
}
/** Gruppi (indici) di note veloci consecutive che iniziano nello stesso beat. */
fun beamGroups(notes: List<MusicalNote>, beats: Int, beatType: Int): List<IntRange> {
if (beats <= 0 || beatType <= 0) return emptyList()
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 almeno metà delle note del gruppo sta sotto la linea mediana. */
fun stemUpForGroup(relPositions: List<Int>): Boolean =
relPositions.count { it < 4 } * 2 >= relPositions.size
/** 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
}
/** 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
}
}
}
/** Draws the accidental glyph appropriate for [note], if any. */
fun DrawScope.drawAccidentalGlyph(note: MusicalNote, cx: Float, cy: Float, s: Float, color: Color) {
when (StaffLayout.accidentalFor(note)) {
StaffLayout.Accidental.SHARP -> drawSharpGlyph(cx, cy, s, color)
StaffLayout.Accidental.FLAT -> drawFlatGlyph(cx, cy, s, color)
null -> Unit
}
}
/** Flat glyph (♭) drawn with strokes: vertical stem plus a bowl bulging right. */
fun DrawScope.drawFlatGlyph(cx: Float, cy: Float, s: Float, color: Color) {
val sw = 1.8f
val stemX = cx - s * 0.4f
drawLine(color, Offset(stemX, cy - s * 1.6f), Offset(stemX, cy + s * 0.9f), sw)
val bowl = Path().apply {
moveTo(stemX, cy - s * 0.1f)
cubicTo(
cx + s * 0.8f, cy - s * 0.5f,
cx + s * 0.8f, cy + s * 0.5f,
stemX, cy + s * 0.9f,
)
}
drawPath(bowl, color, style = Stroke(width = sw))
}
/** Sharp glyph drawn with strokes (Unicode ♯ renders unreliably across platforms). */
fun DrawScope.drawSharpGlyph(cx: Float, cy: Float, s: Float, color: Color) {
val sw = 1.8f
// Two vertical strokes
drawLine(color, Offset(cx - s * 0.3f, cy - s * 1.2f), Offset(cx - s * 0.3f, cy + s * 1.2f), sw)
drawLine(color, Offset(cx + s * 0.3f, cy - s * 1.2f), Offset(cx + s * 0.3f, cy + s * 1.2f), sw)
// Two horizontal strokes (slightly tilted)
drawLine(color, Offset(cx - s * 0.7f, cy - s * 0.35f), Offset(cx + s * 0.7f, cy - s * 0.55f), sw + 0.5f)
drawLine(color, Offset(cx - s * 0.7f, cy + s * 0.55f), Offset(cx + s * 0.7f, cy + s * 0.35f), sw + 0.5f)
}
@@ -0,0 +1,199 @@
package com.notemusicali.ui.components
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import com.notemusicali.music.NoteDuration
/** Primitive di disegno del pentagramma condivise da tutte le viste. */
private fun unit(lineSpacing: Float) = lineSpacing * 0.09f
internal fun staffHeadHalfWidth(lineSpacing: Float): Float = 7.5f * unit(lineSpacing)
/** Testa "a pera": piena per <= QUARTER, contorno per WHOLE/HALF. */
internal fun DrawScope.drawNoteHeadPath(
cx: Float, cy: Float,
lineSpacing: Float,
duration: NoteDuration,
color: Color,
) {
val u = unit(lineSpacing)
val path = Path().apply {
moveTo(cx - 7.4f * u, cy + 2.6f * u)
quadraticTo(cx - 5.4f * u, cy - 4.8f * u, cx + 2.4f * u, cy - 4.9f * u)
quadraticTo(cx + 7.6f * u, cy - 4.9f * u, cx + 7.4f * u, cy - 0.9f * u)
quadraticTo(cx + 6.8f * u, cy + 4.9f * u, cx - 1.6f * u, cy + 5.0f * u)
quadraticTo(cx - 7.4f * u, cy + 5.0f * u, cx - 7.4f * u, cy + 2.6f * u)
close()
}
val hollow = duration == NoteDuration.WHOLE || duration == NoteDuration.HALF
if (hollow) drawPath(path, color, style = Stroke(width = lineSpacing * 0.16f))
else drawPath(path, color)
}
/** Gambo attaccato al bordo della testa; ritorna l'estremità (null per la semibreve). */
internal fun DrawScope.drawStem(
cx: Float, cy: Float,
lineSpacing: Float,
stemUp: Boolean,
duration: NoteDuration,
color: Color,
tipY: Float? = null,
): Offset? {
if (duration == NoteDuration.WHOLE) return null
val u = unit(lineSpacing)
val stemH = lineSpacing * 3.2f
val x = if (stemUp) cx + 7.0f * u else cx - 7.0f * u
val endY = tipY ?: if (stemUp) cy - stemH else cy + stemH
drawLine(color, Offset(x, cy), Offset(x, endY), lineSpacing * 0.14f)
return Offset(x, endY)
}
/** Code a goccia riempite (1-3), specchiate per gambo in giù. */
internal fun DrawScope.drawCurvedFlags(
stemTip: Offset,
stemUp: Boolean,
duration: NoteDuration,
lineSpacing: Float,
color: Color,
) {
val count = when (duration) {
NoteDuration.EIGHTH -> 1
NoteDuration.SIXTEENTH -> 2
NoteDuration.THIRTY_SECOND -> 3
else -> return
}
val u = unit(lineSpacing)
val dir = if (stemUp) 1f else -1f
for (i in 0 until count) {
val oy = stemTip.y + dir * i * 5.5f * u
val path = Path().apply {
moveTo(stemTip.x, oy)
cubicTo(
stemTip.x + 7f * u, oy + dir * 4f * u,
stemTip.x + 10f * u, oy + dir * 11f * u,
stemTip.x + 6.5f * u, oy + dir * 21f * u,
)
cubicTo(
stemTip.x + 10.5f * u, oy + dir * 12f * u,
stemTip.x + 8.5f * u, oy + dir * 6f * u,
stemTip.x, oy + dir * 6f * u,
)
close()
}
drawPath(path, color)
}
}
/**
* Gruppo travato: gambi allineati a una travatura inclinata (pendenza clampata
* a una lineSpacing) più la seconda barra tra coppie di semicrome.
*/
internal fun DrawScope.drawBeamedGroup(
xs: List<Float>, ys: List<Float>,
durations: List<NoteDuration>,
stemUp: Boolean,
lineSpacing: Float,
color: Color,
) {
if (xs.size < 2) return
val u = unit(lineSpacing)
val stemH = lineSpacing * 3.2f
val dir = if (stemUp) -1f else 1f
val attachX = xs.map { if (stemUp) it + 7.0f * u else it - 7.0f * u }
// Estremità ideali, poi pendenza clampata; la travatura deve superare tutte le teste
var y0 = ys.first() + dir * stemH
var y1 = ys.last() + dir * stemH
if (y1 - y0 > lineSpacing) y1 = y0 + lineSpacing
if (y0 - y1 > lineSpacing) y0 = y1 + lineSpacing
val minClearance = lineSpacing * 2.2f
ys.forEachIndexed { i, noteY ->
val t = (attachX[i] - attachX.first()) / (attachX.last() - attachX.first())
val beamY = y0 + (y1 - y0) * t
val clearance = (noteY - beamY) * -dir
if (clearance < minClearance) {
val shift = (minClearance - clearance) * dir
y0 += shift; y1 += shift
}
}
fun beamYAt(x: Float) = y0 + (y1 - y0) * (x - attachX.first()) / (attachX.last() - attachX.first())
// Gambi fino alla travatura
xs.indices.forEach { i ->
drawLine(color, Offset(attachX[i], ys[i]), Offset(attachX[i], beamYAt(attachX[i])), lineSpacing * 0.14f)
}
// La travatura si ispessisce VERSO le teste: in giù se i gambi sono in su
val thicken = lineSpacing * 0.45f * (if (stemUp) 1f else -1f)
fun beamSegment(fromX: Float, toX: Float, offset: Float) {
val path = Path().apply {
moveTo(fromX, beamYAt(fromX) + offset)
lineTo(toX, beamYAt(toX) + offset)
lineTo(toX, beamYAt(toX) + offset + thicken)
lineTo(fromX, beamYAt(fromX) + offset + thicken)
close()
}
drawPath(path, color)
}
// Barra primaria su tutto il gruppo
beamSegment(attachX.first(), attachX.last(), 0f)
// Barra secondaria tra coppie adiacenti di semicrome (o più corte)
val secondOffset = lineSpacing * 0.75f * (if (stemUp) 1f else -1f)
for (i in 0 until xs.size - 1) {
val bothShort = StaffLayout.quarterUnits(durations[i]) <= 0.25 && StaffLayout.quarterUnits(durations[i + 1]) <= 0.25
if (bothShort) beamSegment(attachX[i], attachX[i + 1], secondOffset)
}
}
/** Stanghetta di battuta. */
internal fun DrawScope.drawBarline(x: Float, staffBottom: Float, lineSpacing: Float, color: Color) {
drawLine(color, Offset(x, staffBottom - 4 * lineSpacing), Offset(x, staffBottom), lineSpacing * 0.1f)
}
/** Cifre del tempo (es. 4/4) dopo la chiave. Ritorna la larghezza occupata. */
internal fun DrawScope.drawTimeSignature(
textMeasurer: TextMeasurer,
beats: Int, beatType: Int,
x: Float, staffBottom: Float, lineSpacing: Float,
color: Color,
): Float {
val style = TextStyle(
color = color,
fontSize = (lineSpacing * 1.9f).toSp(),
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Serif,
)
val top = textMeasurer.measure(beats.toString(), style)
val bottom = textMeasurer.measure(beatType.toString(), style)
drawText(top, topLeft = Offset(x, staffBottom - 4f * lineSpacing + (2f * lineSpacing - top.size.height) / 2f))
drawText(bottom, topLeft = Offset(x, staffBottom - 2f * lineSpacing + (2f * lineSpacing - bottom.size.height) / 2f))
return maxOf(top.size.width, bottom.size.width).toFloat()
}
/** Chiave a piena opacità. Ritorna la larghezza occupata. */
internal fun DrawScope.drawClef(
textMeasurer: TextMeasurer,
useTreble: Boolean,
x: Float, staffBottom: Float, lineSpacing: Float,
color: Color,
): Float {
val style = TextStyle(color = color, fontSize = (lineSpacing * 3.4f).toSp(), fontFamily = FontFamily.Serif)
val result = textMeasurer.measure(StaffLayout.clefSymbol(useTreble), style)
val y = if (useTreble) {
(staffBottom - lineSpacing) - result.size.height * 0.42f
} else {
(staffBottom - 3 * lineSpacing) - result.size.height * 0.35f
}
drawText(result, topLeft = Offset(x, y))
return result.size.width.toFloat()
}
@@ -0,0 +1,214 @@
package com.notemusicali.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.music.MusicalNote
@Composable
fun StaffView(
note: MusicalNote,
modifier: Modifier = Modifier,
lightTheme: Boolean = false,
) {
val textMeasurer = rememberTextMeasurer()
Canvas(
modifier = modifier
.fillMaxWidth()
.height(160.dp),
) {
val lineColor = if (lightTheme) Color.Black else Color.White.copy(alpha = 0.5f)
val noteColor = if (lightTheme) Color.Black else Color.White
val lineSpacing = size.height / 12f
val staffBottom = size.height / 2f + lineSpacing * 2f
val halfSpacing = lineSpacing / 2f
val leftMargin = size.width * 0.06f
val rightMargin = size.width * 0.02f
val noteX = size.width * 0.5f
val useTreble = StaffLayout.useTrebleClef(note)
val relPos = StaffLayout.relativePosition(note, useTreble)
val noteY = staffBottom - relPos * halfSpacing
// --- Staff lines ---
for (i in 0..4) {
val y = staffBottom - i * lineSpacing
drawLine(
color = lineColor,
start = Offset(leftMargin, y),
end = Offset(size.width - rightMargin, y),
strokeWidth = 1.5f,
)
}
// --- Clef symbol ---
drawClef(textMeasurer, useTreble, leftMargin + 2f, staffBottom, lineSpacing, noteColor)
// --- Ledger lines ---
val ledgerHalf = lineSpacing * 0.9f
// Below staff
var lp = -2
while (lp >= relPos) {
val ly = staffBottom - lp * halfSpacing
drawLine(
color = lineColor,
start = Offset(noteX - ledgerHalf, ly),
end = Offset(noteX + ledgerHalf, ly),
strokeWidth = 1.5f,
)
lp -= 2
}
// Above staff
lp = 10
while (lp <= relPos) {
val ly = staffBottom - lp * halfSpacing
drawLine(
color = lineColor,
start = Offset(noteX - ledgerHalf, ly),
end = Offset(noteX + ledgerHalf, ly),
strokeWidth = 1.5f,
)
lp += 2
}
// --- Note head + stem + flags ---
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) }
// --- Accidental (sharp) ---
drawAccidentalGlyph(note, noteX - staffHeadHalfWidth(lineSpacing) - lineSpacing * 0.7f, noteY, lineSpacing * 0.45f, noteColor)
// --- Note name label under staff ---
val labelStyle = TextStyle(
color = if (lightTheme) Color.Black else Color.White.copy(alpha = 0.6f),
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
)
val labelResult = textMeasurer.measure(note.fullName, labelStyle)
val labelY = staffBottom + lineSpacing * 1.5f
drawText(
textLayoutResult = labelResult,
topLeft = Offset(
noteX - labelResult.size.width / 2f,
labelY,
),
)
}
}
/**
* Compact staff preview showing all notes of a sequence.
*/
@Composable
fun StaffPreview(
notes: List<MusicalNote>,
modifier: Modifier = Modifier,
beats: Int = 4,
beatType: Int = 4,
) {
if (notes.isEmpty()) return
val textMeasurer = rememberTextMeasurer()
Canvas(
modifier = modifier
.fillMaxWidth()
.height(80.dp),
) {
val lineColor = Color.White.copy(alpha = 0.3f)
val noteColor = Color.White.copy(alpha = 0.85f)
val lineSpacing = size.height / 8f
val staffBottom = size.height / 2f + lineSpacing * 2f
val halfSpacing = lineSpacing / 2f
val leftMargin = size.width * 0.06f
val rightMargin = size.width * 0.02f
val useTreble = StaffLayout.useTrebleClef(notes)
// --- Staff lines ---
for (i in 0..4) {
val y = staffBottom - i * lineSpacing
drawLine(
color = lineColor,
start = Offset(leftMargin, y),
end = Offset(size.width - rightMargin, y),
strokeWidth = 1f,
)
}
// --- Clef symbol ---
drawClef(textMeasurer, useTreble, 4f, staffBottom, lineSpacing, noteColor)
// --- Notes ---
val noteAreaStart = leftMargin + 8f
val noteAreaEnd = size.width - rightMargin - 8f
val noteAreaWidth = noteAreaEnd - noteAreaStart
val xs = StaffLayout.xPositions(notes, noteAreaStart, noteAreaWidth)
val relPositions = notes.map { StaffLayout.relativePosition(it, useTreble) }
val ys = relPositions.map { staffBottom - it * halfSpacing }
val groups = StaffLayout.beamGroups(notes, beats, beatType)
val grouped = groups.flatMap { it }.toSet()
val ledgerHalf = lineSpacing * 0.7f
notes.forEachIndexed { index, note ->
val nx = xs[index]
val relPos = relPositions[index]
val ny = ys[index]
// Ledger lines for this note
var lp = -2
while (lp >= relPos) {
val ly = staffBottom - lp * halfSpacing
drawLine(lineColor, Offset(nx - ledgerHalf, ly), Offset(nx + ledgerHalf, ly), 1f)
lp -= 2
}
lp = 10
while (lp <= relPos) {
val ly = staffBottom - lp * halfSpacing
drawLine(lineColor, Offset(nx - ledgerHalf, ly), Offset(nx + ledgerHalf, ly), 1f)
lp += 2
}
// Note head
drawNoteHeadPath(nx, ny, lineSpacing, note.duration, noteColor)
// Stem + flags (beamed notes get their stems from the beam group)
if (index !in grouped) {
val stemUp = relPos < 4
val tip = drawStem(nx, ny, lineSpacing, stemUp, note.duration, noteColor)
tip?.let { drawCurvedFlags(it, stemUp, note.duration, lineSpacing, noteColor) }
}
// Sharp
drawAccidentalGlyph(note, nx - staffHeadHalfWidth(lineSpacing) - lineSpacing * 0.5f, ny, lineSpacing * 0.3f, noteColor)
}
// --- Beamed groups ---
groups.forEach { group ->
val gxs = group.map { xs[it] }
val gys = group.map { ys[it] }
val gdur = group.map { notes[it].duration }
val stemUp = StaffLayout.stemUpForGroup(group.map { relPositions[it] })
drawBeamedGroup(gxs, gys, gdur, stemUp, lineSpacing, noteColor)
}
}
}
@@ -0,0 +1,316 @@
package com.notemusicali.ui.eartraining
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.EmojiEvents
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.notemusicali.eartraining.EarTrainingDifficulty
import com.notemusicali.eartraining.EarTrainingMode
import com.notemusicali.ui.components.BackTopBar
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun EarTrainingScreen(
onBack: () -> Unit,
viewModel: EarTrainingViewModel = viewModel { EarTrainingViewModel() },
) {
val state by viewModel.uiState.collectAsState()
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(modifier = Modifier.widthIn(max = 600.dp)) {
BackTopBar(title = stringResource(Res.string.ear_training_title), onBack = onBack)
if (state.isSessionComplete) {
SessionCompleteView(
correct = state.correctCount,
total = state.totalQuestions,
onRestart = { viewModel.startSession() },
onBack = onBack,
)
} else if (state.currentQuestion != null) {
QuestionView(
state = state,
onPlay = { viewModel.playSound() },
onAnswer = { viewModel.submitAnswer(it) },
)
} else {
SetupView(
state = state,
onModeChange = { viewModel.setMode(it) },
onDifficultyChange = { viewModel.setDifficulty(it) },
onStart = { viewModel.startSession() },
)
}
}
}
}
@Composable
private fun SetupView(
state: EarTrainingUiState,
onModeChange: (EarTrainingMode) -> Unit,
onDifficultyChange: (EarTrainingDifficulty) -> Unit,
onStart: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
// Mode tabs
val modeIndex = EarTrainingMode.entries.indexOf(state.mode)
TabRow(
selectedTabIndex = modeIndex,
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.primary,
) {
EarTrainingMode.entries.forEach { mode ->
Tab(
selected = state.mode == mode,
onClick = { onModeChange(mode) },
) {
Text(mode.displayName, modifier = Modifier.padding(12.dp))
}
}
}
Spacer(modifier = Modifier.height(32.dp))
// Difficulty
Text(stringResource(Res.string.difficulty), fontSize = 16.sp, color = Color.White.copy(alpha = 0.6f))
Spacer(modifier = Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
EarTrainingDifficulty.entries.forEach { diff ->
FilterChip(
selected = state.difficulty == diff,
onClick = { onDifficultyChange(diff) },
label = { Text(diff.displayName) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
selectedLabelColor = Color.White,
),
)
}
}
Spacer(modifier = Modifier.height(48.dp))
Button(
onClick = onStart,
modifier = Modifier.fillMaxWidth(0.6f),
) {
Text(stringResource(Res.string.start), fontSize = 18.sp)
}
}
}
@Composable
private fun QuestionView(
state: EarTrainingUiState,
onPlay: () -> Unit,
onAnswer: (String) -> Unit,
) {
val question = state.currentQuestion ?: return
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(16.dp))
// Progress
Text(
text = stringResource(Res.string.question_n, state.questionNumber, state.totalQuestions),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.5f),
)
// Score
Text(
text = stringResource(Res.string.correct_count, state.correctCount),
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF4CAF50),
)
Spacer(modifier = Modifier.height(32.dp))
// Play button
IconButton(
onClick = onPlay,
modifier = Modifier
.size(100.dp)
.clip(CircleShape)
.background(
if (state.isPlaying) Color(0xFFFF9800).copy(alpha = 0.3f)
else MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
),
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = stringResource(Res.string.play_sound),
tint = Color.White,
modifier = Modifier.size(48.dp),
)
}
Text(
text = if (state.isPlaying) stringResource(Res.string.listening) else stringResource(Res.string.press_to_listen),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.5f),
modifier = Modifier.padding(top = 8.dp),
)
Spacer(modifier = Modifier.height(40.dp))
// Answer choices (2x2 grid)
val choices = question.choices
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
for (row in choices.chunked(2)) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
row.forEach { choice ->
val bgColor = when {
state.selectedAnswer == null -> Color.White.copy(alpha = 0.1f)
choice == question.correctAnswer -> Color(0xFF4CAF50).copy(alpha = 0.3f)
choice == state.selectedAnswer -> Color(0xFFF44336).copy(alpha = 0.3f)
else -> Color.White.copy(alpha = 0.05f)
}
Button(
onClick = { onAnswer(choice) },
enabled = state.selectedAnswer == null,
modifier = Modifier
.weight(1f)
.height(56.dp),
colors = ButtonDefaults.buttonColors(
containerColor = bgColor,
disabledContainerColor = bgColor,
),
shape = RoundedCornerShape(12.dp),
) {
Text(
text = choice,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Color.White,
)
}
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
@Composable
private fun SessionCompleteView(
correct: Int,
total: Int,
onRestart: () -> Unit,
onBack: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = Icons.Outlined.EmojiEvents,
contentDescription = stringResource(Res.string.result),
modifier = Modifier.size(64.dp),
tint = Color(0xFFFFD54F),
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(Res.string.session_completed),
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "$correct/$total",
fontSize = 48.sp,
fontWeight = FontWeight.Bold,
color = when {
correct.toFloat() / total >= 0.8f -> Color(0xFF4CAF50)
correct.toFloat() / total >= 0.5f -> Color(0xFFFF9800)
else -> Color(0xFFF44336)
},
)
Text(
text = stringResource(Res.string.correct_answers),
fontSize = 16.sp,
color = Color.White.copy(alpha = 0.6f),
)
Spacer(modifier = Modifier.height(32.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Button(onClick = onRestart) {
Text(stringResource(Res.string.retry))
}
Button(onClick = onBack) {
Text(stringResource(Res.string.finish))
}
}
}
}
@@ -0,0 +1,141 @@
package com.notemusicali.ui.eartraining
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.notemusicali.audio.ToneGenerator
import com.notemusicali.audio.createToneGenerator
import com.notemusicali.eartraining.EarTrainingDifficulty
import com.notemusicali.eartraining.EarTrainingEngine
import com.notemusicali.eartraining.EarTrainingMode
import com.notemusicali.eartraining.EarTrainingQuestion
import com.notemusicali.eartraining.EarTrainingSession
import com.notemusicali.eartraining.EarTrainingStatsRepository
import com.notemusicali.goals.DailyGoalManager
import com.notemusicali.util.currentTimeMillis
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class EarTrainingUiState(
val mode: EarTrainingMode = EarTrainingMode.NOTE,
val difficulty: EarTrainingDifficulty = EarTrainingDifficulty.EASY,
val currentQuestion: EarTrainingQuestion? = null,
val questionNumber: Int = 0,
val totalQuestions: Int = 10,
val correctCount: Int = 0,
val selectedAnswer: String? = null,
val isCorrect: Boolean? = null,
val isSessionComplete: Boolean = false,
val isPlaying: Boolean = false,
)
class EarTrainingViewModel : ViewModel() {
private val toneGenerator: ToneGenerator = createToneGenerator()
private val _uiState = MutableStateFlow(EarTrainingUiState())
val uiState: StateFlow<EarTrainingUiState> = _uiState.asStateFlow()
private val sessionStartTime = currentTimeMillis()
fun setMode(mode: EarTrainingMode) {
_uiState.value = _uiState.value.copy(mode = mode)
}
fun setDifficulty(difficulty: EarTrainingDifficulty) {
_uiState.value = _uiState.value.copy(difficulty = difficulty)
}
fun startSession() {
_uiState.value = _uiState.value.copy(
questionNumber = 0,
correctCount = 0,
isSessionComplete = false,
selectedAnswer = null,
isCorrect = null,
)
nextQuestion()
}
private fun nextQuestion() {
val state = _uiState.value
if (state.questionNumber >= state.totalQuestions) {
completeSession()
return
}
val question = when (state.mode) {
EarTrainingMode.NOTE -> EarTrainingEngine.generateNoteQuestion(state.difficulty)
EarTrainingMode.INTERVAL -> EarTrainingEngine.generateIntervalQuestion(state.difficulty)
}
_uiState.value = state.copy(
currentQuestion = question,
questionNumber = state.questionNumber + 1,
selectedAnswer = null,
isCorrect = null,
)
}
fun playSound() {
val question = _uiState.value.currentQuestion ?: return
_uiState.value = _uiState.value.copy(isPlaying = true)
viewModelScope.launch {
when (question.mode) {
EarTrainingMode.NOTE -> {
toneGenerator.playTone(question.targetFrequency, 1500L)
}
EarTrainingMode.INTERVAL -> {
toneGenerator.playTone(question.referenceFrequency, 800L)
delay(1000)
toneGenerator.playTone(question.targetFrequency, 800L)
}
}
delay(1000)
_uiState.value = _uiState.value.copy(isPlaying = false)
}
}
fun submitAnswer(answer: String) {
val state = _uiState.value
val question = state.currentQuestion ?: return
if (state.selectedAnswer != null) return // already answered
val correct = answer == question.correctAnswer
_uiState.value = state.copy(
selectedAnswer = answer,
isCorrect = correct,
correctCount = if (correct) state.correctCount + 1 else state.correctCount,
)
// Auto-advance after delay
viewModelScope.launch {
delay(1500)
nextQuestion()
}
}
private fun completeSession() {
val state = _uiState.value
_uiState.value = state.copy(isSessionComplete = true)
// Save stats
val session = EarTrainingSession(
id = sessionStartTime.toString(),
dateMillis = sessionStartTime,
mode = state.mode.name,
difficulty = state.difficulty.name,
totalQuestions = state.totalQuestions,
correctAnswers = state.correctCount,
)
EarTrainingStatsRepository.save(session)
DailyGoalManager.recordPractice(currentTimeMillis() - sessionStartTime)
}
override fun onCleared() {
super.onCleared()
toneGenerator.release()
}
}
@@ -0,0 +1,135 @@
package com.notemusicali.ui.exercises
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.ui.Alignment
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.StarOutline
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.exercises.Exercise
import com.notemusicali.exercises.ExerciseRepository
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
import com.notemusicali.music.NoteSequence
import com.notemusicali.ui.components.BackTopBar
import com.notemusicali.ui.components.GradientCard
import com.notemusicali.ui.components.StaffPreview
import com.notemusicali.ui.theme.CardGradients
private val StarGold = Color(0xFFFFD54F)
private const val MAX_STARS = 4
@Composable
fun ExercisesScreen(
onBack: () -> Unit,
onExerciseSelected: (NoteSequence) -> Unit,
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
Column(modifier = Modifier.widthIn(max = 600.dp).fillMaxHeight()) {
BackTopBar(
title = stringResource(Res.string.exercises_title),
onBack = onBack,
contentColor = MaterialTheme.colorScheme.primary,
)
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
) {
items(ExerciseRepository.all) { exercise ->
ExerciseCard(
exercise = exercise,
onClick = { onExerciseSelected(exercise.sequence) },
)
}
}
}
}
}
@Composable
private fun ExerciseCard(
exercise: Exercise,
onClick: () -> Unit,
) {
GradientCard(
gradient = CardGradients.forLevel(exercise.level),
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = exercise.title,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
StarRating(level = exercise.level)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = exercise.description,
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.7f),
)
Spacer(modifier = Modifier.height(8.dp))
StaffPreview(
notes = exercise.sequence.notes,
beats = exercise.sequence.beats,
beatType = exercise.sequence.beatType,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(Res.string.n_notes, exercise.sequence.size),
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.5f),
)
}
}
@Composable
private fun StarRating(level: Int) {
Row {
repeat(MAX_STARS) { index ->
Icon(
imageVector = if (index < level) Icons.Filled.Star else Icons.Outlined.StarOutline,
contentDescription = stringResource(Res.string.level_n, index + 1),
modifier = Modifier.size(20.dp),
tint = if (index < level) StarGold else Color.White.copy(alpha = 0.3f),
)
}
}
}
@@ -0,0 +1,209 @@
package com.notemusicali.ui.goals
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.goals.DailyGoalManager
import com.notemusicali.ui.components.BackTopBar
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun DailyGoalScreen(onBack: () -> Unit) {
var settings by remember { mutableStateOf(DailyGoalManager.getSettings()) }
val state = remember { DailyGoalManager.getState() }
val progress = remember { DailyGoalManager.todayProgressFraction() }
val goalMet = remember { DailyGoalManager.isTodayGoalMet() }
val history = remember { DailyGoalManager.recentHistory(30) }
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(modifier = Modifier.widthIn(max = 600.dp)) {
BackTopBar(title = stringResource(Res.string.goals_title), onBack = onBack)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(16.dp))
// Streak display
if (state.streak > 0) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Text("\uD83D\uDD25", fontSize = 40.sp) // fire emoji
Text(
text = "${state.streak}",
fontSize = 48.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFFFF9800),
modifier = Modifier.padding(start = 8.dp),
)
}
Text(
text = if (state.streak == 1) stringResource(Res.string.consecutive_day_one) else stringResource(Res.string.consecutive_days),
fontSize = 16.sp,
color = Color.White.copy(alpha = 0.6f),
)
} else {
Text(
stringResource(Res.string.start_streak),
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White.copy(alpha = 0.7f),
)
}
Spacer(modifier = Modifier.height(32.dp))
// Circular progress for today
Box(contentAlignment = Alignment.Center) {
CircularProgressIndicator(
progress = { progress },
modifier = Modifier.size(160.dp),
color = if (goalMet) Color(0xFF4CAF50) else MaterialTheme.colorScheme.primary,
trackColor = Color.White.copy(alpha = 0.1f),
strokeWidth = 12.dp,
)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
val todayMin = (state.todayMs / 60_000).toInt()
Text(
text = "${todayMin}m",
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
)
Text(
text = stringResource(Res.string.of_target, settings.targetMinutes),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.5f),
)
if (goalMet) {
Text(
text = stringResource(Res.string.goal_reached),
fontSize = 12.sp,
color = Color(0xFF4CAF50),
fontWeight = FontWeight.SemiBold,
)
}
}
}
Spacer(modifier = Modifier.height(32.dp))
// Goal setting
Text(stringResource(Res.string.daily_goal), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf(5, 10, 15, 30).forEach { min ->
FilterChip(
selected = settings.targetMinutes == min,
onClick = {
settings = settings.copy(targetMinutes = min)
DailyGoalManager.saveSettings(settings)
},
label = { Text("${min}m") },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
selectedLabelColor = Color.White,
),
)
}
}
Spacer(modifier = Modifier.height(32.dp))
// Mini calendar (last 30 days)
Text(stringResource(Res.string.last_30_days), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Spacer(modifier = Modifier.height(12.dp))
if (history.isEmpty()) {
Text(
stringResource(Res.string.no_recent_activity),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.4f),
)
} else {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(),
) {
history.forEach { (date, minutes) ->
val met = minutes >= settings.targetMinutes
val intensity = (minutes.toFloat() / settings.targetMinutes).coerceAtMost(1f)
val bgColor = if (met) Color(0xFF4CAF50)
else if (minutes > 0) Color(0xFF4CAF50).copy(alpha = intensity * 0.5f)
else Color.White.copy(alpha = 0.05f)
Box(
modifier = Modifier
.size(28.dp)
.clip(RoundedCornerShape(4.dp))
.background(bgColor)
.then(
if (met) Modifier.border(1.dp, Color(0xFF4CAF50).copy(alpha = 0.5f), RoundedCornerShape(4.dp))
else Modifier
),
contentAlignment = Alignment.Center,
) {
// Show day number
val day = date.takeLast(2)
Text(
text = day,
fontSize = 8.sp,
color = Color.White.copy(alpha = 0.7f),
textAlign = TextAlign.Center,
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
@@ -0,0 +1,287 @@
package com.notemusicali.ui.guide
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Email
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.ui.components.BackTopBar
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun GuideScreen(onBack: () -> Unit) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(modifier = Modifier.widthIn(max = 600.dp)) {
BackTopBar(title = stringResource(Res.string.guide_title), onBack = onBack)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
) {
// --- Strumenti ---
GuideSection(stringResource(Res.string.section_tools))
GuideItem(
stringResource(Res.string.tuner_title),
stringResource(Res.string.guide_tuner_desc),
)
GuideItem(
stringResource(Res.string.metronome_title),
stringResource(Res.string.guide_metronome_desc),
)
GuideItem(
stringResource(Res.string.reference_title),
stringResource(Res.string.guide_reference_desc),
)
Spacer(modifier = Modifier.height(16.dp))
// --- Pratica ---
GuideSection(stringResource(Res.string.section_practice))
GuideItem(
stringResource(Res.string.practice_title),
stringResource(Res.string.guide_practice_desc),
)
GuideItem(
stringResource(Res.string.exercises_title),
stringResource(Res.string.guide_exercises_desc),
)
GuideItem(
stringResource(Res.string.scales_title),
stringResource(Res.string.guide_scales_desc),
)
GuideItem(
stringResource(Res.string.scores_title),
stringResource(Res.string.guide_scores_desc),
)
Spacer(modifier = Modifier.height(16.dp))
// --- Sfida ---
GuideSection(stringResource(Res.string.section_challenge))
GuideItem(
stringResource(Res.string.ear_training_title),
stringResource(Res.string.guide_ear_training_desc),
)
GuideItem(
stringResource(Res.string.challenge_title),
stringResource(Res.string.guide_challenge_desc),
)
GuideItem(
stringResource(Res.string.leaderboard_title),
stringResource(Res.string.guide_leaderboard_desc),
)
Spacer(modifier = Modifier.height(16.dp))
// --- Progressi ---
GuideSection(stringResource(Res.string.section_progress))
GuideItem(
stringResource(Res.string.stats_title),
stringResource(Res.string.guide_stats_desc),
)
GuideItem(
stringResource(Res.string.goals_title),
stringResource(Res.string.guide_goals_desc),
)
GuideItem(
stringResource(Res.string.history_title),
stringResource(Res.string.guide_history_desc),
)
Spacer(modifier = Modifier.height(16.dp))
// --- Suggerimenti ---
GuideSection(stringResource(Res.string.guide_section_tips))
GuideTip(stringResource(Res.string.guide_tip_quiet))
GuideTip(stringResource(Res.string.guide_tip_distance))
GuideTip(stringResource(Res.string.guide_tip_daily))
GuideTip(stringResource(Res.string.guide_tip_scales))
GuideTip(stringResource(Res.string.guide_tip_ear))
Spacer(modifier = Modifier.height(16.dp))
// --- Supporto ---
GuideSection(stringResource(Res.string.guide_section_support))
Text(
text = stringResource(Res.string.guide_support_desc),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.6f),
lineHeight = 20.sp,
)
Spacer(modifier = Modifier.height(12.dp))
val uriHandler = LocalUriHandler.current
val email = stringResource(Res.string.guide_support_email)
Button(
onClick = {
uriHandler.openUri("mailto:$email?subject=InTono%20-%20Feedback")
},
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.15f),
),
modifier = Modifier.fillMaxWidth(),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Outlined.Email,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Color.White,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(Res.string.guide_support_button),
color = Color.White,
)
}
}
Text(
text = email,
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.4f),
modifier = Modifier.padding(top = 4.dp),
)
Spacer(modifier = Modifier.height(16.dp))
// --- Informazioni ---
GuideSection(stringResource(Res.string.guide_section_info))
Text(
text = stringResource(Res.string.info_version, "1.0"),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.6f),
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(Res.string.info_developer),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.6f),
)
Spacer(modifier = Modifier.height(8.dp))
val website = stringResource(Res.string.info_website)
Text(
text = website,
fontSize = 14.sp,
color = Color(0xFF90CAF9),
textDecoration = TextDecoration.Underline,
modifier = Modifier
.clickable { uriHandler.openUri("https://$website") }
.padding(vertical = 4.dp),
)
Spacer(modifier = Modifier.height(8.dp))
val privacyUrl = stringResource(Res.string.info_privacy_url)
Button(
onClick = { uriHandler.openUri(privacyUrl) },
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.15f),
),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringResource(Res.string.info_privacy_policy),
color = Color.White,
)
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
}
@Composable
private fun GuideSection(title: String) {
Text(
text = title,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
modifier = Modifier.padding(bottom = 12.dp),
)
}
@Composable
private fun GuideItem(title: String, description: String) {
Column(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
Text(
text = title,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Color.White.copy(alpha = 0.9f),
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = description,
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.6f),
lineHeight = 20.sp,
)
}
}
@Composable
private fun GuideTip(text: String) {
Text(
text = text,
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.6f),
lineHeight = 20.sp,
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
)
}
@@ -0,0 +1,349 @@
package com.notemusicali.ui.history
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.challenge.ChallengeRepository
import com.notemusicali.challenge.ChallengeResult
import com.notemusicali.eartraining.EarTrainingSession
import com.notemusicali.eartraining.EarTrainingStatsRepository
import com.notemusicali.history.PracticeSession
import com.notemusicali.history.SessionRepository
import com.notemusicali.ui.components.BackTopBar
import com.notemusicali.ui.components.GradientCard
import com.notemusicali.ui.theme.CardAccents
import com.notemusicali.ui.theme.CardGradients
import com.notemusicali.util.formatEpochDate
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
/** Unified history item wrapping different session types. */
private sealed class HistoryItem(val dateMillis: Long) {
class Practice(val session: PracticeSession) : HistoryItem(session.dateMillis)
class EarTraining(val session: EarTrainingSession) : HistoryItem(session.dateMillis)
class Challenge(val result: ChallengeResult) : HistoryItem(result.dateMillis)
}
@Composable
fun HistoryScreen(
onBack: () -> Unit,
onReplay: (PracticeSession) -> Unit,
) {
val allItems = remember {
val practice = SessionRepository.load().map { HistoryItem.Practice(it) }
val earTraining = EarTrainingStatsRepository.load().map { HistoryItem.EarTraining(it) }
val challenges = ChallengeRepository.load().map { HistoryItem.Challenge(it) }
(practice + earTraining + challenges).sortedByDescending { it.dateMillis }
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
Column(modifier = Modifier.widthIn(max = 600.dp).fillMaxHeight()) {
BackTopBar(
title = stringResource(Res.string.history_title),
onBack = onBack,
contentColor = MaterialTheme.colorScheme.primary,
)
if (allItems.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(Res.string.no_sessions),
fontSize = 16.sp,
color = Color.White.copy(alpha = 0.5f),
textAlign = TextAlign.Center,
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(vertical = 16.dp),
) {
items(allItems) { item ->
when (item) {
is HistoryItem.Practice -> PracticeCard(
session = item.session,
onClick = { onReplay(item.session) },
)
is HistoryItem.EarTraining -> EarTrainingCard(
session = item.session,
)
is HistoryItem.Challenge -> ChallengeCard(
result = item.result,
)
}
}
}
}
}
}
}
// ── Practice session card ─────────────────────────────────────────
@Composable
private fun PracticeCard(session: PracticeSession, onClick: () -> Unit) {
GradientCard(
gradient = CardGradients.practice,
onClick = onClick,
accentColor = CardAccents.practice,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = session.sequenceName,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
Text(
text = session.instrumentName,
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.5f),
)
}
TypeBadge(
text = stringResource(Res.string.history_type_practice),
color = CardAccents.practice,
)
}
if (!session.completed) {
Text(
text = stringResource(Res.string.partial),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = Color(0xFFFFB74D),
)
}
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
val noteLabel = if (session.completed) {
"${session.noteResults.size} note"
} else {
"${session.noteResults.size}/${session.noteMidiNumbers.size} note"
}
InfoChip(label = noteLabel)
InfoChip(label = "~${session.averageCents.toInt()} cents")
InfoChip(label = "${(session.firstAttemptRate * 100).toInt()}% 1\u00B0")
val totalSec = session.totalTimeMs / 1000
InfoChip(label = "${totalSec / 60}m ${totalSec % 60}s")
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formatEpochDate(session.dateMillis),
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.4f),
)
}
}
// ── Ear Training session card ─────────────────────────────────────
@Composable
private fun EarTrainingCard(session: EarTrainingSession) {
val modeLabel = when (session.mode) {
"NOTE" -> stringResource(Res.string.history_ear_note)
else -> stringResource(Res.string.history_ear_interval)
}
val diffLabel = when (session.difficulty) {
"EASY" -> "Easy"
"MEDIUM" -> "Medium"
else -> "Hard"
}
val pct = (session.accuracy * 100).toInt()
val scoreColor = when {
pct >= 80 -> Color(0xFF4CAF50)
pct >= 50 -> Color(0xFFFFA726)
else -> Color(0xFFEF5350)
}
GradientCard(
gradient = CardGradients.earTraining,
onClick = {},
accentColor = CardAccents.earTraining,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "$modeLabel — $diffLabel",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
}
TypeBadge(
text = stringResource(Res.string.history_type_ear_training),
color = CardAccents.earTraining,
)
}
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
InfoChip(
label = "${session.correctAnswers}/${session.totalQuestions} ${stringResource(Res.string.history_correct)}",
)
Text(
text = "$pct%",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = scoreColor,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formatEpochDate(session.dateMillis),
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.4f),
)
}
}
// ── Challenge result card ─────────────────────────────────────────
@Composable
private fun ChallengeCard(result: ChallengeResult) {
GradientCard(
gradient = CardGradients.challenge,
onClick = {},
accentColor = CardAccents.challenge,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = result.sequenceName,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
Text(
text = "${result.timeLimitSec}s",
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.5f),
)
}
TypeBadge(
text = stringResource(Res.string.history_type_challenge),
color = CardAccents.challenge,
)
}
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
InfoChip(label = "${result.notesPlayed} note")
InfoChip(label = "${result.maxCombo}x ${stringResource(Res.string.history_combo)}")
Text(
text = "${result.totalScore} pt",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = CardAccents.challenge,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formatEpochDate(result.dateMillis),
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.4f),
)
}
}
// ── Shared UI pieces ──────────────────────────────────────────────
@Composable
private fun TypeBadge(text: String, color: Color) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(color.copy(alpha = 0.15f))
.padding(horizontal = 8.dp, vertical = 3.dp),
) {
Text(
text = text,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
color = color,
)
}
}
@Composable
private fun InfoChip(label: String) {
Text(
text = label,
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.7f),
)
}
@@ -0,0 +1,344 @@
package com.notemusicali.ui.home
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.List
import androidx.compose.material.icons.outlined.BarChart
import androidx.compose.material.icons.outlined.EmojiEvents
import androidx.compose.material.icons.outlined.Flag
import androidx.compose.material.icons.outlined.Hearing
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.Leaderboard
import androidx.compose.material.icons.outlined.LibraryMusic
import androidx.compose.material.icons.outlined.MusicNote
import androidx.compose.material.icons.outlined.Piano
import androidx.compose.material.icons.outlined.School
import androidx.compose.material.icons.outlined.Speed
import androidx.compose.material.icons.outlined.SpeakerPhone
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.ui.components.GradientCard
import com.notemusicali.ui.theme.AppColors
import com.notemusicali.ui.theme.CardAccents
import com.notemusicali.ui.theme.CardGradients
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun HomeScreen(
onNavigateToTuner: () -> Unit,
onNavigateToPractice: () -> Unit,
onNavigateToExercises: () -> Unit,
onNavigateToHistory: () -> Unit,
onNavigateToScores: () -> Unit = {},
onNavigateToMetronome: () -> Unit = {},
onNavigateToReference: () -> Unit = {},
onNavigateToScales: () -> Unit = {},
onNavigateToEarTraining: () -> Unit = {},
onNavigateToChallenge: () -> Unit = {},
onNavigateToLeaderboard: () -> Unit = {},
onNavigateToStats: () -> Unit = {},
onNavigateToDailyGoals: () -> Unit = {},
onNavigateToGuide: () -> Unit = {},
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier
.widthIn(max = 600.dp)
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Logo: "In" white + "Tono" gold (like Remotion video)
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(color = Color.White)) {
append("In")
}
withStyle(SpanStyle(color = AppColors.accent)) {
append("Tono")
}
},
fontSize = 42.sp,
fontWeight = FontWeight.ExtraBold,
letterSpacing = (-1).sp,
)
Text(
text = stringResource(Res.string.app_subtitle),
fontSize = 14.sp,
color = AppColors.textMuted,
letterSpacing = 2.sp,
)
Spacer(modifier = Modifier.height(8.dp))
// Gold decorative line
Box(
modifier = Modifier
.width(80.dp)
.height(2.dp)
.background(
Brush.horizontalGradient(
listOf(Color.Transparent, AppColors.accent, Color.Transparent),
),
),
)
Spacer(modifier = Modifier.height(32.dp))
// --- Strumenti ---
SectionHeader(stringResource(Res.string.section_tools))
HomeCard(
icon = Icons.Outlined.MusicNote,
title = stringResource(Res.string.tuner_title),
description = stringResource(Res.string.tuner_desc),
gradient = CardGradients.tuner,
accentColor = CardAccents.tuner,
onClick = onNavigateToTuner,
)
Spacer(modifier = Modifier.height(10.dp))
HomeCard(
icon = Icons.Outlined.Speed,
title = stringResource(Res.string.metronome_title),
description = stringResource(Res.string.metronome_desc),
gradient = CardGradients.metronome,
accentColor = CardAccents.metronome,
onClick = onNavigateToMetronome,
)
Spacer(modifier = Modifier.height(10.dp))
HomeCard(
icon = Icons.Outlined.SpeakerPhone,
title = stringResource(Res.string.reference_title),
description = stringResource(Res.string.reference_desc),
gradient = CardGradients.reference,
accentColor = CardAccents.reference,
onClick = onNavigateToReference,
)
Spacer(modifier = Modifier.height(24.dp))
// --- Pratica ---
SectionHeader(stringResource(Res.string.section_practice))
HomeCard(
icon = Icons.Outlined.School,
title = stringResource(Res.string.practice_title),
description = stringResource(Res.string.practice_desc),
gradient = CardGradients.practice,
accentColor = CardAccents.practice,
onClick = onNavigateToPractice,
)
Spacer(modifier = Modifier.height(10.dp))
HomeCard(
icon = Icons.AutoMirrored.Outlined.List,
title = stringResource(Res.string.exercises_title),
description = stringResource(Res.string.exercises_desc),
gradient = CardGradients.exercises,
accentColor = CardAccents.exercises,
onClick = onNavigateToExercises,
)
Spacer(modifier = Modifier.height(10.dp))
HomeCard(
icon = Icons.Outlined.Piano,
title = stringResource(Res.string.scales_title),
description = stringResource(Res.string.scales_desc),
gradient = CardGradients.scales,
accentColor = CardAccents.scales,
onClick = onNavigateToScales,
)
Spacer(modifier = Modifier.height(10.dp))
HomeCard(
icon = Icons.Outlined.LibraryMusic,
title = stringResource(Res.string.scores_title),
description = stringResource(Res.string.scores_desc),
gradient = CardGradients.scores,
accentColor = CardAccents.scores,
onClick = onNavigateToScores,
)
Spacer(modifier = Modifier.height(24.dp))
// --- Sfida ---
SectionHeader(stringResource(Res.string.section_challenge))
HomeCard(
icon = Icons.Outlined.Hearing,
title = stringResource(Res.string.ear_training_title),
description = stringResource(Res.string.ear_training_desc),
gradient = CardGradients.earTraining,
accentColor = CardAccents.earTraining,
onClick = onNavigateToEarTraining,
)
Spacer(modifier = Modifier.height(10.dp))
HomeCard(
icon = Icons.Outlined.EmojiEvents,
title = stringResource(Res.string.challenge_title),
description = stringResource(Res.string.challenge_desc),
gradient = CardGradients.challenge,
accentColor = CardAccents.challenge,
onClick = onNavigateToChallenge,
)
Spacer(modifier = Modifier.height(10.dp))
HomeCard(
icon = Icons.Outlined.Leaderboard,
title = stringResource(Res.string.leaderboard_title),
description = stringResource(Res.string.leaderboard_desc),
gradient = CardGradients.leaderboard,
accentColor = CardAccents.leaderboard,
onClick = onNavigateToLeaderboard,
)
Spacer(modifier = Modifier.height(24.dp))
// --- Progressi ---
SectionHeader(stringResource(Res.string.section_progress))
HomeCard(
icon = Icons.Outlined.BarChart,
title = stringResource(Res.string.stats_title),
description = stringResource(Res.string.stats_desc),
gradient = CardGradients.stats,
accentColor = CardAccents.stats,
onClick = onNavigateToStats,
)
Spacer(modifier = Modifier.height(10.dp))
HomeCard(
icon = Icons.Outlined.Flag,
title = stringResource(Res.string.goals_title),
description = stringResource(Res.string.goals_desc),
gradient = CardGradients.goals,
accentColor = CardAccents.goals,
onClick = onNavigateToDailyGoals,
)
Spacer(modifier = Modifier.height(10.dp))
HomeCard(
icon = Icons.Outlined.History,
title = stringResource(Res.string.history_title),
description = stringResource(Res.string.history_desc),
gradient = CardGradients.history,
accentColor = CardAccents.history,
onClick = onNavigateToHistory,
)
Spacer(modifier = Modifier.height(24.dp))
// --- Guida ---
HomeCard(
icon = Icons.AutoMirrored.Outlined.HelpOutline,
title = stringResource(Res.string.guide_title),
description = stringResource(Res.string.guide_desc),
gradient = CardGradients.guide,
accentColor = CardAccents.guide,
onClick = onNavigateToGuide,
)
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@Composable
private fun SectionHeader(title: String) {
Text(
text = title.uppercase(),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = AppColors.accent.copy(alpha = 0.6f),
letterSpacing = 3.sp,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
)
}
@Composable
private fun HomeCard(
icon: ImageVector,
title: String,
description: String,
gradient: Brush,
accentColor: Color,
onClick: () -> Unit,
) {
GradientCard(
gradient = gradient,
onClick = onClick,
accentColor = accentColor,
modifier = Modifier.fillMaxWidth(),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = icon,
contentDescription = title,
modifier = Modifier.size(36.dp),
tint = accentColor.copy(alpha = 0.9f),
)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = title,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = description,
fontSize = 13.sp,
color = Color.White.copy(alpha = 0.55f),
)
}
}
}
}
@@ -0,0 +1,369 @@
package com.notemusicali.ui.metronome
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.notemusicali.metronome.TimeSignature
import com.notemusicali.ui.components.BackTopBar
import com.notemusicali.ui.components.KeepScreenOn
import com.notemusicali.ui.theme.TunerColors
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun MetronomeScreen(
onBack: () -> Unit,
viewModel: MetronomeViewModel = viewModel { MetronomeViewModel() },
) {
val state by viewModel.uiState.collectAsState()
if (state.isPlaying || state.tunerActive) {
KeepScreenOn()
}
DisposableEffect(Unit) {
onDispose { viewModel.stopTuner() }
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
modifier = Modifier.widthIn(max = 600.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
BackTopBar(title = stringResource(Res.string.metronome_title), onBack = onBack)
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
// BPM display
Text(
text = "${state.bpm}",
fontSize = 96.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
)
Text(
text = stringResource(Res.string.bpm),
fontSize = 20.sp,
color = Color.White.copy(alpha = 0.5f),
)
Spacer(modifier = Modifier.height(24.dp))
// BPM slider
Slider(
value = state.bpm.toFloat(),
onValueChange = { viewModel.setBpm(it.toInt()) },
valueRange = 30f..240f,
modifier = Modifier.fillMaxWidth(),
colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary,
inactiveTrackColor = Color.White.copy(alpha = 0.15f),
),
)
Spacer(modifier = Modifier.height(24.dp))
// Time signature
Text(stringResource(Res.string.time_signature), fontSize = 14.sp, color = Color.White.copy(alpha = 0.6f))
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TimeSignature.entries.forEach { ts ->
FilterChip(
selected = state.timeSignature == ts,
onClick = { viewModel.setTimeSignature(ts) },
label = { Text(ts.displayName) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
selectedLabelColor = Color.White,
),
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Beat indicators
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(vertical = 16.dp),
) {
repeat(state.timeSignature.beatsPerMeasure) { beat ->
val isActive = state.isPlaying && state.currentBeat == beat
val isAccent = beat == 0
Box(
modifier = Modifier
.size(if (isAccent) 28.dp else 22.dp)
.clip(CircleShape)
.background(
when {
isActive && isAccent -> Color(0xFFFF9800)
isActive -> MaterialTheme.colorScheme.primary
else -> Color.White.copy(alpha = 0.15f)
}
),
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Play/Stop + Tap Tempo row
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = { viewModel.tapTempo() },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.1f),
),
) {
Text(stringResource(Res.string.tap_tempo), color = Color.White)
}
IconButton(
onClick = { viewModel.togglePlayStop() },
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(
if (state.isPlaying) Color(0xFFF44336).copy(alpha = 0.3f)
else MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
),
) {
Icon(
imageVector = if (state.isPlaying) Icons.Filled.Stop else Icons.Filled.PlayArrow,
contentDescription = if (state.isPlaying) "Stop" else "Play",
tint = Color.White,
modifier = Modifier.size(40.dp),
)
}
// Tuner toggle
IconButton(
onClick = {
if (state.tunerActive) viewModel.stopTuner() else viewModel.startTuner()
},
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(
if (state.tunerActive) Color(0xFF4CAF50).copy(alpha = 0.3f)
else Color.White.copy(alpha = 0.1f)
),
) {
Icon(
imageVector = if (state.tunerActive) Icons.Filled.Mic else Icons.Filled.MicOff,
contentDescription = "Tuner",
tint = if (state.tunerActive) Color(0xFF4CAF50) else Color.White.copy(alpha = 0.5f),
modifier = Modifier.size(24.dp),
)
}
}
Spacer(modifier = Modifier.height(20.dp))
// Tuner display
if (state.tunerActive) {
val tunerBgColor by animateColorAsState(
targetValue = when (state.tuningDisplay) {
TuningDisplay.IN_TUNE -> TunerColors.inTuneBackground
TuningDisplay.OUT_OF_TUNE -> TunerColors.outOfTuneBackground
TuningDisplay.SILENCE -> Color.White.copy(alpha = 0.04f)
},
animationSpec = tween(300),
label = "tunerBg",
)
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(tunerBgColor)
.padding(vertical = 12.dp, horizontal = 16.dp),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Note name + octave
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.Center,
) {
Text(
text = state.noteName.ifEmpty { "---" },
fontSize = 48.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
)
if (state.noteName.isNotEmpty()) {
Text(
text = "${state.octave}",
fontSize = 20.sp,
color = Color.White.copy(alpha = 0.6f),
modifier = Modifier.padding(bottom = 8.dp, start = 4.dp),
)
}
}
// Cents gauge
val isActive = state.tuningDisplay != TuningDisplay.SILENCE
CentsGauge(
cents = state.centsDeviation,
isActive = isActive,
modifier = Modifier
.fillMaxWidth()
.height(40.dp),
)
// Frequency + cents text
if (state.frequency > 0f) {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.padding(top = 4.dp),
) {
Text(
text = "${(state.frequency * 10).toInt() / 10.0} Hz",
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.5f),
)
if (isActive) {
val sign = if (state.centsDeviation >= 0) "+" else ""
Text(
text = " · ${sign}${state.centsDeviation.toInt()} cents",
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.5f),
)
}
}
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
@Composable
private fun CentsGauge(
cents: Float,
isActive: Boolean,
modifier: Modifier = Modifier,
) {
val indicatorColor by animateColorAsState(
targetValue = when {
!isActive -> TunerColors.silence
kotlin.math.abs(cents) <= 15f -> TunerColors.inTune
else -> TunerColors.outOfTune
},
animationSpec = tween(200),
label = "gaugeColor",
)
Canvas(modifier = modifier) {
val centerX = size.width / 2f
val centerY = size.height / 2f
val gaugeWidth = size.width * 0.85f
val gaugeLeft = centerX - gaugeWidth / 2f
// Background track
drawLine(
color = Color.White.copy(alpha = 0.15f),
start = Offset(gaugeLeft, centerY),
end = Offset(gaugeLeft + gaugeWidth, centerY),
strokeWidth = 6f,
cap = StrokeCap.Round,
)
// In-tune zone
val zoneWidth = gaugeWidth * 15f / 50f
drawLine(
color = TunerColors.inTune.copy(alpha = 0.2f),
start = Offset(centerX - zoneWidth, centerY),
end = Offset(centerX + zoneWidth, centerY),
strokeWidth = 6f,
cap = StrokeCap.Round,
)
// Center tick
drawLine(
color = Color.White.copy(alpha = 0.6f),
start = Offset(centerX, centerY - 14f),
end = Offset(centerX, centerY + 14f),
strokeWidth = 2f,
)
// Indicator
if (isActive) {
val clamped = cents.coerceIn(-50f, 50f)
val indicatorX = centerX + (clamped / 50f) * (gaugeWidth / 2f)
drawCircle(
color = indicatorColor.copy(alpha = 0.3f),
radius = 16f,
center = Offset(indicatorX, centerY),
)
drawCircle(
color = indicatorColor,
radius = 10f,
center = Offset(indicatorX, centerY),
)
}
}
}
@@ -0,0 +1,155 @@
package com.notemusicali.ui.metronome
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.notemusicali.audio.AudioProcessor
import com.notemusicali.metronome.MetronomeEngine
import com.notemusicali.metronome.TimeSignature
import com.notemusicali.util.currentTimeMillis
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.launch
import kotlin.math.abs
enum class TuningDisplay {
SILENCE, IN_TUNE, OUT_OF_TUNE
}
data class MetronomeUiState(
val bpm: Int = 120,
val timeSignature: TimeSignature = TimeSignature.FOUR_FOUR,
val isPlaying: Boolean = false,
val currentBeat: Int = 0,
// Tuner
val noteName: String = "",
val octave: Int = 0,
val frequency: Float = 0f,
val centsDeviation: Float = 0f,
val tuningDisplay: TuningDisplay = TuningDisplay.SILENCE,
val tunerActive: Boolean = false,
)
class MetronomeViewModel : ViewModel() {
private val engine = MetronomeEngine()
private val audioProcessor = AudioProcessor()
private val _uiState = MutableStateFlow(MetronomeUiState())
val uiState: StateFlow<MetronomeUiState> = _uiState.asStateFlow()
// Tap tempo tracking
private val tapTimes = mutableListOf<Long>()
private var silenceJob: Job? = null
private var isListening = false
init {
viewModelScope.launch {
engine.isPlaying.collect { playing ->
_uiState.value = _uiState.value.copy(isPlaying = playing)
}
}
viewModelScope.launch {
engine.currentBeat.collect { beat ->
_uiState.value = _uiState.value.copy(currentBeat = beat)
}
}
}
fun setBpm(bpm: Int) {
val clamped = bpm.coerceIn(30, 240)
engine.bpm = clamped
_uiState.value = _uiState.value.copy(bpm = clamped)
}
fun setTimeSignature(ts: TimeSignature) {
val wasPlaying = engine.isPlaying.value
if (wasPlaying) engine.stop()
engine.timeSignature = ts
_uiState.value = _uiState.value.copy(timeSignature = ts)
if (wasPlaying) engine.start(viewModelScope)
}
fun togglePlayStop() {
if (engine.isPlaying.value) {
engine.stop()
} else {
engine.start(viewModelScope)
}
}
fun tapTempo() {
val now = currentTimeMillis()
tapTimes.add(now)
// Keep only last 5 taps
if (tapTimes.size > 5) tapTimes.removeFirst()
if (tapTimes.size >= 2) {
val intervals = tapTimes.zipWithNext { a, b -> b - a }
val avgInterval = intervals.average()
if (avgInterval > 0) {
val bpm = (60_000.0 / avgInterval).toInt().coerceIn(30, 240)
setBpm(bpm)
}
}
}
fun startTuner() {
if (isListening) return
isListening = true
audioProcessor.start(viewModelScope)
_uiState.value = _uiState.value.copy(tunerActive = true)
viewModelScope.launch {
audioProcessor.result.collect { result ->
when (result) {
is AudioProcessor.ProcessedResult.Detected -> {
silenceJob?.cancel()
silenceJob = null
val nr = result.noteResult
val inTune = abs(nr.centsDeviation) <= 15f
_uiState.value = _uiState.value.copy(
noteName = nr.note.displayName,
octave = nr.note.octave,
frequency = nr.frequency,
centsDeviation = nr.centsDeviation,
tuningDisplay = if (inTune) TuningDisplay.IN_TUNE else TuningDisplay.OUT_OF_TUNE,
)
}
is AudioProcessor.ProcessedResult.Silence,
is AudioProcessor.ProcessedResult.NoNote -> {
if (silenceJob == null && _uiState.value.noteName.isNotEmpty()) {
silenceJob = viewModelScope.launch {
delay(1500L)
_uiState.value = _uiState.value.copy(
noteName = "",
tuningDisplay = TuningDisplay.SILENCE,
)
}
}
}
}
}
}
}
fun stopTuner() {
isListening = false
silenceJob?.cancel()
silenceJob = null
audioProcessor.stop()
_uiState.value = _uiState.value.copy(
tunerActive = false,
noteName = "",
tuningDisplay = TuningDisplay.SILENCE,
)
}
override fun onCleared() {
super.onCleared()
stopTuner()
engine.release()
}
}
@@ -0,0 +1,496 @@
package com.notemusicali.ui.practice
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.MusicNote
import androidx.compose.material.icons.outlined.Piano
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.audio.InstrumentPreset
import com.notemusicali.music.ItalianNotation
import com.notemusicali.music.MusicalNote
import com.notemusicali.music.NoteDuration
import com.notemusicali.music.NoteSequence
import com.notemusicali.ui.components.BackTopBar
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
private val NoteButtonGradient = Brush.linearGradient(
listOf(Color(0xFF00695C), Color(0xFF00838F)),
)
private val NoteChipGradient = Brush.linearGradient(
listOf(Color(0xFF1565C0), Color(0xFF7B1FA2)),
)
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ManualInputScreen(
onBack: () -> Unit,
onSequenceReady: (NoteSequence, InstrumentPreset) -> Unit,
) {
val notes = remember { mutableStateListOf<MusicalNote>() }
var selectedInstrument by remember { mutableStateOf(InstrumentPreset.VIOLINO) }
var selectedOctave by remember { mutableIntStateOf(4) }
var useFlats by remember { mutableStateOf(false) }
var selectedDuration by remember { mutableStateOf(NoteDuration.QUARTER) }
val manualSequenceName = stringResource(Res.string.manual_sequence_name)
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier.widthIn(max = 600.dp).fillMaxHeight(),
) {
BackTopBar(
title = stringResource(Res.string.note_input_title),
onBack = onBack,
contentColor = MaterialTheme.colorScheme.primary,
)
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
// Instrument selector section
SectionHeader(icon = Icons.Outlined.Piano, label = stringResource(Res.string.instrument))
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
InstrumentPreset.entries.forEach { preset ->
FilterChip(
selected = selectedInstrument == preset,
onClick = {
selectedInstrument = preset
if (selectedOctave !in preset.octaveRange) {
selectedOctave = preset.octaveRange.first
.coerceAtLeast(3)
.coerceAtMost(preset.octaveRange.last)
}
},
label = { Text(preset.displayName, fontSize = 13.sp) },
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// Octave selector section
SectionHeader(icon = Icons.Outlined.MusicNote, label = stringResource(Res.string.octave))
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
for (oct in selectedInstrument.octaveRange) {
FilterChip(
selected = selectedOctave == oct,
onClick = { selectedOctave = oct },
label = { Text("$oct") },
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Sharp/Flat toggle
Row {
FilterChip(
selected = !useFlats,
onClick = { useFlats = false },
label = { Text("#") },
)
Spacer(modifier = Modifier.width(8.dp))
FilterChip(
selected = useFlats,
onClick = { useFlats = true },
label = { Text("b") },
)
}
Spacer(modifier = Modifier.height(12.dp))
// Duration selector
SectionHeader(icon = Icons.Outlined.MusicNote, label = stringResource(Res.string.duration))
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
NoteDuration.entries.forEach { dur ->
FilterChip(
selected = selectedDuration == dur,
onClick = { selectedDuration = dur },
label = {
NoteDurationIcon(
duration = dur,
color = MaterialTheme.colorScheme.onSurface,
size = 28.dp,
)
},
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Note buttons grid
SectionHeader(icon = Icons.Outlined.Edit, label = stringResource(Res.string.notes))
Spacer(modifier = Modifier.height(4.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ItalianNotation.allNotes().forEachIndexed { index, noteName ->
val displayText = if (useFlats && noteName.flat != null) {
noteName.flat
} else {
noteName.sharp
}
NoteButton(
text = displayText,
onClick = {
notes.add(
MusicalNote.fromNameAndOctave(
index, selectedOctave, selectedDuration,
preferFlat = useFlats && noteName.flat != null,
),
)
},
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Selected notes preview
Text(
text = stringResource(Res.string.selected_notes, notes.size),
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
)
if (notes.isNotEmpty()) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.fillMaxWidth()
.border(
1.dp,
Color.White.copy(alpha = 0.15f),
RoundedCornerShape(12.dp),
)
.padding(12.dp),
) {
items(notes.toList()) { note ->
val chipShape = RoundedCornerShape(8.dp)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clip(chipShape)
.background(NoteChipGradient)
.border(1.dp, Color.White.copy(alpha = 0.15f), chipShape)
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Text(
text = note.fullName,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Color.White,
)
NoteDurationIcon(
duration = note.duration,
color = Color.White.copy(alpha = 0.6f),
size = 14.dp,
)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = {
if (notes.isNotEmpty()) notes.removeLast()
},
enabled = notes.isNotEmpty(),
) {
Text(stringResource(Res.string.undo_last))
}
OutlinedButton(
onClick = { notes.clear() },
enabled = notes.isNotEmpty(),
) {
Text(stringResource(Res.string.clear_all))
}
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = {
onSequenceReady(
NoteSequence(name = manualSequenceName, notes = notes.toList()),
selectedInstrument,
)
},
enabled = notes.size >= 2,
) {
Text(
text = stringResource(Res.string.start_practice_n, notes.size),
fontSize = 14.sp,
)
}
}
}
}
}
}
// ── Note duration icon drawn with Canvas ──────────────────────────
@Composable
private fun NoteDurationIcon(
duration: NoteDuration,
color: Color,
size: Dp,
modifier: Modifier = Modifier,
) {
Canvas(modifier = modifier.size(size)) {
val w = this.size.width
val h = this.size.height
when (duration) {
NoteDuration.WHOLE -> drawWholeNote(color, w, h)
NoteDuration.HALF -> drawHalfNote(color, w, h)
NoteDuration.QUARTER -> drawQuarterNote(color, w, h)
NoteDuration.EIGHTH -> drawNoteWithFlags(color, w, h, flags = 1)
NoteDuration.SIXTEENTH -> drawNoteWithFlags(color, w, h, flags = 2)
NoteDuration.THIRTY_SECOND -> drawNoteWithFlags(color, w, h, flags = 3)
}
}
}
/** Whole note: open oval, no stem. */
private fun DrawScope.drawWholeNote(color: Color, w: Float, h: Float) {
val cx = w * 0.5f
val cy = h * 0.55f
val rx = w * 0.30f
val ry = h * 0.18f
rotate(degrees = -15f, pivot = Offset(cx, cy)) {
drawOval(
color = color,
topLeft = Offset(cx - rx, cy - ry),
size = Size(rx * 2, ry * 2),
style = Stroke(width = w * 0.06f),
)
}
}
/** Half note: open oval + stem. */
private fun DrawScope.drawHalfNote(color: Color, w: Float, h: Float) {
val cx = w * 0.42f
val cy = h * 0.72f
val rx = w * 0.22f
val ry = h * 0.13f
// Oval (open)
rotate(degrees = -15f, pivot = Offset(cx, cy)) {
drawOval(
color = color,
topLeft = Offset(cx - rx, cy - ry),
size = Size(rx * 2, ry * 2),
style = Stroke(width = w * 0.06f),
)
}
// Stem
val stemX = cx + rx * 0.92f
drawLine(
color = color,
start = Offset(stemX, cy),
end = Offset(stemX, h * 0.12f),
strokeWidth = w * 0.06f,
cap = StrokeCap.Round,
)
}
/** Quarter note: filled oval + stem. */
private fun DrawScope.drawQuarterNote(color: Color, w: Float, h: Float) {
val cx = w * 0.42f
val cy = h * 0.72f
val rx = w * 0.22f
val ry = h * 0.13f
// Oval (filled)
rotate(degrees = -15f, pivot = Offset(cx, cy)) {
drawOval(
color = color,
topLeft = Offset(cx - rx, cy - ry),
size = Size(rx * 2, ry * 2),
style = Fill,
)
}
// Stem
val stemX = cx + rx * 0.92f
drawLine(
color = color,
start = Offset(stemX, cy),
end = Offset(stemX, h * 0.12f),
strokeWidth = w * 0.06f,
cap = StrokeCap.Round,
)
}
/** Eighth / sixteenth / thirty-second: filled oval + stem + flags. */
private fun DrawScope.drawNoteWithFlags(color: Color, w: Float, h: Float, flags: Int) {
val cx = w * 0.38f
val cy = h * 0.75f
val rx = w * 0.20f
val ry = h * 0.12f
// Oval (filled)
rotate(degrees = -15f, pivot = Offset(cx, cy)) {
drawOval(
color = color,
topLeft = Offset(cx - rx, cy - ry),
size = Size(rx * 2, ry * 2),
style = Fill,
)
}
// Stem
val stemX = cx + rx * 0.92f
val stemTop = h * 0.10f
drawLine(
color = color,
start = Offset(stemX, cy),
end = Offset(stemX, stemTop),
strokeWidth = w * 0.06f,
cap = StrokeCap.Round,
)
// Flags
val flagSpacing = h * 0.13f
for (i in 0 until flags) {
val flagTop = stemTop + i * flagSpacing
val path = Path().apply {
moveTo(stemX, flagTop)
cubicTo(
stemX + w * 0.25f, flagTop + h * 0.06f,
stemX + w * 0.30f, flagTop + h * 0.12f,
stemX + w * 0.10f, flagTop + h * 0.18f,
)
}
drawPath(
path = path,
color = color,
style = Stroke(width = w * 0.06f, cap = StrokeCap.Round),
)
}
}
// ── Other composables ─────────────────────────────────────────────
@Composable
private fun SectionHeader(icon: ImageVector, label: String) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 4.dp),
) {
Icon(
imageVector = icon,
contentDescription = label,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = label,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
)
}
}
@Composable
private fun NoteButton(
text: String,
onClick: () -> Unit,
) {
val shape = RoundedCornerShape(12.dp)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.clip(shape)
.background(NoteButtonGradient)
.border(1.dp, Color.White.copy(alpha = 0.15f), shape)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Text(
text = text,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Color.White,
)
}
}
@@ -0,0 +1,513 @@
package com.notemusicali.ui.practice
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.outlined.EmojiEvents
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.IconButton
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.notemusicali.history.PracticeSession
import com.notemusicali.metronome.MetronomeEngine
import com.notemusicali.ui.components.BackTopBar
import com.notemusicali.ui.components.KeepScreenOn
import com.notemusicali.ui.components.StaffContextView
import com.notemusicali.ui.components.StaffFullView
import com.notemusicali.ui.components.StaffView
import com.notemusicali.ui.theme.TunerColors
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
private enum class StaffMode {
SINGLE,
CONTEXT,
FULL,
}
@Composable
fun PracticeScreen(
onBack: () -> Unit,
viewModel: PracticeViewModel = viewModel { PracticeViewModel() },
onFinished: () -> Unit,
) {
val state by viewModel.uiState.collectAsState()
var staffMode by rememberSaveable { mutableStateOf(StaffMode.SINGLE.name) }
val currentMode = StaffMode.valueOf(staffMode)
var lightStaff by rememberSaveable { mutableStateOf(false) }
var hideNoteName by rememberSaveable { mutableStateOf(false) }
// Integrated metronome
val metronomeEngine = remember { MetronomeEngine() }
var metronomeOn by rememberSaveable { mutableStateOf(false) }
var metronomeBpm by rememberSaveable { mutableIntStateOf(120) }
KeepScreenOn()
DisposableEffect(Unit) {
onDispose {
viewModel.savePartialSession()
viewModel.stopListening()
metronomeEngine.release()
}
}
val backgroundColor by animateColorAsState(
targetValue = when (state.matchState) {
PracticeViewModel.NoteMatchState.CORRECT -> TunerColors.inTuneBackground
PracticeViewModel.NoteMatchState.INCORRECT -> TunerColors.outOfTuneBackground
PracticeViewModel.NoteMatchState.WAITING -> TunerColors.silenceBackground
},
animationSpec = tween(durationMillis = 300),
label = "bgColor",
)
if (state.isCompleted) {
LaunchedEffect(Unit) {
metronomeOn = false
metronomeEngine.stop()
}
CompletedView(
session = state.lastSession,
onSave = { viewModel.saveSession() },
onFinished = onFinished,
onReset = { viewModel.reset() },
)
return
}
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
.background(
Brush.verticalGradient(
listOf(Color.Transparent, Color.Black.copy(alpha = 0.4f)),
)
),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier.widthIn(max = 600.dp).fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
BackTopBar(title = stringResource(Res.string.practice_title), onBack = onBack)
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Progress bar
LinearProgressIndicator(
progress = { state.progress },
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(4.dp)),
color = TunerColors.inTune,
trackColor = Color.White.copy(alpha = 0.2f),
)
Text(
text = stringResource(Res.string.note_n_of_total, state.currentIndex + 1, state.sequence.size),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.5f),
modifier = Modifier.padding(top = 8.dp),
)
// View mode selector
Row(
modifier = Modifier.padding(top = 8.dp).horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
StaffMode.entries.forEach { mode ->
val modeLabel = when (mode) {
StaffMode.SINGLE -> stringResource(Res.string.view_note)
StaffMode.CONTEXT -> stringResource(Res.string.view_context)
StaffMode.FULL -> stringResource(Res.string.view_score)
}
FilterChip(
selected = currentMode == mode,
onClick = { staffMode = mode.name },
label = { Text(modeLabel, fontSize = 12.sp) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = Color.White.copy(alpha = 0.25f),
selectedLabelColor = Color.White,
),
)
}
FilterChip(
selected = lightStaff,
onClick = { lightStaff = !lightStaff },
label = { Text(stringResource(Res.string.view_light), fontSize = 12.sp) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = Color.White.copy(alpha = 0.25f),
selectedLabelColor = Color.White,
),
)
FilterChip(
selected = hideNoteName,
onClick = { hideNoteName = !hideNoteName },
label = { Text(stringResource(Res.string.hide_note_name), fontSize = 12.sp) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = Color(0xFFFF6B9D).copy(alpha = 0.3f),
selectedLabelColor = Color(0xFFFF6B9D),
),
)
}
// Mini metronome bar
val scope = rememberCoroutineScope()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 6.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.06f))
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = {
metronomeOn = !metronomeOn
if (metronomeOn) {
metronomeEngine.bpm = metronomeBpm
metronomeEngine.start(scope)
} else {
metronomeEngine.stop()
}
},
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = if (metronomeOn) Icons.Filled.Stop else Icons.Filled.PlayArrow,
contentDescription = null,
tint = if (metronomeOn) Color(0xFFFFD700) else Color.White.copy(alpha = 0.6f),
modifier = Modifier.size(22.dp),
)
}
Text(
text = "$metronomeBpm",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = if (metronomeOn) Color(0xFFFFD700) else Color.White.copy(alpha = 0.6f),
modifier = Modifier.widthIn(min = 32.dp),
)
Slider(
value = metronomeBpm.toFloat(),
onValueChange = {
metronomeBpm = it.toInt()
metronomeEngine.bpm = metronomeBpm
},
valueRange = 30f..240f,
modifier = Modifier.weight(1f).height(24.dp),
colors = SliderDefaults.colors(
thumbColor = if (metronomeOn) Color(0xFFFFD700) else Color.White.copy(alpha = 0.4f),
activeTrackColor = if (metronomeOn) Color(0xFFFFD700).copy(alpha = 0.5f) else Color.White.copy(alpha = 0.2f),
inactiveTrackColor = Color.White.copy(alpha = 0.1f),
),
)
Text(
text = "BPM",
fontSize = 10.sp,
color = Color.White.copy(alpha = 0.3f),
modifier = Modifier.padding(start = 4.dp),
)
}
Spacer(modifier = Modifier.weight(1f))
// Staff display based on mode
val staffBgMod = if (lightStaff) {
Modifier.clip(RoundedCornerShape(12.dp)).background(Color.White)
} else Modifier
when (currentMode) {
StaffMode.SINGLE -> {
Text(
text = stringResource(Res.string.play_note),
fontSize = 20.sp,
color = Color.White.copy(alpha = 0.6f),
)
state.targetNote?.let { note ->
StaffView(
note = note,
modifier = Modifier.padding(horizontal = 16.dp).then(staffBgMod),
lightTheme = lightStaff,
)
}
}
StaffMode.CONTEXT -> {
StaffContextView(
notes = state.sequence.notes,
currentIndex = state.currentIndex,
modifier = Modifier.padding(horizontal = 8.dp).then(staffBgMod),
lightTheme = lightStaff,
beats = state.sequence.beats,
beatType = state.sequence.beatType,
)
}
StaffMode.FULL -> {
StaffFullView(
notes = state.sequence.notes,
currentIndex = state.currentIndex,
modifier = staffBgMod,
lightTheme = lightStaff,
beats = state.sequence.beats,
beatType = state.sequence.beatType,
)
}
}
// Note name with glow card
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(top = 16.dp)
.clip(RoundedCornerShape(20.dp))
.background(
if (hideNoteName) Color.White.copy(alpha = 0.04f)
else Color.White.copy(alpha = 0.08f)
)
.padding(horizontal = 40.dp, vertical = 8.dp),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = if (hideNoteName) "?" else (state.targetNote?.displayName ?: ""),
fontSize = if (currentMode == StaffMode.SINGLE) 96.sp else 64.sp,
fontWeight = FontWeight.Bold,
color = if (hideNoteName) Color.White.copy(alpha = 0.15f) else Color.White,
textAlign = TextAlign.Center,
)
Text(
text = if (hideNoteName) "" else stringResource(Res.string.octave_n, (state.targetNote?.octave ?: "").toString()),
fontSize = if (currentMode == StaffMode.SINGLE) 20.sp else 16.sp,
color = Color.White.copy(alpha = 0.6f),
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Detected note card — always reserve space to prevent layout jumps
val hasDetection = state.detectedNoteName.isNotEmpty()
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(
if (hasDetection) Color.White.copy(alpha = 0.06f)
else Color.Transparent
)
.padding(horizontal = 32.dp, vertical = 12.dp),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = if (hasDetection) stringResource(Res.string.detected) else "",
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.4f),
)
Text(
text = if (hasDetection) state.detectedNoteName else " ",
fontSize = 48.sp,
fontWeight = FontWeight.Medium,
color = if (hasDetection) {
when (state.matchState) {
PracticeViewModel.NoteMatchState.CORRECT -> TunerColors.inTune
PracticeViewModel.NoteMatchState.INCORRECT -> TunerColors.outOfTune
PracticeViewModel.NoteMatchState.WAITING -> Color.White
}
} else Color.Transparent,
)
val sign = if (state.detectedCents >= 0) "+" else ""
Text(
text = if (hasDetection) "${sign}${state.detectedCents.toInt()} cents" else "",
fontSize = 16.sp,
color = Color.White.copy(alpha = 0.4f),
)
}
}
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
@Composable
private fun CompletedView(
session: PracticeSession?,
onSave: () -> Unit,
onFinished: () -> Unit,
onReset: () -> Unit,
) {
LaunchedEffect(Unit) {
onSave()
}
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(Color(0xFF1B5E20), Color(0xFF0A280E)),
)
),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
imageVector = Icons.Outlined.EmojiEvents,
contentDescription = stringResource(Res.string.completed),
modifier = Modifier.size(72.dp),
tint = Color(0xFFFFD54F),
)
Text(
text = stringResource(Res.string.completed),
fontSize = 48.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
)
Text(
text = stringResource(Res.string.all_notes_correct),
fontSize = 18.sp,
color = Color.White.copy(alpha = 0.7f),
)
// Session stats
if (session != null) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(Color.White.copy(alpha = 0.1f))
.padding(horizontal = 24.dp, vertical = 16.dp),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
StatRow(
label = stringResource(Res.string.avg_deviation),
value = "${session.averageCents.toInt()} cents",
)
StatRow(
label = stringResource(Res.string.perfect_notes),
value = "${session.perfectNotes}/${session.noteResults.size}",
)
StatRow(
label = stringResource(Res.string.first_attempt),
value = "${(session.firstAttemptRate * 100).toInt()}%",
)
val totalSec = session.totalTimeMs / 1000
StatRow(
label = stringResource(Res.string.duration),
value = "${totalSec / 60}m ${totalSec % 60}s",
)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Button(onClick = onReset) {
Text(stringResource(Res.string.repeat))
}
Button(onClick = onFinished) {
Text(stringResource(Res.string.finish))
}
}
}
}
}
@Composable
private fun StatRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = label,
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.6f),
)
Text(
text = value,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
}
}
@@ -0,0 +1,155 @@
package com.notemusicali.ui.practice
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CameraAlt
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.FileOpen
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.music.MusicXmlParser
import com.notemusicali.music.NoteSequence
import com.notemusicali.ui.components.BackTopBar
import com.notemusicali.ui.components.GradientCard
import com.notemusicali.ui.components.rememberFilePickerLauncher
import com.notemusicali.ui.theme.CardGradients
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun PracticeSetupScreen(
onBack: () -> Unit,
onManualInput: () -> Unit,
onMusicXmlLoaded: (NoteSequence) -> Unit,
onScan: () -> Unit = {},
) {
val filePickerLauncher = rememberFilePickerLauncher(
mimeTypes = listOf("text/xml", "application/xml", "*/*"),
onFileContent = { content ->
try {
val sequence = MusicXmlParser.parse(content, "MusicXML Import")
if (sequence.notes.isNotEmpty()) {
onMusicXmlLoaded(sequence)
}
} catch (_: Exception) {
// Parse failed
}
},
)
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier.widthIn(max = 600.dp).fillMaxHeight(),
) {
BackTopBar(
title = stringResource(Res.string.practice_title),
onBack = onBack,
contentColor = MaterialTheme.colorScheme.primary,
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(Res.string.practice_choose),
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
)
Spacer(modifier = Modifier.height(32.dp))
SetupCard(
icon = Icons.Outlined.Edit,
title = stringResource(Res.string.manual_input_title),
description = stringResource(Res.string.manual_input_desc),
onClick = onManualInput,
)
Spacer(modifier = Modifier.height(16.dp))
SetupCard(
icon = Icons.Outlined.FileOpen,
title = stringResource(Res.string.import_musicxml_title),
description = stringResource(Res.string.import_musicxml_desc),
onClick = filePickerLauncher,
)
Spacer(modifier = Modifier.height(16.dp))
SetupCard(
icon = Icons.Outlined.CameraAlt,
title = stringResource(Res.string.scan_title),
description = stringResource(Res.string.scan_desc),
onClick = onScan,
)
}
}
}
}
@Composable
private fun SetupCard(
icon: ImageVector,
title: String,
description: String,
onClick: () -> Unit,
) {
GradientCard(
gradient = CardGradients.setup,
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = icon,
contentDescription = title,
modifier = Modifier.size(36.dp),
tint = Color.White.copy(alpha = 0.8f),
)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = title,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = description,
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.7f),
)
}
}
}
}
@@ -0,0 +1,231 @@
package com.notemusicali.ui.practice
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.notemusicali.audio.AudioProcessor
import com.notemusicali.audio.InstrumentPreset
import com.notemusicali.goals.DailyGoalManager
import com.notemusicali.history.NoteResult
import com.notemusicali.history.PracticeSession
import com.notemusicali.history.SessionRepository
import com.notemusicali.music.MusicalNote
import com.notemusicali.music.NoteSequence
import com.notemusicali.util.currentTimeMillis
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.launch
import kotlin.math.abs
class PracticeViewModel : ViewModel() {
private val audioProcessor = AudioProcessor()
enum class NoteMatchState {
WAITING,
CORRECT,
INCORRECT,
}
data class PracticeUiState(
val sequence: NoteSequence = NoteSequence.empty(),
val currentIndex: Int = 0,
val targetNote: MusicalNote? = null,
val detectedNoteName: String = "",
val detectedCents: Float = 0f,
val matchState: NoteMatchState = NoteMatchState.WAITING,
val isCompleted: Boolean = false,
val progress: Float = 0f,
val lastSession: PracticeSession? = null,
)
private val _uiState = MutableStateFlow(PracticeUiState())
val uiState: StateFlow<PracticeUiState> = _uiState.asStateFlow()
private var correctHoldJob: Job? = null
private var isListening = false
// Per-note tracking
private var sessionStartTime: Long = 0L
private var noteStartTime: Long = 0L
private var firstAttemptForNote: Boolean = true
private var lastCentsForNote: Float = 0f
private val collectedResults = mutableListOf<NoteResult>()
private var currentInstrument: InstrumentPreset = InstrumentPreset.VIOLINO
fun selectInstrument(preset: InstrumentPreset) {
val wasListening = isListening
if (wasListening) stopListening()
currentInstrument = preset
audioProcessor.updatePreset(preset)
if (wasListening) startListening()
}
fun setSequence(sequence: NoteSequence) {
stopListening()
collectedResults.clear()
sessionStartTime = currentTimeMillis()
noteStartTime = sessionStartTime
firstAttemptForNote = true
lastCentsForNote = 0f
_uiState.value = PracticeUiState(
sequence = sequence,
targetNote = sequence.noteAt(0),
progress = if (sequence.size > 0) 0f else 1f,
)
}
fun startListening() {
if (isListening) return
isListening = true
audioProcessor.start(viewModelScope)
viewModelScope.launch {
audioProcessor.result.collect { result ->
val state = _uiState.value
if (state.isCompleted) return@collect
when (result) {
is AudioProcessor.ProcessedResult.Detected -> {
val detected = result.noteResult
val target = state.targetNote ?: return@collect
val isCorrect = detected.note.noteIndex == target.noteIndex &&
detected.note.octave == target.octave &&
abs(detected.centsDeviation) <= 30f
_uiState.value = state.copy(
detectedNoteName = detected.note.displayName,
detectedCents = detected.centsDeviation,
matchState = if (isCorrect) NoteMatchState.CORRECT else NoteMatchState.INCORRECT,
)
if (isCorrect && correctHoldJob == null) {
lastCentsForNote = detected.centsDeviation
val holdTime = target.duration.holdMs
correctHoldJob = viewModelScope.launch {
delay(holdTime)
advanceToNext()
}
} else if (!isCorrect) {
correctHoldJob?.cancel()
correctHoldJob = null
firstAttemptForNote = false
}
}
is AudioProcessor.ProcessedResult.Silence,
is AudioProcessor.ProcessedResult.NoNote -> {
correctHoldJob?.cancel()
correctHoldJob = null
_uiState.value = state.copy(
detectedNoteName = "",
detectedCents = 0f,
matchState = NoteMatchState.WAITING,
)
}
}
}
}
}
private fun advanceToNext() {
correctHoldJob = null
val state = _uiState.value
val now = currentTimeMillis()
// Collect result for the completed note
state.targetNote?.let { target ->
collectedResults.add(
NoteResult(
noteName = target.fullName,
midiNumber = target.midiNumber,
centsDeviation = lastCentsForNote,
reactionTimeMs = now - noteStartTime,
firstAttemptSuccess = firstAttemptForNote,
)
)
}
val nextIndex = state.currentIndex + 1
if (nextIndex >= state.sequence.size) {
val session = buildSession(now, completed = true)
_uiState.value = state.copy(
isCompleted = true,
progress = 1f,
matchState = NoteMatchState.WAITING,
lastSession = session,
)
} else {
noteStartTime = now
firstAttemptForNote = true
lastCentsForNote = 0f
_uiState.value = state.copy(
currentIndex = nextIndex,
targetNote = state.sequence.noteAt(nextIndex),
matchState = NoteMatchState.WAITING,
detectedNoteName = "",
progress = nextIndex.toFloat() / state.sequence.size,
)
}
}
private fun buildSession(endTime: Long, completed: Boolean): PracticeSession {
val state = _uiState.value
return PracticeSession(
id = sessionStartTime.toString(),
sequenceName = state.sequence.name,
instrumentName = currentInstrument.displayName,
dateMillis = sessionStartTime,
totalTimeMs = endTime - sessionStartTime,
noteResults = collectedResults.toList(),
noteMidiNumbers = state.sequence.notes.map { it.midiNumber },
completed = completed,
)
}
fun saveSession() {
_uiState.value.lastSession?.let { session ->
SessionRepository.save(session)
DailyGoalManager.recordPractice(session.totalTimeMs)
}
}
fun savePartialSession() {
val state = _uiState.value
if (state.isCompleted || sessionStartTime == 0L || state.sequence.size == 0) return
if (collectedResults.isEmpty()) return
val session = buildSession(currentTimeMillis(), completed = false)
SessionRepository.save(session)
DailyGoalManager.recordPractice(session.totalTimeMs)
}
fun stopListening() {
isListening = false
audioProcessor.stop()
correctHoldJob?.cancel()
correctHoldJob = null
}
fun reset() {
stopListening()
val seq = _uiState.value.sequence
collectedResults.clear()
sessionStartTime = currentTimeMillis()
noteStartTime = sessionStartTime
firstAttemptForNote = true
lastCentsForNote = 0f
_uiState.value = PracticeUiState(
sequence = seq,
targetNote = seq.noteAt(0),
)
startListening()
}
override fun onCleared() {
super.onCleared()
stopListening()
}
}
@@ -0,0 +1,206 @@
package com.notemusicali.ui.reference
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.audio.NoteConverter
import com.notemusicali.audio.ToneGenerator
import com.notemusicali.audio.createToneGenerator
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
import com.notemusicali.music.ItalianNotation
import com.notemusicali.ui.components.BackTopBar
private val NOTE_NAMES = (0..11).map { ItalianNotation.fromNoteIndex(it) }
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ReferenceNoteScreen(onBack: () -> Unit) {
val toneGenerator = remember { createToneGenerator() }
var selectedNoteIndex by remember { mutableIntStateOf(9) } // A (La)
var selectedOctave by remember { mutableIntStateOf(4) }
var isPlaying by remember { mutableStateOf(false) }
val midiNumber = (selectedOctave + 1) * 12 + selectedNoteIndex
val frequency = NoteConverter.midiToFrequency(midiNumber)
val noteName = NOTE_NAMES[selectedNoteIndex].display
DisposableEffect(Unit) {
onDispose { toneGenerator.release() }
}
fun playNote(freq: Float = NoteConverter.midiToFrequency((selectedOctave + 1) * 12 + selectedNoteIndex)) {
toneGenerator.playTone(freq, 3000L)
isPlaying = true
}
fun stopNote() {
toneGenerator.stop()
isPlaying = false
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(modifier = Modifier.widthIn(max = 600.dp)) {
BackTopBar(title = stringResource(Res.string.reference_title), onBack = {
stopNote()
onBack()
})
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(24.dp))
// Quick access: A4 440Hz
Button(
onClick = {
selectedNoteIndex = 9
selectedOctave = 4
toneGenerator.playTone(440f, 3000L)
isPlaying = true
},
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFFF9800).copy(alpha = 0.2f),
),
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(Res.string.la_440), fontSize = 18.sp, color = Color(0xFFFF9800))
}
Spacer(modifier = Modifier.height(32.dp))
// Note display
Text(
text = noteName,
fontSize = 72.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
)
Text(
text = stringResource(Res.string.octave_freq, selectedOctave, ((frequency * 10).toInt() / 10f).toString()),
fontSize = 16.sp,
color = Color.White.copy(alpha = 0.5f),
)
Spacer(modifier = Modifier.height(24.dp))
// Play/Stop button
IconButton(
onClick = { if (isPlaying) stopNote() else playNote() },
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(
if (isPlaying) Color(0xFFF44336).copy(alpha = 0.3f)
else MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
),
) {
Icon(
imageVector = if (isPlaying) Icons.Filled.Stop else Icons.Filled.PlayArrow,
contentDescription = if (isPlaying) "Stop" else "Play",
tint = Color.White,
modifier = Modifier.size(40.dp),
)
}
Spacer(modifier = Modifier.height(32.dp))
// Note selector
Text(stringResource(Res.string.select_note), fontSize = 14.sp, color = Color.White.copy(alpha = 0.6f))
Spacer(modifier = Modifier.height(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
NOTE_NAMES.forEachIndexed { idx, name ->
FilterChip(
selected = selectedNoteIndex == idx,
onClick = {
selectedNoteIndex = idx
if (isPlaying) playNote()
},
label = { Text(name.display, fontSize = 13.sp) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
selectedLabelColor = Color.White,
),
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Octave selector
Text(stringResource(Res.string.octave), fontSize = 14.sp, color = Color.White.copy(alpha = 0.6f))
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
(2..7).forEach { oct ->
FilterChip(
selected = selectedOctave == oct,
onClick = {
selectedOctave = oct
if (isPlaying) playNote()
},
label = { Text("$oct") },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
selectedLabelColor = Color.White,
),
)
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
@@ -0,0 +1,247 @@
package com.notemusicali.ui.scales
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.music.ArpeggioType
import com.notemusicali.music.ItalianNotation
import com.notemusicali.music.NoteSequence
import com.notemusicali.music.ScaleDirection
import com.notemusicali.music.ScaleGenerator
import com.notemusicali.music.ScaleType
import com.notemusicali.ui.components.BackTopBar
import com.notemusicali.ui.components.StaffFullView
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
private val NOTE_NAMES = (0..11).map { ItalianNotation.fromNoteIndex(it) }
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ScaleGeneratorScreen(
onBack: () -> Unit,
onPractice: (NoteSequence) -> Unit,
) {
var selectedTab by remember { mutableIntStateOf(0) } // 0=Scale, 1=Arpeggio
var selectedNoteIndex by remember { mutableIntStateOf(0) } // C
var selectedOctave by remember { mutableIntStateOf(4) }
var selectedScaleType by remember { mutableStateOf(ScaleType.MAJOR) }
var selectedArpeggioType by remember { mutableStateOf(ArpeggioType.MAJOR) }
var selectedDirection by remember { mutableStateOf(ScaleDirection.ASCENDING) }
val preview = remember(selectedTab, selectedNoteIndex, selectedOctave, selectedScaleType, selectedArpeggioType, selectedDirection) {
if (selectedTab == 0) {
ScaleGenerator.generateScale(selectedNoteIndex, selectedOctave, selectedScaleType, selectedDirection)
} else {
ScaleGenerator.generateArpeggio(selectedNoteIndex, selectedOctave, selectedArpeggioType, selectedDirection)
}
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(modifier = Modifier.widthIn(max = 600.dp)) {
BackTopBar(title = stringResource(Res.string.scales_title), onBack = onBack)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Tab: Scale / Arpeggio
TabRow(
selectedTabIndex = selectedTab,
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.primary,
) {
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) {
Text(stringResource(Res.string.tab_scales), modifier = Modifier.padding(12.dp))
}
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) {
Text(stringResource(Res.string.tab_arpeggios), modifier = Modifier.padding(12.dp))
}
}
Spacer(modifier = Modifier.height(16.dp))
// Root note selector
Text(stringResource(Res.string.root_note), fontSize = 14.sp, color = Color.White.copy(alpha = 0.6f))
Spacer(modifier = Modifier.height(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
NOTE_NAMES.forEachIndexed { idx, name ->
FilterChip(
selected = selectedNoteIndex == idx,
onClick = { selectedNoteIndex = idx },
label = { Text(name.display, fontSize = 13.sp) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
selectedLabelColor = Color.White,
),
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Octave selector
Text(stringResource(Res.string.octave), fontSize = 14.sp, color = Color.White.copy(alpha = 0.6f))
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
(2..6).forEach { oct ->
FilterChip(
selected = selectedOctave == oct,
onClick = { selectedOctave = oct },
label = { Text("$oct") },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
selectedLabelColor = Color.White,
),
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Scale/Arpeggio type
if (selectedTab == 0) {
Text(stringResource(Res.string.scale_type), fontSize = 14.sp, color = Color.White.copy(alpha = 0.6f))
Spacer(modifier = Modifier.height(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
ScaleType.entries.forEach { type ->
FilterChip(
selected = selectedScaleType == type,
onClick = { selectedScaleType = type },
label = { Text(type.displayName, fontSize = 12.sp) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
selectedLabelColor = Color.White,
),
)
}
}
} else {
Text(stringResource(Res.string.arpeggio_type), fontSize = 14.sp, color = Color.White.copy(alpha = 0.6f))
Spacer(modifier = Modifier.height(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
ArpeggioType.entries.forEach { type ->
FilterChip(
selected = selectedArpeggioType == type,
onClick = { selectedArpeggioType = type },
label = { Text(type.displayName, fontSize = 12.sp) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
selectedLabelColor = Color.White,
),
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Direction
Text(stringResource(Res.string.direction), fontSize = 14.sp, color = Color.White.copy(alpha = 0.6f))
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ScaleDirection.entries.forEach { dir ->
FilterChip(
selected = selectedDirection == dir,
onClick = { selectedDirection = dir },
label = { Text(dir.displayName, fontSize = 12.sp) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
selectedLabelColor = Color.White,
),
)
}
}
Spacer(modifier = Modifier.height(20.dp))
// Preview
if (preview.notes.isNotEmpty()) {
Text(preview.name, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.White)
.padding(8.dp)
) {
StaffFullView(
notes = preview.notes,
currentIndex = -1,
lightTheme = true,
)
}
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = { onPractice(preview) },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(Res.string.start_practice), fontSize = 18.sp)
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
@@ -0,0 +1,6 @@
package com.notemusicali.ui.scan
enum class AiProvider(val displayName: String) {
CLAUDE("Claude (Anthropic)"),
OPENAI("GPT-4o (OpenAI)"),
}
@@ -0,0 +1,15 @@
package com.notemusicali.ui.scan
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.notemusicali.scan.PlatformImage
/**
* Platform-specific camera capture composable.
* On platforms without camera support, shows a placeholder.
*/
@Composable
expect fun CameraCapture(
onImageCaptured: (PlatformImage) -> Unit,
modifier: Modifier,
)
@@ -0,0 +1,427 @@
package com.notemusicali.ui.scan
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.notemusicali.audio.InstrumentPreset
import com.notemusicali.music.MusicalNote
import com.notemusicali.music.NoteSequence
import com.notemusicali.ui.components.BackTopBar
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun ScanScreen(
onBack: () -> Unit,
onSequenceCaptured: (NoteSequence, InstrumentPreset) -> Unit,
viewModel: ScanViewModel,
) {
val state by viewModel.state.collectAsState()
val showApiKeyDialog by viewModel.showApiKeyDialog.collectAsState()
val selectedProvider by viewModel.selectedProvider.collectAsState()
if (showApiKeyDialog) {
ApiKeyDialog(
provider = selectedProvider,
onProviderChange = { viewModel.selectProvider(it) },
onConfirm = { key -> viewModel.saveApiKey(key) },
onDismiss = {
viewModel.dismissApiKeyDialog()
if (!viewModel.hasApiKey()) onBack()
},
)
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
Column(modifier = Modifier.widthIn(max = 600.dp).fillMaxHeight()) {
BackTopBar(
title = stringResource(Res.string.scan_title),
onBack = onBack,
contentColor = when (state) {
is ScanViewModel.ScanState.Camera -> Color.White
else -> MaterialTheme.colorScheme.primary
},
containerColor = Color.Transparent,
)
when (val currentState = state) {
is ScanViewModel.ScanState.Camera -> CameraView(
onCapture = { image -> viewModel.captureAndAnalyze(image) },
onProviderClick = { viewModel.requestApiKey() },
providerName = selectedProvider.displayName,
)
is ScanViewModel.ScanState.Processing -> ProcessingView()
is ScanViewModel.ScanState.Review -> ReviewView(
notes = currentState.notes,
onRemoveNote = { viewModel.removeNote(it) },
onRetry = { viewModel.retryCamera() },
onStartPractice = { instrument ->
onSequenceCaptured(
NoteSequence(
name = "Spartito Scansionato",
notes = currentState.notes,
),
instrument,
)
},
)
is ScanViewModel.ScanState.Error -> ErrorView(
message = currentState.message,
onRetry = { viewModel.retryCamera() },
onChangeKey = { viewModel.requestApiKey() },
)
}
}
}
}
@Composable
private fun CameraView(
onCapture: (com.notemusicali.scan.PlatformImage) -> Unit,
onProviderClick: () -> Unit,
providerName: String,
) {
Box(modifier = Modifier.fillMaxSize()) {
CameraCapture(
onImageCaptured = onCapture,
modifier = Modifier.fillMaxSize(),
)
// Provider badge
Text(
text = providerName,
fontSize = 12.sp,
color = Color.White,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(16.dp)
.background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(16.dp))
.clickable { onProviderClick() }
.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
@Composable
private fun ProcessingView() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
CircularProgressIndicator(
modifier = Modifier.size(64.dp),
color = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(Res.string.analyzing),
fontSize = 18.sp,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = stringResource(Res.string.analyzing_desc),
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f),
)
}
}
}
@Composable
private fun ReviewView(
notes: List<MusicalNote>,
onRemoveNote: (Int) -> Unit,
onRetry: () -> Unit,
onStartPractice: (InstrumentPreset) -> Unit,
) {
var selectedInstrument by remember { mutableStateOf(InstrumentPreset.VIOLINO) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(Res.string.notes_detected, notes.size),
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(16.dp))
if (notes.isNotEmpty()) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.fillMaxWidth()
.border(
1.dp,
MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
RoundedCornerShape(8.dp),
)
.padding(12.dp),
) {
itemsIndexed(notes) { index, note ->
val chipShape = RoundedCornerShape(8.dp)
val chipGradient = Brush.linearGradient(
listOf(Color(0xFF1565C0), Color(0xFF7B1FA2)),
)
Text(
text = note.fullName,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Color.White,
modifier = Modifier
.clip(chipShape)
.background(chipGradient)
.border(1.dp, Color.White.copy(alpha = 0.15f), chipShape)
.clickable { onRemoveNote(index) }
.padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
Text(
text = stringResource(Res.string.tap_to_remove),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.4f),
modifier = Modifier.padding(top = 4.dp),
)
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(Res.string.instrument),
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
InstrumentPreset.entries.forEach { preset ->
FilterChip(
selected = selectedInstrument == preset,
onClick = { selectedInstrument = preset },
label = { Text(preset.displayName, fontSize = 13.sp) },
)
}
}
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = { onStartPractice(selectedInstrument) },
enabled = notes.size >= 2,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(Res.string.start_practice_n, notes.size), fontSize = 18.sp)
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = onRetry,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(Res.string.take_again))
}
}
}
@Composable
private fun ErrorView(
message: String,
onRetry: () -> Unit,
onChangeKey: () -> Unit,
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(32.dp),
) {
Text(
text = stringResource(Res.string.error),
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = message,
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(32.dp))
Button(onClick = onRetry, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(Res.string.retry))
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(onClick = onChangeKey, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(Res.string.change_api_key))
}
}
}
}
@Composable
private fun ApiKeyDialog(
provider: AiProvider,
onProviderChange: (AiProvider) -> Unit,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
var apiKey by remember { mutableStateOf("") }
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(Res.string.api_key),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(16.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AiProvider.entries.forEach { p ->
FilterChip(
selected = provider == p,
onClick = { onProviderChange(p) },
label = { Text(p.displayName, fontSize = 12.sp) },
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = when (provider) {
AiProvider.CLAUDE -> stringResource(Res.string.enter_anthropic_key)
AiProvider.OPENAI -> stringResource(Res.string.enter_openai_key)
},
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = apiKey,
onValueChange = { apiKey = it },
label = { Text(stringResource(Res.string.api_key)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = when (provider) {
AiProvider.CLAUDE -> stringResource(Res.string.get_anthropic_key)
AiProvider.OPENAI -> stringResource(Res.string.get_openai_key)
},
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(20.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(
onClick = onDismiss,
modifier = Modifier.weight(1f),
) {
Text(stringResource(Res.string.cancel))
}
Button(
onClick = { onConfirm(apiKey) },
enabled = apiKey.isNotBlank(),
modifier = Modifier.weight(1f),
) {
Text(stringResource(Res.string.save))
}
}
}
}
}
}
@@ -0,0 +1,122 @@
package com.notemusicali.ui.scan
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.notemusicali.music.MusicalNote
import com.notemusicali.scan.AppSettings
import com.notemusicali.scan.ClaudeOmrProcessor
import com.notemusicali.scan.OpenAiOmrProcessor
import com.notemusicali.scan.PlatformImage
import com.notemusicali.scan.toBase64Jpeg
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class ScanViewModel : ViewModel() {
sealed class ScanState {
data object Camera : ScanState()
data object Processing : ScanState()
data class Review(val notes: List<MusicalNote>) : ScanState()
data class Error(val message: String) : ScanState()
}
private val _state = MutableStateFlow<ScanState>(ScanState.Camera)
val state: StateFlow<ScanState> = _state.asStateFlow()
private val _showApiKeyDialog = MutableStateFlow(!hasApiKey())
val showApiKeyDialog: StateFlow<Boolean> = _showApiKeyDialog.asStateFlow()
private val _selectedProvider = MutableStateFlow(loadProvider())
val selectedProvider: StateFlow<AiProvider> = _selectedProvider.asStateFlow()
fun hasApiKey(): Boolean {
val provider = loadProvider()
return getApiKey(provider) != null
}
fun getApiKey(provider: AiProvider = _selectedProvider.value): String? {
val key = when (provider) {
AiProvider.CLAUDE -> "anthropic_api_key"
AiProvider.OPENAI -> "openai_api_key"
}
return AppSettings.getString(key)?.takeIf { it.isNotBlank() }
}
fun saveApiKey(key: String, provider: AiProvider = _selectedProvider.value) {
val prefKey = when (provider) {
AiProvider.CLAUDE -> "anthropic_api_key"
AiProvider.OPENAI -> "openai_api_key"
}
AppSettings.putString(prefKey, key.trim())
_showApiKeyDialog.value = false
}
fun selectProvider(provider: AiProvider) {
_selectedProvider.value = provider
AppSettings.putString("selected_provider", provider.name)
if (getApiKey(provider) == null) {
_showApiKeyDialog.value = true
}
}
private fun loadProvider(): AiProvider {
val name = AppSettings.getString("selected_provider")
return try {
name?.let { AiProvider.valueOf(it) } ?: AiProvider.CLAUDE
} catch (_: Exception) {
AiProvider.CLAUDE
}
}
fun requestApiKey() {
_showApiKeyDialog.value = true
}
fun dismissApiKeyDialog() {
_showApiKeyDialog.value = false
}
fun captureAndAnalyze(image: PlatformImage) {
val provider = _selectedProvider.value
val apiKey = getApiKey(provider)
if (apiKey == null) {
_showApiKeyDialog.value = true
return
}
_state.value = ScanState.Processing
viewModelScope.launch {
val base64 = image.toBase64Jpeg()
val result = when (provider) {
AiProvider.CLAUDE -> ClaudeOmrProcessor.analyze(base64, apiKey)
AiProvider.OPENAI -> OpenAiOmrProcessor.analyze(base64, apiKey)
}
result.fold(
onSuccess = { notes ->
_state.value = ScanState.Review(notes)
},
onFailure = { error ->
_state.value = ScanState.Error(
error.message ?: "Errore sconosciuto durante l'analisi"
)
},
)
}
}
fun removeNote(index: Int) {
val current = _state.value
if (current is ScanState.Review) {
val updated = current.notes.toMutableList().apply { removeAt(index) }
_state.value = ScanState.Review(updated)
}
}
fun retryCamera() {
_state.value = ScanState.Camera
}
}
@@ -0,0 +1,117 @@
package com.notemusicali.ui.stats
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.sp
@Composable
fun LineChart(
data: List<Float>,
labels: List<String> = emptyList(),
lineColor: Color = Color(0xFF90CAF9),
fillColor: Color = lineColor.copy(alpha = 0.15f),
modifier: Modifier = Modifier,
yAxisLabel: String = "",
) {
val textMeasurer = rememberTextMeasurer()
Canvas(modifier = modifier) {
if (data.isEmpty()) return@Canvas
val paddingLeft = 40f
val paddingBottom = 30f
val paddingTop = 16f
val paddingRight = 16f
val chartWidth = size.width - paddingLeft - paddingRight
val chartHeight = size.height - paddingTop - paddingBottom
val maxVal = data.max().coerceAtLeast(1f)
val minVal = 0f
// Draw grid lines
val gridColor = Color.White.copy(alpha = 0.1f)
for (i in 0..4) {
val y = paddingTop + chartHeight * (1f - i / 4f)
drawLine(gridColor, Offset(paddingLeft, y), Offset(size.width - paddingRight, y))
val labelVal = (minVal + (maxVal - minVal) * i / 4f).toInt().toString()
val result = textMeasurer.measure(
text = labelVal,
style = TextStyle(fontSize = 9.sp, color = Color.White.copy(alpha = 0.4f)),
)
drawText(result, topLeft = Offset(paddingLeft - result.size.width - 4f, y - result.size.height / 2f))
}
// Draw data points and line
if (data.size == 1) {
val x = paddingLeft + chartWidth / 2
val y = paddingTop + chartHeight * (1f - (data[0] - minVal) / (maxVal - minVal))
drawCircle(lineColor, radius = 4f, center = Offset(x, y))
return@Canvas
}
val stepX = chartWidth / (data.size - 1).coerceAtLeast(1)
val points = data.mapIndexed { index, value ->
val x = paddingLeft + stepX * index
val normalized = ((value - minVal) / (maxVal - minVal)).coerceIn(0f, 1f)
val y = paddingTop + chartHeight * (1f - normalized)
Offset(x, y)
}
// Fill area
val fillPath = Path().apply {
moveTo(points.first().x, paddingTop + chartHeight)
points.forEach { lineTo(it.x, it.y) }
lineTo(points.last().x, paddingTop + chartHeight)
close()
}
drawPath(fillPath, fillColor, style = Fill)
// Line
val linePath = Path().apply {
moveTo(points.first().x, points.first().y)
for (i in 1 until points.size) {
lineTo(points[i].x, points[i].y)
}
}
drawPath(linePath, lineColor, style = Stroke(width = 2f))
// Points
points.forEach { point ->
drawCircle(lineColor, radius = 3f, center = point)
}
// X-axis labels
if (labels.isNotEmpty()) {
val labelStep = maxOf(1, labels.size / 6)
labels.forEachIndexed { index, label ->
if (index % labelStep == 0 || index == labels.lastIndex) {
val x = paddingLeft + stepX * index
val result = textMeasurer.measure(
text = label,
style = TextStyle(fontSize = 8.sp, color = Color.White.copy(alpha = 0.4f)),
)
drawText(result, topLeft = Offset(x - result.size.width / 2f, paddingTop + chartHeight + 4f))
}
}
}
// Y-axis label
if (yAxisLabel.isNotEmpty()) {
val result = textMeasurer.measure(
text = yAxisLabel,
style = TextStyle(fontSize = 9.sp, color = Color.White.copy(alpha = 0.4f)),
)
drawText(result, topLeft = Offset(0f, paddingTop - result.size.height - 2f))
}
}
}
@@ -0,0 +1,179 @@
package com.notemusicali.ui.stats
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.history.SessionRepository
import com.notemusicali.stats.StatsCalculator
import com.notemusicali.ui.components.BackTopBar
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun StatsScreen(onBack: () -> Unit) {
val sessions = remember { SessionRepository.load() }
val overall = remember(sessions) { StatsCalculator.calculateOverall(sessions) }
val daily = remember(sessions) { StatsCalculator.calculateDaily(sessions) }
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(modifier = Modifier.widthIn(max = 600.dp)) {
BackTopBar(title = stringResource(Res.string.stats_title), onBack = onBack)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
) {
// Summary cards
Text(stringResource(Res.string.summary), fontSize = 20.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
SummaryCard(
label = stringResource(Res.string.sessions),
value = "${overall.totalSessions}",
modifier = Modifier.weight(1f),
)
SummaryCard(
label = stringResource(Res.string.sessions_completed),
value = "${overall.completedSessions}",
modifier = Modifier.weight(1f),
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
val totalMin = overall.totalPracticeTimeMs / 60_000
SummaryCard(
label = stringResource(Res.string.practice_time),
value = if (totalMin < 60) "${totalMin}m" else "${totalMin / 60}h ${totalMin % 60}m",
modifier = Modifier.weight(1f),
)
SummaryCard(
label = stringResource(Res.string.best_accuracy),
value = "${overall.bestAccuracyCents.toInt()} cents",
modifier = Modifier.weight(1f),
)
}
if (daily.isNotEmpty()) {
Spacer(modifier = Modifier.height(28.dp))
// Cents accuracy chart
ChartSection(
title = stringResource(Res.string.accuracy_cents),
data = daily.map { it.averageCents },
labels = daily.map { it.dateLabel },
lineColor = Color(0xFF4CAF50),
yAxisLabel = stringResource(Res.string.cents),
)
Spacer(modifier = Modifier.height(24.dp))
// First attempt rate chart
ChartSection(
title = stringResource(Res.string.first_attempt_pct),
data = daily.map { it.firstAttemptRate * 100f },
labels = daily.map { it.dateLabel },
lineColor = Color(0xFF2196F3),
yAxisLabel = stringResource(Res.string.pct),
)
Spacer(modifier = Modifier.height(24.dp))
// Reaction time chart
ChartSection(
title = stringResource(Res.string.reaction_time_ms),
data = daily.map { it.averageReactionMs.toFloat() },
labels = daily.map { it.dateLabel },
lineColor = Color(0xFFFF9800),
yAxisLabel = stringResource(Res.string.ms),
)
} else {
Spacer(modifier = Modifier.height(48.dp))
Text(
stringResource(Res.string.no_stats_yet),
fontSize = 16.sp,
color = Color.White.copy(alpha = 0.5f),
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
@Composable
private fun SummaryCard(label: String, value: String, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.08f))
.padding(16.dp),
) {
Column {
Text(label, fontSize = 12.sp, color = Color.White.copy(alpha = 0.5f))
Text(value, fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Color.White)
}
}
}
@Composable
private fun ChartSection(
title: String,
data: List<Float>,
labels: List<String>,
lineColor: Color,
yAxisLabel: String,
) {
Text(title, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.05f))
.padding(12.dp),
) {
LineChart(
data = data,
labels = labels,
lineColor = lineColor,
modifier = Modifier.fillMaxSize(),
yAxisLabel = yAxisLabel,
)
}
}
@@ -0,0 +1,107 @@
package com.notemusicali.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
// Remotion video-inspired palette
object AppColors {
val bgDark = Color(0xFF0A0118)
val bgPurple = Color(0xFF1A0533)
val bgIndigo = Color(0xFF2D1B69)
val accent = Color(0xFFFFD700) // Gold
val accentWarm = Color(0xFFFFA500) // Orange-gold
val accentRose = Color(0xFFFF6B9D) // Rose
val text = Color.White
val textMuted = Color.White.copy(alpha = 0.6f)
val cardBg = Color.White.copy(alpha = 0.06f)
val cardBorder = Color.White.copy(alpha = 0.12f)
val noteGlow = Color(0x66FFD700) // Gold glow
}
private val DarkColorScheme = darkColorScheme(
primary = AppColors.accent,
secondary = AppColors.accentRose,
tertiary = Color(0xFF4ECDC4),
background = AppColors.bgDark,
surface = AppColors.bgPurple,
onPrimary = Color.Black,
onSecondary = Color.Black,
onTertiary = Color.Black,
onBackground = Color.White,
onSurface = Color.White,
surfaceVariant = AppColors.bgIndigo,
)
object CardGradients {
// Home cards — glass-like with subtle colored tints on dark purple base
val tuner = Brush.linearGradient(listOf(Color(0xFF1A1040), Color(0xFF2D1B69)))
val practice = Brush.linearGradient(listOf(Color(0xFF0D1F2D), Color(0xFF1A0533)))
val exercises = Brush.linearGradient(listOf(Color(0xFF2A1020), Color(0xFF1A0533)))
val history = Brush.linearGradient(listOf(Color(0xFF1A0533), Color(0xFF0A0118)))
val scores = Brush.linearGradient(listOf(Color(0xFF0D1A2D), Color(0xFF1A0533)))
val scoreFile = Brush.linearGradient(listOf(Color(0xFF151025), Color(0xFF0A0118)))
val metronome = Brush.linearGradient(listOf(Color(0xFF1E1035), Color(0xFF0A0118)))
val reference = Brush.linearGradient(listOf(Color(0xFF0D1F33), Color(0xFF1A0533)))
val scales = Brush.linearGradient(listOf(Color(0xFF101E15), Color(0xFF1A0533)))
val earTraining = Brush.linearGradient(listOf(Color(0xFF2D1050), Color(0xFF1A0533)))
val challenge = Brush.linearGradient(listOf(Color(0xFF2A0D1A), Color(0xFF1A0533)))
val stats = Brush.linearGradient(listOf(Color(0xFF0D1533), Color(0xFF1A0533)))
val goals = Brush.linearGradient(listOf(Color(0xFF2A1A05), Color(0xFF1A0533)))
val leaderboard = Brush.linearGradient(listOf(Color(0xFF151040), Color(0xFF1A0533)))
val guide = Brush.linearGradient(listOf(Color(0xFF151525), Color(0xFF0A0118)))
// Practice setup
val setup = Brush.linearGradient(listOf(Color(0xFF0D1F2D), Color(0xFF1A0533)))
// Exercise levels — subtle colored tints
val level1 = Brush.linearGradient(listOf(Color(0xFF0D2015), Color(0xFF1A0533)))
val level2 = Brush.linearGradient(listOf(Color(0xFF0D1533), Color(0xFF1A0533)))
val level3 = Brush.linearGradient(listOf(Color(0xFF2A1A05), Color(0xFF1A0533)))
val level4 = Brush.linearGradient(listOf(Color(0xFF2A0D15), Color(0xFF1A0533)))
fun forLevel(level: Int): Brush = when (level) {
1 -> level1
2 -> level2
3 -> level3
else -> level4
}
}
// Accent colors for each card category (used for top accent lines and icon tints)
object CardAccents {
val tuner = Color(0xFF4ECDC4)
val metronome = Color(0xFFFFD700)
val reference = Color(0xFF90CAF9)
val practice = Color(0xFF4CAF50)
val exercises = Color(0xFFFF6B9D)
val scales = Color(0xFF66BB6A)
val scores = Color(0xFF4DD0E1)
val earTraining = Color(0xFFCE93D8)
val challenge = Color(0xFFEF5350)
val leaderboard = Color(0xFF7986CB)
val stats = Color(0xFF42A5F5)
val goals = Color(0xFFFFA726)
val history = Color(0xFFAB47BC)
val guide = Color(0xFF78909C)
}
object TunerColors {
val inTune = Color(0xFF4CAF50)
val outOfTune = Color(0xFFF44336)
val silence = Color(0xFF424242)
val inTuneBackground = Color(0xFF0D200D)
val outOfTuneBackground = Color(0xFF2A0D0D)
val silenceBackground = AppColors.bgDark
}
@Composable
fun NoteMusicaliTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = DarkColorScheme,
content = content,
)
}
@@ -0,0 +1,283 @@
package com.notemusicali.ui.tuner
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.notemusicali.audio.InstrumentPreset
import com.notemusicali.ui.components.BackTopBar
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
import com.notemusicali.ui.components.KeepScreenOn
import com.notemusicali.ui.theme.TunerColors
@Composable
fun TunerScreen(
onBack: () -> Unit,
viewModel: TunerViewModel = viewModel { TunerViewModel() },
) {
val state by viewModel.uiState.collectAsState()
KeepScreenOn()
DisposableEffect(Unit) {
viewModel.startListening()
onDispose { viewModel.stopListening() }
}
val backgroundColor by animateColorAsState(
targetValue = when (state.tuningState) {
TunerViewModel.TuningState.IN_TUNE -> TunerColors.inTuneBackground
TunerViewModel.TuningState.OUT_OF_TUNE -> TunerColors.outOfTuneBackground
TunerViewModel.TuningState.SILENCE -> TunerColors.silenceBackground
},
animationSpec = tween(durationMillis = 300),
label = "bgColor",
)
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
.background(
Brush.verticalGradient(
listOf(Color.Transparent, Color.Black.copy(alpha = 0.4f)),
)
),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier.widthIn(max = 600.dp).fillMaxHeight(),
) {
BackTopBar(title = stringResource(Res.string.tuner_title), onBack = onBack)
// Instrument selector
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
InstrumentPreset.entries.forEach { preset ->
FilterChip(
selected = state.instrument == preset,
onClick = { viewModel.selectInstrument(preset) },
label = { Text(preset.displayName, fontSize = 13.sp) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = Color.White.copy(alpha = 0.25f),
selectedLabelColor = Color.White,
),
)
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
) {
// Note name with glow background
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.clip(RoundedCornerShape(24.dp))
.background(Color.White.copy(alpha = 0.08f))
.padding(horizontal = 48.dp, vertical = 16.dp),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = if (state.noteName.isNotEmpty()) state.noteName else "---",
fontSize = 96.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
textAlign = TextAlign.Center,
)
if (state.noteName.isNotEmpty()) {
Text(
text = stringResource(Res.string.octave_n, state.octave.toString()),
fontSize = 24.sp,
color = Color.White.copy(alpha = 0.7f),
)
}
}
}
Spacer(modifier = Modifier.height(32.dp))
// Cents gauge
CentsGauge(
cents = state.centsDeviation,
isActive = state.tuningState != TunerViewModel.TuningState.SILENCE,
modifier = Modifier
.fillMaxWidth()
.height(60.dp),
)
Spacer(modifier = Modifier.height(24.dp))
// Frequency and cents info
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
if (state.frequency > 0f) {
Text(
text = "${(state.frequency * 10).toInt() / 10.0} Hz",
fontSize = 20.sp,
color = Color.White.copy(alpha = 0.6f),
)
}
if (state.tuningState != TunerViewModel.TuningState.SILENCE && state.frequency > 0f) {
Text(
text = " \u00B7 ",
fontSize = 20.sp,
color = Color.White.copy(alpha = 0.3f),
)
val sign = if (state.centsDeviation >= 0) "+" else ""
Text(
text = "${sign}${state.centsDeviation.toInt()} cents",
fontSize = 20.sp,
color = Color.White.copy(alpha = 0.6f),
)
}
}
}
}
}
}
}
@Composable
private fun CentsGauge(
cents: Float,
isActive: Boolean,
modifier: Modifier = Modifier,
) {
val indicatorColor by animateColorAsState(
targetValue = when {
!isActive -> TunerColors.silence
kotlin.math.abs(cents) <= 15f -> TunerColors.inTune
else -> TunerColors.outOfTune
},
animationSpec = tween(durationMillis = 200),
label = "gaugeColor",
)
Canvas(modifier = modifier) {
val centerX = size.width / 2f
val centerY = size.height / 2f
val gaugeWidth = size.width * 0.8f
val gaugeLeft = centerX - gaugeWidth / 2f
// Background track
drawLine(
color = Color.White.copy(alpha = 0.15f),
start = Offset(gaugeLeft, centerY),
end = Offset(gaugeLeft + gaugeWidth, centerY),
strokeWidth = 8f,
cap = StrokeCap.Round,
)
// In-tune zone highlight
val zoneWidth = gaugeWidth * 15f / 50f
drawLine(
color = TunerColors.inTune.copy(alpha = 0.2f),
start = Offset(centerX - zoneWidth, centerY),
end = Offset(centerX + zoneWidth, centerY),
strokeWidth = 8f,
cap = StrokeCap.Round,
)
// Center tick mark
drawLine(
color = Color.White.copy(alpha = 0.6f),
start = Offset(centerX, centerY - 20f),
end = Offset(centerX, centerY + 20f),
strokeWidth = 2f,
)
// Zone boundary markers
drawLine(
color = Color.White.copy(alpha = 0.3f),
start = Offset(centerX - zoneWidth, centerY - 12f),
end = Offset(centerX - zoneWidth, centerY + 12f),
strokeWidth = 1f,
)
drawLine(
color = Color.White.copy(alpha = 0.3f),
start = Offset(centerX + zoneWidth, centerY - 12f),
end = Offset(centerX + zoneWidth, centerY + 12f),
strokeWidth = 1f,
)
// Indicator with glow
if (isActive) {
val clamped = cents.coerceIn(-50f, 50f)
val indicatorX = centerX + (clamped / 50f) * (gaugeWidth / 2f)
// Glow ring
drawCircle(
color = indicatorColor.copy(alpha = 0.3f),
radius = 20f,
center = Offset(indicatorX, centerY),
)
// Main indicator
drawCircle(
color = indicatorColor,
radius = 14f,
center = Offset(indicatorX, centerY),
)
// Inner highlight
drawCircle(
color = Color.White.copy(alpha = 0.3f),
radius = 6f,
center = Offset(indicatorX - 2f, centerY - 2f),
)
}
}
}
@@ -0,0 +1,104 @@
package com.notemusicali.ui.tuner
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.notemusicali.audio.AudioProcessor
import com.notemusicali.audio.InstrumentPreset
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.launch
import kotlin.math.abs
class TunerViewModel : ViewModel() {
private val audioProcessor = AudioProcessor()
companion object {
private const val HOLD_TIME_MS = 1500L
}
enum class TuningState {
SILENCE,
IN_TUNE,
OUT_OF_TUNE,
}
data class TunerUiState(
val noteName: String = "",
val octave: Int = 0,
val frequency: Float = 0f,
val centsDeviation: Float = 0f,
val confidence: Float = 0f,
val tuningState: TuningState = TuningState.SILENCE,
val instrument: InstrumentPreset = InstrumentPreset.VIOLINO,
)
private val _uiState = MutableStateFlow(TunerUiState())
val uiState: StateFlow<TunerUiState> = _uiState.asStateFlow()
private var isListening = false
private var silenceJob: Job? = null
fun selectInstrument(preset: InstrumentPreset) {
val wasListening = isListening
if (wasListening) stopListening()
audioProcessor.updatePreset(preset)
_uiState.value = TunerUiState(instrument = preset)
if (wasListening) startListening()
}
fun startListening() {
if (isListening) return
isListening = true
audioProcessor.start(viewModelScope)
viewModelScope.launch {
audioProcessor.result.collect { result ->
when (result) {
is AudioProcessor.ProcessedResult.Detected -> {
silenceJob?.cancel()
silenceJob = null
val noteResult = result.noteResult
val isInTune = abs(noteResult.centsDeviation) <= 15f
_uiState.value = _uiState.value.copy(
noteName = noteResult.note.displayName,
octave = noteResult.note.octave,
frequency = noteResult.frequency,
centsDeviation = noteResult.centsDeviation,
confidence = result.confidence,
tuningState = if (isInTune) TuningState.IN_TUNE else TuningState.OUT_OF_TUNE,
)
}
is AudioProcessor.ProcessedResult.Silence,
is AudioProcessor.ProcessedResult.NoNote -> {
if (silenceJob == null && _uiState.value.noteName.isNotEmpty()) {
silenceJob = viewModelScope.launch {
delay(HOLD_TIME_MS)
_uiState.value = _uiState.value.copy(
noteName = "",
tuningState = TuningState.SILENCE,
)
}
}
}
}
}
}
}
fun stopListening() {
isListening = false
silenceJob?.cancel()
silenceJob = null
audioProcessor.stop()
_uiState.value = TunerUiState()
}
override fun onCleared() {
super.onCleared()
stopListening()
}
}
@@ -0,0 +1,8 @@
package com.notemusicali.util
/**
* Platform-specific crash handler for logging uncaught exceptions.
*/
expect object CrashHandler {
fun install()
}
@@ -0,0 +1,8 @@
package com.notemusicali.util
expect fun currentTimeMillis(): Long
expect fun formatEpochDate(epochMillis: Long): String
/** Returns current date as "YYYYMMDD" string. */
expect fun currentDateString(): String
@@ -0,0 +1,75 @@
package com.notemusicali.audio
import kotlin.math.abs
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class NoteConverterTest {
@Test
fun a4at440Hz() {
val result = NoteConverter.frequencyToNote(440f)
assertEquals("LA", result.note.displayName)
assertEquals(4, result.note.octave)
assertEquals(69, result.note.midiNumber)
assertTrue(abs(result.centsDeviation) < 1f, "Cents deviation should be near 0")
}
@Test
fun c4do4at261_63Hz() {
val result = NoteConverter.frequencyToNote(261.63f)
assertEquals("DO", result.note.displayName)
assertEquals(4, result.note.octave)
assertEquals(60, result.note.midiNumber)
assertTrue(abs(result.centsDeviation) < 2f, "Cents deviation should be near 0")
}
@Test
fun g3sol3at196HzViolinOpenString() {
val result = NoteConverter.frequencyToNote(196f)
assertEquals("SOL", result.note.displayName)
assertEquals(3, result.note.octave)
}
@Test
fun d4re4at293_66Hz() {
val result = NoteConverter.frequencyToNote(293.66f)
assertEquals("RE", result.note.displayName)
assertEquals(4, result.note.octave)
}
@Test
fun e5mi5at659_26HzViolinEString() {
val result = NoteConverter.frequencyToNote(659.26f)
assertEquals("MI", result.note.displayName)
assertEquals(5, result.note.octave)
}
@Test
fun slightlySharpA4at442HzShowsPositiveCents() {
val result = NoteConverter.frequencyToNote(442f)
assertEquals("LA", result.note.displayName)
assertTrue(result.centsDeviation > 0f, "Should be sharp (positive cents)")
}
@Test
fun slightlyFlatA4at438HzShowsNegativeCents() {
val result = NoteConverter.frequencyToNote(438f)
assertEquals("LA", result.note.displayName)
assertTrue(result.centsDeviation < 0f, "Should be flat (negative cents)")
}
@Test
fun midiToFrequencyAndFrequencyToNoteRoundtrip() {
for (midi in 48..84) {
val freq = NoteConverter.midiToFrequency(midi)
val result = NoteConverter.frequencyToNote(freq)
assertEquals(midi, result.note.midiNumber, "MIDI $midi should roundtrip")
assertTrue(
abs(result.centsDeviation) < 0.1f,
"Cents should be near 0 for exact MIDI frequency",
)
}
}
}
@@ -0,0 +1,94 @@
package com.notemusicali.audio
import kotlin.math.PI
import kotlin.math.sin
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class PitchDetectorTest {
private lateinit var detector: PitchDetector
private val sampleRate = 44100
private val bufferSize = 2048
@BeforeTest
fun setUp() {
detector = PitchDetector(
sampleRate = sampleRate,
bufferSize = bufferSize,
threshold = 0.15f,
)
}
private fun generateSineWave(frequency: Float, amplitude: Float = 0.8f): FloatArray {
return FloatArray(bufferSize) { i ->
(amplitude * sin(2.0 * PI * frequency * i / sampleRate)).toFloat()
}
}
@Test
fun detectA4at440Hz() {
repeat(3) { detector.detect(generateSineWave(440f)) }
val result = detector.detect(generateSineWave(440f))
assertTrue(result.isValid, "Should detect a valid pitch")
assertEquals(440f, result.frequency, 5f)
}
@Test
fun detectA3at220Hz() {
repeat(3) { detector.detect(generateSineWave(220f)) }
val result = detector.detect(generateSineWave(220f))
assertTrue(result.isValid, "Should detect a valid pitch")
assertEquals(220f, result.frequency, 5f)
}
@Test
fun detectE5at659HzViolinOpenString() {
repeat(3) { detector.detect(generateSineWave(659.26f)) }
val result = detector.detect(generateSineWave(659.26f))
assertTrue(result.isValid, "Should detect a valid pitch")
assertEquals(659.26f, result.frequency, 10f)
}
@Test
fun detectG3at196HzViolinLowestOpenString() {
repeat(3) { detector.detect(generateSineWave(196f)) }
val result = detector.detect(generateSineWave(196f))
assertTrue(result.isValid, "Should detect a valid pitch")
assertEquals(196f, result.frequency, 5f)
}
@Test
fun detectD4at293HzViolinOpenString() {
repeat(3) { detector.detect(generateSineWave(293.66f)) }
val result = detector.detect(generateSineWave(293.66f))
assertTrue(result.isValid, "Should detect a valid pitch")
assertEquals(293.66f, result.frequency, 5f)
}
@Test
fun silenceReturnsInvalidResult() {
val silence = FloatArray(bufferSize) { 0f }
val result = detector.detect(silence)
assertFalse(result.isValid, "Should not detect a pitch in silence")
assertEquals(0f, result.frequency, 0.01f)
}
@Test
fun veryLowAmplitudeReturnsSilence() {
val quiet = generateSineWave(440f, amplitude = 0.001f)
val result = detector.detect(quiet)
assertFalse(result.isValid, "Very quiet signal should not be detected")
}
@Test
fun resetClearsMedianFilter() {
repeat(3) { detector.detect(generateSineWave(440f)) }
detector.reset()
detector.detect(generateSineWave(440f))
assertTrue(true)
}
}
@@ -0,0 +1,23 @@
package com.notemusicali.audio
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class PitchResultTest {
@Test
fun `result emitted by detector is valid regardless of its confidence value`() {
// detect() only emits frequency > 0 for results that already passed the
// detector's configurable confidenceThreshold, so a custom-threshold
// detector (e.g. 0.5) must not be silently re-filtered by a hardcoded 0.7
val result = PitchDetector.PitchResult(frequency = 440f, confidence = 0.5f, rmsAmplitude = 0.1f)
assertTrue(result.isValid)
}
@Test
fun `zero frequency result is not valid`() {
val result = PitchDetector.PitchResult(frequency = 0f, confidence = 0f, rmsAmplitude = 0.001f)
assertFalse(result.isValid)
}
}
@@ -0,0 +1,27 @@
package com.notemusicali.challenge
import kotlin.test.Test
import kotlin.test.assertEquals
class ChallengeScoringTest {
@Test
fun `accuracy is ratio of first-attempt notes over notes played`() {
assertEquals(0.7f, ChallengeScoring.accuracy(notesPlayed = 10, firstAttemptNotes = 7))
}
@Test
fun `accuracy is 1 when every note was right on first attempt`() {
assertEquals(1.0f, ChallengeScoring.accuracy(notesPlayed = 5, firstAttemptNotes = 5))
}
@Test
fun `accuracy is 0 when no notes were played`() {
assertEquals(0f, ChallengeScoring.accuracy(notesPlayed = 0, firstAttemptNotes = 0))
}
@Test
fun `accuracy never exceeds 1 even with inconsistent counters`() {
assertEquals(1.0f, ChallengeScoring.accuracy(notesPlayed = 3, firstAttemptNotes = 4))
}
}
@@ -0,0 +1,64 @@
package com.notemusicali.metronome
import com.notemusicali.audio.ToneGenerator
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import kotlin.math.abs
import kotlin.math.roundToLong
import kotlin.test.Test
import kotlin.test.assertTrue
class MetronomeEngineTest {
private class RecordingToneGenerator(private val nowMs: () -> Long) : ToneGenerator {
val clickTimes = mutableListOf<Long>()
override fun playTone(frequencyHz: Float, durationMs: Long) {}
override fun playClick(accent: Boolean) {
clickTimes.add(nowMs())
}
override fun stop() {}
override fun release() {}
}
@Test
fun `clicks stay on absolute time grid without drift`() = runTest {
val clock = { testScheduler.currentTime }
val generator = RecordingToneGenerator(clock)
val engine = MetronomeEngine(toneGenerator = generator, nowMs = clock)
engine.bpm = 137 // 60000/137 = 437.956ms: non-integer interval exposes quantization drift
engine.start(backgroundScope)
advanceTimeBy(60_001)
engine.stop()
assertTrue(generator.clickTimes.size in 137..139, "expected ~137 beats in 1 min, got ${generator.clickTimes.size}")
generator.clickTimes.forEachIndexed { k, t ->
val expected = (k * 60_000.0 / 137).roundToLong()
assertTrue(
abs(t - expected) <= 1,
"beat $k fired at ${t}ms, expected ~${expected}ms (drift ${t - expected}ms)",
)
}
}
@Test
fun `bpm change mid-run reschedules subsequent beats`() = runTest {
val clock = { testScheduler.currentTime }
val generator = RecordingToneGenerator(clock)
val engine = MetronomeEngine(toneGenerator = generator, nowMs = clock)
engine.bpm = 60
engine.start(backgroundScope)
advanceTimeBy(2_001) // beats at 0, 1000, 2000
engine.bpm = 120
advanceTimeBy(2_000) // beats every 500ms from 2500 (approximately)
engine.stop()
val afterChange = generator.clickTimes.filter { it > 2_000 }
assertTrue(afterChange.size >= 3, "expected faster beats after bpm change, got $afterChange")
val intervals = afterChange.zipWithNext { a, b -> b - a }
intervals.forEach { interval ->
assertTrue(abs(interval - 500) <= 1, "expected ~500ms interval after bpm change, got $interval")
}
}
}
@@ -0,0 +1,68 @@
package com.notemusicali.music
import com.notemusicali.scan.NoteParser
import com.notemusicali.ui.components.StaffLayout
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class FlatNotationTest {
@Test
fun `note built with flat preference displays the flat name`() {
val note = MusicalNote.fromNameAndOctave(3, 4, preferFlat = true)
assertEquals("MIb", note.displayName)
assertEquals("MIb4", note.fullName)
}
@Test
fun `flat preference on a natural note falls back to the natural name`() {
val note = MusicalNote.fromNameAndOctave(4, 4, preferFlat = true)
assertEquals("MI", note.displayName)
}
@Test
fun `default preference stays sharp`() {
assertEquals("RE#", MusicalNote.fromNameAndOctave(3, 4).displayName)
}
@Test
fun `musicxml negative alter produces a flat note`() {
val xml = """
<score-partwise><part id="P1"><measure number="1">
<note><pitch><step>B</step><alter>-1</alter><octave>3</octave></pitch><duration>4</duration></note>
</measure></part></score-partwise>
""".trimIndent()
val sequence = MusicXmlParser.parse(xml)
assertEquals(1, sequence.notes.size)
assertEquals("SIb", sequence.notes[0].displayName)
assertEquals(10, sequence.notes[0].noteIndex)
}
@Test
fun `scanned note with b suffix produces a flat note`() {
val notes = NoteParser.parseNotes("Bb3\nC#4")
assertEquals(2, notes.size)
assertEquals("SIb", notes[0].displayName)
assertEquals("DO#", notes[1].displayName)
}
@Test
fun `flat note sits on the line of the natural above it`() {
val eFlat = MusicalNote.fromNameAndOctave(3, 4, preferFlat = true)
val eNatural = MusicalNote.fromNameAndOctave(4, 4)
assertEquals(StaffLayout.diatonicPosition(eNatural), StaffLayout.diatonicPosition(eFlat))
val dSharp = MusicalNote.fromNameAndOctave(3, 4)
val dNatural = MusicalNote.fromNameAndOctave(2, 4)
assertEquals(StaffLayout.diatonicPosition(dNatural), StaffLayout.diatonicPosition(dSharp))
}
@Test
fun `accidental kind follows the note preference`() {
assertEquals(StaffLayout.Accidental.SHARP, StaffLayout.accidentalFor(MusicalNote.fromNameAndOctave(3, 4)))
assertEquals(StaffLayout.Accidental.FLAT, StaffLayout.accidentalFor(MusicalNote.fromNameAndOctave(3, 4, preferFlat = true)))
assertNull(StaffLayout.accidentalFor(MusicalNote.fromNameAndOctave(0, 4)))
}
}
@@ -0,0 +1,112 @@
package com.notemusicali.music
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class ItalianNotationTest {
@Test
fun all12NotesAreMapped() {
val notes = ItalianNotation.allNotes()
assertEquals(12, notes.size)
}
@Test
fun noteIndex0IsDo() {
assertEquals("DO", ItalianNotation.fromNoteIndex(0).sharp)
}
@Test
fun noteIndex9IsLa() {
assertEquals("LA", ItalianNotation.fromNoteIndex(9).sharp)
}
@Test
fun noteIndex11IsSi() {
assertEquals("SI", ItalianNotation.fromNoteIndex(11).sharp)
}
@Test
fun sharpAndFlatVariantsExistForAccidentals() {
val cSharp = ItalianNotation.fromNoteIndex(1)
assertEquals("DO#", cSharp.sharp)
assertEquals("REb", cSharp.flat)
val fSharp = ItalianNotation.fromNoteIndex(6)
assertEquals("FA#", fSharp.sharp)
assertEquals("SOLb", fSharp.flat)
}
@Test
fun naturalNotesHaveNoFlatVariant() {
assertNull(ItalianNotation.fromNoteIndex(0).flat)
assertNull(ItalianNotation.fromNoteIndex(2).flat)
assertNull(ItalianNotation.fromNoteIndex(4).flat)
assertNull(ItalianNotation.fromNoteIndex(5).flat)
assertNull(ItalianNotation.fromNoteIndex(7).flat)
assertNull(ItalianNotation.fromNoteIndex(9).flat)
assertNull(ItalianNotation.fromNoteIndex(11).flat)
}
@Test
fun toNoteIndexParsesSharpNames() {
assertEquals(0, ItalianNotation.toNoteIndex("DO"))
assertEquals(1, ItalianNotation.toNoteIndex("DO#"))
assertEquals(7, ItalianNotation.toNoteIndex("SOL"))
assertEquals(9, ItalianNotation.toNoteIndex("LA"))
}
@Test
fun toNoteIndexParsesFlatNames() {
assertEquals(1, ItalianNotation.toNoteIndex("REb"))
assertEquals(3, ItalianNotation.toNoteIndex("MIb"))
assertEquals(8, ItalianNotation.toNoteIndex("LAb"))
assertEquals(10, ItalianNotation.toNoteIndex("SIb"))
}
@Test
fun toNoteIndexIsCaseInsensitive() {
assertEquals(0, ItalianNotation.toNoteIndex("do"))
assertEquals(7, ItalianNotation.toNoteIndex("sol"))
assertEquals(1, ItalianNotation.toNoteIndex("do#"))
}
@Test
fun toNoteIndexReturnsNullForInvalidNames() {
assertNull(ItalianNotation.toNoteIndex("XYZ"))
assertNull(ItalianNotation.toNoteIndex(""))
}
@Test
fun fromMusicXmlStepMapsCorrectly() {
assertEquals(0, ItalianNotation.fromMusicXmlStep("C"))
assertEquals(2, ItalianNotation.fromMusicXmlStep("D"))
assertEquals(4, ItalianNotation.fromMusicXmlStep("E"))
assertEquals(5, ItalianNotation.fromMusicXmlStep("F"))
assertEquals(7, ItalianNotation.fromMusicXmlStep("G"))
assertEquals(9, ItalianNotation.fromMusicXmlStep("A"))
assertEquals(11, ItalianNotation.fromMusicXmlStep("B"))
}
@Test
fun fromMusicXmlStepWithAlterHandlesSharpsAndFlats() {
assertEquals(1, ItalianNotation.fromMusicXmlStep("C", 1))
assertEquals(11, ItalianNotation.fromMusicXmlStep("C", -1))
assertEquals(6, ItalianNotation.fromMusicXmlStep("G", -1))
}
@Test
fun fromMusicXmlStepReturnsNullForInvalidStep() {
assertNull(ItalianNotation.fromMusicXmlStep("X"))
}
@Test
fun indexWrapsCorrectly() {
assertEquals(
ItalianNotation.fromNoteIndex(0).sharp,
ItalianNotation.fromNoteIndex(12).sharp,
)
}
}
@@ -0,0 +1,288 @@
package com.notemusicali.music
import kotlin.test.Test
import kotlin.test.assertEquals
class MusicXmlParserTest {
@Test
fun parseSimpleMusicXmlWithTwoNotes() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<score-partwise>
<part id="P1">
<measure number="1">
<note>
<pitch>
<step>C</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
</note>
<note>
<pitch>
<step>E</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
</note>
</measure>
</part>
</score-partwise>
""".trimIndent()
val sequence = MusicXmlParser.parse(xml)
assertEquals(2, sequence.notes.size)
assertEquals("DO", sequence.notes[0].displayName)
assertEquals(4, sequence.notes[0].octave)
assertEquals("MI", sequence.notes[1].displayName)
assertEquals(4, sequence.notes[1].octave)
}
@Test
fun parseMusicXmlWithSharpNotes() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<score-partwise>
<part id="P1">
<measure number="1">
<note>
<pitch>
<step>F</step>
<alter>1</alter>
<octave>4</octave>
</pitch>
<duration>4</duration>
</note>
</measure>
</part>
</score-partwise>
""".trimIndent()
val sequence = MusicXmlParser.parse(xml)
assertEquals(1, sequence.notes.size)
assertEquals("FA#", sequence.notes[0].displayName)
}
@Test
fun parseMusicXmlSkipsRests() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<score-partwise>
<part id="P1">
<measure number="1">
<note>
<pitch>
<step>A</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
</note>
<note>
<rest/>
<duration>4</duration>
</note>
<note>
<pitch>
<step>B</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
</note>
</measure>
</part>
</score-partwise>
""".trimIndent()
val sequence = MusicXmlParser.parse(xml)
assertEquals(2, sequence.notes.size)
assertEquals("LA", sequence.notes[0].displayName)
assertEquals("SI", sequence.notes[1].displayName)
}
@Test
fun parseTakesOnlyFirstPartOfMultiPartScore() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<score-partwise>
<part id="P1">
<measure number="1">
<note><pitch><step>C</step><octave>4</octave></pitch><duration>4</duration></note>
</measure>
</part>
<part id="P2">
<measure number="1">
<note><pitch><step>G</step><octave>2</octave></pitch><duration>4</duration></note>
</measure>
</part>
</score-partwise>
""".trimIndent()
val sequence = MusicXmlParser.parse(xml)
assertEquals(1, sequence.notes.size)
assertEquals("DO", sequence.notes[0].displayName)
}
@Test
fun parseKeepsOnlyFirstNoteOfChord() {
val xml = """
<score-partwise><part id="P1"><measure number="1">
<note><pitch><step>C</step><octave>4</octave></pitch><duration>4</duration></note>
<note><chord/><pitch><step>E</step><octave>4</octave></pitch><duration>4</duration></note>
<note><chord/><pitch><step>G</step><octave>4</octave></pitch><duration>4</duration></note>
<note><pitch><step>D</step><octave>4</octave></pitch><duration>4</duration></note>
</measure></part></score-partwise>
""".trimIndent()
val sequence = MusicXmlParser.parse(xml)
assertEquals(2, sequence.notes.size)
assertEquals("DO", sequence.notes[0].displayName)
assertEquals("RE", sequence.notes[1].displayName)
}
@Test
fun parseKeepsOnlyFirstVoice() {
val xml = """
<score-partwise><part id="P1"><measure number="1">
<note><pitch><step>C</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice></note>
<note><pitch><step>D</step><octave>5</octave></pitch><duration>4</duration><voice>1</voice></note>
<backup><duration>8</duration></backup>
<note><pitch><step>C</step><octave>3</octave></pitch><duration>8</duration><voice>2</voice></note>
</measure></part></score-partwise>
""".trimIndent()
val sequence = MusicXmlParser.parse(xml)
assertEquals(2, sequence.notes.size)
assertEquals(5, sequence.notes[0].octave)
assertEquals(5, sequence.notes[1].octave)
}
@Test
fun parseSkipsGraceNotes() {
val xml = """
<score-partwise><part id="P1"><measure number="1">
<note><grace/><pitch><step>B</step><octave>4</octave></pitch></note>
<note><pitch><step>C</step><octave>5</octave></pitch><duration>4</duration></note>
</measure></part></score-partwise>
""".trimIndent()
val sequence = MusicXmlParser.parse(xml)
assertEquals(1, sequence.notes.size)
assertEquals("DO", sequence.notes[0].displayName)
}
@Test
fun parseSkipsTieContinuations() {
val xml = """
<score-partwise><part id="P1"><measure number="1">
<note><pitch><step>C</step><octave>4</octave></pitch><duration>4</duration><tie type="start"/></note>
<note><pitch><step>C</step><octave>4</octave></pitch><duration>4</duration><tie type="stop"/></note>
<note><pitch><step>D</step><octave>4</octave></pitch><duration>4</duration></note>
</measure></part></score-partwise>
""".trimIndent()
val sequence = MusicXmlParser.parse(xml)
assertEquals(2, sequence.notes.size)
assertEquals("DO", sequence.notes[0].displayName)
assertEquals("RE", sequence.notes[1].displayName)
}
@Test
fun parseSkipsRestsWithAttributes() {
val xml = """
<score-partwise><part id="P1"><measure number="1">
<note><rest measure="yes"/><duration>16</duration></note>
<note><pitch><step>E</step><octave>4</octave></pitch><duration>4</duration></note>
</measure></part></score-partwise>
""".trimIndent()
val sequence = MusicXmlParser.parse(xml)
assertEquals(1, sequence.notes.size)
assertEquals("MI", sequence.notes[0].displayName)
}
@Test
fun parseDerivesDurationFromDivisionsWhenTypeIsMissing() {
val xml = """
<score-partwise><part id="P1"><measure number="1">
<attributes><divisions>2</divisions></attributes>
<note><pitch><step>C</step><octave>4</octave></pitch><duration>8</duration></note>
<note><pitch><step>D</step><octave>4</octave></pitch><duration>2</duration></note>
<note><pitch><step>E</step><octave>4</octave></pitch><duration>1</duration></note>
</measure></part></score-partwise>
""".trimIndent()
val sequence = MusicXmlParser.parse(xml)
assertEquals(3, sequence.notes.size)
assertEquals(NoteDuration.WHOLE, sequence.notes[0].duration) // 8/2 = 4 quarters
assertEquals(NoteDuration.QUARTER, sequence.notes[1].duration) // 2/2 = 1 quarter
assertEquals(NoteDuration.EIGHTH, sequence.notes[2].duration) // 1/2 = half quarter
}
@Test
fun parseExplicitTypeWinsOverDurationRatio() {
val xml = """
<score-partwise><part id="P1"><measure number="1">
<attributes><divisions>2</divisions></attributes>
<note><pitch><step>C</step><octave>4</octave></pitch><duration>8</duration><type>half</type></note>
</measure></part></score-partwise>
""".trimIndent()
val sequence = MusicXmlParser.parse(xml)
assertEquals(NoteDuration.HALF, sequence.notes[0].duration)
}
@Test
fun parseEmptyMusicXmlReturnsEmptySequence() {
val xml = """
<?xml version="1.0" encoding="UTF-8"?>
<score-partwise>
<part id="P1">
<measure number="1">
</measure>
</part>
</score-partwise>
""".trimIndent()
val sequence = MusicXmlParser.parse(xml)
assertEquals(0, sequence.notes.size)
}
@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)
}
@Test
fun parseExtractsTimeSignatureWithAttributes() {
val xml = """
<score-partwise><part id="P1"><measure number="1">
<attributes><time symbol="common"><beats>4</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(4, sequence.beats)
assertEquals(4, sequence.beatType)
}
}
@@ -0,0 +1,61 @@
package com.notemusicali.scan
import com.russhwolf.settings.MapSettings
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ApiKeyStoreTest {
@Test
fun `api key is written to the secure store when available`() {
val plain = MapSettings()
val secure = MapSettings()
val store = ApiKeyStore(plain, secure)
store.putString("anthropic_api_key", "sk-ant-secret")
assertEquals("sk-ant-secret", secure.getStringOrNull("anthropic_api_key"))
assertNull(plain.getStringOrNull("anthropic_api_key"), "the plain store must not receive the key")
assertEquals("sk-ant-secret", store.getString("anthropic_api_key"))
}
@Test
fun `legacy obfuscated key is migrated to the secure store on read`() {
val plain = MapSettings()
val legacyStore = ApiKeyStore(plain, secure = null)
legacyStore.putString("openai_api_key", "sk-legacy")
val secure = MapSettings()
val store = ApiKeyStore(plain, secure)
assertEquals("sk-legacy", store.getString("openai_api_key"))
assertEquals("sk-legacy", secure.getStringOrNull("openai_api_key"))
assertNull(plain.getStringOrNull("openai_api_key"), "legacy copy must be removed after migration")
}
@Test
fun `without a secure store keys stay obfuscated in the plain store`() {
val plain = MapSettings()
val store = ApiKeyStore(plain, secure = null)
store.putString("anthropic_api_key", "sk-fallback")
val raw = plain.getStringOrNull("anthropic_api_key")
assertTrue(raw != null && raw.startsWith("OBF:") && !raw.contains("sk-fallback"))
assertEquals("sk-fallback", store.getString("anthropic_api_key"))
}
@Test
fun `non sensitive keys stay in the plain store even when secure exists`() {
val plain = MapSettings()
val secure = MapSettings()
val store = ApiKeyStore(plain, secure)
store.putString("scores_folder_uri", "/some/path")
assertNull(secure.getStringOrNull("scores_folder_uri"))
assertEquals("/some/path", store.getString("scores_folder_uri"))
}
}
@@ -0,0 +1,62 @@
package com.notemusicali.scan
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class OmrResponseParsingTest {
@Test
fun `claude response with note list is parsed into notes`() {
val body = """{"content":[{"type":"text","text":"C4\nD#5\nBb3"}]}"""
val result = ClaudeOmrProcessor.parseResponse(body)
assertTrue(result.isSuccess, "expected success, got $result")
val notes = result.getOrThrow()
assertEquals(3, notes.size)
assertEquals("DO", notes[0].displayName)
assertEquals(4, notes[0].octave)
assertEquals("RE#", notes[1].displayName)
assertEquals("SIb", notes[2].displayName) // "Bb" keeps its flat spelling
}
@Test
fun `claude response with NESSUNA_NOTA fails with explanatory message`() {
val body = """{"content":[{"type":"text","text":"NESSUNA_NOTA"}]}"""
val result = ClaudeOmrProcessor.parseResponse(body)
assertTrue(result.isFailure)
}
@Test
fun `claude malformed response fails instead of crashing`() {
val result = ClaudeOmrProcessor.parseResponse("not json at all")
assertTrue(result.isFailure)
}
@Test
fun `openai response with note list is parsed into notes`() {
val body = """{"choices":[{"message":{"role":"assistant","content":"E4\nF#4"}}]}"""
val result = OpenAiOmrProcessor.parseResponse(body)
assertTrue(result.isSuccess, "expected success, got $result")
val notes = result.getOrThrow()
assertEquals(2, notes.size)
assertEquals("MI", notes[0].displayName)
assertEquals("FA#", notes[1].displayName)
}
@Test
fun `openai empty choices fails gracefully`() {
val result = OpenAiOmrProcessor.parseResponse("""{"choices":[]}""")
assertTrue(result.isFailure)
}
@Test
fun `error message is extracted from api error body`() {
val body = """{"error":{"type":"invalid_request_error","message":"invalid x-api-key"}}"""
assertEquals("invalid x-api-key", OmrErrorParsing.errorMessage(body, statusCode = 401))
}
@Test
fun `error message falls back to http status for unparseable body`() {
assertEquals("Errore HTTP 500", OmrErrorParsing.errorMessage("<html>oops</html>", statusCode = 500))
}
}
@@ -0,0 +1,123 @@
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.assertFalse
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))
}
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`() {
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)))
assertFalse(StaffLayout.stemUpForGroup(listOf(5, 6, 2)))
}
@Test
fun `stem direction tie goes stem up`() {
assertTrue(StaffLayout.stemUpForGroup(listOf(2, 6)))
}
@Test
fun `degenerate meter yields no beam groups`() {
assertEquals(emptyList(), StaffLayout.beamGroups(List(4) { n(NoteDuration.EIGHTH) }, beats = 0, beatType = 0))
}
@Test
fun `empty sequence has no beam groups`() {
assertEquals(emptyList(), StaffLayout.beamGroups(emptyList(), 4, 4))
}
@Test
fun `empty group defaults to stem up`() {
assertTrue(StaffLayout.stemUpForGroup(emptyList()))
}
@Test
fun `degenerate meter yields no barlines`() {
assertEquals(emptyList(), StaffLayout.measurePositions(List(4) { n(NoteDuration.QUARTER) }, beats = 0, beatType = 4))
}
@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`() {
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))
}
@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)
}
}
@@ -0,0 +1,56 @@
package com.notemusicali.ui.components
import com.notemusicali.music.MusicalNote
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class StaffLayoutTest {
private fun note(name: String, octave: Int): MusicalNote {
val index = when (name) {
"DO" -> 0; "DO#" -> 1; "RE" -> 2; "MI" -> 4; "FA" -> 5
"SOL" -> 7; "LA" -> 9; "SI" -> 11
else -> error("unknown $name")
}
return MusicalNote.fromNameAndOctave(index, octave)
}
@Test
fun `diatonic position maps chromatic notes onto seven steps per octave`() {
assertEquals(28, StaffLayout.diatonicPosition(note("DO", 4)))
assertEquals(28, StaffLayout.diatonicPosition(note("DO#", 4))) // sharp shares the line of its natural
assertEquals(34, StaffLayout.diatonicPosition(note("SI", 4)))
assertEquals(35, StaffLayout.diatonicPosition(note("DO", 5)))
}
@Test
fun `treble clef is chosen from octave 3 up and bass below`() {
assertTrue(StaffLayout.useTrebleClef(note("SOL", 3)))
assertFalse(StaffLayout.useTrebleClef(note("DO", 2)))
assertTrue(StaffLayout.useTrebleClef(listOf(note("DO", 2), note("SOL", 3))))
assertFalse(StaffLayout.useTrebleClef(listOf(note("DO", 2), note("MI", 2))))
}
@Test
fun `relative position puts E4 on the bottom line of the treble staff`() {
// E4 (MI4) sits exactly on the first line of the treble staff → relPos 0
assertEquals(0, StaffLayout.relativePosition(note("MI", 4), useTreble = true))
// G2 (SOL2) sits on the first line of the bass staff → relPos 0
assertEquals(0, StaffLayout.relativePosition(note("SOL", 2), useTreble = false))
}
@Test
fun `only black-key notes need an accidental`() {
assertTrue(StaffLayout.needsSharp(note("DO#", 4)))
assertFalse(StaffLayout.needsSharp(note("DO", 4)))
assertFalse(StaffLayout.needsSharp(note("FA", 4)))
}
@Test
fun `clef symbols are the musical unicode glyphs`() {
assertEquals("𝄞", StaffLayout.clefSymbol(useTreble = true))
assertEquals("𝄢", StaffLayout.clefSymbol(useTreble = false))
}
}
@@ -0,0 +1,62 @@
package com.notemusicali.audio
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.isActive
import javax.sound.sampled.AudioFormat
import javax.sound.sampled.AudioSystem
import javax.sound.sampled.TargetDataLine
private class DesktopAudioSource(
private val sampleRate: Int,
private val bufferSize: Int,
) : AudioSource {
override fun audioFlow(): Flow<FloatArray> = callbackFlow {
val format = AudioFormat(
sampleRate.toFloat(),
16, // 16-bit samples
1, // mono
true, // signed
false, // little-endian
)
val line: TargetDataLine = try {
val dataLineInfo = javax.sound.sampled.DataLine.Info(TargetDataLine::class.java, format)
AudioSystem.getLine(dataLineInfo) as TargetDataLine
} catch (e: Exception) {
close(IllegalStateException("No audio input available: ${e.message}"))
return@callbackFlow
}
try {
line.open(format, bufferSize * 2)
line.start()
val byteBuffer = ByteArray(bufferSize * 2) // 16-bit = 2 bytes per sample
while (isActive) {
val bytesRead = line.read(byteBuffer, 0, byteBuffer.size)
if (bytesRead > 0) {
val samplesRead = bytesRead / 2
val floatBuffer = FloatArray(samplesRead)
for (i in 0 until samplesRead) {
val low = byteBuffer[i * 2].toInt() and 0xFF
val high = byteBuffer[i * 2 + 1].toInt()
val sample = (high shl 8) or low
floatBuffer[i] = sample / 32768f
}
trySend(floatBuffer)
}
}
} finally {
line.stop()
line.close()
}
awaitClose()
}
}
actual fun createAudioSource(sampleRate: Int, bufferSize: Int): AudioSource =
DesktopAudioSource(sampleRate, bufferSize)
@@ -0,0 +1,67 @@
package com.notemusicali.audio
import javax.sound.sampled.AudioFormat
import javax.sound.sampled.AudioSystem
private class DesktopToneGenerator(
private val sampleRate: Int,
) : ToneGenerator {
private var currentLine: javax.sound.sampled.SourceDataLine? = null
private var playThread: Thread? = 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 format = AudioFormat(sampleRate.toFloat(), 16, 1, true, false)
val buffer = ByteArray(floatSamples.size * 2)
for (i in floatSamples.indices) {
val value = (floatSamples[i] * Short.MAX_VALUE).toInt()
.coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort()
buffer[i * 2] = (value.toInt() and 0xFF).toByte()
buffer[i * 2 + 1] = ((value.toInt() shr 8) and 0xFF).toByte()
}
playThread = Thread {
try {
val line = AudioSystem.getSourceDataLine(format)
line.open(format)
currentLine = line
line.start()
line.write(buffer, 0, buffer.size)
line.drain()
line.close()
} catch (_: Exception) { }
}.apply { isDaemon = true; start() }
}
override fun stop() {
currentLine?.let { line ->
try {
line.stop()
line.close()
} catch (_: Exception) { }
}
currentLine = null
playThread?.interrupt()
playThread = null
}
override fun release() {
stop()
}
}
actual fun createToneGenerator(sampleRate: Int): ToneGenerator =
DesktopToneGenerator(sampleRate)
@@ -0,0 +1,26 @@
package com.notemusicali.scan
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import javax.imageio.ImageIO
actual typealias PlatformImage = BufferedImage
actual fun PlatformImage.toBase64Jpeg(maxDim: Int, quality: Int): String {
val scaled = if (width > maxDim || height > maxDim) {
val scale = maxDim.toDouble() / maxOf(width, height)
val newW = (width * scale).toInt()
val newH = (height * scale).toInt()
val img = BufferedImage(newW, newH, BufferedImage.TYPE_INT_RGB)
val g = img.createGraphics()
g.drawImage(this, 0, 0, newW, newH, null)
g.dispose()
img
} else {
this
}
val stream = ByteArrayOutputStream()
ImageIO.write(scaled, "JPEG", stream)
return java.util.Base64.getEncoder().encodeToString(stream.toByteArray())
}
@@ -0,0 +1,6 @@
package com.notemusicali.scan
import com.russhwolf.settings.Settings
// No OS-agnostic secure store on desktop: keys stay in the obfuscated store
internal actual fun createSecureSettings(): Settings? = null
Loaded 100 of 245 files, more files were not shown because too many files have changed in this diff. Show more