Compare commits

..

No commits in common. "b605fc87fc0d36b4386ad57281dd8101e87831b4" and "d4174635c418bfb0f5558ebcff9a85cdb08dd2d0" have entirely different histories.

8 changed files with 141 additions and 694 deletions

View file

@ -2,7 +2,6 @@ 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)
@ -14,13 +13,13 @@ plugins {
} }
kotlin { kotlin {
/*linuxX64 { linuxX64 {
binaries { binaries {
executable { executable {
} }
} }
}*/ }
androidTarget { androidTarget {
@OptIn(ExperimentalKotlinGradlePluginApi::class) @OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions { compilerOptions {
@ -52,7 +51,10 @@ 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("com.github.billthefarmer:mididriver:1.25") implementation("androidx.camera:camera-view:1.5.2")
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,7 +1,6 @@
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
@ -31,12 +30,6 @@ 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,8 +36,6 @@ 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
@ -52,16 +50,6 @@ 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,7 +10,6 @@ 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?) {
@ -22,11 +21,9 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
KoinAndroidContext {
App() App()
} }
} }
}
private fun hideSystemBar() { private fun hideSystemBar() {
// Pour les versions d'Android plus récentes (API 30+) // Pour les versions d'Android plus récentes (API 30+)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {

View file

@ -1,575 +1,170 @@
package mg.dot.feufaro.midi package mg.dot.feufaro.midi
import SharedScreenModel import android.media.MediaPlayer
import kotlinx.coroutines.* import kotlinx.coroutines.CoroutineScope
import org.billthefarmer.mididriver.MidiDriver import kotlinx.coroutines.Dispatchers
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.RandomAccessFile import java.io.FileInputStream
actual class FMediaPlayer actual constructor( private var androidMediaPlayer: MediaPlayer?= null
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)
)
private data class MidiSequence( actual class FMediaPlayer actual constructor(val filename: String, onFinished: () -> Unit) {
val resolution: Int, private var mediaPlayer: android.media.MediaPlayer? = android.media.MediaPlayer()
val events: List<MidiEvent>, // private val voiceStates = mutableListOf(true, true, true, true)
val totalTicks: Long private val midiFileName = filename
) // 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 playJob: Job? = null private var abJob: Job? = null
private var navigationJob: Job? = null
private var syncJob: Job? = null
private var dynamicJob: Job? = null
private var boundModel: SharedScreenModel? = null
private data class NavigationStep( private var isLoopingAB: Boolean = false
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 {
midiDriver.start() playerScope.launch {
val file = File(filename)
if (file.exists()) {
try { try {
sequence = MidiParser.parse(file) val file = File(midiFileName)
resolution = sequence!!.resolution if (file.exists()) {
usPerTick = (60_000_000.0 / targetBpm) / resolution val fis = FileInputStream(file)
} catch (e: Exception) { e.printStackTrace() } mediaPlayer?.setDataSource(fis.fd)
}
}
private fun send(vararg bytes: Int) { mediaPlayer?.setOnCompletionListener {
midiDriver.write(ByteArray(bytes.size) { bytes[it].toByte() })
}
private fun controlChange(ch: Int, cc: Int, v: Int) = send(0xB0 or ch, cc, v)
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
}
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() onFinished()
} else {
println("Farany 1er passage — DC pas encore vu → on continue ✅")
} }
mediaPlayer?.prepareAsync()
fis.close()
} }
} catch (e: Exception) {
// ── Point d'orgue ───────────────────── e.printStackTrace()
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() {
if (sequence == null) return mediaPlayer?.let { mp ->
applyVoiceStates() if (!mp.isPlaying) {
startPlaybackLoop() // Ici, le player est prêt sans avoir bloqué l'UI
println("Android MIDI play — tick=$currentTickPos bpm=$targetBpm") if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
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() {
isRunning = false; isHolding = false mediaPlayer?.pause()
playJob?.cancel(); allNotesOff()
} }
actual fun stop() { actual fun stop() {
isRunning = false; isHolding = false try {
playJob?.cancel(); navigationJob?.cancel() val file = File(midiFileName)
allNotesOff(); currentTickPos = 0L if (file.exists() &&
resetNavigationFlags(); navigationSteps.clear(); clearLoop() (mediaPlayer?.isPlaying == true)) { // Vérifie l'état avant d'agir
currentDynamicFactor = Dynamic.MF.factor mediaPlayer?.stop()
mediaPlayer?.reset()
mediaPlayer?.prepare()
mediaPlayer?.seekTo(0)
} }
actual fun release() { clearLoop()
stop(); midiDriver.stop(); playerScope.cancel() } catch(e: IllegalStateException) {
//
} }
}
actual fun getDuration(): Long {
return mediaPlayer?.duration?.toLong() ?: 0L
}
actual fun getCurrentPosition(): Long {
return mediaPlayer?.currentPosition?.toLong() ?: 0L
}
actual fun seekTo(position: Long) { actual fun seekTo(position: Long) {
currentTickPos = msToTicks(position) mediaPlayer?.seekTo(position.toInt())
lastEventNano = System.nanoTime()
applyVoiceStates()
} }
actual fun getDuration(): Long = sequence?.let { ticksToMs(it.totalTicks) } ?: 0L
actual fun getCurrentPosition(): Long = ticksToMs(currentTickPos)
actual fun setVolume(level: Float) { actual fun setVolume(level: Float) {
currentGlobalVolume = level mediaPlayer?.setVolume(level, level)
midiDriver.setVolume((level*100).toInt().coerceIn(0,100))
applyVoiceStates()
} }
actual fun setTempo(bpm: Float) {
targetBpm = bpm; usPerTick = (60_000_000.0/bpm)/resolution actual fun setPointA() {
boundModel?.let { prepareNavigation(it) } pointA = mediaPlayer?.currentPosition?.toLong() ?: 0L
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 = getCurrentPosition() pointB = mediaPlayer?.currentPosition?.toLong() ?: 0L
if (pointB > pointA && pointA != -1L) isLoopingAB = true if (pointB > pointA && pointA != -1L) {
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) { applyVoiceStates() } actual fun toggleVoice(index: Int) {
actual fun updateVoiceVolume(voiceIndex: Int, newVolume: Float) { // voiceStates[index] = !voiceStates[index]
if (voiceIndex in 0..3) { voiceVolumes[voiceIndex] = newVolume; applyVoiceStates() } println("Toggle voice $index (Limitation: Nécessite SoundFont/Fluidsynth sur Android)")
} }
actual fun getVoiceVolumes(): List<Float> = voiceVolumes.toList()
// actual fun getVoiceStates(): List<Boolean> = voiceStates
actual fun getVoiceVolumes(): List<Float> = MutableList(4) { 127f }
actual fun changeInstru(noInstru: Int) { actual fun changeInstru(noInstru: Int) {
for (ch in 0 until 4) send(0xC0 or ch, noInstru) }
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) {
if (voiceIndex in 0..3) {
//TODO: implements split voices & change volume per voices
}
}
fun release() {
mediaPlayer?.release()
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) {
screenModelScope.launch(Dispatchers.Default) {
sharedScreenModel.reset() sharedScreenModel.reset()
parse(path) parse(path)
screenModelScope.launch {
stateSettings.saveLastUsedDir(path) stateSettings.saveLastUsedDir(path)
} }
//loadSolfa() //loadSolfa()

View file

@ -213,8 +213,6 @@ 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
@ -289,24 +287,6 @@ 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
} }
@ -425,8 +405,6 @@ 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,9 +46,6 @@ 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
@ -64,12 +61,6 @@ 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>()
@ -204,54 +195,11 @@ 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
@ -282,37 +230,6 @@ 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
@ -498,9 +415,6 @@ 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) {
@ -549,25 +463,6 @@ 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)
@ -706,7 +601,6 @@ 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 {