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 2bee00465c559bda184ee24260b26af75b3d2c6f
parent bed2e8104f7aafb7fb1e216e38fb5230b7cb7fae
Author: Jean Schurger <jean@schurger.org>
Date:   Mon,  6 Apr 2026 16:41:46 -0400

Add loop zones, Bluetooth fix, delay options, and UI tweaks

- Multiple loop zones (like skip zones), tap to activate/deactivate
- Loop zones shown as blue tint on timeline
- Fix position tracking over Bluetooth (use monotonic clock)
- Restart position monitoring on seek
- Delay picker options: 2, 5, 15, 30 seconds
- Prev button goes to start if no previous mark
- Next button disabled when no next mark

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Diffstat:
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/model/models.kt | 5+++++
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/player/player_screen.kt | 53+++++++++++++++++++++++++++++++++++++++++++++--------
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/player/player_view_model.kt | 61++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
McomposeApp/src/iosMain/kotlin/ubi2/sonodrill/audio/ios_audio_player.kt | 41++++++++++++++++++++++++++++++++---------
MiosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme | 18+++++++++++-------
5 files changed, 151 insertions(+), 27 deletions(-)

diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/model/models.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/model/models.kt @@ -29,3 +29,8 @@ data class SkipZone( val start_ms: Long, val end_ms: Long, ) + +data class LoopZone( + val start_ms: Long, + val end_ms: Long, +) diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/player/player_screen.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/player/player_screen.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import ubi2.sonodrill.model.LoopState +import ubi2.sonodrill.model.LoopZone import ubi2.sonodrill.model.MarkInfo import ubi2.sonodrill.model.SkipZone import ubi2.sonodrill.model.set_idle_timer_disabled @@ -43,6 +44,7 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { val file by vm.current_file.collectAsState() val waveform by vm.waveform.collectAsState() val skip_zones by vm.skip_zones.collectAsState() + val loop_zones by vm.loop_zones.collectAsState() val play_delay_s by vm.play_delay_s.collectAsState() val play_delay_enabled by vm.play_delay_enabled.collectAsState() @@ -80,10 +82,18 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { position_ms = position, duration_ms = duration, marks = marks, - loop_state = loop_state, waveform = waveform, skip_zones = skip_zones, + loop_zones = loop_zones, + loop_state = loop_state, on_seek = { tap_ms -> + // Check if tapping inside a loop zone first + val tapped_zone = loop_zones.firstOrNull { tap_ms >= it.start_ms && tap_ms < it.end_ms } + if (tapped_zone != null) { + vm.tap_in_loop_zone(tap_ms) + vm.seek(tap_ms) + return@ProgressTimeline + } vm.seek(tap_ms) // If mark loop is active and tap is outside current loop, re-loop around tap position val ls = loop_state @@ -138,7 +148,10 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { ) } } - IconButton(onClick = { vm.next_mark() }) { + IconButton( + onClick = { vm.next_mark() }, + enabled = marks.any { it.position_ms > position + 500 }, + ) { Icon(Icons.Default.SkipNext, "Next mark") } @@ -298,7 +311,7 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { // Delay picker dialog if (show_delay_picker) { - val delays = listOf(1, 2, 3, 5, 10) + val delays = listOf(2, 5, 15, 30) AlertDialog( onDismissRequest = { show_delay_picker = false }, title = { Text("Play delay") }, @@ -308,7 +321,8 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { FilterChip( selected = play_delay_s == d, onClick = { vm.set_play_delay(d) }, - label = { Text("${d}s") }, + label = { Text("${d}s", maxLines = 1) }, + modifier = Modifier.padding(horizontal = 2.dp), ) } } @@ -334,9 +348,10 @@ private fun ProgressTimeline( position_ms: Long, duration_ms: Long, marks: List<MarkInfo>, - loop_state: LoopState, waveform: FloatArray, skip_zones: List<SkipZone>, + loop_zones: List<LoopZone>, + loop_state: LoopState, on_seek: (Long) -> Unit, on_mark_label_click: (MarkInfo) -> Unit, ) { @@ -346,6 +361,8 @@ private fun ProgressTimeline( val mark_color = Color(0xFFFF6B35) val loop_color = MaterialTheme.colorScheme.primaryContainer val skip_color = Color(0x33FF0000) + val loop_zone_color = Color(0x220066FF) + val loop_zone_active_color = Color(0x440066FF) var zoom by remember { mutableFloatStateOf(1f) } val rows = (BASE_ROWS * zoom).toInt().coerceAtLeast(BASE_ROWS) @@ -386,10 +403,12 @@ private fun ProgressTimeline( TimelineRow( row = row, row_duration = row_duration, samples_per_row = samples_per_row, position_ms = position_ms, duration_ms = duration_ms, marks = marks, - loop_state = loop_state, waveform = waveform, on_seek = on_seek, + waveform = waveform, on_seek = on_seek, primary = primary, surface_variant = surface_variant, on_surface = on_surface, mark_color = mark_color, loop_color = loop_color, skip_color = skip_color, skip_zones = skip_zones, + loop_zones = loop_zones, loop_state = loop_state, + loop_zone_color = loop_zone_color, loop_zone_active_color = loop_zone_active_color, on_mark_label_click = on_mark_label_click, modifier = Modifier.fillMaxWidth().height(ROW_HEIGHT_DP.dp), ) @@ -405,10 +424,12 @@ private fun ProgressTimeline( TimelineRow( row = row, row_duration = row_duration, samples_per_row = samples_per_row, position_ms = position_ms, duration_ms = duration_ms, marks = marks, - loop_state = loop_state, waveform = waveform, on_seek = on_seek, + waveform = waveform, on_seek = on_seek, primary = primary, surface_variant = surface_variant, on_surface = on_surface, mark_color = mark_color, loop_color = loop_color, skip_color = skip_color, skip_zones = skip_zones, + loop_zones = loop_zones, loop_state = loop_state, + loop_zone_color = loop_zone_color, loop_zone_active_color = loop_zone_active_color, on_mark_label_click = on_mark_label_click, modifier = Modifier.fillMaxWidth().weight(1f), ) @@ -422,10 +443,12 @@ private fun ProgressTimeline( private fun TimelineRow( row: Int, row_duration: Float, samples_per_row: Float, position_ms: Long, duration_ms: Long, marks: List<MarkInfo>, - loop_state: LoopState, waveform: FloatArray, on_seek: (Long) -> Unit, + waveform: FloatArray, on_seek: (Long) -> Unit, primary: Color, surface_variant: Color, on_surface: Color, mark_color: Color, loop_color: Color, skip_color: Color, skip_zones: List<SkipZone>, + loop_zones: List<LoopZone>, loop_state: LoopState, + loop_zone_color: Color, loop_zone_active_color: Color, on_mark_label_click: (MarkInfo) -> Unit, modifier: Modifier, ) { @@ -473,6 +496,20 @@ private fun TimelineRow( } } + // Loop zones (blue tint) + for (zone in loop_zones) { + val zs = ((zone.start_ms - row_start_ms) / (row_end_ms - row_start_ms)).coerceIn(0f, 1f) + val ze = ((zone.end_ms - row_start_ms) / (row_end_ms - row_start_ms)).coerceIn(0f, 1f) + if (ze > zs && zone.start_ms < row_end_ms && zone.end_ms > row_start_ms) { + val is_active = loop_state.enabled && loop_state.mark_a?.position_ms == zone.start_ms && loop_state.mark_b?.position_ms == zone.end_ms + drawRect( + color = if (is_active) loop_zone_active_color else loop_zone_color, + topLeft = Offset(zs * w, 0f), + size = androidx.compose.ui.geometry.Size((ze - zs) * w, h), + ) + } + } + // Skip zones (red tint) for (zone in skip_zones) { val zs = ((zone.start_ms - row_start_ms) / (row_end_ms - row_start_ms)).coerceIn(0f, 1f) 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 @@ -6,6 +6,7 @@ import ubi2.sonodrill.audio.extract_waveform import ubi2.sonodrill.data.AudioRepository import ubi2.sonodrill.model.AudioFileInfo import ubi2.sonodrill.model.LoopState +import ubi2.sonodrill.model.LoopZone import ubi2.sonodrill.model.MarkInfo import ubi2.sonodrill.model.SkipZone import ubi2.sonodrill.model.current_time_ms @@ -37,6 +38,9 @@ class PlayerViewModel( private val _skip_zones = MutableStateFlow<List<SkipZone>>(emptyList()) val skip_zones: StateFlow<List<SkipZone>> = _skip_zones.asStateFlow() + private val _loop_zones = MutableStateFlow<List<LoopZone>>(emptyList()) + val loop_zones: StateFlow<List<LoopZone>> = _loop_zones.asStateFlow() + private val _play_delay_s = MutableStateFlow(3) val play_delay_s: StateFlow<Int> = _play_delay_s.asStateFlow() private val _play_delay_enabled = MutableStateFlow(false) @@ -158,7 +162,8 @@ class PlayerViewModel( fun prev_mark() { val pos = position.value - _marks.value.lastOrNull { it.position_ms < pos - 500 }?.let { seek_to_mark(it) } + val prev = _marks.value.lastOrNull { it.position_ms < pos - 500 } + if (prev != null) seek_to_mark(prev) else player.seek(0) } fun delete_prev_mark() { @@ -182,6 +187,12 @@ class PlayerViewModel( } fun loop_around(position_ms: Long) { + // Check if tapping inside an existing loop zone + val zone = _loop_zones.value.firstOrNull { position_ms >= it.start_ms && position_ms < it.end_ms } + if (zone != null) { + activate_loop_zone(zone) + return + } val sorted = _marks.value.sortedBy { it.position_ms } val prev = sorted.lastOrNull { it.position_ms <= position_ms } val next = sorted.firstOrNull { it.position_ms > position_ms } @@ -204,21 +215,62 @@ class PlayerViewModel( val sorted = _marks.value.sortedBy { it.position_ms } val prev = sorted.lastOrNull { it.position_ms < pos } if (prev != null) { - loop.set_loop(prev, new_mark, seek_to_start = false) + val zone = LoopZone(prev.position_ms, new_mark.position_ms) + if (_loop_zones.value.none { it.start_ms == zone.start_ms && it.end_ms == zone.end_ms }) { + _loop_zones.value = (_loop_zones.value + zone).sortedBy { it.start_ms } + } + activate_loop_zone(zone) } } fun toggle_mark_loop() { + // If loop is active, deactivate if (loop_state.value.enabled && loop_state.value.mark_a?.id != "_full_a") { loop.clear() return } + // Find surrounding marks and create/activate a loop zone val pos = position.value val sorted = _marks.value.sortedBy { it.position_ms } val prev = sorted.lastOrNull { it.position_ms <= pos + 500 } val next = sorted.firstOrNull { it.position_ms > pos - 500 && it != prev } if (prev != null && next != null) { - loop.set_loop(prev, next, seek_to_start = false) + val zone = LoopZone(prev.position_ms, next.position_ms) + if (_loop_zones.value.none { it.start_ms == zone.start_ms && it.end_ms == zone.end_ms }) { + _loop_zones.value = (_loop_zones.value + zone).sortedBy { it.start_ms } + } + activate_loop_zone(zone) + } + } + + fun activate_loop_zone(zone: LoopZone) { + val sorted = _marks.value.sortedBy { it.position_ms } + val a = sorted.lastOrNull { it.position_ms <= zone.start_ms } ?: MarkInfo(id = "_lz_a", file_id = "", position_ms = zone.start_ms, created_at = 0) + val b = sorted.firstOrNull { it.position_ms >= zone.end_ms } ?: MarkInfo(id = "_lz_b", file_id = "", position_ms = zone.end_ms, created_at = 0) + loop.set_loop(a, b, seek_to_start = false) + } + + fun remove_loop_zone(zone: LoopZone) { + _loop_zones.value = _loop_zones.value.filter { it != zone } + // If this was the active loop, clear it + val ls = loop_state.value + if (ls.enabled && ls.mark_a != null && ls.mark_b != null) { + if (ls.mark_a!!.position_ms == zone.start_ms && ls.mark_b!!.position_ms == zone.end_ms) { + loop.clear() + } + } + } + + fun tap_in_loop_zone(position_ms: Long) { + val zone = _loop_zones.value.firstOrNull { position_ms >= it.start_ms && position_ms < it.end_ms } + if (zone != null) { + val ls = loop_state.value + // If this zone is already active, deactivate + if (ls.enabled && ls.mark_a?.position_ms == zone.start_ms && ls.mark_b?.position_ms == zone.end_ms) { + loop.clear() + } else { + activate_loop_zone(zone) + } } } @@ -239,6 +291,9 @@ class PlayerViewModel( _skip_zones.value = _skip_zones.value.filter { zone -> mark_positions.contains(zone.start_ms) && mark_positions.contains(zone.end_ms) } + _loop_zones.value = _loop_zones.value.filter { zone -> + mark_positions.contains(zone.start_ms) && mark_positions.contains(zone.end_ms) + } } fun add_skip_zone() { diff --git a/composeApp/src/iosMain/kotlin/ubi2/sonodrill/audio/ios_audio_player.kt b/composeApp/src/iosMain/kotlin/ubi2/sonodrill/audio/ios_audio_player.kt @@ -28,6 +28,8 @@ class IosAudioPlayer : AudioPlayer { private var position_job: Job? = null private var seek_offset_ms: Long = 0 + private var play_start_mark = kotlin.time.TimeSource.Monotonic.markNow() + private var play_start_position_ms: Long = 0 override fun load(uri: String) { release_engine() @@ -67,13 +69,24 @@ class IosAudioPlayer : AudioPlayer { node.scheduleFile(file, atTime = null, completionHandler = null) } - override fun play() { player_node?.play(); _playing.value = true; start_position_updates() } - override fun pause() { player_node?.pause(); _playing.value = false; stop_position_updates() } + override fun play() { + player_node?.play() + play_start_mark = kotlin.time.TimeSource.Monotonic.markNow() + play_start_position_ms = _position.value + _playing.value = true + start_position_updates() + } + override fun pause() { + player_node?.pause() + _playing.value = false + stop_position_updates() + } override fun toggle() { if (_playing.value) pause() else play() } override fun seek(position_ms: Long) { val file = audio_file ?: return; val node = player_node ?: return - val was_playing = _playing.value; node.stop() + val was_playing = _playing.value + node.stop() val frame = (position_ms.toDouble() / 1000.0 * file.processingFormat.sampleRate).toLong() val remaining = file.length - frame if (remaining > 0) { @@ -81,10 +94,22 @@ class IosAudioPlayer : AudioPlayer { } seek_offset_ms = position_ms _position.value = position_ms - if (was_playing) node.play() + play_start_mark = kotlin.time.TimeSource.Monotonic.markNow() + play_start_position_ms = position_ms + if (was_playing) { + node.play() + // Restart position monitoring to ensure it's running + start_position_updates() + } } - override fun set_speed(rate: Float) { _speed.value = rate; time_pitch?.rate = rate } + override fun set_speed(rate: Float) { + if (_playing.value) { + play_start_position_ms = _position.value + play_start_mark = kotlin.time.TimeSource.Monotonic.markNow() + } + _speed.value = rate; time_pitch?.rate = rate + } override fun skip(delta_ms: Long) = seek((_position.value + delta_ms).coerceIn(0, _duration.value)) override fun release() { release_engine(); scope.cancel() } @@ -93,10 +118,8 @@ class IosAudioPlayer : AudioPlayer { private fun stop_position_updates() { position_job?.cancel(); position_job = null } private fun update_current_position() { - val node = player_node ?: return; val file = audio_file ?: return - val time = node.lastRenderTime ?: return; val pt = node.playerTimeForNodeTime(time) ?: return - val elapsed_ms = ((pt.sampleTime.toDouble() / file.processingFormat.sampleRate) * 1000).toLong() - val pos = seek_offset_ms + elapsed_ms + val elapsed_ms = (play_start_mark.elapsedNow().inWholeMilliseconds * _speed.value).toLong() + val pos = play_start_position_ms + elapsed_ms if (pos >= _duration.value) { _position.value = _duration.value _playing.value = false diff --git a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme @@ -1,10 +1,11 @@ <?xml version="1.0" encoding="UTF-8"?> <Scheme LastUpgradeVersion = "1430" - version = "1.3"> + version = "1.7"> <BuildAction parallelizeBuildables = "YES" - buildImplicitDependencies = "YES"> + buildImplicitDependencies = "YES" + runPostActionsOnFailure = "NO"> <BuildActionEntries> <BuildActionEntry buildForTesting = "YES" @@ -15,7 +16,7 @@ <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "78B53CC79860CBFC2F5543C6" - BuildableName = "Sonodrill.app" + BuildableName = "iosApp.app" BlueprintName = "iosApp" ReferencedContainer = "container:iosApp.xcodeproj"> </BuildableReference> @@ -26,12 +27,13 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES" + onlyGenerateCoverageForSpecifiedTargets = "NO"> <MacroExpansion> <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "78B53CC79860CBFC2F5543C6" - BuildableName = "Sonodrill.app" + BuildableName = "iosApp.app" BlueprintName = "iosApp" ReferencedContainer = "container:iosApp.xcodeproj"> </BuildableReference> @@ -54,11 +56,13 @@ <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "78B53CC79860CBFC2F5543C6" - BuildableName = "Sonodrill.app" + BuildableName = "iosApp.app" BlueprintName = "iosApp" ReferencedContainer = "container:iosApp.xcodeproj"> </BuildableReference> </BuildableProductRunnable> + <CommandLineArguments> + </CommandLineArguments> </LaunchAction> <ProfileAction buildConfiguration = "Release" @@ -71,7 +75,7 @@ <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "78B53CC79860CBFC2F5543C6" - BuildableName = "Sonodrill.app" + BuildableName = "iosApp.app" BlueprintName = "iosApp" ReferencedContainer = "container:iosApp.xcodeproj"> </BuildableReference>