sonodrill

Sonodrill, app iOS/Android d’entraînement musical (repères, boucles, vitesse), Kotlin Multiplatform + Compose
git clone https://git.schurger.org/sonodrill.git
Log | Files | Refs

commit de3476b54d273df9aaa78c7941b3d739f8b3f14f
parent 9266199620d9d85efa5e252deffd3e83c7cbe831
Author: Jean Schurger <jean@schurger.org>
Date:   Fri, 16 Oct 2026 15:14:10 -0400

Skip zones inside a loop are honored

Skip and loop monitors now resolve one target: past the skip zones, and back
to the loop start when that reaches the loop end (skipping again if the loop
start is itself skipped). No more playing past the loop end or into a skipped
span at the loop start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141Zm4UQq8PATQAG98MdNCc

Diffstat:
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/audio/loop_controller.kt | 6++++--
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/player/player_view_model.kt | 21++++++++++++++++++---
2 files changed, 22 insertions(+), 5 deletions(-)

diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/audio/loop_controller.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/audio/loop_controller.kt @@ -12,12 +12,14 @@ class LoopController(private val player: AudioPlayer) { val state: StateFlow<LoopState> = _state.asStateFlow() private var loop_job: Job? = null + // Adjusts where a loop restarts (e.g. past a skip zone at its start) + var resolve_target: (Long) -> Long = { it } private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) fun set_loop(a: MarkInfo, b: MarkInfo, seek_to_start: Boolean = true) { val (start, end) = if (a.position_ms <= b.position_ms) a to b else b to a _state.value = LoopState(mark_a = start, mark_b = end, enabled = true) - if (seek_to_start) player.seek(start.position_ms) + if (seek_to_start) player.seek(resolve_target(start.position_ms)) start_monitoring() } @@ -36,7 +38,7 @@ class LoopController(private val player: AudioPlayer) { if (pos >= end) { // Reaching the end of the track stops the player: restart it for loops ending there val track_ended = !player.is_playing.value && player.duration_ms.value in 1..pos - player.seek(start) + player.seek(resolve_target(start)) if (track_ended) player.play() } delay(50) diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/player/player_view_model.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/player/player_view_model.kt @@ -26,7 +26,7 @@ class PlayerViewModel( private val player: AudioPlayer, private val repo: AudioRepository, ) { - val loop = LoopController(player) + val loop = LoopController(player).also { it.resolve_target = { pos -> jump_target(pos) } } val position = player.current_position_ms val duration = player.duration_ms val is_playing = player.is_playing @@ -316,6 +316,22 @@ class PlayerViewModel( set_skip_zones(_skip_zones.value.filter { !(pos >= it.start_ms && pos < it.end_ms) }) } + // Where playback continues from pos: past skip zones, and back to the start of the + // active loop when that reaches its end, so skip zones inside a loop are honored + private fun jump_target(pos: Long): Long { + val ls = loop_state.value + val loop_start = ls.mark_a?.position_ms + val loop_end = ls.mark_b?.position_ms + var target = pos + // Bounded: zones chained end to start can't send us around forever + repeat(_skip_zones.value.size + 2) { + if (ls.enabled && loop_start != null && loop_end != null && target >= loop_end) target = loop_start + val zone = _skip_zones.value.firstOrNull { target >= it.start_ms && target < it.end_ms } ?: return target + target = zone.end_ms + } + return target + } + private fun start_skip_monitoring() { skip_job?.cancel() skip_job = scope.launch { @@ -323,8 +339,7 @@ class PlayerViewModel( delay(80) if (!is_playing.value) continue val pos = position.value - val zone = _skip_zones.value.firstOrNull { pos >= it.start_ms && pos < it.end_ms } - if (zone != null) player.seek(zone.end_ms) + if (_skip_zones.value.any { pos >= it.start_ms && pos < it.end_ms }) player.seek(jump_target(pos)) } } }