feufaro/composeApp/src/androidMain/kotlin/mg/dot/feufaro/midi/MidiPlayer.kt

842 lines
37 KiB
Kotlin
Raw Normal View History

2026-01-18 16:22:42 +01:00
package mg.dot.feufaro.midi
import SharedScreenModel
import com.russhwolf.settings.Settings
import kotlinx.coroutines.*
import org.billthefarmer.mididriver.MidiDriver
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
2026-02-06 09:51:33 +03:00
import java.io.File
import java.io.RandomAccessFile
2026-01-18 16:22:42 +01:00
actual class FMediaPlayer actual constructor(
private val filename: String,
private val sharedScreenModel: SharedScreenModel,
private val onFinished: () -> Unit
): KoinComponent {
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)
)
2026-01-18 16:22:42 +01:00
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
@Volatile private var usPerTick: Double = (60_000_000.0 / 120.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
@Volatile private var currentPlaybackBpm: Float = 120f
@Volatile private var isInTempoChange: Boolean = false
@Volatile private var needsClockSync = false
private var tempoChangeJob: Job? = null
private val voiceVolumes = FloatArray(4) { 127f }
private var currentGlobalVolume: Float = 0.8f
private var currentDynamicFactor: Float = Dynamic.MF.factor
2026-01-18 16:22:42 +01:00
private var pointA: Long = -1L
private var pointB: Long = -1L
private var isLoopingAB = false
2026-02-06 09:51:33 +03:00
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 val settings: Settings by inject()
2026-02-06 09:51:33 +03:00
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,
// rit & rall
val isTempoChange: Boolean = false,
val tempoType: String = "",
val endGridForTempo: Int = -1,
val targetTempoMultiplier: Float = 0.6f
)
private val navigationSteps = mutableListOf<NavigationStep>()
2026-02-06 09:51:33 +03:00
init {
midiDriver.start()
loadVoiceVolumes()
loadSavedInstrumentsToPlayer(this)
val file = File(filename)
if (file.exists()) {
try {
sequence = MidiParser.parse(file)
resolution = sequence!!.resolution
usPerTick = (60_000_000.0 / targetBpm) / resolution
} catch (e: Exception) { e.printStackTrace() }
2026-02-06 09:51:33 +03:00
}
}
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)
}
2026-01-18 16:22:42 +01:00
}
private fun applyDynamic(dynamic: Dynamic) {
applyDynamicSmooth(if (dynamic == Dynamic.MF) 1.0f else dynamic.factor, 300L)
2026-01-18 16:22:42 +01:00
}
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()
}
2026-01-18 16:22:42 +01:00
}
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()
}
2026-01-18 16:22:42 +01:00
}
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
currentPlaybackBpm = startBpm + (endBpm - startBpm) * smooth
usPerTick = (60_000_000.0 / currentPlaybackBpm) / resolution
needsClockSync = true
delay(stepMs)
if (!isActive) break
}
currentPlaybackBpm = endBpm
usPerTick = (60_000_000.0 / currentPlaybackBpm) / resolution
isInTempoChange = false
println("$tempoType terminé → $endBpm BPM")
}
}
private fun resetTempoToNormal() {
if (kotlin.math.abs(currentPlaybackBpm - targetBpm) > 0.5f) {
applyTempoChange(
startBpm = currentPlaybackBpm,
endBpm = targetBpm,
durationMs = 800L,
tempoType = "Retour tempo"
)
}
}
private fun extractDynamic(marker: String) = Dynamic.entries.firstOrNull {
it.label == marker.trim().removeSuffix("=").trim()
2026-01-18 16:22:42 +01:00
}
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
2026-01-18 16:22:42 +01:00
}
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 (needsClockSync) {
clockNano = System.nanoTime()
clockTick = currentTickPos
needsClockSync = false
}
if (isHolding) {
yield()
// 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()
val targetNano = clockNano + ((ev.tickAbsolute - clockTick) * usPerTick * 1000).toLong()
val now = System.nanoTime()
val waitNano = targetNano - now
// if (waitNano > 0) delay((waitNano / 1_000_000).coerceAtLeast(0L))
if (waitNano > 0) {
// Thread.sleep est beaucoup plus précis que delay() pour les micro-délais MIDI
val ms = waitNano / 1_000_000
val ns = (waitNano % 1_000_000).toInt()
try {
Thread.sleep(ms, ns)
} catch (e: Exception) {
yield()
}
}
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é */ }
}
val currentGrid = (ev.tickAbsolute / resolution).toLong()
if (boundModel?.activeIndex?.value != currentGrid.toInt()) {
boundModel?.updateActiveIndex(currentGrid)
}
idx++
}
}
2026-01-18 16:22:42 +01:00
}
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()
2026-01-18 16:22:42 +01:00
}
private fun ticksToMs(ticks: Long) = (ticks * usPerTick / 1000.0).toLong()
private fun msToTicks(ms: Long) = (ms * 1000.0 / usPerTick).toLong()
2026-01-18 16:22:42 +01:00
private fun getPendingDcStep(): NavigationStep? =
navigationSteps.firstOrNull {
!it.alreadyDone &&
it.hairPin == null &&
it.dynamic == null &&
!it.isHold &&
!it.isFarany
2026-01-18 16:22:42 +01:00
}
private fun resetNavigationFlags() {
navigationSteps.forEach { it.alreadyDone = false }
2026-02-06 09:51:33 +03:00
}
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 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))
}
ritRegex.containsMatchIn(marker) -> {
val endGrid = if(lastCallerMarker == 0) last_grid else lastCallerMarker
navigationSteps.add(
NavigationStep(
marker = marker,
gridIndex = ci,
isTempoChange = true,
tempoType = "rit",
endGridForTempo = endGrid,
targetTempoMultiplier = 0.55f
)
)
}
rallRegex.containsMatchIn(marker) -> {
val endGrid = last_grid
navigationSteps.add(
NavigationStep(
marker = marker,
gridIndex = ci,
isTempoChange = true,
tempoType = "rall",
endGridForTempo = endGrid,
targetTempoMultiplier = 0.50f
)
)
}
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
))
2026-02-06 09:51:33 +03:00
}
2026-01-18 16:22:42 +01:00
}
}
}
private fun startNavigationMonitor(sharedScreenModel: SharedScreenModel) {
var lastProcessedIndex = -1
navigationJob?.cancel()
navigationJob = playerScope.launch(Dispatchers.Default) {
sharedScreenModel.activeIndex.collect { currentIndex ->
if (currentIndex <= lastProcessedIndex) {
return@collect
}
lastProcessedIndex = currentIndex
if (!isRunning || currentIndex < 0) return@collect
2026-01-18 16:22:42 +01:00
val step = navigationSteps.find {
it.gridIndex == currentIndex && !it.alreadyDone
}
println("pas: ${currentIndex}")
if (step != null) {
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
}
// Rit. / Rall. , ...
step.isTempoChange -> {
step.alreadyDone = true
val startBpm = currentPlaybackBpm
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(" $startBpm BPM → $endBpm BPM sur ${durationMs}ms")
applyTempoChange(
startBpm = startBpm,
endBpm = endBpm,
durationMs = durationMs,
tempoType = step.tempoType
)
}
// ── 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)
}
2026-02-06 09:51:33 +03:00
// ── 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}")
}
}
}
2026-02-06 09:51:33 +03:00
}
2026-01-18 16:22:42 +01:00
}
}
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)
}
}
2026-02-06 09:51:33 +03:00
}
private var countInJob: Job? = null
private fun getCountInBeats(measureStr: String): Int {
val firstNumeratorChar = measureStr.substringBefore('/').trim()
return firstNumeratorChar.toIntOrNull() ?: 4
}
actual fun play() {
if (sequence == null) return
if(currentTickPos == 0L) {
playCountIn()
} else {
applyVoiceStates()
startPlaybackLoop()
println("Android MIDI play — tick=$currentTickPos bpm=$targetBpm")
}
}
private fun playCountIn() {
countInJob?.cancel()
countInJob = playerScope.launch(Dispatchers.Default) {
val bpm = targetBpm
val beatDurationMs = (60_000L / bpm).toLong()
val noteEvent = 42 // 39:Hand clap
val velocity = 100
val originalMeasure = sharedScreenModel.measureInt.value
val nbSilent = sharedScreenModel.silentDurationBefore.value
val adjustedMeasure = when {
originalMeasure == 6 -> 3
originalMeasure % 2 != 0 && originalMeasure > 3 -> 3
originalMeasure % 2 == 0 && originalMeasure > 4 -> 4
else -> originalMeasure
}
val anacrouseBeats = nbSilent / 4
val noteOnEvent = byteArrayOf(0x99.toByte(), noteEvent.toByte(), velocity.toByte())
val noteOffEvent = byteArrayOf(0x89.toByte(), noteEvent.toByte(), 0.toByte())
val noteOnSnare = byteArrayOf(0x99.toByte(), 38.toByte(), velocity.toByte())
val noteOffSnare = byteArrayOf(0x89.toByte(), 38.toByte(), 0.toByte())
val isEvenMeasure = adjustedMeasure in setOf(2, 4, 8)
if (isEvenMeasure) {
for (i in 1..2) {
midiDriver.write(noteOnEvent)
delay(100)
midiDriver.write(noteOffEvent)
val slowDuration = beatDurationMs * 2
delay((slowDuration - 100).coerceAtLeast(0L))
}
val remainingBeats = if (anacrouseBeats == 0) 4 else anacrouseBeats
for (i in 1..remainingBeats) {
val onBytes = if (i == remainingBeats) noteOnSnare else noteOnEvent
val offBytes = if (i == remainingBeats) noteOffSnare else noteOffEvent
midiDriver.write(onBytes)
delay(100)
midiDriver.write(offBytes)
delay((beatDurationMs - 100).coerceAtLeast(0L))
}
} else {
val totalBeats = if (anacrouseBeats > 5) anacrouseBeats else adjustedMeasure + anacrouseBeats
// println("Android dcompte total : $totalBeats bips ($adjustedMeasure de préparation + $anacrouseBeats d'anacrouse) à $bpm BPM")
for (i in 1..totalBeats) {
val onBytes = if (i == adjustedMeasure) noteOnSnare else noteOnEvent
val offBytes = if (i == adjustedMeasure) noteOffSnare else noteOffEvent
midiDriver.write(onBytes)
delay(100)
midiDriver.write(offBytes)
delay((beatDurationMs - 100).coerceAtLeast(0L))
}
}
withContext(Dispatchers.Main) {
applyVoiceStates()
startPlaybackLoop()
}
}
}
actual fun pause() {
countInJob?.cancel()
isRunning = false; isHolding = false
playJob?.cancel(); allNotesOff()
}
actual fun stop() {
countInJob?.cancel()
isRunning = false; isHolding = false
playJob?.cancel(); navigationJob?.cancel()
allNotesOff(); currentTickPos = 0L
resetNavigationFlags(); navigationSteps.clear(); clearLoop()
currentDynamicFactor = Dynamic.MF.factor
resetTempoToNormal()
}
actual fun release() {
stop(); midiDriver.stop(); playerScope.cancel()
}
actual fun seekTo(position: Long) {
currentTickPos = msToTicks(position)
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) {
currentGlobalVolume = level
midiDriver.setVolume((level*100).toInt().coerceIn(0,100))
applyVoiceStates()
}
actual fun setTempo(bpm: Float) {
targetBpm = bpm; usPerTick = (60_000_000.0/bpm)/resolution
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() {
pointB = getCurrentPosition()
if (pointB > pointA && pointA != -1L) isLoopingAB = true
}
actual fun clearLoop() { isLoopingAB = false; pointA = -1L; pointB = -1L }
actual fun getLoopState() = Triple(pointA, pointB, isLoopingAB)
actual fun toggleVoice(index: Int) { applyVoiceStates() }
private fun saveVoicesVolumes() {
val data = voiceVolumes.joinToString(",")
settings.putString("voices_volumes", data)
}
private fun loadVoiceVolumes() {
val data = settings.getString("voices_volumes", "127,127,127,127")
val volumesArray = data.split(",")
if (volumesArray.size == 4) {
for (i in 0 until 4) {
voiceVolumes[i] = volumesArray[i].toFloatOrNull() ?: 127f
}
}
}
actual fun updateVoiceVolume(voiceIndex: Int, newVolume: Float) {
if (voiceIndex in 0..3) {
voiceVolumes[voiceIndex] = newVolume;
saveVoicesVolumes()
applyVoiceStates()
}
}
actual fun getVoiceVolumes(): List<Float> = voiceVolumes.toList()
actual fun changeInstru(noInstru: Int) {
for (ch in 0 until 4) send(0xC0 or ch, noInstru)
}
actual fun getAvalaibleInstruments(): List<MidiInstrument> {
val gmInstruments = listOf(
"Acoustic Grand Piano", "Bright Acoustic Piano", "Electric Grand Piano", "Honky-tonk Piano", "Electric Piano 1", "Electric Piano 2", "Harpsichord", "Clavi", "Celesta", "Glockenspiel", "Music Box", "Vibraphone", "Marimba", "Xylophone", "Tubular Bells", "Dulcimer", "Drawbar Organ", "Percussive Organ", "Rock Organ", "Church Organ", "Reed Organ", "Accordion", "Harmonica", "Tango Accordion", "Acoustic Guitar (nylon)", "Acoustic Guitar (steel)", "Electric Guitar (jazz)", "Electric Guitar (clean)", "Electric Guitar (muted)", "Overdriven Guitar", "Distortion Guitar", "Guitar harmonics", "Acoustic Bass", "Electric Bass (finger)", "Electric Bass (pick)", "Fretless Bass", "Slap Bass 1", "Slap Bass 2", "Synth Bass 1", "Synth Bass 2", "Violin", "Viola", "Cello", "Contrabass", "Tremolo Strings", "Pizzicato Strings", "Orchestral Harp", "Timpani", "String Ensemble 1", "String Ensemble 2", "SynthStrings 1", "SynthStrings 2", "Choir Aahs", "Voice Oohs", "Synth Voice", "Orchestra Hit", "Trumpet", "Trombone", "Tuba", "Muted Trumpet", "French Horn", "Brass Section", "SynthBrass 1", "SynthBrass 2", "Soprano Sax", "Alto Sax", "Tenor Sax", "Baritone Sax", "Oboe", "English Horn", "Bassoon", "Clarinet", "Piccolo", "Flute", "Recorder", "Pan Flute", "Blown Bottle", "Shakuhachi", "Whistle", "Ocarina", "Lead 1 (square)", "Lead 2 (sawtooth)", "Lead 3 (calliope)", "Lead 4 (chiff)", "Lead 5 (charang)", "Lead 6 (voice)", "Lead 7 (fifths)", "Lead 8 (bass+lead)", "Pad 1 (new age)", "Pad 2 (warm)", "Pad 3 (polysynth)", "Pad 4 (choir)", "Pad 5 (bowed)", "Pad 6 (metallic)", "Pad 7 (halo)", "Pad 8 (sweep)", "FX 1 (rain)", "FX 2 (soundtrack)", "FX 3 (crystal)", "FX 4 (atmosphere)", "FX 5 (brightness)", "FX 6 (goblins)", "FX 7 (echoes)", "FX 8 (sci-fi)", "Sitar", "Banjo", "Shamisen", "Koto", "Kalimba", "Bag pipe", "Fiddle", "Shanai", "Tinkle Bell", "Agogo", "Steel Drums", "Woodblock", "Taiko Drum", "Melodic Tom", "Synth Drum", "Reverse Cymbal", "Guitar Fret Noise", "Breath Noise", "Seashore", "Bird Tweet", "Telephone Ring", "Helicopter", "Applause", "Gunshot"
)
return gmInstruments.mapIndexed { index, name ->
MidiInstrument(
program = index,
name = name
)
}
}
actual fun saveVoiceInstrument(program: Int) {
settings.putInt("voice_instrument", program)
}
actual fun getVoiceInstrument(): Int {
val defaultProgram = 0
return settings.getInt("voice_instrument", defaultProgram)
}
fun loadSavedInstrumentsToPlayer(mediaPlayer: FMediaPlayer) {
val savedProgram = getVoiceInstrument()
mediaPlayer.changeInstru(savedProgram)
2026-01-18 16:22:42 +01:00
}
2026-02-06 09:51:33 +03:00
}