Compare commits

...

5 commits

8 changed files with 695 additions and 142 deletions

View file

@ -2,6 +2,7 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet
import java.util.Properties
plugins { plugins {
alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinMultiplatform)
@ -13,13 +14,13 @@ plugins {
} }
kotlin { kotlin {
linuxX64 { /*linuxX64 {
binaries { binaries {
executable { executable {
} }
} }
} }*/
androidTarget { androidTarget {
@OptIn(ExperimentalKotlinGradlePluginApi::class) @OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions { compilerOptions {
@ -51,10 +52,7 @@ kotlin {
implementation(libs.koin.android) // Koin Android-specific extensions implementation(libs.koin.android) // Koin Android-specific extensions
implementation(libs.koin.androidx.compose) implementation(libs.koin.androidx.compose)
implementation("com.google.zxing:core:3.5.4") implementation("com.google.zxing:core:3.5.4")
implementation("androidx.camera:camera-view:1.5.2") implementation("com.github.billthefarmer:mididriver:1.25")
implementation("androidx.camera:camera-core:1.5.2")
implementation("androidx.camera:camera-camera2:1.5.2")
implementation("androidx.camera:camera-lifecycle:1.5.2")
} }
commonMain.dependencies { commonMain.dependencies {
// implementation(compose.components.resources) // implementation(compose.components.resources)

View file

@ -1,6 +1,7 @@
package mg.dot.feufaro package mg.dot.feufaro
import android.content.Context import android.content.Context
import android.net.Uri
import feufaro.composeapp.generated.resources.Res import feufaro.composeapp.generated.resources.Res
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -30,6 +31,12 @@ class AndroidFileRepository(private val context: Context) : FileRepository {
override suspend fun readFileLines(filePath: String): List<String> = withContext(Dispatchers.IO) { override suspend fun readFileLines(filePath: String): List<String> = withContext(Dispatchers.IO) {
try { try {
when { when {
filePath.startsWith("content://") -> {
val uri = Uri.parse(filePath)
val inputStream = context.contentResolver.openInputStream(uri)
?: throw IOException("Impossible d'ouvrir : $filePath")
inputStream.bufferedReader(Charsets.UTF_8).readLines()
}
filePath.startsWith("assets://") -> { filePath.startsWith("assets://") -> {
readAssetFileLines(filePath) readAssetFileLines(filePath)
} }

View file

@ -36,6 +36,8 @@ actual fun launchFilePicker(
if (mimeTypes.size > 1) { if (mimeTypes.size > 1) {
putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes) putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
} }
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
} }
// 3. Lance le sélecteur // 3. Lance le sélecteur
@ -50,6 +52,16 @@ fun setFilePickerActivity(activity: ComponentActivity) {
) { result -> ) { result ->
if (result.resultCode == Activity.RESULT_OK) { if (result.resultCode == Activity.RESULT_OK) {
val uri: Uri? = result.data?.data val uri: Uri? = result.data?.data
if (uri != null) {
try {
activity.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
} catch (e: Exception) {
println("takePersistableUriPermission failed: ${e.message}")
}
}
fileSelectionCallback?.invoke(uri?.toString()) fileSelectionCallback?.invoke(uri?.toString())
} else { } else {
fileSelectionCallback?.invoke(null) fileSelectionCallback?.invoke(null)

View file

@ -10,6 +10,7 @@ import androidx.core.view.WindowCompat
import android.view.WindowManager import android.view.WindowManager
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import org.koin.androidx.compose.KoinAndroidContext
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -21,7 +22,9 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
App() KoinAndroidContext {
App()
}
} }
} }
private fun hideSystemBar() { private fun hideSystemBar() {

View file

@ -1,170 +1,575 @@
package mg.dot.feufaro.midi package mg.dot.feufaro.midi
import android.media.MediaPlayer import SharedScreenModel
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers import org.billthefarmer.mididriver.MidiDriver
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.RandomAccessFile
private var androidMediaPlayer: MediaPlayer?= null actual class FMediaPlayer actual constructor(
private val filename: String,
private val onFinished: () -> Unit
) {
private data class MidiEvent(
val tickAbsolute: Long,
val type: Int,
val channel: Int,
val data1: Int,
val data2: Int,
val metaType: Int = -1,
val metaData: ByteArray = ByteArray(0)
)
actual class FMediaPlayer actual constructor(val filename: String, onFinished: () -> Unit) { private data class MidiSequence(
private var mediaPlayer: android.media.MediaPlayer? = android.media.MediaPlayer() val resolution: Int,
// private val voiceStates = mutableListOf(true, true, true, true) val events: List<MidiEvent>,
private val midiFileName = filename val totalTicks: Long
// private var currentGlobalVolume: Float = 0.8f )
private object MidiParser {
fun parse(file: File): MidiSequence {
val raf = RandomAccessFile(file, "r")
val events = mutableListOf<MidiEvent>()
val header = ByteArray(4); raf.readFully(header)
require(String(header) == "MThd") { "Not a MIDI file" }
raf.readInt()
val nTracks = run { raf.readShort(); raf.readShort().toInt() and 0xFFFF }
val division = raf.readShort().toInt() and 0xFFFF
val resolution = division and 0x7FFF
for (t in 0 until nTracks) {
val trkHeader = ByteArray(4); raf.readFully(trkHeader)
if (String(trkHeader) != "MTrk") break
val trkLen = raf.readInt()
val trkData = ByteArray(trkLen); raf.readFully(trkData)
parseTrack(trkData, events)
}
raf.close()
events.sortBy { it.tickAbsolute }
val totalTicks = events.maxOfOrNull { it.tickAbsolute } ?: 0L
return MidiSequence(resolution, events, totalTicks)
}
private fun parseTrack(data: ByteArray, out: MutableList<MidiEvent>) {
var pos = 0; var tick = 0L; var runningStatus = 0
fun readByte() = (data[pos++].toInt() and 0xFF)
fun readVarLen(): Long {
var value = 0L; var b: Int
do { b = readByte(); value = (value shl 7) or (b and 0x7F).toLong() } while (b and 0x80 != 0)
return value
}
while (pos < data.size) {
tick += readVarLen()
var status = readByte()
if (status and 0x80 == 0) {
pos--; status = runningStatus
}
else if (status and 0xF0 != 0xF0) runningStatus = status
val type = status and 0xF0;
val ch = status and 0x0F
when {
status == 0xFF -> {
val mt=readByte();
val len=readVarLen().toInt();
val md=ByteArray(len){data[pos++]};
out.add(MidiEvent(tick,0xFF,0,0,0,mt,md))
}
status == 0xF0 || status == 0xF7 -> repeat(readVarLen().toInt()) {
pos++
}
type == 0x80 -> {
val d1=readByte();
val d2=readByte();
out.add(MidiEvent(tick,0x80,ch,d1,d2))
}
type == 0x90 -> {
val d1=readByte();
val d2=readByte();
out.add(MidiEvent(tick,if(d2==0) 0x80 else 0x90,ch,d1,d2))
}
type == 0xA0 -> {
readByte(); readByte()
}
type == 0xB0 -> {
val d1=readByte();
val d2=readByte();
out.add(MidiEvent(tick,0xB0,ch,d1,d2))
}
type == 0xC0 -> {
val d1=readByte();
out.add(MidiEvent(tick,0xC0,ch,d1,0))
}
type == 0xD0 -> readByte()
type == 0xE0 -> {
readByte(); readByte()
}
}
}
}
}
private val midiDriver: MidiDriver = MidiDriver.getInstance()
private var sequence: MidiSequence? = null
private var resolution: Int = 480
private var usPerTick: Double = 500_000.0 / 480.0
@Volatile private var isRunning = false
@Volatile private var isHolding = false
private var currentTickPos: Long = 0L
private var lastEventNano: Long = System.nanoTime()
private var targetBpm: Float = 120f
private val voiceVolumes = FloatArray(4) { 127f }
private var currentGlobalVolume: Float = 0.8f
private var currentDynamicFactor: Float = Dynamic.MF.factor
private var pointA: Long = -1L private var pointA: Long = -1L
private var pointB: Long = -1L private var pointB: Long = -1L
private var isLoopingAB = false
private val playerScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private val playerScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var abJob: Job? = null private var playJob: Job? = null
private var navigationJob: Job? = null
private var syncJob: Job? = null
private var dynamicJob: Job? = null
private var boundModel: SharedScreenModel? = null
private var isLoopingAB: Boolean = false private data class NavigationStep(
val marker: String,
val gridIndex: Int,
val targetGrid: Int = 0,
val isHold: Boolean = false,
var alreadyDone: Boolean = false,
var beatInDC: Int = 1,
val dynamic: Dynamic? = null,
val hairPin: Char? = null,
val hairPinEndGrid: Int = -1,
val hairPinFromFactor: Float = 1.0f,
val hairPinToFactor: Float = 1.0f,
val isFarany: Boolean = false,
var faranyActive: Boolean = false,
)
private val navigationSteps = mutableListOf<NavigationStep>()
init { init {
playerScope.launch { midiDriver.start()
val file = File(filename)
if (file.exists()) {
try { try {
val file = File(midiFileName) sequence = MidiParser.parse(file)
if (file.exists()) { resolution = sequence!!.resolution
val fis = FileInputStream(file) usPerTick = (60_000_000.0 / targetBpm) / resolution
mediaPlayer?.setDataSource(fis.fd) } catch (e: Exception) { e.printStackTrace() }
}
}
mediaPlayer?.setOnCompletionListener { private fun send(vararg bytes: Int) {
onFinished() midiDriver.write(ByteArray(bytes.size) { bytes[it].toByte() })
} }
mediaPlayer?.prepareAsync() private fun controlChange(ch: Int, cc: Int, v: Int) = send(0xB0 or ch, cc, v)
fis.close() private fun allNotesOff() {
for (ch in 0 until 4) { controlChange(ch, 123, 0); controlChange(ch, 64, 0) }
}
private fun applyVoiceStates() {
for (i in 0 until 4) {
val vol = (127f * (voiceVolumes[i]/127f) * currentGlobalVolume * currentDynamicFactor)
.toInt().coerceIn(0, 127)
controlChange(i, 7, vol); controlChange(i, 11, vol)
if (vol == 0) controlChange(i, 123, 0)
}
}
private fun applyDynamic(dynamic: Dynamic) {
applyDynamicSmooth(if (dynamic == Dynamic.MF) 1.0f else dynamic.factor, 300L)
}
private fun applyDynamicSmooth(targetFactor: Float, durationMs: Long = 300L) {
dynamicJob?.cancel()
dynamicJob = playerScope.launch {
val start = currentDynamicFactor; val steps = 30; val stepMs = durationMs / steps
for (i in 1..steps) {
val p = i.toFloat()/steps; val s = p*p*(3f-2f*p)
currentDynamicFactor = start + (targetFactor - start) * s
applyVoiceStates(); delay(stepMs)
}
currentDynamicFactor = targetFactor; applyVoiceStates()
}
}
private fun applyCrescendo(fromFactor: Float, toFactor: Float, durationMs: Long) {
dynamicJob?.cancel()
dynamicJob = playerScope.launch {
val steps=40; val stepMs=(durationMs/steps).coerceAtLeast(10L)
for (i in 1..steps) {
val p=i.toFloat()/steps; val s=p*p*(3f-2f*p)
currentDynamicFactor = fromFactor + (toFactor-fromFactor)*s
applyVoiceStates(); delay(stepMs)
}
currentDynamicFactor = toFactor; applyVoiceStates()
}
}
private fun extractDynamic(marker: String) = Dynamic.entries.firstOrNull {
it.label == marker.trim().removeSuffix("=").trim()
}
fun factorToDynamic(factor: Float) =
Dynamic.entries.minByOrNull { kotlin.math.abs(it.factor - factor) } ?: Dynamic.MF
fun nextDynamic(currentFactor: Float, symbol: Char): Float {
val idx = Dynamic.entries.indexOf(factorToDynamic(currentFactor))
return if (symbol == '<') Dynamic.entries.getOrElse(idx+1){Dynamic.FFF}.factor
else Dynamic.entries.getOrElse(idx-1){Dynamic.PPP}.factor
}
private fun startPlaybackLoop() {
val seq = sequence ?: return
isRunning = true
playJob?.cancel()
playJob = playerScope.launch(Dispatchers.Default) {
val events = seq.events
var idx = events.indexOfFirst { it.tickAbsolute >= currentTickPos }
.takeIf { it >= 0 } ?: events.size
var clockNano = System.nanoTime()
var clockTick = currentTickPos
while (isActive && isRunning) {
if (isHolding) {
delay(10)
clockNano = System.nanoTime()
clockTick = currentTickPos
continue
} }
} catch (e: Exception) {
e.printStackTrace() if (idx >= events.size) {
val dc = getPendingDcStep()
if (dc != null) {
dc.alreadyDone = true
allNotesOff()
seekToGrid(dc.targetGrid)
clockNano = System.nanoTime(); clockTick = currentTickPos
idx = events.indexOfFirst { it.tickAbsolute >= currentTickPos }
.takeIf { it >= 0 } ?: events.size
continue
} else {
isRunning = false; allNotesOff(); onFinished(); break
}
}
val ev = events[idx]
val waitNano = (clockNano + ((ev.tickAbsolute - clockTick) * usPerTick * 1000)
.toLong()) - System.nanoTime()
if (waitNano > 0) delay((waitNano / 1_000_000).coerceAtLeast(0L))
currentTickPos = ev.tickAbsolute
lastEventNano = System.nanoTime()
// A-B loop
if (isLoopingAB && pointA >= 0 && pointB > pointA &&
ticksToMs(currentTickPos) >= pointB) {
allNotesOff(); currentTickPos = msToTicks(pointA)
clockNano = System.nanoTime(); clockTick = currentTickPos
idx = events.indexOfFirst { it.tickAbsolute >= currentTickPos }
.takeIf { it >= 0 } ?: events.size
continue
}
when (ev.type) {
0x80 -> send(0x80 or ev.channel, ev.data1, ev.data2)
0x90 -> send(0x90 or ev.channel, ev.data1,
(ev.data2 * currentDynamicFactor).toInt().coerceIn(0, 127))
0xB0 -> if (ev.data1 != 7 && ev.data1 != 11)
send(0xB0 or ev.channel, ev.data1, ev.data2)
0xC0 -> send(0xC0 or ev.channel, ev.data1)
0xFF -> { /* tempo ignoré */ }
}
idx++
}
}
}
actual fun seekToGrid(gridIndex: Int) {
currentTickPos = gridIndex.toLong() * resolution
lastEventNano = System.nanoTime() // ← recaler l'horloge d'interpolation
val lastDyn = navigationSteps
.filter { it.dynamic != null && it.gridIndex <= gridIndex }
.maxByOrNull { it.gridIndex }?.dynamic ?: Dynamic.MF
currentDynamicFactor = if (lastDyn == Dynamic.MF) 1.0f else lastDyn.factor
applyVoiceStates()
}
private fun ticksToMs(ticks: Long) = (ticks * usPerTick / 1000.0).toLong()
private fun msToTicks(ms: Long) = (ms * 1000.0 / usPerTick).toLong()
private fun getPendingDcStep(): NavigationStep? =
navigationSteps.firstOrNull {
!it.alreadyDone &&
it.hairPin == null &&
it.dynamic == null &&
!it.isHold &&
!it.isFarany
}
private fun resetNavigationFlags() {
navigationSteps.forEach { it.alreadyDone = false }
}
private fun prepareNavigation(sharedScreenModel: SharedScreenModel) {
val metadataList = sharedScreenModel.getFullMarkers()
navigationSteps.clear()
var lastSegno=0; var lastFactor=Dynamic.MF.factor
metadataList.forEach { (_, gridIndex, _, _, marker, _, _, note) ->
val ci = gridIndex ?: 0
val dsR = Regex("""D\.?S\.?"""); val dcR = Regex("""D\.?C\.?""")
val dsG = Regex("""D\.?S\.?_GROUP_PART"""); val dcG = Regex("""D\.?C\.?_GROUP_PART""")
val last_grid = sharedScreenModel.getTotalGridCount()
val hairPins = sharedScreenModel.getHairPins()
when {
marker.contains("$") -> lastSegno = ci
marker.contains("\uD834\uDD10") -> {
val beat = if(note.contains('•')) 2 else 1
navigationSteps.add(NavigationStep(marker, ci, isHold=true, beatInDC=beat))
}
dsG.matches(marker.trim()) -> {
val target = if(lastSegno>0) lastSegno else 0
val indx = if((last_grid-ci)<=0) ci-1 else ci
navigationSteps.add(NavigationStep(marker, indx, targetGrid=target))
}
dsR.matches(marker.trim()) || marker == "DSFin" -> {
navigationSteps.add(NavigationStep(marker, ci,
targetGrid = if(lastSegno>0) lastSegno else 0))
}
dcG.matches(marker.trim()) -> {
val indx = if((last_grid-ci)<=0) ci-1 else ci
navigationSteps.add(NavigationStep(marker, indx, targetGrid=0))
println("DC_GROUP créé à $indx → 0")
}
dcR.matches(marker.trim()) && !marker.contains("DC_GROUP_PART") -> {
val indx = if((last_grid-ci)<=0) ci else ci
navigationSteps.add(NavigationStep(marker, indx, targetGrid=0))
println("DC créé à $indx → 0")
}
// ── Farany ────────────────────────────────
marker.trim().equals("Farany_GROUP_PART", ignoreCase = true) -> {
val hasDcAfter = metadataList.any { (_, gi, _, _, mk, _, _, _) ->
(gi ?: 0) > ci &&
(mk.contains(Regex("""D\.?C\.?""")) || mk.contains("DC_GROUP_PART"))
}
if (hasDcAfter) {
navigationSteps.add(NavigationStep(
marker = marker,
gridIndex = ci,
targetGrid = last_grid,
isFarany = true,
faranyActive = false
))
println("Farany mémorisé à $ci")
} else {
println("Farany ignoré à $ci")
}
}
extractDynamic(marker) != null && marker.trim() != "=" -> {
val dyn = extractDynamic(marker) ?: return@forEach
lastFactor = if(dyn==Dynamic.MF) 1.0f else dyn.factor
navigationSteps.add(NavigationStep(marker=marker, gridIndex=ci, dynamic=dyn))
}
marker.trim()=="<" || marker.trim()==">" ||
marker.trim().contains("cres", ignoreCase=true) ||
marker.trim().contains("dim", ignoreCase=true) -> {
val sym = when {
marker.trim()=="<" -> '<'; marker.trim()==">" -> '>'
marker.trim().contains("cres",ignoreCase=true) -> '<'; else -> '>'
}
val pair = hairPins.find{it.startGrid==ci} ?: return@forEach
val explicitAfter = metadataList
.filter{(_,gi,_,_,mk,_,_,_)->(gi?:0)>=pair.endGrid && extractDynamic(mk)!=null}
.minByOrNull{it.gridIndex?:0}?.let{m->extractDynamic(m.marker)}
val from = lastFactor
val to = when {
explicitAfter!=null && sym=='<' && explicitAfter.factor>from -> explicitAfter.factor
explicitAfter!=null && sym=='>' && explicitAfter.factor<from -> explicitAfter.factor
else -> nextDynamic(from, sym)
}
lastFactor = to
navigationSteps.add(NavigationStep(
marker=marker.trim(), gridIndex=ci, hairPin=sym,
hairPinEndGrid=pair.endGrid,
hairPinFromFactor=from, hairPinToFactor=to
))
}
}
}
}
private fun startNavigationMonitor(sharedScreenModel: SharedScreenModel) {
navigationJob?.cancel()
navigationJob = playerScope.launch(Dispatchers.Default) {
sharedScreenModel.activeIndex.collect { currentIndex ->
if (!isRunning || currentIndex < 0) return@collect
val step = navigationSteps.find {
it.gridIndex == currentIndex && !it.alreadyDone
} ?: return@collect
when {
// ── Farany ────────────────────────────
step.isFarany -> {
if (step.faranyActive) {
println("Farany activé → STOP à grille $currentIndex")
step.alreadyDone = true
isHolding = false
isRunning = false
playJob?.cancel()
allNotesOff()
onFinished()
} else {
println("Farany 1er passage — DC pas encore vu → on continue ✅")
}
}
// ── Point d'orgue ─────────────────────
step.isHold -> {
val beatMs = (60_000 / targetBpm).toLong()
val holdDuration = if(step.beatInDC==2) beatMs/2 else beatMs*2
println("POINT D'ORGUE grille $currentIndex | ${holdDuration}ms")
for (ch in 0 until 4) controlChange(ch, 64, 127)
isHolding = true
delay(holdDuration)
isHolding = false
for (ch in 0 until 4) controlChange(ch, 64, 0)
step.alreadyDone = true
}
// ── Soufflet ──────────────────────────
step.hairPin != null -> {
step.alreadyDone = true
val dist = (step.hairPinEndGrid - step.gridIndex).coerceAtLeast(1)
val beatMs = (60_000L / targetBpm).toLong()
applyCrescendo(step.hairPinFromFactor, step.hairPinToFactor,
(dist * beatMs).coerceIn(200L, 5000L))
}
// ── Dynamique ─────────────────────────
step.dynamic != null -> {
step.alreadyDone = true
applyDynamic(step.dynamic)
}
// ── DC / DS ───────────────────────────
else -> {
step.alreadyDone = true
val beatMs = (60_000 / targetBpm).toLong()
println("avant de sauter bpm=$targetBpm")
for (ch in 0 until 4) controlChange(ch, 64, 127)
isHolding = true
delay(beatMs)
allNotesOff()
seekToGrid(step.targetGrid)
isHolding = false
for (ch in 0 until 4) controlChange(ch, 64, 0)
navigationSteps
.filter { it.isFarany && step.gridIndex > it.gridIndex }
.forEach {
it.faranyActive = true
it.alreadyDone = false
println("DC grille ${step.gridIndex} > Farany grille ${it.gridIndex} → Farany activé 🔴")
}
startPlaybackLoop()
println("DC/DS → grille ${step.targetGrid}")
}
}
}
}
}
private fun startSyncLoop(sharedScreenModel: SharedScreenModel) {
syncJob?.cancel()
syncJob = playerScope.launch(Dispatchers.Default) {
while (isActive) {
if (isRunning && !isHolding) {
val elapsedNano = System.nanoTime() - lastEventNano
val extraTicks = (elapsedNano / (usPerTick * 1000)).toLong().coerceAtLeast(0L)
val interpolated = (currentTickPos + extraTicks) / resolution
val rawGrid = interpolated.toInt().coerceAtLeast(0)
sharedScreenModel.updateActiveIndexByIndex(rawGrid)
}
delay(16)
} }
} }
} }
actual fun play() { actual fun play() {
mediaPlayer?.let { mp -> if (sequence == null) return
if (!mp.isPlaying) { applyVoiceStates()
// Ici, le player est prêt sans avoir bloqué l'UI startPlaybackLoop()
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { println("Android MIDI play — tick=$currentTickPos bpm=$targetBpm")
try {
val params = mp.playbackParams ?: android.media.PlaybackParams()
params.speed = 1.0f
mp.playbackParams = params
} catch (e: Exception) {
e.printStackTrace()
}
}
mp.start()
}
}
} }
actual fun pause() { actual fun pause() {
mediaPlayer?.pause() isRunning = false; isHolding = false
playJob?.cancel(); allNotesOff()
} }
actual fun stop() { actual fun stop() {
try { isRunning = false; isHolding = false
val file = File(midiFileName) playJob?.cancel(); navigationJob?.cancel()
if (file.exists() && allNotesOff(); currentTickPos = 0L
(mediaPlayer?.isPlaying == true)) { // Vérifie l'état avant d'agir resetNavigationFlags(); navigationSteps.clear(); clearLoop()
mediaPlayer?.stop() currentDynamicFactor = Dynamic.MF.factor
mediaPlayer?.reset()
mediaPlayer?.prepare()
mediaPlayer?.seekTo(0)
}
clearLoop()
} catch(e: IllegalStateException) {
//
}
} }
actual fun release() {
actual fun getDuration(): Long { stop(); midiDriver.stop(); playerScope.cancel()
return mediaPlayer?.duration?.toLong() ?: 0L
} }
actual fun getCurrentPosition(): Long {
return mediaPlayer?.currentPosition?.toLong() ?: 0L
}
actual fun seekTo(position: Long) { actual fun seekTo(position: Long) {
mediaPlayer?.seekTo(position.toInt()) currentTickPos = msToTicks(position)
lastEventNano = System.nanoTime()
applyVoiceStates()
} }
actual fun setVolume(level: Float) { actual fun getDuration(): Long = sequence?.let { ticksToMs(it.totalTicks) } ?: 0L
mediaPlayer?.setVolume(level, level) actual fun getCurrentPosition(): Long = ticksToMs(currentTickPos)
actual fun setVolume(level: Float) {
currentGlobalVolume = level
midiDriver.setVolume((level*100).toInt().coerceIn(0,100))
applyVoiceStates()
} }
actual fun setTempo(bpm: Float) {
actual fun setPointA() { targetBpm = bpm; usPerTick = (60_000_000.0/bpm)/resolution
pointA = mediaPlayer?.currentPosition?.toLong() ?: 0L boundModel?.let { prepareNavigation(it) }
println("Tempo → $bpm BPM")
} }
actual fun getCurrentBPM(): Float = targetBpm
actual fun requestSync(sharedScreenModel: SharedScreenModel) {
val seq = sequence ?: return
val totalGrids = (seq.totalTicks/resolution).toInt()
val timestamps = (0..totalGrids).map { ticksToMs(it.toLong()*resolution)*1000L }
sharedScreenModel.updateTimestamps(timestamps)
println("requestSync Android — $totalGrids grilles")
}
actual fun syncNavigationMonitor(sharedScreenModel: SharedScreenModel) {
this.boundModel = sharedScreenModel
prepareNavigation(sharedScreenModel)
startNavigationMonitor(sharedScreenModel)
startSyncLoop(sharedScreenModel)
}
actual fun setPointA() { pointA = getCurrentPosition() }
actual fun setPointB() { actual fun setPointB() {
pointB = mediaPlayer?.currentPosition?.toLong() ?: 0L pointB = getCurrentPosition()
if (pointB > pointA && pointA != -1L) { if (pointB > pointA && pointA != -1L) isLoopingAB = true
isLoopingAB = true
startABMonitor()
}
} }
actual fun clearLoop() { isLoopingAB = false; pointA = -1L; pointB = -1L }
actual fun clearLoop() {
isLoopingAB = false
pointA = -1L
pointB = -1L
abJob?.cancel()
}
private fun startABMonitor() {
abJob?.cancel()
abJob = playerScope.launch {
while (isLoopingAB) {
val currentPos = mediaPlayer?.currentPosition?.toLong() ?: 0L
if (currentPos >= pointB) {
mediaPlayer?.seekTo(pointA.toInt())
}
delay(50)
}
}
}
actual fun getLoopState() = Triple(pointA, pointB, isLoopingAB) actual fun getLoopState() = Triple(pointA, pointB, isLoopingAB)
actual fun toggleVoice(index: Int) { actual fun toggleVoice(index: Int) { applyVoiceStates() }
// voiceStates[index] = !voiceStates[index]
println("Toggle voice $index (Limitation: Nécessite SoundFont/Fluidsynth sur Android)")
}
// actual fun getVoiceStates(): List<Boolean> = voiceStates
actual fun getVoiceVolumes(): List<Float> = MutableList(4) { 127f }
actual fun changeInstru(noInstru: Int) {
}
actual fun setTempo(factor: Float) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
mediaPlayer?.let {
val params = it.playbackParams
params.speed = factor
it.playbackParams = params
}
}
}
actual fun getCurrentBPM(): Float {
return 120f
}
actual fun updateVoiceVolume(voiceIndex: Int, newVolume: Float) { actual fun updateVoiceVolume(voiceIndex: Int, newVolume: Float) {
if (voiceIndex in 0..3) { if (voiceIndex in 0..3) { voiceVolumes[voiceIndex] = newVolume; applyVoiceStates() }
//TODO: implements split voices & change volume per voices
}
} }
actual fun getVoiceVolumes(): List<Float> = voiceVolumes.toList()
fun release() { actual fun changeInstru(noInstru: Int) {
mediaPlayer?.release() for (ch in 0 until 4) send(0xC0 or ch, noInstru)
mediaPlayer = null
playerScope.coroutineContext.cancel()
} }
} }

View file

@ -161,9 +161,9 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
initialDirectory = if (initialPath.isNotBlank()) initialPath else homedir, initialDirectory = if (initialPath.isNotBlank()) initialPath else homedir,
onFileSelected = { path -> onFileSelected = { path ->
if (path != null) { if (path != null) {
sharedScreenModel.reset() screenModelScope.launch(Dispatchers.Default) {
parse(path) sharedScreenModel.reset()
screenModelScope.launch { parse(path)
stateSettings.saveLastUsedDir(path) stateSettings.saveLastUsedDir(path)
} }
//loadSolfa() //loadSolfa()

View file

@ -213,6 +213,8 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
val isDS = markerText.contains(Regex("""D\.?S\.?""")) val isDS = markerText.contains(Regex("""D\.?S\.?"""))
val isFarany = markerText.trim().equals("Farany", ignoreCase = true) val isFarany = markerText.trim().equals("Farany", ignoreCase = true)
val isRit = Regex("""rit\.?|ritard\.?|ritenuto\.?|ritardando""", RegexOption.IGNORE_CASE)
var resultMarker: MidiMarkers = marker var resultMarker: MidiMarkers = marker
if(isFarany) { if(isFarany) {
var forwardIndex = index var forwardIndex = index
@ -287,6 +289,24 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
forwardIndex++ forwardIndex++
} }
} }
} else if(isRit.containsMatchIn(markerText)) {
var forwardIndex = index
var foundSeparator = false
while (forwardIndex < tuos.size) {
val sep = tuos.getOrNull(forwardIndex)?.sep0 ?: ""
if (sep == "/") {
resultMarker = marker.copy(
gridIndex = index,
lastCallerMarker = forwardIndex - 1,
marker = "Ritenuto"
)
println("Rit finalisé : grille $index${forwardIndex - 1}")
foundSeparator = true
break
}
forwardIndex++
}
} else { } else {
marker marker
} }
@ -405,6 +425,8 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
_showMidiCtrl.value = false _showMidiCtrl.value = false
_expandedFAB.value = false _expandedFAB.value = false
_showSearchMenu.value = false _showSearchMenu.value = false
_midiMarkersList.value = emptyList()
_tuoTimestamps.value = emptyList()
updateSearchTxt("") updateSearchTxt("")
tempTimeUnitObjectList.clear() tempTimeUnitObjectList.clear()
} }

View file

@ -46,6 +46,9 @@ actual class FMediaPlayer actual constructor(
private var abJob: Job? = null private var abJob: Job? = null
private var navigationJob: Job? = null private var navigationJob: Job? = null
private var isInTempoChange: Boolean = false
private var tempoChangeJob: Job? = null
private data class NavigationStep( private data class NavigationStep(
val marker: String, val marker: String,
val gridIndex: Int, // L'ancre absolue val gridIndex: Int, // L'ancre absolue
@ -61,6 +64,12 @@ actual class FMediaPlayer actual constructor(
val hairPinToFactor: Float = 1.0f, val hairPinToFactor: Float = 1.0f,
val isFin: Boolean = false, // Farany D.C. val isFin: Boolean = false, // Farany D.C.
var finActive: Boolean =false, var finActive: Boolean =false,
// rit & rall
val isTempoChange: Boolean = false,
val tempoType: String = "",
val endGridForTempo: Int = -1,
val targetTempoMultiplier: Float = 0.6f
) )
private val navigationSteps = mutableListOf<NavigationStep>() private val navigationSteps = mutableListOf<NavigationStep>()
@ -195,11 +204,54 @@ actual class FMediaPlayer actual constructor(
println("applyCrescendo terminé → factor=$currentDynamicFactor") println("applyCrescendo terminé → factor=$currentDynamicFactor")
} }
} }
private fun applyTempoChange(
startBpm: Float,
endBpm: Float,
durationMs: Long,
tempoType: String
) {
tempoChangeJob?.cancel()
isInTempoChange = true
tempoChangeJob = playerScope.launch {
val steps = 50
val stepMs = (durationMs / steps).coerceAtLeast(20L)
val startTime = System.currentTimeMillis()
for (i in 1..steps) {
val elapsed = System.currentTimeMillis() - startTime
val progress = (elapsed.toFloat() / durationMs).coerceIn(0f, 1f)
val smooth = progress * progress * (3f - 2f * progress) // ease-in-out
val currentBpm = startBpm + (endBpm - startBpm) * smooth
sequencer?.tempoInBPM = currentBpm
delay(stepMs)
if (!isActive) break
}
sequencer?.tempoInBPM = endBpm
isInTempoChange = false
}
}
private fun resetTempoToNormal() {
if (sequencer?.tempoInBPM != targetBpm) {
// println("Retour au tempo normal: ${sequencer?.tempoInBPM} → $targetBpm BPM")
applyTempoChange(
startBpm = sequencer?.tempoInBPM ?: targetBpm,
endBpm = targetBpm,
durationMs = 800L,
tempoType = "Retour tempo"
)
}
}
private fun prepareNavigation(sharedScreenModel: SharedScreenModel) { private fun prepareNavigation(sharedScreenModel: SharedScreenModel) {
val metadataList = sharedScreenModel.getFullMarkers() val metadataList = sharedScreenModel.getFullMarkers()
navigationSteps.clear() navigationSteps.clear()
var lastSegno = 0 var lastSegno = 0
var lastFactor = Dynamic.MF.factor var lastFactor = Dynamic.MF.factor
val ritRegex = Regex("""rit\.?|ritard\.?|ritenuto\.?|ritardando""", RegexOption.IGNORE_CASE)
val rallRegex = Regex("""rall\.?|rallent\.?|rallentando""", RegexOption.IGNORE_CASE)
metadataList.forEach { (timestamp, gridIndex, template, lastCallerMarker, marker, noteBefore, separat, note) -> metadataList.forEach { (timestamp, gridIndex, template, lastCallerMarker, marker, noteBefore, separat, note) ->
val currentIndex = gridIndex ?: 0 val currentIndex = gridIndex ?: 0
@ -230,6 +282,37 @@ actual class FMediaPlayer actual constructor(
) )
println("Point d'orgue (\uD834\uDD10) mémorisée au grille n° $gridIndex") println("Point d'orgue (\uD834\uDD10) mémorisée au grille n° $gridIndex")
} }
// Rit ...
ritRegex.containsMatchIn(marker) -> {
val endGrid = if(lastCallerMarker == 0) last_grid else lastCallerMarker
navigationSteps.add(
NavigationStep(
marker = marker,
gridIndex = currentIndex,
isTempoChange = true,
tempoType = "rit",
endGridForTempo = endGrid,
targetTempoMultiplier = 0.55f // 55% du tempo initial
)
)
println("Ritardando mémorisé à grille $currentIndex jusqu'à $endGrid")
}
rallRegex.containsMatchIn(marker) -> {
val endGrid = last_grid
navigationSteps.add(
NavigationStep(
marker = marker,
gridIndex = currentIndex,
isTempoChange = true,
tempoType = "rall",
endGridForTempo = endGrid,
targetTempoMultiplier = 0.50f // 50% du tempo initial
)
)
println("Rallentando mémorisé à grille $currentIndex jusqu'à $endGrid")
}
// DS // DS
dsGPattern.matches(marker.trim())-> { dsGPattern.matches(marker.trim())-> {
val target = if (lastSegno > 0) lastSegno else 0 val target = if (lastSegno > 0) lastSegno else 0
@ -415,6 +498,9 @@ actual class FMediaPlayer actual constructor(
} }
if (step != null) { if (step != null) {
if (Math.abs(sequencer!!.tempoInBPM - targetBpm) > 0.1 && !isInTempoChange) {
forceTempo(targetBpm.toDouble())
}
when { when {
step.isFin -> { step.isFin -> {
if(step.finActive) { if(step.finActive) {
@ -463,6 +549,25 @@ actual class FMediaPlayer actual constructor(
) )
} }
//Rit | Rall
step.isTempoChange -> {
step.alreadyDone = true
val currentBpm = sequencer?.tempoInBPM ?: targetBpm
val endBpm = targetBpm * step.targetTempoMultiplier
val gridDistance = (step.endGridForTempo - step.gridIndex).coerceAtLeast(1)
val beatDurationMs = (60_000L / targetBpm).toLong()
val durationMs = (gridDistance * beatDurationMs).coerceIn(1000L, 8000L)
// println("${step.tempoType.uppercase()} : Grille ${step.gridIndex} → ${step.endGridForTempo}")
// println(" ${currentBpm} BPM → $endBpm BPM sur ${durationMs}ms")
applyTempoChange(
startBpm = currentBpm,
endBpm = endBpm,
durationMs = durationMs,
tempoType = step.tempoType
)
}
//velocity //velocity
step.dynamic != null -> { step.dynamic != null -> {
applyDynamic(step.dynamic) applyDynamic(step.dynamic)
@ -601,6 +706,7 @@ actual class FMediaPlayer actual constructor(
navigationSteps.clear() navigationSteps.clear()
clearLoop() clearLoop()
currentDynamicFactor = Dynamic.MF.factor currentDynamicFactor = Dynamic.MF.factor
resetTempoToNormal()
// release() // release()
} }
actual fun getDuration(): Long { actual fun getDuration(): Long {