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

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

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

No files matched your search

@@ -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())
}