Implement all fun markers for Android using midiDriver (sonivox EAS lib)
This commit is contained in:
parent
ff03753109
commit
e32bda0f2c
2 changed files with 541 additions and 138 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue