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())
|
||||
}
|
||||
Reference in new issue
Block a user