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:
commit
21ad969b10
245 files changed
+28963
No files matched your search
@@ -0,0 +1,63 @@
|
||||
package com.notemusicali.audio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
private class AndroidAudioSource(
|
||||
private val sampleRate: Int,
|
||||
private val bufferSize: Int,
|
||||
) : AudioSource {
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun audioFlow(): Flow<FloatArray> = callbackFlow {
|
||||
val minBufferSize = AudioRecord.getMinBufferSize(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_FLOAT,
|
||||
)
|
||||
val recordBufferSize = maxOf(minBufferSize, bufferSize * 4)
|
||||
|
||||
val audioRecord = AudioRecord(
|
||||
MediaRecorder.AudioSource.MIC,
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_FLOAT,
|
||||
recordBufferSize,
|
||||
)
|
||||
|
||||
if (audioRecord.state != AudioRecord.STATE_INITIALIZED) {
|
||||
audioRecord.release()
|
||||
close(IllegalStateException("AudioRecord failed to initialize"))
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
audioRecord.startRecording()
|
||||
|
||||
val buffer = FloatArray(bufferSize)
|
||||
|
||||
try {
|
||||
while (isActive) {
|
||||
val read = audioRecord.read(buffer, 0, bufferSize, AudioRecord.READ_BLOCKING)
|
||||
if (read > 0) {
|
||||
trySend(buffer.copyOf(read))
|
||||
} else if (read < 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
audioRecord.stop()
|
||||
audioRecord.release()
|
||||
}
|
||||
|
||||
awaitClose()
|
||||
}
|
||||
}
|
||||
|
||||
actual fun createAudioSource(sampleRate: Int, bufferSize: Int): AudioSource =
|
||||
AndroidAudioSource(sampleRate, bufferSize)
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.notemusicali.audio
|
||||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioTrack
|
||||
|
||||
private class AndroidToneGenerator(
|
||||
private val sampleRate: Int,
|
||||
) : ToneGenerator {
|
||||
|
||||
private var audioTrack: AudioTrack? = null
|
||||
|
||||
override fun playTone(frequencyHz: Float, durationMs: Long) {
|
||||
stop()
|
||||
val floatSamples = WaveGenerator.generateToneSamples(frequencyHz, durationMs, sampleRate)
|
||||
playFloatSamples(floatSamples)
|
||||
}
|
||||
|
||||
override fun playClick(accent: Boolean) {
|
||||
stop()
|
||||
val floatSamples = WaveGenerator.generateClickSamples(accent, sampleRate)
|
||||
playFloatSamples(floatSamples)
|
||||
}
|
||||
|
||||
private fun playFloatSamples(floatSamples: FloatArray) {
|
||||
val buffer = ShortArray(floatSamples.size) { i ->
|
||||
(floatSamples[i] * Short.MAX_VALUE).toInt().coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort()
|
||||
}
|
||||
|
||||
val track = AudioTrack.Builder()
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
.build()
|
||||
)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.build()
|
||||
)
|
||||
.setBufferSizeInBytes(buffer.size * 2)
|
||||
.setTransferMode(AudioTrack.MODE_STATIC)
|
||||
.build()
|
||||
|
||||
track.write(buffer, 0, buffer.size)
|
||||
track.play()
|
||||
audioTrack = track
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
audioTrack?.let { track ->
|
||||
try {
|
||||
track.stop()
|
||||
track.release()
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
audioTrack = null
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
actual fun createToneGenerator(sampleRate: Int): ToneGenerator =
|
||||
AndroidToneGenerator(sampleRate)
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.notemusicali.scan
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Base64
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
actual typealias PlatformImage = Bitmap
|
||||
|
||||
actual fun PlatformImage.toBase64Jpeg(maxDim: Int, quality: Int): String {
|
||||
val scaled = if (width > maxDim || height > maxDim) {
|
||||
val scale = maxDim.toFloat() / maxOf(width, height)
|
||||
Bitmap.createScaledBitmap(
|
||||
this,
|
||||
(width * scale).toInt(),
|
||||
(height * scale).toInt(),
|
||||
true,
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
val stream = ByteArrayOutputStream()
|
||||
scaled.compress(Bitmap.CompressFormat.JPEG, quality, stream)
|
||||
return Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.notemusicali.scan
|
||||
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.notemusicali.scores.ScoreProviderContext
|
||||
import com.russhwolf.settings.SharedPreferencesSettings
|
||||
import com.russhwolf.settings.Settings
|
||||
|
||||
internal actual fun createSecureSettings(): Settings? {
|
||||
val context = ScoreProviderContext.appContext ?: return null
|
||||
return try {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
val prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"intono_secure_prefs",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
SharedPreferencesSettings(prefs)
|
||||
} catch (_: Exception) {
|
||||
// Keystore unavailable (rare): fall back to the obfuscated store
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.notemusicali.ui.components
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.notemusicali.scores.extractMusicXmlFromBytes
|
||||
|
||||
@Composable
|
||||
actual fun rememberFilePickerLauncher(
|
||||
mimeTypes: List<String>,
|
||||
onFileContent: (String) -> Unit,
|
||||
): () -> Unit {
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument()
|
||||
) { uri: Uri? ->
|
||||
uri?.let {
|
||||
try {
|
||||
context.contentResolver.openInputStream(it)?.use { stream ->
|
||||
val bytes = stream.readBytes()
|
||||
val content = extractMusicXmlFromBytes(bytes)
|
||||
if (content != null) {
|
||||
onFileContent(content)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// File read failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { launcher.launch(mimeTypes.toTypedArray()) }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.notemusicali.ui.components
|
||||
|
||||
import android.content.Intent
|
||||
import android.provider.DocumentsContract
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@Composable
|
||||
actual fun rememberFolderPickerLauncher(
|
||||
onFolderSelected: (uri: String, displayName: String) -> Unit,
|
||||
): () -> Unit {
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree()
|
||||
) { uri ->
|
||||
uri?.let {
|
||||
try {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
it, Intent.FLAG_GRANT_READ_URI_PERMISSION,
|
||||
)
|
||||
} catch (_: Exception) {}
|
||||
val docId = DocumentsContract.getTreeDocumentId(it)
|
||||
val name = docId
|
||||
.substringAfterLast(":")
|
||||
.substringAfterLast("/")
|
||||
.ifEmpty { "Cartella" }
|
||||
onFolderSelected(it.toString(), name)
|
||||
}
|
||||
}
|
||||
return { launcher.launch(null) }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.notemusicali.ui.components
|
||||
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
|
||||
@Composable
|
||||
actual fun KeepScreenOn() {
|
||||
val view = LocalView.current
|
||||
DisposableEffect(Unit) {
|
||||
val window = (view.context as? android.app.Activity)?.window
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
onDispose {
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.notemusicali.ui.scan
|
||||
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageCapture
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.notemusicali.scan.PlatformImage
|
||||
|
||||
@Composable
|
||||
actual fun CameraCapture(
|
||||
onImageCaptured: (PlatformImage) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val imageCapture = remember { ImageCapture.Builder().build() }
|
||||
|
||||
Box(modifier = modifier) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PreviewView(ctx).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
||||
cameraProviderFuture.addListener({
|
||||
val cameraProvider = cameraProviderFuture.get()
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.surfaceProvider = surfaceProvider
|
||||
}
|
||||
|
||||
try {
|
||||
cameraProvider.unbindAll()
|
||||
cameraProvider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||
preview,
|
||||
imageCapture,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("CameraCapture", "Camera bind failed", e)
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(ctx))
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
||||
// Capture button
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 48.dp)
|
||||
.size(72.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White)
|
||||
.border(4.dp, Color.White.copy(alpha = 0.5f), CircleShape)
|
||||
.clickable {
|
||||
imageCapture.takePicture(
|
||||
ContextCompat.getMainExecutor(context),
|
||||
object : ImageCapture.OnImageCapturedCallback() {
|
||||
override fun onCaptureSuccess(image: ImageProxy) {
|
||||
val bitmap = image.toBitmap()
|
||||
image.close()
|
||||
onImageCaptured(bitmap)
|
||||
}
|
||||
|
||||
override fun onError(exception: androidx.camera.core.ImageCaptureException) {
|
||||
Log.e("CameraCapture", "Capture failed", exception)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(60.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White)
|
||||
.border(2.dp, Color.LightGray, CircleShape),
|
||||
)
|
||||
}
|
||||
|
||||
// Hint text
|
||||
Text(
|
||||
text = "Inquadra lo spartito e scatta",
|
||||
fontSize = 14.sp,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 132.dp)
|
||||
.background(Color.Black.copy(alpha = 0.4f), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.notemusicali.util
|
||||
|
||||
import android.util.Log
|
||||
|
||||
actual object CrashHandler {
|
||||
private const val TAG = "InTono"
|
||||
|
||||
actual fun install() {
|
||||
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
Log.e(TAG, "Uncaught exception in ${thread.name}", throwable)
|
||||
defaultHandler?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.notemusicali.util
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
|
||||
|
||||
actual fun formatEpochDate(epochMillis: Long): String {
|
||||
val sdf = SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.getDefault())
|
||||
return sdf.format(Date(epochMillis))
|
||||
}
|
||||
|
||||
actual fun currentDateString(): String {
|
||||
// Machine-readable format parsed by DailyGoalManager: must stay ASCII in every locale
|
||||
val sdf = SimpleDateFormat("yyyyMMdd", Locale.US)
|
||||
return sdf.format(Date())
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<resources>
|
||||
<!-- App -->
|
||||
<string name="app_name">InTono</string>
|
||||
<string name="app_subtitle">Pitch check and practice for musicians</string>
|
||||
|
||||
<!-- Home sections -->
|
||||
<string name="section_tools">Tools</string>
|
||||
<string name="section_practice">Practice</string>
|
||||
<string name="section_challenge">Challenge</string>
|
||||
<string name="section_progress">Progress</string>
|
||||
|
||||
<!-- Home cards -->
|
||||
<string name="tuner_title">Pitch Check</string>
|
||||
<string name="tuner_desc">Check in real time if you\'re playing the correct note</string>
|
||||
<string name="metronome_title">Metronome</string>
|
||||
<string name="metronome_desc">Adjustable tempo with accents and tap tempo</string>
|
||||
<string name="reference_title">Reference Note</string>
|
||||
<string name="reference_desc">Generate any note for tuning or ear training</string>
|
||||
<string name="practice_title">Practice Mode</string>
|
||||
<string name="practice_desc">Note sequences with visual guidance and automatic progression</string>
|
||||
<string name="exercises_title">Exercises</string>
|
||||
<string name="exercises_desc">Guided exercises with progressive difficulty</string>
|
||||
<string name="scales_title">Scales & Arpeggios</string>
|
||||
<string name="scales_desc">Generate scales and arpeggios in any key</string>
|
||||
<string name="scores_title">Scores</string>
|
||||
<string name="scores_desc">Browse MusicXML scores and practice</string>
|
||||
<string name="ear_training_title">Ear Training</string>
|
||||
<string name="ear_training_desc">Recognize notes and intervals by ear</string>
|
||||
<string name="challenge_title">Challenge Mode</string>
|
||||
<string name="challenge_desc">Play as many correct notes as you can before time runs out!</string>
|
||||
<string name="leaderboard_title">Leaderboard</string>
|
||||
<string name="leaderboard_desc">Your best challenge scores</string>
|
||||
<string name="stats_title">Statistics</string>
|
||||
<string name="stats_desc">Charts for accuracy, speed and progress</string>
|
||||
<string name="goals_title">Daily Goals</string>
|
||||
<string name="goals_desc">Set goals and keep your streak going</string>
|
||||
<string name="history_title">History</string>
|
||||
<string name="history_desc">Review past practice sessions</string>
|
||||
<string name="guide_title">Guide</string>
|
||||
<string name="guide_desc">How to use the app and tips</string>
|
||||
|
||||
<!-- Navigation -->
|
||||
<string name="back">Back</string>
|
||||
|
||||
<!-- Common -->
|
||||
<string name="octave">Octave</string>
|
||||
<string name="octave_n">Octave %1$s</string>
|
||||
<string name="start">Start</string>
|
||||
<string name="finish">Finish</string>
|
||||
<string name="retry">Retry</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="error">Error</string>
|
||||
<string name="play_note">Play:</string>
|
||||
<string name="detected">Detected:</string>
|
||||
<string name="notes">Notes</string>
|
||||
<string name="instrument">Instrument</string>
|
||||
<string name="duration">Duration</string>
|
||||
<string name="score">Score</string>
|
||||
<string name="combo">Combo</string>
|
||||
<string name="points">points</string>
|
||||
<string name="partial">Partial</string>
|
||||
<string name="n_notes">%1$d notes</string>
|
||||
<string name="note_n_of_total">Note %1$d of %2$d</string>
|
||||
<string name="select_note">Select note</string>
|
||||
<string name="direction">Direction</string>
|
||||
|
||||
<!-- Tuner -->
|
||||
<string name="tuner_silence">Silence</string>
|
||||
|
||||
<!-- Metronome -->
|
||||
<string name="bpm">BPM</string>
|
||||
<string name="time_signature">Time</string>
|
||||
<string name="tap_tempo">Tap Tempo</string>
|
||||
|
||||
<!-- Reference Note -->
|
||||
<string name="la_440">A 440 Hz</string>
|
||||
<string name="octave_freq">Octave %1$d • %2$s Hz</string>
|
||||
|
||||
<!-- Practice -->
|
||||
<string name="practice_choose">Choose how to enter notes</string>
|
||||
<string name="manual_input_title">Manual Input</string>
|
||||
<string name="manual_input_desc">Select notes one by one using the grid</string>
|
||||
<string name="import_musicxml_title">Import MusicXML</string>
|
||||
<string name="import_musicxml_desc">Load a score from MuseScore, Finale or Sibelius</string>
|
||||
<string name="scan_title">Scan Score</string>
|
||||
<string name="scan_desc">Take a photo of the score and recognize notes with AI</string>
|
||||
<string name="view_note">Note</string>
|
||||
<string name="view_context">Context</string>
|
||||
<string name="view_score">Score</string>
|
||||
<string name="view_light">Light</string>
|
||||
<string name="hide_note_name">Hide</string>
|
||||
<string name="completed">Completed!</string>
|
||||
<string name="all_notes_correct">All notes played correctly</string>
|
||||
<string name="avg_deviation">Average deviation</string>
|
||||
<string name="perfect_notes">Perfect notes</string>
|
||||
<string name="first_attempt">First attempt</string>
|
||||
<string name="repeat">Repeat</string>
|
||||
|
||||
<!-- Manual Input -->
|
||||
<string name="note_input_title">Note Input</string>
|
||||
<string name="selected_notes">Selected notes (%1$d)</string>
|
||||
<string name="undo_last">Undo Last</string>
|
||||
<string name="clear_all">Clear All</string>
|
||||
<string name="start_practice_n">Start Practice (%1$d notes)</string>
|
||||
<string name="manual_sequence_name">Manual Sequence</string>
|
||||
|
||||
<!-- Exercises -->
|
||||
<string name="level_n">Level %1$d</string>
|
||||
|
||||
<!-- Scales -->
|
||||
<string name="tab_scales">Scales</string>
|
||||
<string name="tab_arpeggios">Arpeggios</string>
|
||||
<string name="root_note">Root note</string>
|
||||
<string name="scale_type">Scale type</string>
|
||||
<string name="arpeggio_type">Arpeggio type</string>
|
||||
<string name="start_practice">Practice</string>
|
||||
|
||||
<!-- Scores -->
|
||||
<string name="folder_app">App folder</string>
|
||||
<string name="folder_named">Folder: %1$s</string>
|
||||
<string name="change_folder">Change folder</string>
|
||||
<string name="change">Change</string>
|
||||
<string name="no_scores_found">No scores found.</string>
|
||||
<string name="folder">Folder</string>
|
||||
<string name="score_file">Score</string>
|
||||
|
||||
<!-- Ear Training -->
|
||||
<string name="difficulty">Difficulty</string>
|
||||
<string name="question_n">Question %1$d/%2$d</string>
|
||||
<string name="correct_count">%1$d correct</string>
|
||||
<string name="play_sound">Play</string>
|
||||
<string name="listening">Listening...</string>
|
||||
<string name="press_to_listen">Press to listen</string>
|
||||
<string name="session_completed">Session Completed!</string>
|
||||
<string name="correct_answers">correct answers</string>
|
||||
<string name="result">Result</string>
|
||||
|
||||
<!-- Challenge -->
|
||||
<string name="challenge_subtitle">Play as many correct notes before time runs out!</string>
|
||||
<string name="challenge_completed">Challenge Completed!</string>
|
||||
<string name="notes_played">Notes played</string>
|
||||
<string name="max_combo">Max combo</string>
|
||||
<string name="time">Time</string>
|
||||
<string name="start_challenge">Start Challenge!</string>
|
||||
<string name="challenge_random">Random Challenge</string>
|
||||
<string name="source_exercises">Exercises</string>
|
||||
<string name="source_scales">Scales</string>
|
||||
<string name="source_random">Random</string>
|
||||
<string name="sequence">Sequence</string>
|
||||
<string name="time_limit">Time limit</string>
|
||||
|
||||
<!-- Leaderboard -->
|
||||
<string name="no_results">No results yet. Play a challenge!</string>
|
||||
|
||||
<!-- Stats -->
|
||||
<string name="summary">Summary</string>
|
||||
<string name="sessions">Sessions</string>
|
||||
<string name="sessions_completed">Completed</string>
|
||||
<string name="practice_time">Practice time</string>
|
||||
<string name="best_accuracy">Best accuracy</string>
|
||||
<string name="accuracy_cents">Accuracy (cents)</string>
|
||||
<string name="cents">cents</string>
|
||||
<string name="first_attempt_pct">First attempt (%)</string>
|
||||
<string name="reaction_time_ms">Reaction time (ms)</string>
|
||||
<string name="ms">ms</string>
|
||||
<string name="pct">%</string>
|
||||
<string name="no_stats_yet">Complete some practice sessions to see statistics!</string>
|
||||
|
||||
<!-- Goals -->
|
||||
<string name="consecutive_day_one">consecutive day</string>
|
||||
<string name="consecutive_days">consecutive days</string>
|
||||
<string name="start_streak">Start your streak!</string>
|
||||
<string name="of_target">of %1$dm</string>
|
||||
<string name="goal_reached">Goal reached!</string>
|
||||
<string name="daily_goal">Daily goal</string>
|
||||
<string name="last_30_days">Last 30 days</string>
|
||||
<string name="no_recent_activity">No recent activity</string>
|
||||
|
||||
<!-- History -->
|
||||
<string name="no_sessions">No sessions saved.\nComplete an activity to see it here.</string>
|
||||
<string name="history_type_practice">Practice</string>
|
||||
<string name="history_type_ear_training">Ear Training</string>
|
||||
<string name="history_type_challenge">Challenge</string>
|
||||
<string name="history_ear_note">Notes</string>
|
||||
<string name="history_ear_interval">Intervals</string>
|
||||
<string name="history_score">Score</string>
|
||||
<string name="history_combo">combo</string>
|
||||
<string name="history_correct">correct</string>
|
||||
|
||||
<!-- Scan -->
|
||||
<string name="analyzing">Analyzing...</string>
|
||||
<string name="analyzing_desc">Recognizing notes from the score</string>
|
||||
<string name="notes_detected">%1$d notes detected</string>
|
||||
<string name="tap_to_remove">Tap a note to remove it</string>
|
||||
<string name="take_again">Take again</string>
|
||||
<string name="change_api_key">Change API Key</string>
|
||||
<string name="api_key">API Key</string>
|
||||
<string name="enter_anthropic_key">Enter your Anthropic API key</string>
|
||||
<string name="enter_openai_key">Enter your OpenAI API key</string>
|
||||
<string name="get_anthropic_key">Get your key at console.anthropic.com</string>
|
||||
<string name="get_openai_key">Get your key at platform.openai.com</string>
|
||||
|
||||
<!-- Guide -->
|
||||
<string name="guide_section_tips">Tips</string>
|
||||
<string name="guide_tuner_desc">Detects the note you\'re playing in real time via the microphone. Shows the note name, cent deviation, and a visual indicator: green (in tune) or red (out of tune). Ideal for string, wind instruments or voice: check if you\'re playing the right note.</string>
|
||||
<string name="guide_metronome_desc">Digital metronome with BPM adjustable from 30 to 240, available time signatures (2/4, 3/4, 4/4, 6/8), visual beat indicators and Tap Tempo. Press the microphone button to activate the built-in tuner and check your intonation while playing in time.</string>
|
||||
<string name="guide_reference_desc">Generate a reference note for tuning or ear training. Quick A 440 Hz button, grid of 12 chromatic notes, and selectable octave from 2 to 7.</string>
|
||||
<string name="guide_practice_desc">The heart of the app. Load a note sequence (manual, photo scan, or MusicXML) and play them one by one. The app detects the note from the microphone and advances automatically. View the staff in Note, Context, Score, or Light mode. Use \"Hide\" to practice reading from the staff. Built-in metronome to play in time.</string>
|
||||
<string name="guide_exercises_desc">Ready-made exercises with progressive difficulty: Level 1 (basic notes C-G), Level 2 (complete scales), Level 3 (scales with accidentals and melodies), Level 4 (arpeggios and leaps).</string>
|
||||
<string name="guide_scales_desc">Generate scales and arpeggios in any key. Scales: Major, Minor, Harmonic, Melodic, Pentatonic, Chromatic. Arpeggios: Major, Minor, Diminished, Augmented, Seventh. Choose root note, octave, type and direction.</string>
|
||||
<string name="guide_scores_desc">Browse your MusicXML file library and practice directly from imported pieces.</string>
|
||||
<string name="guide_ear_training_desc">Train your musical ear: Recognize Note (guess the note you hear) and Recognize Interval (identify the interval between two notes). Three difficulty levels with 10 questions per session.</string>
|
||||
<string name="guide_challenge_desc">Test your speed and accuracy: choose source and time limit (30s, 1min, 2min) and play as many correct notes as possible. Each note is shown on the staff. Scoring system with accuracy bonus, speed bonus and combo multiplier.</string>
|
||||
<string name="guide_leaderboard_desc">Your top 20 challenge scores, with score, notes played, max combo and date.</string>
|
||||
<string name="guide_stats_desc">Overview of your progress: total sessions, practice time, best accuracy. Daily charts for accuracy, first attempt and reaction time.</string>
|
||||
<string name="guide_goals_desc">Set a goal (5, 10, 15 or 30 minutes per day), follow the circular progress, maintain your streak and view the calendar of the last 30 days. Time is tracked from all activities: practice, challenges and ear training.</string>
|
||||
<string name="guide_history_desc">Review all past sessions in a single chronological list: practice, ear training and challenges. Each type has a colored badge for easy identification. Tap a practice session to replay it.</string>
|
||||
<string name="guide_tip_quiet">Quiet environment: the app works best with little background noise.</string>
|
||||
<string name="guide_tip_distance">Microphone distance: keep the device 30-50 cm from your instrument.</string>
|
||||
<string name="guide_tip_daily">Daily practice: even 10 minutes a day makes a difference.</string>
|
||||
<string name="guide_tip_scales">Start with scales: mastering scales is the foundation for playing any piece.</string>
|
||||
<string name="guide_tip_ear">Regular ear training: even 5 minutes a day greatly improves your musical ear.</string>
|
||||
<string name="guide_section_support">Support</string>
|
||||
<string name="guide_support_desc">Need help, want to report a bug, or suggest an improvement? Write to us!</string>
|
||||
<string name="guide_support_button">Contact us via email</string>
|
||||
<string name="guide_support_email">info@djluza.com</string>
|
||||
|
||||
<!-- Info -->
|
||||
<string name="guide_section_info">About</string>
|
||||
<string name="info_version">Version %1$s</string>
|
||||
<string name="info_developer">Developed by LuZa</string>
|
||||
<string name="info_website">djluza.com</string>
|
||||
<string name="info_privacy_policy">Privacy Policy</string>
|
||||
<string name="info_privacy_url">https://djluza.com/intono/privacy</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,237 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<resources>
|
||||
<!-- App -->
|
||||
<string name="app_name">InTono</string>
|
||||
<string name="app_subtitle">Intonazione e pratica per musicisti</string>
|
||||
|
||||
<!-- Home sections -->
|
||||
<string name="section_tools">Strumenti</string>
|
||||
<string name="section_practice">Pratica</string>
|
||||
<string name="section_challenge">Sfida</string>
|
||||
<string name="section_progress">Progressi</string>
|
||||
|
||||
<!-- Home cards -->
|
||||
<string name="tuner_title">Controllo Intonazione</string>
|
||||
<string name="tuner_desc">Verifica in tempo reale se stai suonando la nota corretta</string>
|
||||
<string name="metronome_title">Metronomo</string>
|
||||
<string name="metronome_desc">Tempo regolabile con accenti e tap tempo</string>
|
||||
<string name="reference_title">Nota di Riferimento</string>
|
||||
<string name="reference_desc">Genera qualsiasi nota per accordare o esercitarsi</string>
|
||||
<string name="practice_title">Modalita Pratica</string>
|
||||
<string name="practice_desc">Sequenze di note con guida visiva e avanzamento automatico</string>
|
||||
<string name="exercises_title">Esercizi</string>
|
||||
<string name="exercises_desc">Esercizi guidati con difficolta progressiva</string>
|
||||
<string name="scales_title">Scale e Arpeggi</string>
|
||||
<string name="scales_desc">Genera scale e arpeggi in qualsiasi tonalita</string>
|
||||
<string name="scores_title">Spartiti</string>
|
||||
<string name="scores_desc">Sfoglia spartiti MusicXML e pratica</string>
|
||||
<string name="ear_training_title">Ear Training</string>
|
||||
<string name="ear_training_desc">Riconosci note e intervalli ad orecchio</string>
|
||||
<string name="challenge_title">Modalita Sfida</string>
|
||||
<string name="challenge_desc">Suona piu note possibili nel tempo limite!</string>
|
||||
<string name="leaderboard_title">Classifica</string>
|
||||
<string name="leaderboard_desc">I tuoi migliori punteggi nelle sfide</string>
|
||||
<string name="stats_title">Statistiche</string>
|
||||
<string name="stats_desc">Grafici di precisione, velocita e progressi</string>
|
||||
<string name="goals_title">Obiettivi Giornalieri</string>
|
||||
<string name="goals_desc">Imposta obiettivi e mantieni la serie</string>
|
||||
<string name="history_title">Cronologia</string>
|
||||
<string name="history_desc">Rivedi le sessioni di pratica passate</string>
|
||||
<string name="guide_title">Guida</string>
|
||||
<string name="guide_desc">Come usare l\'app e suggerimenti</string>
|
||||
|
||||
<!-- Navigation -->
|
||||
<string name="back">Indietro</string>
|
||||
|
||||
<!-- Common -->
|
||||
<string name="octave">Ottava</string>
|
||||
<string name="octave_n">Ottava %1$s</string>
|
||||
<string name="start">Inizia</string>
|
||||
<string name="finish">Fine</string>
|
||||
<string name="retry">Riprova</string>
|
||||
<string name="cancel">Annulla</string>
|
||||
<string name="save">Salva</string>
|
||||
<string name="error">Errore</string>
|
||||
<string name="play_note">Suona:</string>
|
||||
<string name="detected">Rilevato:</string>
|
||||
<string name="notes">Note</string>
|
||||
<string name="instrument">Strumento</string>
|
||||
<string name="duration">Durata</string>
|
||||
<string name="score">Punteggio</string>
|
||||
<string name="combo">Combo</string>
|
||||
<string name="points">punti</string>
|
||||
<string name="partial">Parziale</string>
|
||||
<string name="n_notes">%1$d note</string>
|
||||
<string name="note_n_of_total">Nota %1$d di %2$d</string>
|
||||
<string name="select_note">Seleziona nota</string>
|
||||
<string name="direction">Direzione</string>
|
||||
|
||||
<!-- Tuner -->
|
||||
<string name="tuner_silence">Silenzio</string>
|
||||
|
||||
<!-- Metronome -->
|
||||
<string name="bpm">BPM</string>
|
||||
<string name="time_signature">Tempo</string>
|
||||
<string name="tap_tempo">Tap Tempo</string>
|
||||
|
||||
<!-- Reference Note -->
|
||||
<string name="la_440">LA 440 Hz</string>
|
||||
<string name="octave_freq">Ottava %1$d • %2$s Hz</string>
|
||||
|
||||
<!-- Practice -->
|
||||
<string name="practice_choose">Scegli come inserire le note</string>
|
||||
<string name="manual_input_title">Inserimento Manuale</string>
|
||||
<string name="manual_input_desc">Seleziona le note una per una usando la griglia</string>
|
||||
<string name="import_musicxml_title">Importa MusicXML</string>
|
||||
<string name="import_musicxml_desc">Carica uno spartito da MuseScore, Finale o Sibelius</string>
|
||||
<string name="scan_title">Scansiona Spartito</string>
|
||||
<string name="scan_desc">Scatta una foto allo spartito e riconosci le note con AI</string>
|
||||
<string name="view_note">Nota</string>
|
||||
<string name="view_context">Contesto</string>
|
||||
<string name="view_score">Spartito</string>
|
||||
<string name="view_light">Chiaro</string>
|
||||
<string name="hide_note_name">Nascondi</string>
|
||||
<string name="completed">Completato!</string>
|
||||
<string name="all_notes_correct">Tutte le note suonate correttamente</string>
|
||||
<string name="avg_deviation">Deviazione media</string>
|
||||
<string name="perfect_notes">Note perfette</string>
|
||||
<string name="first_attempt">Primo tentativo</string>
|
||||
<string name="repeat">Ripeti</string>
|
||||
|
||||
<!-- Manual Input -->
|
||||
<string name="note_input_title">Inserimento Note</string>
|
||||
<string name="selected_notes">Note selezionate (%1$d)</string>
|
||||
<string name="undo_last">Annulla Ultima</string>
|
||||
<string name="clear_all">Cancella Tutto</string>
|
||||
<string name="start_practice_n">Inizia Pratica (%1$d note)</string>
|
||||
<string name="manual_sequence_name">Sequenza Manuale</string>
|
||||
|
||||
<!-- Exercises -->
|
||||
<string name="level_n">Livello %1$d</string>
|
||||
|
||||
<!-- Scales -->
|
||||
<string name="tab_scales">Scale</string>
|
||||
<string name="tab_arpeggios">Arpeggi</string>
|
||||
<string name="root_note">Nota fondamentale</string>
|
||||
<string name="scale_type">Tipo di scala</string>
|
||||
<string name="arpeggio_type">Tipo di arpeggio</string>
|
||||
<string name="start_practice">Pratica</string>
|
||||
|
||||
<!-- Scores -->
|
||||
<string name="folder_app">Cartella app</string>
|
||||
<string name="folder_named">Cartella: %1$s</string>
|
||||
<string name="change_folder">Cambia cartella</string>
|
||||
<string name="change">Cambia</string>
|
||||
<string name="no_scores_found">Nessuno spartito trovato.</string>
|
||||
<string name="folder">Cartella</string>
|
||||
<string name="score_file">Spartito</string>
|
||||
|
||||
<!-- Ear Training -->
|
||||
<string name="difficulty">Difficolta</string>
|
||||
<string name="question_n">Domanda %1$d/%2$d</string>
|
||||
<string name="correct_count">%1$d corrette</string>
|
||||
<string name="play_sound">Riproduci</string>
|
||||
<string name="listening">Ascolta...</string>
|
||||
<string name="press_to_listen">Premi per ascoltare</string>
|
||||
<string name="session_completed">Sessione Completata!</string>
|
||||
<string name="correct_answers">risposte corrette</string>
|
||||
<string name="result">Risultato</string>
|
||||
|
||||
<!-- Challenge -->
|
||||
<string name="challenge_subtitle">Suona piu note possibili nel tempo limite!</string>
|
||||
<string name="challenge_completed">Sfida Completata!</string>
|
||||
<string name="notes_played">Note suonate</string>
|
||||
<string name="max_combo">Combo massima</string>
|
||||
<string name="time">Tempo</string>
|
||||
<string name="start_challenge">Inizia Sfida!</string>
|
||||
<string name="challenge_random">Sfida Random</string>
|
||||
<string name="source_exercises">Esercizi</string>
|
||||
<string name="source_scales">Scale</string>
|
||||
<string name="source_random">Random</string>
|
||||
<string name="sequence">Sequenza</string>
|
||||
<string name="time_limit">Tempo limite</string>
|
||||
|
||||
<!-- Leaderboard -->
|
||||
<string name="no_results">Nessun risultato ancora. Gioca una sfida!</string>
|
||||
|
||||
<!-- Stats -->
|
||||
<string name="summary">Riepilogo</string>
|
||||
<string name="sessions">Sessioni</string>
|
||||
<string name="sessions_completed">Completate</string>
|
||||
<string name="practice_time">Tempo pratica</string>
|
||||
<string name="best_accuracy">Miglior precisione</string>
|
||||
<string name="accuracy_cents">Precisione (cents)</string>
|
||||
<string name="cents">cents</string>
|
||||
<string name="first_attempt_pct">Primo tentativo (%)</string>
|
||||
<string name="reaction_time_ms">Tempo reazione (ms)</string>
|
||||
<string name="ms">ms</string>
|
||||
<string name="pct">%</string>
|
||||
<string name="no_stats_yet">Completa qualche sessione di pratica per vedere le statistiche!</string>
|
||||
|
||||
<!-- Goals -->
|
||||
<string name="consecutive_day_one">giorno consecutivo</string>
|
||||
<string name="consecutive_days">giorni consecutivi</string>
|
||||
<string name="start_streak">Inizia la tua serie!</string>
|
||||
<string name="of_target">di %1$dm</string>
|
||||
<string name="goal_reached">Obiettivo raggiunto!</string>
|
||||
<string name="daily_goal">Obiettivo giornaliero</string>
|
||||
<string name="last_30_days">Ultimi 30 giorni</string>
|
||||
<string name="no_recent_activity">Nessuna attivita recente</string>
|
||||
|
||||
<!-- History -->
|
||||
<string name="no_sessions">Nessuna sessione salvata.\nCompleta un\'attività per vederla qui.</string>
|
||||
<string name="history_type_practice">Pratica</string>
|
||||
<string name="history_type_ear_training">Ear Training</string>
|
||||
<string name="history_type_challenge">Sfida</string>
|
||||
<string name="history_ear_note">Note</string>
|
||||
<string name="history_ear_interval">Intervalli</string>
|
||||
<string name="history_score">Punteggio</string>
|
||||
<string name="history_combo">combo</string>
|
||||
<string name="history_correct">corrette</string>
|
||||
|
||||
<!-- Scan -->
|
||||
<string name="analyzing">Analisi in corso...</string>
|
||||
<string name="analyzing_desc">Riconoscimento delle note dallo spartito</string>
|
||||
<string name="notes_detected">%1$d note rilevate</string>
|
||||
<string name="tap_to_remove">Tocca una nota per rimuoverla</string>
|
||||
<string name="take_again">Scatta di nuovo</string>
|
||||
<string name="change_api_key">Cambia API Key</string>
|
||||
<string name="api_key">API Key</string>
|
||||
<string name="enter_anthropic_key">Inserisci la tua API key Anthropic</string>
|
||||
<string name="enter_openai_key">Inserisci la tua API key OpenAI</string>
|
||||
<string name="get_anthropic_key">Ottieni la key su console.anthropic.com</string>
|
||||
<string name="get_openai_key">Ottieni la key su platform.openai.com</string>
|
||||
|
||||
<!-- Guide -->
|
||||
<string name="guide_section_tips">Suggerimenti</string>
|
||||
<string name="guide_tuner_desc">Rileva in tempo reale la nota che stai suonando tramite il microfono. Mostra il nome della nota, la deviazione in cents e un indicatore visivo verde (intonato) o rosso (stonato). Ideale per strumenti a corda, a fiato o voce: verifica se stai suonando la nota giusta.</string>
|
||||
<string name="guide_metronome_desc">Metronomo digitale con BPM regolabile da 30 a 240, tempi disponibili (2/4, 3/4, 4/4, 6/8), indicatori visivi dei battiti e funzione Tap Tempo. Premi il pulsante microfono per attivare il tuner integrato e verificare l\'intonazione mentre suoni a tempo.</string>
|
||||
<string name="guide_reference_desc">Genera una nota di riferimento per accordare o esercitarsi a orecchio. Pulsante rapido LA 440 Hz, griglia di 12 note cromatiche e ottava selezionabile da 2 a 7.</string>
|
||||
<string name="guide_practice_desc">Il cuore dell\'app. Carica una sequenza di note (manuale, scansione foto o MusicXML) e suonale una per una. L\'app rileva la nota dal microfono e avanza automaticamente. Visualizza il pentagramma in modalita Nota, Contesto, Spartito o Chiaro. Usa \"Nascondi\" per esercitarti a leggere dal pentagramma. Metronomo integrato per suonare a tempo.</string>
|
||||
<string name="guide_exercises_desc">Esercizi pronti con difficolta progressiva: Livello 1 (note base Do-Sol), Livello 2 (scale complete), Livello 3 (scale con alterazioni e melodie), Livello 4 (arpeggi e salti).</string>
|
||||
<string name="guide_scales_desc">Genera scale e arpeggi in qualsiasi tonalita. Scale: Maggiore, Minore, Armonica, Melodica, Pentatonica, Cromatica. Arpeggi: Maggiore, Minore, Diminuito, Aumentato, Settima. Scegli nota fondamentale, ottava, tipo e direzione.</string>
|
||||
<string name="guide_scores_desc">Sfoglia la libreria di file MusicXML e pratica direttamente dai brani importati.</string>
|
||||
<string name="guide_ear_training_desc">Allena l\'orecchio musicale: Riconosci Nota (indovina la nota ascoltata) e Riconosci Intervallo (identifica l\'intervallo tra due note). Tre livelli di difficolta con 10 domande per sessione.</string>
|
||||
<string name="guide_challenge_desc">Metti alla prova velocita e precisione: scegli sorgente e tempo limite (30s, 1min, 2min) e suona il maggior numero di note corrette. Ogni nota e\' mostrata sul pentagramma. Sistema di punteggio con bonus precisione, velocita e moltiplicatore combo.</string>
|
||||
<string name="guide_leaderboard_desc">I tuoi 20 migliori punteggi nelle sfide, con punteggio, note suonate, combo massima e data.</string>
|
||||
<string name="guide_stats_desc">Panoramica dei tuoi progressi: sessioni totali, tempo di pratica, miglior precisione. Grafici giornalieri di precisione, primo tentativo e tempo di reazione.</string>
|
||||
<string name="guide_goals_desc">Imposta un obiettivo (5, 10, 15 o 30 minuti al giorno), segui il progresso circolare, mantieni la serie consecutiva e visualizza il calendario degli ultimi 30 giorni. Il tempo viene conteggiato da tutte le attivita: pratica, sfide e ear training.</string>
|
||||
<string name="guide_history_desc">Rivedi tutte le sessioni passate in un\'unica lista cronologica: pratica, ear training e sfide. Ogni tipo ha un badge colorato per distinguerlo. Tocca una sessione di pratica per rieseguirla.</string>
|
||||
<string name="guide_tip_quiet">Ambiente silenzioso: l\'app funziona meglio con poco rumore di fondo.</string>
|
||||
<string name="guide_tip_distance">Distanza dal microfono: tieni il dispositivo a 30-50 cm dallo strumento.</string>
|
||||
<string name="guide_tip_daily">Pratica quotidiana: anche 10 minuti al giorno fanno la differenza.</string>
|
||||
<string name="guide_tip_scales">Inizia dalle scale: padroneggiare le scale e\' la base per suonare qualsiasi brano.</string>
|
||||
<string name="guide_tip_ear">Ear training regolare: anche 5 minuti al giorno migliorano l\'orecchio musicale.</string>
|
||||
<string name="guide_section_support">Supporto</string>
|
||||
<string name="guide_support_desc">Hai bisogno di aiuto, vuoi segnalare un problema o suggerire un miglioramento? Scrivici!</string>
|
||||
<string name="guide_support_button">Contattaci via email</string>
|
||||
<string name="guide_support_email">info@djluza.com</string>
|
||||
|
||||
<!-- Info -->
|
||||
<string name="guide_section_info">Informazioni</string>
|
||||
<string name="info_version">Versione %1$s</string>
|
||||
<string name="info_developer">Sviluppato da LuZa</string>
|
||||
<string name="info_website">djluza.com</string>
|
||||
<string name="info_privacy_policy">Privacy Policy</string>
|
||||
<string name="info_privacy_url">https://djluza.com/intono/privacy</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.notemusicali.audio
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Orchestrates the audio pipeline: AudioSource → PitchDetector → NoteConverter.
|
||||
* Exposes processed results as StateFlow.
|
||||
*/
|
||||
class AudioProcessor(
|
||||
preset: InstrumentPreset = InstrumentPreset.VIOLINO,
|
||||
) {
|
||||
private var audioSource: AudioSource = createAudioSource(bufferSize = preset.bufferSize)
|
||||
private var pitchDetector: PitchDetector = PitchDetector(
|
||||
bufferSize = preset.bufferSize,
|
||||
threshold = preset.yinThreshold,
|
||||
minFrequency = preset.minFrequency,
|
||||
maxFrequency = preset.maxFrequency,
|
||||
)
|
||||
|
||||
fun updatePreset(preset: InstrumentPreset) {
|
||||
stop()
|
||||
audioSource = createAudioSource(bufferSize = preset.bufferSize)
|
||||
pitchDetector = PitchDetector(
|
||||
bufferSize = preset.bufferSize,
|
||||
threshold = preset.yinThreshold,
|
||||
minFrequency = preset.minFrequency,
|
||||
maxFrequency = preset.maxFrequency,
|
||||
)
|
||||
}
|
||||
|
||||
sealed class ProcessedResult {
|
||||
data object Silence : ProcessedResult()
|
||||
|
||||
data class Detected(
|
||||
val noteResult: NoteConverter.NoteResult,
|
||||
val confidence: Float,
|
||||
val rmsAmplitude: Float,
|
||||
) : ProcessedResult()
|
||||
|
||||
data class NoNote(
|
||||
val rmsAmplitude: Float,
|
||||
) : ProcessedResult()
|
||||
}
|
||||
|
||||
private val _result = MutableStateFlow<ProcessedResult>(ProcessedResult.Silence)
|
||||
val result: StateFlow<ProcessedResult> = _result.asStateFlow()
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
fun start(scope: CoroutineScope) {
|
||||
stop()
|
||||
pitchDetector.reset()
|
||||
job = scope.launch {
|
||||
audioSource.audioFlow()
|
||||
.flowOn(Dispatchers.Default)
|
||||
.collect { buffer ->
|
||||
val pitchResult = pitchDetector.detect(buffer)
|
||||
_result.value = when {
|
||||
pitchResult.rmsAmplitude < 0.01f -> ProcessedResult.Silence
|
||||
pitchResult.isValid -> {
|
||||
val noteResult = NoteConverter.frequencyToNote(pitchResult.frequency)
|
||||
ProcessedResult.Detected(
|
||||
noteResult = noteResult,
|
||||
confidence = pitchResult.confidence,
|
||||
rmsAmplitude = pitchResult.rmsAmplitude,
|
||||
)
|
||||
}
|
||||
else -> ProcessedResult.NoNote(pitchResult.rmsAmplitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
_result.value = ProcessedResult.Silence
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.notemusicali.audio
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Platform-agnostic audio source that provides audio buffers as a Flow.
|
||||
*/
|
||||
interface AudioSource {
|
||||
fun audioFlow(): Flow<FloatArray>
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a platform-specific audio source.
|
||||
*/
|
||||
expect fun createAudioSource(sampleRate: Int = 44100, bufferSize: Int = 2048): AudioSource
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.notemusicali.audio
|
||||
|
||||
enum class InstrumentPreset(
|
||||
val displayName: String,
|
||||
val minFrequency: Float,
|
||||
val maxFrequency: Float,
|
||||
val yinThreshold: Float,
|
||||
val bufferSize: Int,
|
||||
val minOctave: Int,
|
||||
val maxOctave: Int,
|
||||
val description: String,
|
||||
) {
|
||||
VIOLINO(
|
||||
displayName = "Violino",
|
||||
minFrequency = 180f,
|
||||
maxFrequency = 2700f,
|
||||
yinThreshold = 0.15f,
|
||||
bufferSize = 2048,
|
||||
minOctave = 3,
|
||||
maxOctave = 7,
|
||||
description = "G3-E7 \u2022 Ottimizzato per archi",
|
||||
),
|
||||
VIOLA(
|
||||
displayName = "Viola",
|
||||
minFrequency = 120f,
|
||||
maxFrequency = 1800f,
|
||||
yinThreshold = 0.15f,
|
||||
bufferSize = 2048,
|
||||
minOctave = 3,
|
||||
maxOctave = 6,
|
||||
description = "C3-A6 \u2022 Ottimizzato per archi",
|
||||
),
|
||||
VIOLONCELLO(
|
||||
displayName = "Violoncello",
|
||||
minFrequency = 60f,
|
||||
maxFrequency = 1100f,
|
||||
yinThreshold = 0.15f,
|
||||
bufferSize = 4096,
|
||||
minOctave = 2,
|
||||
maxOctave = 6,
|
||||
description = "C2-C6 \u2022 Buffer ampio per note basse",
|
||||
),
|
||||
PIANOFORTE(
|
||||
displayName = "Pianoforte",
|
||||
minFrequency = 27f,
|
||||
maxFrequency = 4200f,
|
||||
yinThreshold = 0.10f,
|
||||
bufferSize = 4096,
|
||||
minOctave = 0,
|
||||
maxOctave = 8,
|
||||
description = "A0-C8 \u2022 Soglia bassa, segnale pulito",
|
||||
),
|
||||
CHITARRA(
|
||||
displayName = "Chitarra",
|
||||
minFrequency = 78f,
|
||||
maxFrequency = 1400f,
|
||||
yinThreshold = 0.12f,
|
||||
bufferSize = 2048,
|
||||
minOctave = 2,
|
||||
maxOctave = 6,
|
||||
description = "E2-E6 \u2022 Corde pizzicate",
|
||||
),
|
||||
VOCE(
|
||||
displayName = "Voce",
|
||||
minFrequency = 75f,
|
||||
maxFrequency = 1200f,
|
||||
yinThreshold = 0.15f,
|
||||
bufferSize = 2048,
|
||||
minOctave = 2,
|
||||
maxOctave = 6,
|
||||
description = "D2-D6 \u2022 Range vocale esteso",
|
||||
),
|
||||
|
||||
;
|
||||
|
||||
val octaveRange: IntRange get() = minOctave..maxOctave
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.notemusicali.audio
|
||||
|
||||
import com.notemusicali.music.ItalianNotation
|
||||
import com.notemusicali.music.MusicalNote
|
||||
import kotlin.math.log2
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Converts frequency (Hz) to musical note information.
|
||||
* Uses A4 = 440Hz as reference pitch.
|
||||
*/
|
||||
object NoteConverter {
|
||||
|
||||
private const val A4_FREQUENCY = 440.0
|
||||
private const val A4_MIDI = 69
|
||||
|
||||
data class NoteResult(
|
||||
val note: MusicalNote,
|
||||
val centsDeviation: Float,
|
||||
val frequency: Float,
|
||||
)
|
||||
|
||||
fun frequencyToNote(frequency: Float): NoteResult {
|
||||
val midiNumber = frequencyToMidi(frequency)
|
||||
val roundedMidi = midiNumber.roundToInt()
|
||||
val centsDeviation = ((midiNumber - roundedMidi) * 100).toFloat()
|
||||
|
||||
val noteIndex = ((roundedMidi % 12) + 12) % 12
|
||||
val octave = (roundedMidi / 12) - 1
|
||||
|
||||
val italianName = ItalianNotation.fromNoteIndex(noteIndex)
|
||||
|
||||
val note = MusicalNote(
|
||||
noteIndex = noteIndex,
|
||||
octave = octave,
|
||||
italianName = italianName,
|
||||
midiNumber = roundedMidi,
|
||||
)
|
||||
|
||||
return NoteResult(
|
||||
note = note,
|
||||
centsDeviation = centsDeviation,
|
||||
frequency = frequency,
|
||||
)
|
||||
}
|
||||
|
||||
private fun frequencyToMidi(frequency: Float): Double {
|
||||
return A4_MIDI + 12.0 * log2(frequency.toDouble() / A4_FREQUENCY)
|
||||
}
|
||||
|
||||
fun midiToFrequency(midiNumber: Int): Float {
|
||||
return (A4_FREQUENCY * 2.0.pow((midiNumber - A4_MIDI).toDouble() / 12.0)).toFloat()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.notemusicali.audio
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* YIN algorithm implementation for pitch detection.
|
||||
* Optimized for violin range (180Hz-2700Hz, G3-E7).
|
||||
*
|
||||
* The algorithm follows 5 steps:
|
||||
* 1. Difference function
|
||||
* 2. Cumulative mean normalized difference (CMND)
|
||||
* 3. Absolute threshold
|
||||
* 4. Parabolic interpolation
|
||||
* 5. Hz conversion
|
||||
*/
|
||||
class PitchDetector(
|
||||
private val sampleRate: Int = 44100,
|
||||
private val bufferSize: Int = 2048,
|
||||
private val threshold: Float = 0.15f,
|
||||
private val minFrequency: Float = 180f,
|
||||
private val maxFrequency: Float = 2700f,
|
||||
private val confidenceThreshold: Float = 0.7f,
|
||||
) {
|
||||
private val maxLag = (sampleRate / minFrequency).toInt().coerceAtMost(bufferSize / 2)
|
||||
private val minLag = (sampleRate / maxFrequency).toInt().coerceAtLeast(2)
|
||||
|
||||
private val recentPitches = ArrayDeque<Float>(3)
|
||||
|
||||
data class PitchResult(
|
||||
val frequency: Float,
|
||||
val confidence: Float,
|
||||
val rmsAmplitude: Float,
|
||||
) {
|
||||
// detect() only emits frequency > 0 after applying confidenceThreshold,
|
||||
// so validity must not re-check confidence against a hardcoded value
|
||||
val isValid: Boolean get() = frequency > 0f
|
||||
}
|
||||
|
||||
fun detect(audioBuffer: FloatArray): PitchResult {
|
||||
val rms = computeRms(audioBuffer)
|
||||
if (rms < 0.01f) {
|
||||
return PitchResult(0f, 0f, rms)
|
||||
}
|
||||
|
||||
// Step 1: Difference function
|
||||
val diff = differenceFunction(audioBuffer)
|
||||
|
||||
// Step 2: Cumulative mean normalized difference
|
||||
val cmnd = cumulativeMeanNormalizedDifference(diff)
|
||||
|
||||
// Step 3: Absolute threshold
|
||||
val lag = absoluteThreshold(cmnd) ?: return PitchResult(0f, 0f, rms)
|
||||
|
||||
// Step 4: Parabolic interpolation
|
||||
val refinedLag = parabolicInterpolation(cmnd, lag)
|
||||
|
||||
// Step 5: Hz conversion
|
||||
val frequency = sampleRate.toFloat() / refinedLag
|
||||
val confidence = 1f - cmnd[lag]
|
||||
|
||||
if (frequency < minFrequency || frequency > maxFrequency || confidence < confidenceThreshold) {
|
||||
return PitchResult(0f, confidence, rms)
|
||||
}
|
||||
|
||||
// Median filter on last 3 detections
|
||||
val filteredFrequency = medianFilter(frequency)
|
||||
|
||||
return PitchResult(filteredFrequency, confidence, rms)
|
||||
}
|
||||
|
||||
private fun computeRms(buffer: FloatArray): Float {
|
||||
var sum = 0f
|
||||
for (sample in buffer) {
|
||||
sum += sample * sample
|
||||
}
|
||||
return kotlin.math.sqrt(sum / buffer.size)
|
||||
}
|
||||
|
||||
private fun differenceFunction(buffer: FloatArray): FloatArray {
|
||||
val diff = FloatArray(maxLag + 1)
|
||||
for (tau in 1..maxLag) {
|
||||
var sum = 0f
|
||||
for (i in 0 until bufferSize - tau) {
|
||||
val delta = buffer[i] - buffer[i + tau]
|
||||
sum += delta * delta
|
||||
}
|
||||
diff[tau] = sum
|
||||
}
|
||||
return diff
|
||||
}
|
||||
|
||||
private fun cumulativeMeanNormalizedDifference(diff: FloatArray): FloatArray {
|
||||
val cmnd = FloatArray(diff.size)
|
||||
cmnd[0] = 1f
|
||||
var runningSum = 0f
|
||||
for (tau in 1 until diff.size) {
|
||||
runningSum += diff[tau]
|
||||
cmnd[tau] = if (runningSum != 0f) {
|
||||
diff[tau] * tau / runningSum
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
}
|
||||
return cmnd
|
||||
}
|
||||
|
||||
private fun absoluteThreshold(cmnd: FloatArray): Int? {
|
||||
// Find first dip below threshold after minLag
|
||||
var tau = minLag
|
||||
while (tau < cmnd.size) {
|
||||
if (cmnd[tau] < threshold) {
|
||||
// Walk to the minimum of this dip
|
||||
while (tau + 1 < cmnd.size && cmnd[tau + 1] < cmnd[tau]) {
|
||||
tau++
|
||||
}
|
||||
return tau
|
||||
}
|
||||
tau++
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun parabolicInterpolation(cmnd: FloatArray, tau: Int): Float {
|
||||
if (tau < 1 || tau >= cmnd.size - 1) return tau.toFloat()
|
||||
|
||||
val s0 = cmnd[tau - 1]
|
||||
val s1 = cmnd[tau]
|
||||
val s2 = cmnd[tau + 1]
|
||||
|
||||
val adjustment = (s2 - s0) / (2f * (2f * s1 - s2 - s0))
|
||||
|
||||
return if (abs(adjustment) < 1f) tau + adjustment else tau.toFloat()
|
||||
}
|
||||
|
||||
private fun medianFilter(frequency: Float): Float {
|
||||
recentPitches.addLast(frequency)
|
||||
if (recentPitches.size > 3) {
|
||||
recentPitches.removeFirst()
|
||||
}
|
||||
|
||||
if (recentPitches.size < 3) return frequency
|
||||
|
||||
val sorted = recentPitches.toList().sorted()
|
||||
return sorted[1]
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
recentPitches.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.notemusicali.audio
|
||||
|
||||
/**
|
||||
* Platform-agnostic tone generator for producing audio output.
|
||||
* Used by metronome, ear training, and reference note features.
|
||||
*/
|
||||
interface ToneGenerator {
|
||||
/** Play a sine wave tone at the given frequency for the specified duration in milliseconds. */
|
||||
fun playTone(frequencyHz: Float, durationMs: Long)
|
||||
|
||||
/** Play a short click sound. If accent is true, play a louder/higher click. */
|
||||
fun playClick(accent: Boolean = false)
|
||||
|
||||
/** Stop any currently playing sound. */
|
||||
fun stop()
|
||||
|
||||
/** Release all audio resources. Call when done using the generator. */
|
||||
fun release()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a platform-specific ToneGenerator.
|
||||
*/
|
||||
expect fun createToneGenerator(sampleRate: Int = 44100): ToneGenerator
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.notemusicali.audio
|
||||
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* Shared waveform generator with additive synthesis and ADSR envelopes.
|
||||
* Produces piano-like tones and realistic metronome clicks.
|
||||
*/
|
||||
object WaveGenerator {
|
||||
|
||||
// Piano-like harmonic series: (multiplier, relative amplitude)
|
||||
// Mimics the overtone structure of a struck string
|
||||
private val pianoHarmonics = floatArrayOf(
|
||||
1f, 1.0f,
|
||||
2f, 0.50f,
|
||||
3f, 0.28f,
|
||||
4f, 0.14f,
|
||||
5f, 0.09f,
|
||||
6f, 0.05f,
|
||||
7f, 0.03f,
|
||||
8f, 0.015f,
|
||||
)
|
||||
private const val NUM_HARMONICS = 8
|
||||
private const val HARMONIC_SUM = 2.085f // sum of amplitudes for normalization
|
||||
|
||||
private const val TWO_PI = 2.0 * PI
|
||||
|
||||
/**
|
||||
* Generate a piano-like tone with additive synthesis and ADSR envelope.
|
||||
*/
|
||||
fun generateToneSamples(
|
||||
frequencyHz: Float,
|
||||
durationMs: Long,
|
||||
sampleRate: Int,
|
||||
): FloatArray {
|
||||
val totalSamples = (sampleRate * durationMs / 1000).toInt()
|
||||
val samples = FloatArray(totalSamples)
|
||||
|
||||
// ADSR envelope (in samples)
|
||||
val attackLen = (sampleRate * 0.008).toInt() // 8ms attack
|
||||
val decayLen = (sampleRate * 0.12).toInt() // 120ms decay
|
||||
val sustainLevel = 0.50
|
||||
val releaseLen = (sampleRate * 0.06).toInt() // 60ms release
|
||||
val sustainEnd = totalSamples - releaseLen
|
||||
|
||||
val amplitude = 0.70
|
||||
|
||||
// Per-harmonic: progressively faster decay for higher harmonics (natural behavior)
|
||||
for (i in 0 until totalSamples) {
|
||||
// ADSR envelope
|
||||
val env = when {
|
||||
i < attackLen -> i.toDouble() / attackLen
|
||||
i < attackLen + decayLen -> {
|
||||
val progress = (i - attackLen).toDouble() / decayLen
|
||||
1.0 - (1.0 - sustainLevel) * progress
|
||||
}
|
||||
i >= sustainEnd -> {
|
||||
val progress = (i - sustainEnd).toDouble() / releaseLen
|
||||
sustainLevel * (1.0 - progress)
|
||||
}
|
||||
else -> sustainLevel
|
||||
}
|
||||
|
||||
// Additive synthesis
|
||||
var sample = 0.0
|
||||
var idx = 0
|
||||
while (idx < NUM_HARMONICS) {
|
||||
val mult = pianoHarmonics[idx * 2]
|
||||
val amp = pianoHarmonics[idx * 2 + 1]
|
||||
val harmonicFreq = frequencyHz * mult
|
||||
// Anti-aliasing: skip harmonics above Nyquist frequency
|
||||
if (harmonicFreq > sampleRate / 2) break
|
||||
// Higher harmonics decay faster over time
|
||||
val harmonicDecay = exp(-i.toDouble() / sampleRate * mult * 1.2)
|
||||
sample += amp * harmonicDecay * sin(TWO_PI * harmonicFreq * i / sampleRate)
|
||||
idx++
|
||||
}
|
||||
|
||||
// Normalize and apply envelope
|
||||
samples[i] = (sample / HARMONIC_SUM * env * amplitude).toFloat()
|
||||
}
|
||||
|
||||
return samples
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a realistic metronome click with tonal + noise components.
|
||||
* Uses inharmonic overtones for a wood-block-like sound.
|
||||
*/
|
||||
fun generateClickSamples(
|
||||
accent: Boolean,
|
||||
sampleRate: Int,
|
||||
): FloatArray {
|
||||
val durationMs = if (accent) 40 else 30
|
||||
val totalSamples = sampleRate * durationMs / 1000
|
||||
val samples = FloatArray(totalSamples)
|
||||
|
||||
val toneFreq = if (accent) 1800.0 else 1200.0
|
||||
val amplitude = if (accent) 0.80 else 0.60
|
||||
val noiseAmount = 0.25
|
||||
|
||||
// Simple deterministic pseudo-random for noise (reproducible across platforms)
|
||||
var noiseState = 48271L
|
||||
|
||||
for (i in 0 until totalSamples) {
|
||||
val t = i.toDouble() / sampleRate
|
||||
|
||||
// Sharp exponential decay with initial transient
|
||||
val progress = i.toDouble() / totalSamples
|
||||
val decay = exp(-progress * 8.0)
|
||||
|
||||
// Tonal component: inharmonic overtones for wood-like timbre
|
||||
val tone = sin(TWO_PI * toneFreq * t) +
|
||||
0.45 * sin(TWO_PI * toneFreq * 2.4 * t) +
|
||||
0.20 * sin(TWO_PI * toneFreq * 4.1 * t) +
|
||||
0.10 * sin(TWO_PI * toneFreq * 5.7 * t)
|
||||
|
||||
// Noise component (linear congruential generator)
|
||||
noiseState = (noiseState * 1103515245L + 12345L) and 0x7FFFFFFFL
|
||||
val noise = noiseState.toDouble() / 0x7FFFFFFFL * 2.0 - 1.0
|
||||
|
||||
// Mix tone and noise, then apply decay
|
||||
val mixed = (1.0 - noiseAmount) * tone / 1.75 + noiseAmount * noise
|
||||
samples[i] = (mixed * decay * amplitude).toFloat()
|
||||
}
|
||||
|
||||
return samples
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.notemusicali.challenge
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
|
||||
@Serializable
|
||||
data class ChallengeResult(
|
||||
val id: String,
|
||||
val dateMillis: Long,
|
||||
val sequenceName: String,
|
||||
val timeLimitSec: Int,
|
||||
val totalScore: Int,
|
||||
val notesPlayed: Int,
|
||||
val maxCombo: Int,
|
||||
val accuracy: Float,
|
||||
)
|
||||
|
||||
enum class ChallengeTimeLimit(val seconds: Int, val displayName: String) {
|
||||
SHORT(30, "30s"),
|
||||
MEDIUM(60, "1 min"),
|
||||
LONG(120, "2 min"),
|
||||
}
|
||||
|
||||
object ChallengeScoring {
|
||||
private const val BASE_POINTS = 100
|
||||
private const val PERFECT_BONUS = 50 // ≤5 cents
|
||||
private const val GOOD_BONUS = 25 // ≤15 cents
|
||||
private const val SPEED_BONUS = 30 // <2s reaction
|
||||
|
||||
/**
|
||||
* Score a single note. Returns points earned.
|
||||
* @param centsDeviation absolute cents deviation
|
||||
* @param reactionTimeMs time to play the note
|
||||
* @param comboStreak current combo streak (0-based)
|
||||
* @param firstAttempt true if played correctly on first try
|
||||
*/
|
||||
fun scoreNote(
|
||||
centsDeviation: Float,
|
||||
reactionTimeMs: Long,
|
||||
comboStreak: Int,
|
||||
firstAttempt: Boolean,
|
||||
): Int {
|
||||
if (!firstAttempt) return 0
|
||||
|
||||
val absCents = abs(centsDeviation)
|
||||
var points = BASE_POINTS
|
||||
|
||||
// Accuracy bonus
|
||||
when {
|
||||
absCents <= 5f -> points += PERFECT_BONUS
|
||||
absCents <= 15f -> points += GOOD_BONUS
|
||||
}
|
||||
|
||||
// Speed bonus
|
||||
if (reactionTimeMs < 2000) {
|
||||
points += SPEED_BONUS
|
||||
}
|
||||
|
||||
// Combo multiplier: 1.0x base, +0.1x per streak note (max 2.0x)
|
||||
val multiplier = 1.0f + (comboStreak * 0.1f).coerceAtMost(1.0f)
|
||||
return (points * multiplier).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* Session accuracy: fraction of played notes that were correct on the first attempt.
|
||||
*/
|
||||
fun accuracy(notesPlayed: Int, firstAttemptNotes: Int): Float =
|
||||
if (notesPlayed > 0) (firstAttemptNotes.toFloat() / notesPlayed).coerceAtMost(1f) else 0f
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.notemusicali.challenge
|
||||
|
||||
import com.notemusicali.scan.AppSettings
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object ChallengeRepository {
|
||||
private const val KEY = "challenge_results"
|
||||
private const val MAX_RESULTS = 20
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun load(): List<ChallengeResult> {
|
||||
val raw = AppSettings.getString(KEY) ?: return emptyList()
|
||||
return try {
|
||||
json.decodeFromString<List<ChallengeResult>>(raw)
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun save(result: ChallengeResult) {
|
||||
val current = load().toMutableList()
|
||||
current.add(result)
|
||||
current.sortByDescending { it.totalScore }
|
||||
if (current.size > MAX_RESULTS) {
|
||||
current.subList(MAX_RESULTS, current.size).clear()
|
||||
}
|
||||
AppSettings.putString(KEY, json.encodeToString(current))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.notemusicali.eartraining
|
||||
|
||||
import com.notemusicali.audio.NoteConverter
|
||||
import com.notemusicali.music.ItalianNotation
|
||||
import kotlin.random.Random
|
||||
|
||||
enum class IntervalType(val semitones: Int, val displayName: String) {
|
||||
UNISON(0, "Unisono"),
|
||||
MINOR_2ND(1, "Seconda min."),
|
||||
MAJOR_2ND(2, "Seconda magg."),
|
||||
MINOR_3RD(3, "Terza min."),
|
||||
MAJOR_3RD(4, "Terza magg."),
|
||||
PERFECT_4TH(5, "Quarta giusta"),
|
||||
TRITONE(6, "Tritono"),
|
||||
PERFECT_5TH(7, "Quinta giusta"),
|
||||
MINOR_6TH(8, "Sesta min."),
|
||||
MAJOR_6TH(9, "Sesta magg."),
|
||||
MINOR_7TH(10, "Settima min."),
|
||||
MAJOR_7TH(11, "Settima magg."),
|
||||
OCTAVE(12, "Ottava"),
|
||||
}
|
||||
|
||||
enum class EarTrainingMode(val displayName: String) {
|
||||
NOTE("Riconosci Nota"),
|
||||
INTERVAL("Riconosci Intervallo"),
|
||||
}
|
||||
|
||||
enum class EarTrainingDifficulty(val displayName: String) {
|
||||
EASY("Facile"),
|
||||
MEDIUM("Medio"),
|
||||
HARD("Difficile"),
|
||||
}
|
||||
|
||||
data class EarTrainingQuestion(
|
||||
val mode: EarTrainingMode,
|
||||
val referenceMidi: Int,
|
||||
val targetMidi: Int,
|
||||
val correctAnswer: String,
|
||||
val choices: List<String>,
|
||||
val interval: IntervalType? = null,
|
||||
) {
|
||||
val referenceFrequency: Float get() = NoteConverter.midiToFrequency(referenceMidi)
|
||||
val targetFrequency: Float get() = NoteConverter.midiToFrequency(targetMidi)
|
||||
}
|
||||
|
||||
object EarTrainingEngine {
|
||||
|
||||
private val NOTE_NAMES = (0..11).map { ItalianNotation.fromNoteIndex(it).display }
|
||||
|
||||
fun generateNoteQuestion(difficulty: EarTrainingDifficulty): EarTrainingQuestion {
|
||||
val midiRange = when (difficulty) {
|
||||
EarTrainingDifficulty.EASY -> 60..72 // C4-C5 (one octave)
|
||||
EarTrainingDifficulty.MEDIUM -> 55..79 // G3-G5
|
||||
EarTrainingDifficulty.HARD -> 48..84 // C3-C6
|
||||
}
|
||||
|
||||
val targetMidi = Random.nextInt(midiRange.first, midiRange.last + 1)
|
||||
val targetNoteIndex = ((targetMidi % 12) + 12) % 12
|
||||
val correctName = NOTE_NAMES[targetNoteIndex]
|
||||
|
||||
// Generate 3 wrong choices
|
||||
val wrongChoices = mutableSetOf<String>()
|
||||
while (wrongChoices.size < 3) {
|
||||
val wrong = NOTE_NAMES[Random.nextInt(12)]
|
||||
if (wrong != correctName) wrongChoices.add(wrong)
|
||||
}
|
||||
|
||||
val choices = (wrongChoices.toList() + correctName).shuffled()
|
||||
|
||||
return EarTrainingQuestion(
|
||||
mode = EarTrainingMode.NOTE,
|
||||
referenceMidi = targetMidi,
|
||||
targetMidi = targetMidi,
|
||||
correctAnswer = correctName,
|
||||
choices = choices,
|
||||
)
|
||||
}
|
||||
|
||||
fun generateIntervalQuestion(difficulty: EarTrainingDifficulty): EarTrainingQuestion {
|
||||
val availableIntervals = when (difficulty) {
|
||||
EarTrainingDifficulty.EASY -> listOf(
|
||||
IntervalType.UNISON, IntervalType.MAJOR_3RD,
|
||||
IntervalType.PERFECT_5TH, IntervalType.OCTAVE,
|
||||
)
|
||||
EarTrainingDifficulty.MEDIUM -> listOf(
|
||||
IntervalType.MAJOR_2ND, IntervalType.MINOR_3RD, IntervalType.MAJOR_3RD,
|
||||
IntervalType.PERFECT_4TH, IntervalType.PERFECT_5TH,
|
||||
IntervalType.MINOR_7TH, IntervalType.OCTAVE,
|
||||
)
|
||||
EarTrainingDifficulty.HARD -> IntervalType.entries
|
||||
}
|
||||
|
||||
val interval = availableIntervals.random()
|
||||
val baseMidi = Random.nextInt(60, 73) // C4-C5
|
||||
val targetMidi = baseMidi + interval.semitones
|
||||
|
||||
// Generate wrong choices
|
||||
val wrongIntervals = availableIntervals.filter { it != interval }.shuffled().take(3)
|
||||
|
||||
val choices = (wrongIntervals.map { it.displayName } + interval.displayName).shuffled()
|
||||
|
||||
return EarTrainingQuestion(
|
||||
mode = EarTrainingMode.INTERVAL,
|
||||
referenceMidi = baseMidi,
|
||||
targetMidi = targetMidi,
|
||||
correctAnswer = interval.displayName,
|
||||
choices = choices,
|
||||
interval = interval,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.notemusicali.eartraining
|
||||
|
||||
import com.notemusicali.scan.AppSettings
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class EarTrainingSession(
|
||||
val id: String,
|
||||
val dateMillis: Long,
|
||||
val mode: String,
|
||||
val difficulty: String,
|
||||
val totalQuestions: Int,
|
||||
val correctAnswers: Int,
|
||||
) {
|
||||
val accuracy: Float get() = if (totalQuestions == 0) 0f
|
||||
else correctAnswers.toFloat() / totalQuestions
|
||||
}
|
||||
|
||||
object EarTrainingStatsRepository {
|
||||
private const val KEY = "ear_training_sessions"
|
||||
private const val MAX_SESSIONS = 50
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun load(): List<EarTrainingSession> {
|
||||
val raw = AppSettings.getString(KEY) ?: return emptyList()
|
||||
return try {
|
||||
json.decodeFromString<List<EarTrainingSession>>(raw)
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun save(session: EarTrainingSession) {
|
||||
val current = load().toMutableList()
|
||||
current.add(0, session)
|
||||
if (current.size > MAX_SESSIONS) {
|
||||
current.subList(MAX_SESSIONS, current.size).clear()
|
||||
}
|
||||
AppSettings.putString(KEY, json.encodeToString(current))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.notemusicali.exercises
|
||||
|
||||
import com.notemusicali.music.MusicalNote
|
||||
import com.notemusicali.music.NoteSequence
|
||||
|
||||
data class Exercise(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val level: Int,
|
||||
val sequence: NoteSequence,
|
||||
)
|
||||
|
||||
object ExerciseRepository {
|
||||
|
||||
fun getAllExercises(): List<NoteSequence> = all.map { it.sequence }
|
||||
|
||||
val all: List<Exercise> = listOf(
|
||||
Exercise(
|
||||
id = 1,
|
||||
title = "Prime Note",
|
||||
description = "5 note naturali base",
|
||||
level = 1,
|
||||
sequence = NoteSequence(
|
||||
name = "Prime Note",
|
||||
notes = listOf(
|
||||
MusicalNote.fromNameAndOctave(0, 4), // DO4
|
||||
MusicalNote.fromNameAndOctave(2, 4), // RE4
|
||||
MusicalNote.fromNameAndOctave(4, 4), // MI4
|
||||
MusicalNote.fromNameAndOctave(5, 4), // FA4
|
||||
MusicalNote.fromNameAndOctave(7, 4), // SOL4
|
||||
),
|
||||
),
|
||||
),
|
||||
Exercise(
|
||||
id = 2,
|
||||
title = "Scala di DO Maggiore",
|
||||
description = "Scala completa su un'ottava",
|
||||
level = 2,
|
||||
sequence = NoteSequence(
|
||||
name = "Scala di DO Maggiore",
|
||||
notes = listOf(
|
||||
MusicalNote.fromNameAndOctave(0, 4), // DO4
|
||||
MusicalNote.fromNameAndOctave(2, 4), // RE4
|
||||
MusicalNote.fromNameAndOctave(4, 4), // MI4
|
||||
MusicalNote.fromNameAndOctave(5, 4), // FA4
|
||||
MusicalNote.fromNameAndOctave(7, 4), // SOL4
|
||||
MusicalNote.fromNameAndOctave(9, 4), // LA4
|
||||
MusicalNote.fromNameAndOctave(11, 4), // SI4
|
||||
MusicalNote.fromNameAndOctave(0, 5), // DO5
|
||||
),
|
||||
),
|
||||
),
|
||||
Exercise(
|
||||
id = 3,
|
||||
title = "Scala di SOL Maggiore",
|
||||
description = "Introduce FA# e cambio ottava",
|
||||
level = 3,
|
||||
sequence = NoteSequence(
|
||||
name = "Scala di SOL Maggiore",
|
||||
notes = listOf(
|
||||
MusicalNote.fromNameAndOctave(7, 3), // SOL3
|
||||
MusicalNote.fromNameAndOctave(9, 3), // LA3
|
||||
MusicalNote.fromNameAndOctave(11, 3), // SI3
|
||||
MusicalNote.fromNameAndOctave(0, 4), // DO4
|
||||
MusicalNote.fromNameAndOctave(2, 4), // RE4
|
||||
MusicalNote.fromNameAndOctave(4, 4), // MI4
|
||||
MusicalNote.fromNameAndOctave(6, 4), // FA#4
|
||||
MusicalNote.fromNameAndOctave(7, 4), // SOL4
|
||||
),
|
||||
),
|
||||
),
|
||||
Exercise(
|
||||
id = 4,
|
||||
title = "Arpeggi Maggiori",
|
||||
description = "Salti di terza e quinta",
|
||||
level = 4,
|
||||
sequence = NoteSequence(
|
||||
name = "Arpeggi Maggiori",
|
||||
notes = listOf(
|
||||
MusicalNote.fromNameAndOctave(0, 4), // DO4
|
||||
MusicalNote.fromNameAndOctave(4, 4), // MI4
|
||||
MusicalNote.fromNameAndOctave(7, 4), // SOL4
|
||||
MusicalNote.fromNameAndOctave(0, 5), // DO5
|
||||
MusicalNote.fromNameAndOctave(7, 3), // SOL3
|
||||
MusicalNote.fromNameAndOctave(11, 3), // SI3
|
||||
MusicalNote.fromNameAndOctave(2, 4), // RE4
|
||||
MusicalNote.fromNameAndOctave(7, 4), // SOL4
|
||||
),
|
||||
),
|
||||
),
|
||||
Exercise(
|
||||
id = 5,
|
||||
title = "Ave Maria — Schubert",
|
||||
description = "Melodia principale (Sib maggiore)",
|
||||
level = 3,
|
||||
sequence = NoteSequence(
|
||||
name = "Ave Maria — Schubert",
|
||||
notes = listOf(
|
||||
MusicalNote.fromMidi(70), // Sib4
|
||||
MusicalNote.fromMidi(69), // La4
|
||||
MusicalNote.fromMidi(70), // Sib4
|
||||
MusicalNote.fromMidi(74), // Re5
|
||||
MusicalNote.fromMidi(72), // Do5
|
||||
MusicalNote.fromMidi(70), // Sib4
|
||||
MusicalNote.fromMidi(72), // Do5
|
||||
MusicalNote.fromMidi(74), // Re5
|
||||
MusicalNote.fromMidi(72), // Do5
|
||||
MusicalNote.fromMidi(70), // Sib4
|
||||
MusicalNote.fromMidi(69), // La4
|
||||
MusicalNote.fromMidi(67), // Sol4
|
||||
MusicalNote.fromMidi(69), // La4
|
||||
MusicalNote.fromMidi(70), // Sib4
|
||||
MusicalNote.fromMidi(74), // Re5
|
||||
MusicalNote.fromMidi(74), // Re5
|
||||
MusicalNote.fromMidi(72), // Do5
|
||||
MusicalNote.fromMidi(70), // Sib4
|
||||
MusicalNote.fromMidi(69), // La4
|
||||
MusicalNote.fromMidi(67), // Sol4
|
||||
MusicalNote.fromMidi(74), // Re5
|
||||
MusicalNote.fromMidi(76), // Mi5
|
||||
MusicalNote.fromMidi(74), // Re5
|
||||
MusicalNote.fromMidi(73), // Do#5
|
||||
MusicalNote.fromMidi(69), // La4
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.notemusicali.goals
|
||||
|
||||
import com.notemusicali.scan.AppSettings
|
||||
import com.notemusicali.util.currentDateString
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class DailyGoalSettings(
|
||||
val targetMinutes: Int = 15,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DailyGoalState(
|
||||
val streak: Int = 0,
|
||||
val lastGoalMetDate: String = "",
|
||||
val todayMs: Long = 0L,
|
||||
val todayDate: String = "",
|
||||
val history: Map<String, Long> = emptyMap(), // date -> ms practiced
|
||||
)
|
||||
|
||||
object DailyGoalManager {
|
||||
|
||||
private const val SETTINGS_KEY = "daily_goal_settings"
|
||||
private const val STATE_KEY = "daily_goal_state"
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun getSettings(): DailyGoalSettings {
|
||||
val raw = AppSettings.getString(SETTINGS_KEY) ?: return DailyGoalSettings()
|
||||
return try {
|
||||
json.decodeFromString<DailyGoalSettings>(raw)
|
||||
} catch (_: Exception) { DailyGoalSettings() }
|
||||
}
|
||||
|
||||
fun saveSettings(settings: DailyGoalSettings) {
|
||||
AppSettings.putString(SETTINGS_KEY, json.encodeToString(settings))
|
||||
}
|
||||
|
||||
fun getState(): DailyGoalState {
|
||||
val raw = AppSettings.getString(STATE_KEY) ?: return DailyGoalState()
|
||||
return try {
|
||||
val state = json.decodeFromString<DailyGoalState>(raw)
|
||||
val today = currentDateString()
|
||||
if (state.todayDate != today) {
|
||||
// New day — reset today counter, keep history
|
||||
state.copy(todayMs = 0L, todayDate = today)
|
||||
} else state
|
||||
} catch (_: Exception) { DailyGoalState() }
|
||||
}
|
||||
|
||||
fun recordPractice(durationMs: Long) {
|
||||
val today = currentDateString()
|
||||
val settings = getSettings()
|
||||
var state = getState()
|
||||
|
||||
// Ensure todayDate is current
|
||||
if (state.todayDate != today) {
|
||||
state = state.copy(todayMs = 0L, todayDate = today)
|
||||
}
|
||||
|
||||
val newTodayMs = state.todayMs + durationMs
|
||||
val updatedHistory = state.history.toMutableMap()
|
||||
updatedHistory[today] = newTodayMs
|
||||
|
||||
// Keep only last 60 days of history
|
||||
val sortedKeys = updatedHistory.keys.sorted()
|
||||
if (sortedKeys.size > 60) {
|
||||
sortedKeys.take(sortedKeys.size - 60).forEach { updatedHistory.remove(it) }
|
||||
}
|
||||
|
||||
val targetMs = settings.targetMinutes * 60_000L
|
||||
val goalMetToday = newTodayMs >= targetMs
|
||||
val wasMetBefore = state.todayMs >= targetMs
|
||||
|
||||
var streak = state.streak
|
||||
if (goalMetToday && !wasMetBefore) {
|
||||
// Calculate streak: check if yesterday was the last goal-met date
|
||||
val yesterday = previousDateString(today)
|
||||
streak = if (state.lastGoalMetDate == yesterday || state.lastGoalMetDate == today) {
|
||||
state.streak + 1
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
val newState = state.copy(
|
||||
todayMs = newTodayMs,
|
||||
todayDate = today,
|
||||
streak = streak,
|
||||
lastGoalMetDate = if (goalMetToday) today else state.lastGoalMetDate,
|
||||
history = updatedHistory,
|
||||
)
|
||||
|
||||
AppSettings.putString(STATE_KEY, json.encodeToString(newState))
|
||||
}
|
||||
|
||||
fun isTodayGoalMet(): Boolean {
|
||||
val settings = getSettings()
|
||||
val state = getState()
|
||||
val today = currentDateString()
|
||||
if (state.todayDate != today) return false
|
||||
return state.todayMs >= settings.targetMinutes * 60_000L
|
||||
}
|
||||
|
||||
fun todayProgressFraction(): Float {
|
||||
val settings = getSettings()
|
||||
val state = getState()
|
||||
val today = currentDateString()
|
||||
if (state.todayDate != today) return 0f
|
||||
val target = settings.targetMinutes * 60_000L
|
||||
if (target <= 0) return 1f
|
||||
return (state.todayMs.toFloat() / target).coerceAtMost(1f)
|
||||
}
|
||||
|
||||
/** Get last N days of practice history (date -> minutes). */
|
||||
fun recentHistory(days: Int = 30): List<Pair<String, Int>> {
|
||||
val state = getState()
|
||||
val allDates = state.history.entries
|
||||
.sortedByDescending { it.key }
|
||||
.take(days)
|
||||
.reversed()
|
||||
return allDates.map { (date, ms) -> date to (ms / 60_000L).toInt() }
|
||||
}
|
||||
|
||||
private fun previousDateString(yyyymmdd: String): String {
|
||||
if (yyyymmdd.length != 8) return ""
|
||||
val y = yyyymmdd.substring(0, 4).toIntOrNull() ?: return ""
|
||||
val m = yyyymmdd.substring(4, 6).toIntOrNull() ?: return ""
|
||||
val d = yyyymmdd.substring(6, 8).toIntOrNull() ?: return ""
|
||||
|
||||
return if (d > 1) {
|
||||
"$y${m.toString().padStart(2, '0')}${(d - 1).toString().padStart(2, '0')}"
|
||||
} else if (m > 1) {
|
||||
val prevMonth = m - 1
|
||||
val daysInPrevMonth = daysInMonth(prevMonth, y)
|
||||
"$y${prevMonth.toString().padStart(2, '0')}${daysInPrevMonth.toString().padStart(2, '0')}"
|
||||
} else {
|
||||
"${y - 1}1231"
|
||||
}
|
||||
}
|
||||
|
||||
private fun daysInMonth(month: Int, year: Int): Int = when (month) {
|
||||
1 -> 31; 2 -> if (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) 29 else 28
|
||||
3 -> 31; 4 -> 30; 5 -> 31; 6 -> 30; 7 -> 31; 8 -> 31
|
||||
9 -> 30; 10 -> 31; 11 -> 30; 12 -> 31; else -> 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.notemusicali.history
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class NoteResult(
|
||||
val noteName: String,
|
||||
val midiNumber: Int,
|
||||
val centsDeviation: Float,
|
||||
val reactionTimeMs: Long,
|
||||
val firstAttemptSuccess: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PracticeSession(
|
||||
val id: String,
|
||||
val sequenceName: String,
|
||||
val instrumentName: String,
|
||||
val dateMillis: Long,
|
||||
val totalTimeMs: Long,
|
||||
val noteResults: List<NoteResult>,
|
||||
val noteMidiNumbers: List<Int>,
|
||||
val completed: Boolean = true,
|
||||
) {
|
||||
val averageCents: Float
|
||||
get() = if (noteResults.isEmpty()) 0f
|
||||
else noteResults.map { kotlin.math.abs(it.centsDeviation) }.average().toFloat()
|
||||
|
||||
val perfectNotes: Int
|
||||
get() = noteResults.count { kotlin.math.abs(it.centsDeviation) <= 10f }
|
||||
|
||||
val firstAttemptRate: Float
|
||||
get() = if (noteResults.isEmpty()) 0f
|
||||
else noteResults.count { it.firstAttemptSuccess }.toFloat() / noteResults.size
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.notemusicali.history
|
||||
|
||||
import com.notemusicali.scan.AppSettings
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object SessionRepository {
|
||||
private const val KEY = "practice_sessions"
|
||||
private const val MAX_SESSIONS = 50
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun load(): List<PracticeSession> {
|
||||
val raw = AppSettings.getString(KEY) ?: return emptyList()
|
||||
return try {
|
||||
json.decodeFromString<List<PracticeSession>>(raw)
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun save(session: PracticeSession) {
|
||||
val current = load().toMutableList()
|
||||
current.add(0, session)
|
||||
if (current.size > MAX_SESSIONS) {
|
||||
current.subList(MAX_SESSIONS, current.size).clear()
|
||||
}
|
||||
AppSettings.putString(KEY, json.encodeToString(current))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.notemusicali.metronome
|
||||
|
||||
import com.notemusicali.audio.ToneGenerator
|
||||
import com.notemusicali.audio.createToneGenerator
|
||||
import com.notemusicali.util.currentTimeMillis
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class TimeSignature(val displayName: String, val beatsPerMeasure: Int) {
|
||||
TWO_FOUR("2/4", 2),
|
||||
THREE_FOUR("3/4", 3),
|
||||
FOUR_FOUR("4/4", 4),
|
||||
SIX_EIGHT("6/8", 6),
|
||||
}
|
||||
|
||||
class MetronomeEngine(
|
||||
private val toneGenerator: ToneGenerator = createToneGenerator(),
|
||||
private val nowMs: () -> Long = { currentTimeMillis() },
|
||||
) {
|
||||
private val _currentBeat = MutableStateFlow(0)
|
||||
val currentBeat: StateFlow<Int> = _currentBeat.asStateFlow()
|
||||
|
||||
private val _isPlaying = MutableStateFlow(false)
|
||||
val isPlaying: StateFlow<Boolean> = _isPlaying.asStateFlow()
|
||||
|
||||
var bpm: Int = 120
|
||||
var timeSignature: TimeSignature = TimeSignature.FOUR_FOUR
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
fun start(scope: CoroutineScope) {
|
||||
if (_isPlaying.value) return
|
||||
_isPlaying.value = true
|
||||
_currentBeat.value = 0
|
||||
|
||||
job = scope.launch {
|
||||
var beat = 0
|
||||
// Absolute-time grid: each tick is scheduled from the accumulated ideal
|
||||
// time, so click/scheduler latency never accumulates as drift.
|
||||
var nextTickMs = nowMs().toDouble()
|
||||
while (isActive) {
|
||||
val isAccent = beat == 0
|
||||
_currentBeat.value = beat
|
||||
toneGenerator.playClick(accent = isAccent)
|
||||
|
||||
nextTickMs += 60_000.0 / bpm
|
||||
delay((nextTickMs - nowMs()).toLong().coerceAtLeast(0L))
|
||||
|
||||
beat = (beat + 1) % timeSignature.beatsPerMeasure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
_isPlaying.value = false
|
||||
_currentBeat.value = 0
|
||||
toneGenerator.stop()
|
||||
}
|
||||
|
||||
fun release() {
|
||||
stop()
|
||||
toneGenerator.release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.notemusicali.music
|
||||
|
||||
/**
|
||||
* Maps note indices (0-11) to Italian notation names.
|
||||
* 0=DO(C), 1=DO#, 2=RE(D), ... 11=SI(B)
|
||||
*/
|
||||
object ItalianNotation {
|
||||
|
||||
data class NoteName(
|
||||
val sharp: String,
|
||||
val flat: String?,
|
||||
) {
|
||||
val display: String get() = sharp
|
||||
}
|
||||
|
||||
private val NOTE_NAMES = arrayOf(
|
||||
NoteName("DO", null), // 0 - C
|
||||
NoteName("DO#", "REb"), // 1 - C#/Db
|
||||
NoteName("RE", null), // 2 - D
|
||||
NoteName("RE#", "MIb"), // 3 - D#/Eb
|
||||
NoteName("MI", null), // 4 - E
|
||||
NoteName("FA", null), // 5 - F
|
||||
NoteName("FA#", "SOLb"), // 6 - F#/Gb
|
||||
NoteName("SOL", null), // 7 - G
|
||||
NoteName("SOL#", "LAb"), // 8 - G#/Ab
|
||||
NoteName("LA", null), // 9 - A
|
||||
NoteName("LA#", "SIb"), // 10 - A#/Bb
|
||||
NoteName("SI", null), // 11 - B
|
||||
)
|
||||
|
||||
fun fromNoteIndex(index: Int): NoteName {
|
||||
return NOTE_NAMES[index % 12]
|
||||
}
|
||||
|
||||
fun allNotes(): List<NoteName> = NOTE_NAMES.toList()
|
||||
|
||||
/**
|
||||
* Parse an Italian note name to its index (0-11).
|
||||
* Accepts both sharp and flat variants.
|
||||
*/
|
||||
fun toNoteIndex(name: String): Int? {
|
||||
val upper = name.uppercase().trim()
|
||||
return NOTE_NAMES.indexOfFirst { noteName ->
|
||||
noteName.sharp.uppercase() == upper ||
|
||||
noteName.flat?.uppercase() == upper
|
||||
}.takeIf { it >= 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Map MusicXML pitch step (C,D,E,F,G,A,B) + alter to note index.
|
||||
*/
|
||||
fun fromMusicXmlStep(step: String, alter: Int = 0): Int? {
|
||||
val baseIndex = when (step.uppercase()) {
|
||||
"C" -> 0
|
||||
"D" -> 2
|
||||
"E" -> 4
|
||||
"F" -> 5
|
||||
"G" -> 7
|
||||
"A" -> 9
|
||||
"B" -> 11
|
||||
else -> return null
|
||||
}
|
||||
return ((baseIndex + alter) + 12) % 12
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.notemusicali.music
|
||||
|
||||
/**
|
||||
* Parses MusicXML content to extract a monophonic note sequence suitable for
|
||||
* guided practice:
|
||||
* - only the first <part> is read (typically the melody / right hand)
|
||||
* - only the first voice of that part is kept
|
||||
* - chord continuation notes (<chord/>), grace notes and tied-note
|
||||
* continuations (<tie type="stop">) are skipped
|
||||
* - rests are skipped
|
||||
* - only the first time signature is used; mid-piece meter changes are ignored
|
||||
*/
|
||||
object MusicXmlParser {
|
||||
|
||||
// <part\s...> or <part>, but not <part-list>/<part-name>
|
||||
private val PART_START_REGEX = Regex("""<part(\s[^>]*)?>""")
|
||||
private val NOTE_BLOCK_REGEX = Regex("""<note\b[^>]*>(.*?)</note>""", RegexOption.DOT_MATCHES_ALL)
|
||||
private val REST_REGEX = Regex("""<rest\b""")
|
||||
private val CHORD_REGEX = Regex("""<chord\b""")
|
||||
private val GRACE_REGEX = Regex("""<grace\b""")
|
||||
private val TIE_STOP_REGEX = Regex("""<tie\b[^>]*type\s*=\s*["']stop["']""")
|
||||
private val VOICE_REGEX = Regex("""<voice>\s*(\d+)\s*</voice>""")
|
||||
private val DIVISIONS_REGEX = Regex("""<divisions>\s*(\d+)\s*</divisions>""")
|
||||
private val DURATION_REGEX = Regex("""<duration>\s*(\d+)\s*</duration>""")
|
||||
private val STEP_REGEX = Regex("""<step>([A-Ga-g])</step>""")
|
||||
private val ALTER_REGEX = Regex("""<alter>(-?\d+)</alter>""")
|
||||
private val OCTAVE_REGEX = Regex("""<octave>(\d+)</octave>""")
|
||||
private val TYPE_REGEX = Regex("""<type>(\w+)</type>""")
|
||||
private val TIME_REGEX = Regex(
|
||||
"""<time(?:\s[^>]*)?>.*?<beats>\s*(\d+)\s*</beats>.*?<beat-type>\s*(\d+)\s*</beat-type>.*?</time>""",
|
||||
RegexOption.DOT_MATCHES_ALL,
|
||||
)
|
||||
|
||||
fun parse(xmlContent: String, name: String = "MusicXML"): NoteSequence {
|
||||
val partContent = firstPartContent(xmlContent)
|
||||
val notes = mutableListOf<MusicalNote>()
|
||||
var firstVoice: String? = null
|
||||
|
||||
// <divisions> (in <attributes>) defines how many duration units make a
|
||||
// quarter note; it can change mid-part, so track each occurrence's position
|
||||
val divisionsByOffset = DIVISIONS_REGEX.findAll(partContent)
|
||||
.mapNotNull { m -> m.groupValues[1].toIntOrNull()?.let { m.range.first to it } }
|
||||
.toList()
|
||||
|
||||
NOTE_BLOCK_REGEX.findAll(partContent).forEach { noteMatch ->
|
||||
val noteBlock = noteMatch.groupValues[1]
|
||||
|
||||
if (REST_REGEX.containsMatchIn(noteBlock)) return@forEach
|
||||
if (CHORD_REGEX.containsMatchIn(noteBlock)) return@forEach
|
||||
if (GRACE_REGEX.containsMatchIn(noteBlock)) return@forEach
|
||||
if (TIE_STOP_REGEX.containsMatchIn(noteBlock)) return@forEach
|
||||
|
||||
val voice = VOICE_REGEX.find(noteBlock)?.groupValues?.get(1)
|
||||
if (voice != null) {
|
||||
if (firstVoice == null) firstVoice = voice
|
||||
else if (voice != firstVoice) return@forEach
|
||||
}
|
||||
|
||||
val step = STEP_REGEX.find(noteBlock)?.groupValues?.get(1) ?: return@forEach
|
||||
val alter = ALTER_REGEX.find(noteBlock)?.groupValues?.get(1)?.toIntOrNull() ?: 0
|
||||
val octave = OCTAVE_REGEX.find(noteBlock)?.groupValues?.get(1)?.toIntOrNull() ?: 4
|
||||
|
||||
val noteIndex = ItalianNotation.fromMusicXmlStep(step.uppercase(), alter) ?: return@forEach
|
||||
val type = TYPE_REGEX.find(noteBlock)?.groupValues?.get(1)
|
||||
val duration = when {
|
||||
type != null -> NoteDuration.fromMusicXml(type)
|
||||
else -> durationFromRatio(noteBlock, noteMatch.range.first, divisionsByOffset)
|
||||
}
|
||||
notes.add(MusicalNote.fromNameAndOctave(noteIndex, octave, duration, preferFlat = alter < 0))
|
||||
}
|
||||
|
||||
val time = TIME_REGEX.find(partContent)
|
||||
val beats = time?.groupValues?.get(1)?.toIntOrNull() ?: 4
|
||||
val beatType = time?.groupValues?.get(2)?.toIntOrNull() ?: 4
|
||||
return NoteSequence(name = name, notes = notes, beats = beats, beatType = beatType)
|
||||
}
|
||||
|
||||
/** Derives the note value from <duration>/<divisions> when <type> is absent. */
|
||||
private fun durationFromRatio(
|
||||
noteBlock: String,
|
||||
noteOffset: Int,
|
||||
divisionsByOffset: List<Pair<Int, Int>>,
|
||||
): NoteDuration {
|
||||
val durationUnits = DURATION_REGEX.find(noteBlock)?.groupValues?.get(1)?.toIntOrNull()
|
||||
?: return NoteDuration.QUARTER
|
||||
val divisions = divisionsByOffset.lastOrNull { it.first < noteOffset }?.second ?: 1
|
||||
if (divisions <= 0) return NoteDuration.QUARTER
|
||||
val quarters = durationUnits.toDouble() / divisions
|
||||
return when {
|
||||
quarters >= 4.0 -> NoteDuration.WHOLE
|
||||
quarters >= 2.0 -> NoteDuration.HALF
|
||||
quarters >= 1.0 -> NoteDuration.QUARTER
|
||||
quarters >= 0.5 -> NoteDuration.EIGHTH
|
||||
quarters >= 0.25 -> NoteDuration.SIXTEENTH
|
||||
else -> NoteDuration.THIRTY_SECOND
|
||||
}
|
||||
}
|
||||
|
||||
/** Content of the first <part> element, or the whole document if none is found. */
|
||||
private fun firstPartContent(xml: String): String {
|
||||
val start = PART_START_REGEX.find(xml) ?: return xml
|
||||
val contentStart = start.range.last + 1
|
||||
val end = xml.indexOf("</part>", contentStart)
|
||||
return if (end >= 0) xml.substring(contentStart, end) else xml.substring(contentStart)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.notemusicali.music
|
||||
|
||||
enum class NoteDuration(val holdMs: Long) {
|
||||
WHOLE(2500),
|
||||
HALF(1500),
|
||||
QUARTER(1000),
|
||||
EIGHTH(600),
|
||||
SIXTEENTH(400),
|
||||
THIRTY_SECOND(300),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromMusicXml(type: String): NoteDuration = when (type) {
|
||||
"whole" -> WHOLE
|
||||
"half" -> HALF
|
||||
"quarter" -> QUARTER
|
||||
"eighth" -> EIGHTH
|
||||
"16th" -> SIXTEENTH
|
||||
"32nd" -> THIRTY_SECOND
|
||||
else -> QUARTER
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a musical note with its properties.
|
||||
*/
|
||||
data class MusicalNote(
|
||||
val noteIndex: Int,
|
||||
val octave: Int,
|
||||
val italianName: ItalianNotation.NoteName,
|
||||
val midiNumber: Int,
|
||||
val duration: NoteDuration = NoteDuration.QUARTER,
|
||||
// Enharmonic preference: a black key can be spelled as sharp (default) or flat
|
||||
val preferFlat: Boolean = false,
|
||||
) {
|
||||
val displayName: String
|
||||
get() = if (preferFlat) italianName.flat ?: italianName.sharp else italianName.sharp
|
||||
val fullName: String get() = "$displayName$octave"
|
||||
|
||||
companion object {
|
||||
fun fromMidi(
|
||||
midiNumber: Int,
|
||||
duration: NoteDuration = NoteDuration.QUARTER,
|
||||
preferFlat: Boolean = false,
|
||||
): MusicalNote {
|
||||
val noteIndex = ((midiNumber % 12) + 12) % 12
|
||||
val octave = (midiNumber / 12) - 1
|
||||
return MusicalNote(
|
||||
noteIndex = noteIndex,
|
||||
octave = octave,
|
||||
italianName = ItalianNotation.fromNoteIndex(noteIndex),
|
||||
midiNumber = midiNumber,
|
||||
duration = duration,
|
||||
preferFlat = preferFlat,
|
||||
)
|
||||
}
|
||||
|
||||
fun fromNameAndOctave(
|
||||
noteIndex: Int,
|
||||
octave: Int,
|
||||
duration: NoteDuration = NoteDuration.QUARTER,
|
||||
preferFlat: Boolean = false,
|
||||
): MusicalNote {
|
||||
val midiNumber = (octave + 1) * 12 + noteIndex
|
||||
return MusicalNote(
|
||||
noteIndex = noteIndex,
|
||||
octave = octave,
|
||||
italianName = ItalianNotation.fromNoteIndex(noteIndex),
|
||||
midiNumber = midiNumber,
|
||||
duration = duration,
|
||||
preferFlat = preferFlat,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.notemusicali.music
|
||||
|
||||
/**
|
||||
* Ordered list of notes for practice mode.
|
||||
*/
|
||||
data class NoteSequence(
|
||||
val name: String,
|
||||
val notes: List<MusicalNote>,
|
||||
val beats: Int = 4,
|
||||
val beatType: Int = 4,
|
||||
) {
|
||||
val size: Int get() = notes.size
|
||||
|
||||
fun noteAt(index: Int): MusicalNote? = notes.getOrNull(index)
|
||||
|
||||
companion object {
|
||||
fun empty(name: String = "Nuova Sequenza"): NoteSequence {
|
||||
return NoteSequence(name = name, notes = emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.notemusicali.music
|
||||
|
||||
enum class ScaleType(val displayName: String, val intervals: List<Int>) {
|
||||
MAJOR("Maggiore", listOf(0, 2, 4, 5, 7, 9, 11, 12)),
|
||||
NATURAL_MINOR("Minore Naturale", listOf(0, 2, 3, 5, 7, 8, 10, 12)),
|
||||
HARMONIC_MINOR("Minore Armonica", listOf(0, 2, 3, 5, 7, 8, 11, 12)),
|
||||
MELODIC_MINOR("Minore Melodica", listOf(0, 2, 3, 5, 7, 9, 11, 12)),
|
||||
PENTATONIC_MAJOR("Pentatonica Maggiore", listOf(0, 2, 4, 7, 9, 12)),
|
||||
PENTATONIC_MINOR("Pentatonica Minore", listOf(0, 3, 5, 7, 10, 12)),
|
||||
CHROMATIC("Cromatica", listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)),
|
||||
}
|
||||
|
||||
enum class ArpeggioType(val displayName: String, val intervals: List<Int>) {
|
||||
MAJOR("Maggiore", listOf(0, 4, 7, 12)),
|
||||
MINOR("Minore", listOf(0, 3, 7, 12)),
|
||||
DIMINISHED("Diminuito", listOf(0, 3, 6, 12)),
|
||||
AUGMENTED("Aumentato", listOf(0, 4, 8, 12)),
|
||||
DOMINANT_7TH("Settima Dom.", listOf(0, 4, 7, 10, 12)),
|
||||
MAJOR_7TH("Settima Magg.", listOf(0, 4, 7, 11, 12)),
|
||||
MINOR_7TH("Settima Min.", listOf(0, 3, 7, 10, 12)),
|
||||
}
|
||||
|
||||
enum class ScaleDirection(val displayName: String) {
|
||||
ASCENDING("Ascendente"),
|
||||
DESCENDING("Discendente"),
|
||||
BOTH("Asc. + Disc."),
|
||||
}
|
||||
|
||||
object ScaleGenerator {
|
||||
|
||||
fun generateScale(
|
||||
rootNoteIndex: Int,
|
||||
octave: Int,
|
||||
scaleType: ScaleType,
|
||||
direction: ScaleDirection = ScaleDirection.ASCENDING,
|
||||
): NoteSequence {
|
||||
val rootMidi = (octave + 1) * 12 + rootNoteIndex
|
||||
val rootName = ItalianNotation.fromNoteIndex(rootNoteIndex).display
|
||||
val name = "Scala $rootName ${scaleType.displayName}"
|
||||
|
||||
val ascending = scaleType.intervals.map { interval ->
|
||||
MusicalNote.fromMidi(rootMidi + interval)
|
||||
}
|
||||
|
||||
val notes = when (direction) {
|
||||
ScaleDirection.ASCENDING -> ascending
|
||||
ScaleDirection.DESCENDING -> ascending.reversed()
|
||||
ScaleDirection.BOTH -> ascending + ascending.dropLast(1).reversed()
|
||||
}
|
||||
|
||||
return NoteSequence(name = name, notes = notes)
|
||||
}
|
||||
|
||||
fun generateArpeggio(
|
||||
rootNoteIndex: Int,
|
||||
octave: Int,
|
||||
arpeggioType: ArpeggioType,
|
||||
direction: ScaleDirection = ScaleDirection.ASCENDING,
|
||||
): NoteSequence {
|
||||
val rootMidi = (octave + 1) * 12 + rootNoteIndex
|
||||
val rootName = ItalianNotation.fromNoteIndex(rootNoteIndex).display
|
||||
val name = "Arpeggio $rootName ${arpeggioType.displayName}"
|
||||
|
||||
val ascending = arpeggioType.intervals.map { interval ->
|
||||
MusicalNote.fromMidi(rootMidi + interval)
|
||||
}
|
||||
|
||||
val notes = when (direction) {
|
||||
ScaleDirection.ASCENDING -> ascending
|
||||
ScaleDirection.DESCENDING -> ascending.reversed()
|
||||
ScaleDirection.BOTH -> ascending + ascending.dropLast(1).reversed()
|
||||
}
|
||||
|
||||
return NoteSequence(name = name, notes = notes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package com.notemusicali.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.notemusicali.audio.InstrumentPreset
|
||||
import com.notemusicali.challenge.ChallengeTimeLimit
|
||||
import com.notemusicali.music.MusicalNote
|
||||
import com.notemusicali.music.NoteSequence
|
||||
import com.notemusicali.ui.challenge.ChallengeScreen
|
||||
import com.notemusicali.ui.challenge.ChallengeSetupScreen
|
||||
import com.notemusicali.ui.challenge.ChallengeViewModel
|
||||
import com.notemusicali.ui.challenge.LeaderboardScreen
|
||||
import com.notemusicali.ui.eartraining.EarTrainingScreen
|
||||
import com.notemusicali.ui.exercises.ExercisesScreen
|
||||
import com.notemusicali.ui.goals.DailyGoalScreen
|
||||
import com.notemusicali.ui.guide.GuideScreen
|
||||
import com.notemusicali.ui.history.HistoryScreen
|
||||
import com.notemusicali.ui.home.HomeScreen
|
||||
import com.notemusicali.ui.metronome.MetronomeScreen
|
||||
import com.notemusicali.ui.practice.ManualInputScreen
|
||||
import com.notemusicali.ui.practice.PracticeScreen
|
||||
import com.notemusicali.ui.practice.PracticeSetupScreen
|
||||
import com.notemusicali.ui.practice.PracticeViewModel
|
||||
import com.notemusicali.ui.reference.ReferenceNoteScreen
|
||||
import com.notemusicali.ui.scales.ScaleGeneratorScreen
|
||||
import com.notemusicali.ui.scan.ScanScreen
|
||||
import com.notemusicali.ui.scan.ScanViewModel
|
||||
import com.notemusicali.ui.scores.ScoresScreen
|
||||
import com.notemusicali.ui.stats.StatsScreen
|
||||
import com.notemusicali.ui.tuner.TunerScreen
|
||||
|
||||
object Routes {
|
||||
const val HOME = "home"
|
||||
const val TUNER = "tuner"
|
||||
const val PRACTICE_SETUP = "practice_setup"
|
||||
const val MANUAL_INPUT = "manual_input"
|
||||
const val SCAN = "scan"
|
||||
const val PRACTICE = "practice"
|
||||
const val EXERCISES = "exercises"
|
||||
const val HISTORY = "history"
|
||||
const val SCORES = "scores"
|
||||
const val METRONOME = "metronome"
|
||||
const val REFERENCE_NOTE = "reference_note"
|
||||
const val SCALES = "scales"
|
||||
const val EAR_TRAINING = "ear_training"
|
||||
const val CHALLENGE_SETUP = "challenge_setup"
|
||||
const val CHALLENGE = "challenge"
|
||||
const val LEADERBOARD = "leaderboard"
|
||||
const val STATS = "stats"
|
||||
const val DAILY_GOALS = "daily_goals"
|
||||
const val GUIDE = "guide"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppNavigation() {
|
||||
val navController = rememberNavController()
|
||||
|
||||
var currentSequence by remember { mutableStateOf<NoteSequence?>(null) }
|
||||
var currentInstrument by remember { mutableStateOf(InstrumentPreset.VIOLINO) }
|
||||
var challengeTimeLimit by remember { mutableStateOf(ChallengeTimeLimit.MEDIUM) }
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Routes.HOME,
|
||||
) {
|
||||
composable(Routes.HOME) {
|
||||
HomeScreen(
|
||||
onNavigateToTuner = { navController.navigate(Routes.TUNER) },
|
||||
onNavigateToPractice = { navController.navigate(Routes.PRACTICE_SETUP) },
|
||||
onNavigateToExercises = { navController.navigate(Routes.EXERCISES) },
|
||||
onNavigateToHistory = { navController.navigate(Routes.HISTORY) },
|
||||
onNavigateToScores = { navController.navigate(Routes.SCORES) },
|
||||
onNavigateToMetronome = { navController.navigate(Routes.METRONOME) },
|
||||
onNavigateToReference = { navController.navigate(Routes.REFERENCE_NOTE) },
|
||||
onNavigateToScales = { navController.navigate(Routes.SCALES) },
|
||||
onNavigateToEarTraining = { navController.navigate(Routes.EAR_TRAINING) },
|
||||
onNavigateToChallenge = { navController.navigate(Routes.CHALLENGE_SETUP) },
|
||||
onNavigateToLeaderboard = { navController.navigate(Routes.LEADERBOARD) },
|
||||
onNavigateToStats = { navController.navigate(Routes.STATS) },
|
||||
onNavigateToDailyGoals = { navController.navigate(Routes.DAILY_GOALS) },
|
||||
onNavigateToGuide = { navController.navigate(Routes.GUIDE) },
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.TUNER) {
|
||||
TunerScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
composable(Routes.METRONOME) {
|
||||
MetronomeScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
composable(Routes.REFERENCE_NOTE) {
|
||||
ReferenceNoteScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
composable(Routes.SCALES) {
|
||||
ScaleGeneratorScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onPractice = { sequence ->
|
||||
currentSequence = sequence
|
||||
currentInstrument = InstrumentPreset.VIOLINO
|
||||
navController.navigate(Routes.PRACTICE) {
|
||||
popUpTo(Routes.SCALES)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.EAR_TRAINING) {
|
||||
EarTrainingScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
composable(Routes.CHALLENGE_SETUP) {
|
||||
ChallengeSetupScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onStart = { sequence, timeLimit ->
|
||||
currentSequence = sequence
|
||||
challengeTimeLimit = timeLimit
|
||||
navController.navigate(Routes.CHALLENGE) {
|
||||
popUpTo(Routes.CHALLENGE_SETUP)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.CHALLENGE) {
|
||||
val challengeViewModel: ChallengeViewModel = viewModel { ChallengeViewModel() }
|
||||
LaunchedEffect(currentSequence, challengeTimeLimit) {
|
||||
currentSequence?.let { seq ->
|
||||
challengeViewModel.setSequence(seq)
|
||||
challengeViewModel.setTimeLimit(challengeTimeLimit)
|
||||
challengeViewModel.start()
|
||||
}
|
||||
}
|
||||
ChallengeScreen(
|
||||
onBack = { navController.popBackStack(Routes.HOME, inclusive = false) },
|
||||
viewModel = challengeViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.LEADERBOARD) {
|
||||
LeaderboardScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
composable(Routes.STATS) {
|
||||
StatsScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
composable(Routes.DAILY_GOALS) {
|
||||
DailyGoalScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
composable(Routes.GUIDE) {
|
||||
GuideScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
composable(Routes.PRACTICE_SETUP) {
|
||||
PracticeSetupScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onManualInput = { navController.navigate(Routes.MANUAL_INPUT) },
|
||||
onScan = { navController.navigate(Routes.SCAN) },
|
||||
onMusicXmlLoaded = { sequence ->
|
||||
currentSequence = sequence
|
||||
navController.navigate(Routes.PRACTICE)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.MANUAL_INPUT) {
|
||||
ManualInputScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onSequenceReady = { sequence, instrument ->
|
||||
currentSequence = sequence
|
||||
currentInstrument = instrument
|
||||
navController.navigate(Routes.PRACTICE)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.SCAN) {
|
||||
val scanViewModel: ScanViewModel = viewModel { ScanViewModel() }
|
||||
ScanScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onSequenceCaptured = { sequence, instrument ->
|
||||
currentSequence = sequence
|
||||
currentInstrument = instrument
|
||||
navController.navigate(Routes.PRACTICE) {
|
||||
popUpTo(Routes.PRACTICE_SETUP)
|
||||
}
|
||||
},
|
||||
viewModel = scanViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.EXERCISES) {
|
||||
ExercisesScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onExerciseSelected = { sequence ->
|
||||
currentSequence = sequence
|
||||
currentInstrument = InstrumentPreset.VIOLINO
|
||||
navController.navigate(Routes.PRACTICE) {
|
||||
popUpTo(Routes.EXERCISES)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.SCORES) {
|
||||
ScoresScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onScoreSelected = { sequence ->
|
||||
currentSequence = sequence
|
||||
currentInstrument = InstrumentPreset.PIANOFORTE
|
||||
navController.navigate(Routes.PRACTICE) {
|
||||
popUpTo(Routes.SCORES)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.HISTORY) {
|
||||
HistoryScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onReplay = { session ->
|
||||
val notes = session.noteMidiNumbers.map { MusicalNote.fromMidi(it) }
|
||||
currentSequence = NoteSequence(
|
||||
name = session.sequenceName,
|
||||
notes = notes,
|
||||
)
|
||||
currentInstrument = InstrumentPreset.entries.firstOrNull {
|
||||
it.displayName == session.instrumentName
|
||||
} ?: InstrumentPreset.VIOLINO
|
||||
navController.navigate(Routes.PRACTICE) {
|
||||
popUpTo(Routes.HISTORY)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.PRACTICE) {
|
||||
val practiceViewModel: PracticeViewModel = viewModel { PracticeViewModel() }
|
||||
LaunchedEffect(currentSequence, currentInstrument) {
|
||||
practiceViewModel.selectInstrument(currentInstrument)
|
||||
currentSequence?.let { seq ->
|
||||
practiceViewModel.setSequence(seq)
|
||||
}
|
||||
practiceViewModel.startListening()
|
||||
}
|
||||
PracticeScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
viewModel = practiceViewModel,
|
||||
onFinished = {
|
||||
navController.popBackStack(Routes.HOME, inclusive = false)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.notemusicali.scan
|
||||
|
||||
import com.russhwolf.settings.Settings
|
||||
|
||||
/**
|
||||
* Platform-backed secure key-value store (iOS Keychain, Android
|
||||
* EncryptedSharedPreferences). Null when the platform has none — values then
|
||||
* stay in the regular store with obfuscation.
|
||||
*/
|
||||
internal expect fun createSecureSettings(): Settings?
|
||||
|
||||
/**
|
||||
* Routes sensitive keys to the secure store when one exists, transparently
|
||||
* migrating legacy values out of the plain store. Everything else stays in
|
||||
* the plain store (obfuscated, to avoid trivial plaintext inspection).
|
||||
*/
|
||||
internal class ApiKeyStore(
|
||||
private val plain: Settings,
|
||||
private val secure: Settings?,
|
||||
) {
|
||||
private val secureKeys = setOf("anthropic_api_key", "openai_api_key")
|
||||
private val obfuscationKey = 0x4D // 'M' for music
|
||||
private val hexChars = "0123456789abcdef"
|
||||
|
||||
fun getString(key: String): String? {
|
||||
if (key in secureKeys && secure != null) {
|
||||
secure.getStringOrNull(key)?.let { return it }
|
||||
// One-time migration of a value stored before the secure store existed
|
||||
val legacy = plain.getStringOrNull(key)?.let(::deobfuscate) ?: return null
|
||||
secure.putString(key, legacy)
|
||||
plain.remove(key)
|
||||
return legacy
|
||||
}
|
||||
return plain.getStringOrNull(key)?.let(::deobfuscate)
|
||||
}
|
||||
|
||||
fun putString(key: String, value: String) {
|
||||
if (key in secureKeys && secure != null) {
|
||||
secure.putString(key, value)
|
||||
plain.remove(key)
|
||||
} else {
|
||||
plain.putString(key, obfuscate(value))
|
||||
}
|
||||
}
|
||||
|
||||
fun migratePlaintextIfNeeded(keys: List<String>) {
|
||||
for (key in keys) {
|
||||
val raw = plain.getStringOrNull(key) ?: continue
|
||||
if (!raw.startsWith("OBF:")) {
|
||||
plain.putString(key, obfuscate(raw))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun obfuscate(value: String): String {
|
||||
val bytes = value.encodeToByteArray()
|
||||
val sb = StringBuilder("OBF:")
|
||||
for (b in bytes) {
|
||||
val xored = (b.toInt() xor obfuscationKey) and 0xFF
|
||||
sb.append(hexChars[xored shr 4])
|
||||
sb.append(hexChars[xored and 0x0F])
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun deobfuscate(stored: String): String {
|
||||
if (!stored.startsWith("OBF:")) return stored // legacy plaintext
|
||||
val hex = stored.removePrefix("OBF:")
|
||||
val bytes = ByteArray(hex.length / 2) { i ->
|
||||
val hi = hex[i * 2].digitToInt(16)
|
||||
val lo = hex[i * 2 + 1].digitToInt(16)
|
||||
((hi shl 4 or lo) xor obfuscationKey).toByte()
|
||||
}
|
||||
return bytes.decodeToString()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.notemusicali.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import com.notemusicali.scores.extractMusicXmlFromBytes
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.swing.JFileChooser
|
||||
import javax.swing.filechooser.FileNameExtensionFilter
|
||||
|
||||
@Composable
|
||||
actual fun rememberFilePickerLauncher(
|
||||
mimeTypes: List<String>,
|
||||
onFileContent: (String) -> Unit,
|
||||
): () -> Unit {
|
||||
val scope = rememberCoroutineScope()
|
||||
return {
|
||||
scope.launch(Dispatchers.Default) {
|
||||
val chooser = JFileChooser().apply {
|
||||
fileFilter = FileNameExtensionFilter("MusicXML files", "xml", "musicxml", "mxl")
|
||||
dialogTitle = "Seleziona file MusicXML"
|
||||
}
|
||||
val result = chooser.showOpenDialog(null)
|
||||
if (result == JFileChooser.APPROVE_OPTION) {
|
||||
try {
|
||||
val bytes = chooser.selectedFile.readBytes()
|
||||
val content = extractMusicXmlFromBytes(bytes)
|
||||
if (content != null) {
|
||||
onFileContent(content)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// File read failed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 245 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user