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.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet
import java.util.Properties
plugins {
alias(libs.plugins.kotlinMultiplatform)
@ -14,13 +13,13 @@ plugins {
}
kotlin {
/*linuxX64 {
linuxX64 {
binaries {
executable {
}
}
}*/
}
androidTarget {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
@ -52,7 +51,10 @@ kotlin {
implementation(libs.koin.android) // Koin Android-specific extensions
implementation(libs.koin.androidx.compose)
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 {
// implementation(compose.components.resources)

View file

@ -1,7 +1,6 @@
package mg.dot.feufaro
import android.content.Context
import android.net.Uri
import feufaro.composeapp.generated.resources.Res
import kotlinx.coroutines.Dispatchers
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) {
try {
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://") -> {
readAssetFileLines(filePath)
}

View file

@ -36,8 +36,6 @@ actual fun launchFilePicker(
if (mimeTypes.size > 1) {
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
@ -52,16 +50,6 @@ fun setFilePickerActivity(activity: ComponentActivity) {
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
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())
} else {
fileSelectionCallback?.invoke(null)

View file

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

View file

@ -1,575 +1,170 @@
package mg.dot.feufaro.midi
import SharedScreenModel
import kotlinx.coroutines.*
import org.billthefarmer.mididriver.MidiDriver
import android.media.MediaPlayer
import kotlinx.coroutines.CoroutineScope
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.RandomAccessFile
import java.io.FileInputStream
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)
)
private var androidMediaPlayer: MediaPlayer?= null
private data class MidiSequence(
val resolution: Int,
val events: List<MidiEvent>,
val totalTicks: Long
)
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
actual class FMediaPlayer actual constructor(val filename: String, onFinished: () -> Unit) {
private var mediaPlayer: android.media.MediaPlayer? = android.media.MediaPlayer()
// private val voiceStates = mutableListOf(true, true, true, true)
private val midiFileName = filename
// private var currentGlobalVolume: Float = 0.8f
private var pointA: Long = -1L
private var pointB: Long = -1L
private var isLoopingAB = false
private val playerScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
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 abJob: Job? = null
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>()
private var isLoopingAB: Boolean = false
init {
midiDriver.start()
val file = File(filename)
if (file.exists()) {
playerScope.launch {
try {
sequence = MidiParser.parse(file)
resolution = sequence!!.resolution
usPerTick = (60_000_000.0 / targetBpm) / resolution
} catch (e: Exception) { e.printStackTrace() }
}
}
val file = File(midiFileName)
if (file.exists()) {
val fis = FileInputStream(file)
mediaPlayer?.setDataSource(fis.fd)
private fun send(vararg bytes: Int) {
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
mediaPlayer?.setOnCompletionListener {
onFinished()
}
mediaPlayer?.prepareAsync()
fis.close()
}
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)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
actual fun play() {
if (sequence == null) return
applyVoiceStates()
startPlaybackLoop()
println("Android MIDI play — tick=$currentTickPos bpm=$targetBpm")
mediaPlayer?.let { mp ->
if (!mp.isPlaying) {
// Ici, le player est prêt sans avoir bloqué l'UI
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() {
isRunning = false; isHolding = false
playJob?.cancel(); allNotesOff()
mediaPlayer?.pause()
}
actual fun stop() {
isRunning = false; isHolding = false
playJob?.cancel(); navigationJob?.cancel()
allNotesOff(); currentTickPos = 0L
resetNavigationFlags(); navigationSteps.clear(); clearLoop()
currentDynamicFactor = Dynamic.MF.factor
try {
val file = File(midiFileName)
if (file.exists() &&
(mediaPlayer?.isPlaying == true)) { // Vérifie l'état avant d'agir
mediaPlayer?.stop()
mediaPlayer?.reset()
mediaPlayer?.prepare()
mediaPlayer?.seekTo(0)
}
clearLoop()
} catch(e: IllegalStateException) {
//
}
}
actual fun release() {
stop(); midiDriver.stop(); playerScope.cancel()
actual fun getDuration(): Long {
return mediaPlayer?.duration?.toLong() ?: 0L
}
actual fun getCurrentPosition(): Long {
return mediaPlayer?.currentPosition?.toLong() ?: 0L
}
actual fun seekTo(position: Long) {
currentTickPos = msToTicks(position)
lastEventNano = System.nanoTime()
applyVoiceStates()
mediaPlayer?.seekTo(position.toInt())
}
actual fun getDuration(): Long = sequence?.let { ticksToMs(it.totalTicks) } ?: 0L
actual fun getCurrentPosition(): Long = ticksToMs(currentTickPos)
actual fun setVolume(level: Float) {
currentGlobalVolume = level
midiDriver.setVolume((level*100).toInt().coerceIn(0,100))
applyVoiceStates()
actual fun setVolume(level: Float) {
mediaPlayer?.setVolume(level, level)
}
actual fun setTempo(bpm: Float) {
targetBpm = bpm; usPerTick = (60_000_000.0/bpm)/resolution
boundModel?.let { prepareNavigation(it) }
println("Tempo → $bpm BPM")
actual fun setPointA() {
pointA = mediaPlayer?.currentPosition?.toLong() ?: 0L
}
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() {
pointB = getCurrentPosition()
if (pointB > pointA && pointA != -1L) isLoopingAB = true
pointB = mediaPlayer?.currentPosition?.toLong() ?: 0L
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 toggleVoice(index: Int) { applyVoiceStates() }
actual fun updateVoiceVolume(voiceIndex: Int, newVolume: Float) {
if (voiceIndex in 0..3) { voiceVolumes[voiceIndex] = newVolume; applyVoiceStates() }
actual fun toggleVoice(index: Int) {
// voiceStates[index] = !voiceStates[index]
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) {
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,
onFileSelected = { path ->
if (path != null) {
screenModelScope.launch(Dispatchers.Default) {
sharedScreenModel.reset()
parse(path)
sharedScreenModel.reset()
parse(path)
screenModelScope.launch {
stateSettings.saveLastUsedDir(path)
}
//loadSolfa()

View file

@ -213,8 +213,6 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
val isDS = markerText.contains(Regex("""D\.?S\.?"""))
val isFarany = markerText.trim().equals("Farany", ignoreCase = true)
val isRit = Regex("""rit\.?|ritard\.?|ritenuto\.?|ritardando""", RegexOption.IGNORE_CASE)
var resultMarker: MidiMarkers = marker
if(isFarany) {
var forwardIndex = index
@ -289,24 +287,6 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
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 {
marker
}
@ -425,8 +405,6 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
_showMidiCtrl.value = false
_expandedFAB.value = false
_showSearchMenu.value = false
_midiMarkersList.value = emptyList()
_tuoTimestamps.value = emptyList()
updateSearchTxt("")
tempTimeUnitObjectList.clear()
}

View file

@ -46,9 +46,6 @@ actual class FMediaPlayer actual constructor(
private var abJob: Job? = null
private var navigationJob: Job? = null
private var isInTempoChange: Boolean = false
private var tempoChangeJob: Job? = null
private data class NavigationStep(
val marker: String,
val gridIndex: Int, // L'ancre absolue
@ -64,12 +61,6 @@ actual class FMediaPlayer actual constructor(
val hairPinToFactor: Float = 1.0f,
val isFin: Boolean = false, // Farany D.C.
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>()
@ -204,54 +195,11 @@ actual class FMediaPlayer actual constructor(
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) {
val metadataList = sharedScreenModel.getFullMarkers()
navigationSteps.clear()
var lastSegno = 0
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) ->
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")
}
// 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
dsGPattern.matches(marker.trim())-> {
val target = if (lastSegno > 0) lastSegno else 0
@ -498,9 +415,6 @@ actual class FMediaPlayer actual constructor(
}
if (step != null) {
if (Math.abs(sequencer!!.tempoInBPM - targetBpm) > 0.1 && !isInTempoChange) {
forceTempo(targetBpm.toDouble())
}
when {
step.isFin -> {
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
step.dynamic != null -> {
applyDynamic(step.dynamic)
@ -706,7 +601,6 @@ actual class FMediaPlayer actual constructor(
navigationSteps.clear()
clearLoop()
currentDynamicFactor = Dynamic.MF.factor
resetTempoToNormal()
// release()
}
actual fun getDuration(): Long {