2026-01-18 16:22:42 +01:00
|
|
|
package mg.dot.feufaro.midi
|
|
|
|
|
|
2026-03-17 11:29:44 +03:00
|
|
|
import SharedScreenModel
|
2026-06-22 17:40:37 +03:00
|
|
|
import com.russhwolf.settings.Settings
|
2026-03-17 11:29:44 +03:00
|
|
|
import kotlinx.coroutines.*
|
|
|
|
|
import org.billthefarmer.mididriver.MidiDriver
|
2026-06-22 17:40:37 +03:00
|
|
|
import org.koin.core.component.KoinComponent
|
|
|
|
|
import org.koin.core.component.inject
|
2026-02-06 09:51:33 +03:00
|
|
|
import java.io.File
|
2026-03-17 11:29:44 +03:00
|
|
|
import java.io.RandomAccessFile
|
2026-01-18 16:22:42 +01:00
|
|
|
|
2026-03-17 11:29:44 +03:00
|
|
|
actual class FMediaPlayer actual constructor(
|
|
|
|
|
private val filename: String,
|
2026-07-09 12:00:30 +03:00
|
|
|
private val sharedScreenModel: SharedScreenModel,
|
2026-03-17 11:29:44 +03:00
|
|
|
private val onFinished: () -> Unit
|
2026-06-22 17:40:37 +03:00
|
|
|
): KoinComponent {
|
2026-03-17 11:29:44 +03:00
|
|
|
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
|
|
|
|
2026-03-17 11:29:44 +03: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
|
2026-04-07 15:38:44 +03:00
|
|
|
@Volatile private var usPerTick: Double = (60_000_000.0 / 120.0) / 480.0
|
2026-03-17 11:29:44 +03:00
|
|
|
|
|
|
|
|
@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
|
|
|
|
|
|
2026-04-07 15:38:44 +03:00
|
|
|
@Volatile private var currentPlaybackBpm: Float = 120f
|
|
|
|
|
@Volatile private var isInTempoChange: Boolean = false
|
|
|
|
|
@Volatile private var needsClockSync = false
|
|
|
|
|
private var tempoChangeJob: Job? = null
|
2026-03-17 11:29:44 +03:00
|
|
|
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
|
2026-03-17 11:29:44 +03:00
|
|
|
private var isLoopingAB = false
|
2026-02-06 09:51:33 +03:00
|
|
|
|
|
|
|
|
private val playerScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
2026-03-17 11:29:44 +03:00
|
|
|
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
|
2026-06-22 17:40:37 +03:00
|
|
|
private val settings: Settings by inject()
|
2026-02-06 09:51:33 +03:00
|
|
|
|
2026-03-17 11:29:44 +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,
|
2026-04-07 15:38:44 +03:00
|
|
|
// rit & rall
|
|
|
|
|
val isTempoChange: Boolean = false,
|
|
|
|
|
val tempoType: String = "",
|
|
|
|
|
val endGridForTempo: Int = -1,
|
|
|
|
|
val targetTempoMultiplier: Float = 0.6f
|
2026-03-17 11:29:44 +03:00
|
|
|
)
|
|
|
|
|
private val navigationSteps = mutableListOf<NavigationStep>()
|
2026-02-06 09:51:33 +03:00
|
|
|
|
|
|
|
|
init {
|
2026-03-17 11:29:44 +03:00
|
|
|
midiDriver.start()
|
2026-06-22 17:40:37 +03:00
|
|
|
loadVoiceVolumes()
|
2026-07-10 15:26:17 +03:00
|
|
|
loadSavedInstrumentsToPlayer(this)
|
2026-03-17 11:29:44 +03:00
|
|
|
val file = File(filename)
|
|
|
|
|
if (file.exists()) {
|
2026-02-16 17:20:32 +01:00
|
|
|
try {
|
2026-03-17 11:29:44 +03:00
|
|
|
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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 11:29:44 +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-02-18 09:36:09 +03:00
|
|
|
}
|
2026-01-18 16:22:42 +01:00
|
|
|
}
|
2026-03-17 11:29:44 +03: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
|
|
|
}
|
2026-03-17 11:29:44 +03: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)
|
2026-02-16 17:20:32 +01:00
|
|
|
}
|
2026-03-17 11:29:44 +03:00
|
|
|
currentDynamicFactor = targetFactor; applyVoiceStates()
|
2026-02-16 17:20:32 +01:00
|
|
|
}
|
2026-01-18 16:22:42 +01:00
|
|
|
}
|
2026-03-17 11:29:44 +03: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
|
|
|
}
|
2026-04-07 15:38:44 +03: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"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 11:29:44 +03:00
|
|
|
private fun extractDynamic(marker: String) = Dynamic.entries.firstOrNull {
|
|
|
|
|
it.label == marker.trim().removeSuffix("=").trim()
|
2026-01-18 16:22:42 +01:00
|
|
|
}
|
2026-03-17 11:29:44 +03: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
|
|
|
}
|
2026-03-17 11:29:44 +03: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) {
|
2026-04-07 15:38:44 +03:00
|
|
|
if (needsClockSync) {
|
|
|
|
|
clockNano = System.nanoTime()
|
|
|
|
|
clockTick = currentTickPos
|
|
|
|
|
needsClockSync = false
|
|
|
|
|
}
|
2026-03-17 11:29:44 +03:00
|
|
|
if (isHolding) {
|
2026-04-07 15:38:44 +03:00
|
|
|
yield()
|
|
|
|
|
// delay(10)
|
2026-03-17 11:29:44 +03:00
|
|
|
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]
|
2026-04-07 15:38:44 +03:00
|
|
|
// 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()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-17 11:29:44 +03:00
|
|
|
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é */ }
|
|
|
|
|
}
|
2026-04-07 15:38:44 +03:00
|
|
|
val currentGrid = (ev.tickAbsolute / resolution).toLong()
|
|
|
|
|
if (boundModel?.activeIndex?.value != currentGrid.toInt()) {
|
|
|
|
|
boundModel?.updateActiveIndex(currentGrid)
|
|
|
|
|
}
|
2026-03-17 11:29:44 +03:00
|
|
|
idx++
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-18 16:22:42 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-17 11:29:44 +03: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
|
|
|
}
|
2026-03-17 11:29:44 +03: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
|
|
|
|
2026-03-17 11:29:44 +03: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
|
|
|
}
|
|
|
|
|
|
2026-03-17 11:29:44 +03:00
|
|
|
private fun resetNavigationFlags() {
|
|
|
|
|
navigationSteps.forEach { it.alreadyDone = false }
|
2026-02-06 09:51:33 +03:00
|
|
|
}
|
|
|
|
|
|
2026-03-17 11:29:44 +03:00
|
|
|
private fun prepareNavigation(sharedScreenModel: SharedScreenModel) {
|
|
|
|
|
val metadataList = sharedScreenModel.getFullMarkers()
|
|
|
|
|
navigationSteps.clear()
|
2026-04-07 15:38:44 +03:00
|
|
|
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)
|
2026-03-17 11:29:44 +03:00
|
|
|
|
2026-04-07 15:38:44 +03:00
|
|
|
metadataList.forEach { (timestamp, gridIndex, template, lastCallerMarker, marker, noteBefore, separat, note) ->
|
2026-03-17 11:29:44 +03:00
|
|
|
val ci = gridIndex ?: 0
|
2026-04-07 15:38:44 +03:00
|
|
|
val dsR = Regex("""D\.?S\.?""");
|
|
|
|
|
val dcR = Regex("""D\.?C\.?""")
|
|
|
|
|
val dsG = Regex("""D\.?S\.?_GROUP_PART""");
|
|
|
|
|
val dcG = Regex("""D\.?C\.?_GROUP_PART""")
|
2026-03-17 11:29:44 +03:00
|
|
|
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))
|
|
|
|
|
}
|
2026-04-07 15:38:44 +03:00
|
|
|
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
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-03-17 11:29:44 +03:00
|
|
|
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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 11:29:44 +03:00
|
|
|
private fun startNavigationMonitor(sharedScreenModel: SharedScreenModel) {
|
2026-04-07 15:38:44 +03:00
|
|
|
var lastProcessedIndex = -1
|
2026-03-17 11:29:44 +03:00
|
|
|
navigationJob?.cancel()
|
|
|
|
|
navigationJob = playerScope.launch(Dispatchers.Default) {
|
|
|
|
|
sharedScreenModel.activeIndex.collect { currentIndex ->
|
2026-04-07 15:38:44 +03:00
|
|
|
if (currentIndex <= lastProcessedIndex) {
|
|
|
|
|
return@collect
|
|
|
|
|
}
|
|
|
|
|
lastProcessedIndex = currentIndex
|
2026-03-17 11:29:44 +03:00
|
|
|
if (!isRunning || currentIndex < 0) return@collect
|
2026-01-18 16:22:42 +01:00
|
|
|
|
2026-03-17 11:29:44 +03:00
|
|
|
val step = navigationSteps.find {
|
|
|
|
|
it.gridIndex == currentIndex && !it.alreadyDone
|
2026-04-07 15:38:44 +03:00
|
|
|
}
|
|
|
|
|
println("pas: ${currentIndex}")
|
|
|
|
|
if (step != null) {
|
|
|
|
|
when {
|
2026-03-17 11:29:44 +03:00
|
|
|
// ── 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
|
|
|
|
|
}
|
2026-04-07 15:38:44 +03:00
|
|
|
// 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
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-03-17 11:29:44 +03:00
|
|
|
|
|
|
|
|
// ── 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
|
|
|
|
2026-03-17 11:29:44 +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-04-07 15:38:44 +03:00
|
|
|
}
|
2026-02-06 09:51:33 +03:00
|
|
|
}
|
2026-01-18 16:22:42 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 11:29:44 +03:00
|
|
|
private fun startSyncLoop(sharedScreenModel: SharedScreenModel) {
|
|
|
|
|
syncJob?.cancel()
|
|
|
|
|
syncJob = playerScope.launch(Dispatchers.Default) {
|
|
|
|
|
while (isActive) {
|
|
|
|
|
if (isRunning && !isHolding) {
|
|
|
|
|
val elapsedNano = System.nanoTime() - lastEventNano
|
2026-03-17 11:52:03 +03:00
|
|
|
val extraTicks = (elapsedNano / (usPerTick * 1000)).toLong().coerceAtLeast(0L)
|
2026-03-17 11:29:44 +03:00
|
|
|
val interpolated = (currentTickPos + extraTicks) / resolution
|
|
|
|
|
val rawGrid = interpolated.toInt().coerceAtLeast(0)
|
|
|
|
|
sharedScreenModel.updateActiveIndexByIndex(rawGrid)
|
|
|
|
|
}
|
|
|
|
|
delay(16)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-06 09:51:33 +03:00
|
|
|
}
|
|
|
|
|
|
2026-07-09 12:00:30 +03:00
|
|
|
private var countInJob: Job? = null
|
|
|
|
|
|
|
|
|
|
private fun getCountInBeats(measureStr: String): Int {
|
|
|
|
|
val firstNumeratorChar = measureStr.substringBefore('/').trim()
|
|
|
|
|
return firstNumeratorChar.toIntOrNull() ?: 4
|
|
|
|
|
}
|
2026-03-17 11:29:44 +03:00
|
|
|
actual fun play() {
|
|
|
|
|
if (sequence == null) return
|
2026-07-09 12:00:30 +03:00
|
|
|
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())
|
|
|
|
|
|
2026-07-10 16:05:32 +03:00
|
|
|
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)
|
2026-07-09 12:00:30 +03:00
|
|
|
|
2026-07-10 16:05:32 +03:00
|
|
|
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))
|
|
|
|
|
}
|
2026-07-09 12:00:30 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
withContext(Dispatchers.Main) {
|
|
|
|
|
applyVoiceStates()
|
|
|
|
|
startPlaybackLoop()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-17 11:29:44 +03:00
|
|
|
}
|
|
|
|
|
actual fun pause() {
|
2026-07-09 12:00:30 +03:00
|
|
|
countInJob?.cancel()
|
2026-03-17 11:29:44 +03:00
|
|
|
isRunning = false; isHolding = false
|
|
|
|
|
playJob?.cancel(); allNotesOff()
|
|
|
|
|
}
|
|
|
|
|
actual fun stop() {
|
2026-07-09 12:00:30 +03:00
|
|
|
countInJob?.cancel()
|
2026-03-17 11:29:44 +03:00
|
|
|
isRunning = false; isHolding = false
|
|
|
|
|
playJob?.cancel(); navigationJob?.cancel()
|
|
|
|
|
allNotesOff(); currentTickPos = 0L
|
|
|
|
|
resetNavigationFlags(); navigationSteps.clear(); clearLoop()
|
|
|
|
|
currentDynamicFactor = Dynamic.MF.factor
|
2026-04-07 15:38:44 +03:00
|
|
|
resetTempoToNormal()
|
2026-03-17 11:29:44 +03:00
|
|
|
}
|
|
|
|
|
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() }
|
2026-06-22 17:40:37 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-17 16:23:53 +03:00
|
|
|
actual fun updateVoiceVolume(voiceIndex: Int, newVolume: Float) {
|
2026-06-22 17:40:37 +03:00
|
|
|
if (voiceIndex in 0..3) {
|
|
|
|
|
voiceVolumes[voiceIndex] = newVolume;
|
|
|
|
|
saveVoicesVolumes()
|
|
|
|
|
applyVoiceStates()
|
|
|
|
|
}
|
2026-02-17 16:23:53 +03:00
|
|
|
}
|
2026-03-17 11:29:44 +03:00
|
|
|
actual fun getVoiceVolumes(): List<Float> = voiceVolumes.toList()
|
|
|
|
|
actual fun changeInstru(noInstru: Int) {
|
2026-07-10 16:05:32 +03:00
|
|
|
for (ch in 0 until 4) send(0xC0 or ch, noInstru)
|
2026-07-10 15:26:17 +03:00
|
|
|
}
|
|
|
|
|
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
|
|
|
}
|