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 3c25b70f3308f0fc5daa8b5527336d4bca0dc472
parent c11498b838af7782187c1dbb8cf6fa02b0467c2c
Author: Jean Schurger <jean@schurger.org>
Date:   Sat, 17 Oct 2026 11:38:06 -0400

Version 1.2: practice options, sound settings, shared and synced marks, French UI

- Practice sheet: delay, metronome count-in (before playing and each pass),
  pause between passes, repetitions, progressive speed
- Sound sheet: pitch in semitones, left/right channel, no center, bass cut
  (SoundFilter; iOS player now schedules filtered chunks, Android uses an
  ExoPlayer AudioProcessor)
- Marks: drag a label to move a mark, fine adjust and delete in its dialog;
  zones and the active loop follow
- Share marks as .sonodrill files and import them on a track; audio and marks
  can be opened from other apps
- Marks and zones synced through iCloud key-value storage (iOS)
- Interface in French or English, following the system
- Schema v3: sound settings per file, marks time, preferences
- Tests: sound filter, marks file, loop passes, mark moves, iCloud merge

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

Diffstat:
MandroidApp/build.gradle.kts | 4++--
MandroidApp/src/main/AndroidManifest.xml | 25+++++++++++++++++++++++++
MandroidApp/src/main/kotlin/ubi2/sonodrill/MainActivity.kt | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
AandroidApp/src/main/res/xml/file_paths.xml | 4++++
Mbuild.gradle.kts | 1+
McomposeApp/build.gradle.kts | 11+++++++++++
McomposeApp/src/androidMain/kotlin/ubi2/sonodrill/audio/android_audio_player.kt | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
AcomposeApp/src/androidMain/kotlin/ubi2/sonodrill/audio/sound_filter_processor.kt | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
McomposeApp/src/androidMain/kotlin/ubi2/sonodrill/data/android_audio_storage.kt | 2++
AcomposeApp/src/androidMain/kotlin/ubi2/sonodrill/data/android_cloud_store.kt | 4++++
McomposeApp/src/androidMain/kotlin/ubi2/sonodrill/model/android_platform.kt | 20++++++++++++++++++++
AcomposeApp/src/androidUnitTest/kotlin/ubi2/sonodrill/PlayerViewModelTest.kt | 247+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/audio/audio_player.kt | 6++++++
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/audio/loop_controller.kt | 16++++++++++++++--
AcomposeApp/src/commonMain/kotlin/ubi2/sonodrill/audio/sound_filter.kt | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/data/audio_storage.kt | 2++
AcomposeApp/src/commonMain/kotlin/ubi2/sonodrill/data/cloud_store.kt | 23+++++++++++++++++++++++
AcomposeApp/src/commonMain/kotlin/ubi2/sonodrill/data/marks_file.kt | 39+++++++++++++++++++++++++++++++++++++++
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/data/repository.kt | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/model/models.kt | 35+++++++++++++++++++++++++++++++++++
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/model/platform.kt | 4++++
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/app.kt | 104++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
AcomposeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/incoming.kt | 22++++++++++++++++++++++
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/library/library_screen.kt | 21+++++++++++----------
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/player/player_screen.kt | 299++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
McomposeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/player/player_view_model.kt | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
AcomposeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/strings.kt | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AcomposeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/2.sqm | 15+++++++++++++++
McomposeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/AudioFile.sq | 12+++++++++++-
McomposeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/Mark.sq | 3+++
AcomposeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/Preference.sq | 11+++++++++++
AcomposeApp/src/commonTest/kotlin/ubi2/sonodrill/MarksFileTest.kt | 47+++++++++++++++++++++++++++++++++++++++++++++++
AcomposeApp/src/commonTest/kotlin/ubi2/sonodrill/SoundFilterTest.kt | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
McomposeApp/src/iosMain/kotlin/ubi2/sonodrill/audio/ios_audio_player.kt | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
McomposeApp/src/iosMain/kotlin/ubi2/sonodrill/data/ios_audio_storage.kt | 2++
AcomposeApp/src/iosMain/kotlin/ubi2/sonodrill/data/ios_cloud_store.kt | 31+++++++++++++++++++++++++++++++
McomposeApp/src/iosMain/kotlin/ubi2/sonodrill/main_ios.kt | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
McomposeApp/src/iosMain/kotlin/ubi2/sonodrill/model/ios_platform.kt | 44++++++++++++++++++++++++++++++++++++++++++++
Mgradle/libs.versions.toml | 5+++++
MiosApp/ContentView.swift | 2++
MiosApp/project.yml | 35++++++++++++++++++++++++++++++++++-
41 files changed, 2017 insertions(+), 188 deletions(-)

diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "ubi2.sonodrill" minSdk = 26 targetSdk = 35 - versionCode = 2 - versionName = "1.1.0" + versionCode = 3 + versionName = "1.2.0" } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml @@ -8,11 +8,36 @@ <activity android:name="ubi2.sonodrill.MainActivity" android:exported="true" + android:launchMode="singleTask" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> + <!-- "Open with" and share from other apps: audio --> + <intent-filter> + <action android:name="android.intent.action.VIEW" /> + <action android:name="android.intent.action.SEND" /> + <category android:name="android.intent.category.DEFAULT" /> + <data android:mimeType="audio/*" /> + </intent-filter> + <!-- Shared marks (.sonodrill, JSON) --> + <intent-filter> + <action android:name="android.intent.action.SEND" /> + <category android:name="android.intent.category.DEFAULT" /> + <data android:mimeType="application/json" /> + <data android:mimeType="application/octet-stream" /> + </intent-filter> </activity> + <!-- Files offered by "Share marks" --> + <provider + android:name="androidx.core.content.FileProvider" + android:authorities="ubi2.sonodrill.files" + android:exported="false" + android:grantUriPermissions="true"> + <meta-data + android:name="android.support.FILE_PROVIDER_PATHS" + android:resource="@xml/file_paths" /> + </provider> </application> </manifest> diff --git a/androidApp/src/main/kotlin/ubi2/sonodrill/MainActivity.kt b/androidApp/src/main/kotlin/ubi2/sonodrill/MainActivity.kt @@ -1,11 +1,13 @@ package ubi2.sonodrill +import android.content.Intent import android.net.Uri import android.os.Bundle import android.provider.OpenableColumns import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.IntentCompat import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -14,12 +16,16 @@ import ubi2.sonodrill.audio.AndroidAudioPlayer import ubi2.sonodrill.data.AUDIO_DIR import ubi2.sonodrill.data.AudioRepository import ubi2.sonodrill.data.DriverFactory +import ubi2.sonodrill.data.MARKS_EXTENSION import ubi2.sonodrill.data.app_context import ubi2.sonodrill.data.audio_dir import ubi2.sonodrill.data.choose_import_name import ubi2.sonodrill.data.create_database import ubi2.sonodrill.model.current_activity +import ubi2.sonodrill.ui.Incoming import ubi2.sonodrill.ui.SonodrillApp +import ubi2.sonodrill.ui.incoming_files +import ubi2.sonodrill.ui.receive_marks_text import java.io.File import java.lang.ref.WeakReference @@ -33,31 +39,69 @@ class MainActivity : ComponentActivity() { on_import_done = null if (uris.isEmpty()) return@registerForActivityResult lifecycleScope.launch { - withContext(Dispatchers.IO) { uris.forEach { import_audio(it) } } + // Shared marks ask for their track; audio is just added to the library + for (uri in uris) { + if (is_marks(uri)) receive_marks(uri) + else withContext(Dispatchers.IO) { import_audio(uri) }?.let { repo.register_file(it) } + } done?.invoke() } } - // Copies the picked document into the app's audio directory - private fun import_audio(uri: Uri) { - try { - var name = uri.lastPathSegment?.substringAfterLast('/') ?: "audio" - var size = -1L + private fun display_name(uri: Uri): Pair<String, Long> { + var name = uri.lastPathSegment?.substringAfterLast('/') ?: "audio" + var size = -1L + runCatching { contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { c -> if (c.moveToFirst()) { c.getString(0)?.let { name = it } if (!c.isNull(1)) size = c.getLong(1) } } + } + return name to size + } + + private fun is_marks(uri: Uri): Boolean { + val type = contentResolver.getType(uri) + return display_name(uri).first.endsWith(".$MARKS_EXTENSION", ignoreCase = true) || + (type != null && !type.startsWith("audio/")) + } + + private suspend fun receive_marks(uri: Uri) { + val text = withContext(Dispatchers.IO) { + runCatching { contentResolver.openInputStream(uri)?.use { it.readBytes().decodeToString() } }.getOrNull() + } + receive_marks_text(text) + } + + // A file given by another app ("Open with", share): audio is imported and opened + private fun handle_intent(intent: Intent?) { + val uri = when (intent?.action) { + Intent.ACTION_VIEW -> intent.data + Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java) + else -> null + } ?: return + lifecycleScope.launch { + if (is_marks(uri)) receive_marks(uri) + else withContext(Dispatchers.IO) { import_audio(uri) }?.let { incoming_files.trySend(Incoming.Audio(repo.register_file(it))) } + } + } + + // Copies a document into the app's audio directory; returns its uri (see audio_storage.kt) + private fun import_audio(uri: Uri): String? { + try { + val (name, size) = display_name(uri) val dir = audio_dir().apply { mkdirs() } val (target, already_stored) = choose_import_name(name, size) { n -> File(dir, n).takeIf { it.exists() }?.length() } if (!already_stored) { - val input = contentResolver.openInputStream(uri) ?: return + val input = contentResolver.openInputStream(uri) ?: return null input.use { src -> File(dir, target).outputStream().use { src.copyTo(it) } } } - repo.register_file("$AUDIO_DIR/$target") + return "$AUDIO_DIR/$target" } catch (e: Exception) { println("Sonodrill: failed to import $uri: $e") + return null } } @@ -73,10 +117,16 @@ class MainActivity : ComponentActivity() { player = player, repo = repo, on_import = { on_done -> on_import_done = on_done - file_picker.launch(arrayOf("audio/*")) + file_picker.launch(arrayOf("audio/*", "application/json", "application/octet-stream")) }, ) } + if (savedInstanceState == null) handle_intent(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handle_intent(intent) } override fun onDestroy() { diff --git a/androidApp/src/main/res/xml/file_paths.xml b/androidApp/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<paths> + <cache-path name="shared" path="shared/" /> +</paths> diff --git a/build.gradle.kts b/build.gradle.kts @@ -4,5 +4,6 @@ plugins { alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.composeMultiplatform) apply false alias(libs.plugins.composeCompiler) apply false + alias(libs.plugins.kotlinSerialization) apply false alias(libs.plugins.sqldelight) apply false } diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.androidLibrary) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) + alias(libs.plugins.kotlinSerialization) alias(libs.plugins.sqldelight) } @@ -31,6 +32,10 @@ kotlin { implementation(libs.kotlinx.coroutines.core) implementation(libs.sqldelight.runtime) implementation(libs.sqldelight.coroutines) + implementation(libs.kotlinx.serialization.json) + } + commonTest.dependencies { + implementation(kotlin("test")) } androidMain.dependencies { implementation(libs.kotlinx.coroutines.android) @@ -38,6 +43,10 @@ kotlin { implementation(libs.exoplayer) implementation(libs.activity.compose) } + androidUnitTest.dependencies { + implementation(libs.sqldelight.jvm) + implementation(libs.kotlinx.coroutines.test) + } iosMain.dependencies { implementation(libs.sqldelight.native) } @@ -48,6 +57,8 @@ android { namespace = "ubi2.sonodrill" compileSdk = 35 defaultConfig { minSdk = 26 } + // PlayerViewModel tests run on the JVM: Android calls (waveform) return defaults + testOptions { unitTests.isReturnDefaultValues = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/composeApp/src/androidMain/kotlin/ubi2/sonodrill/audio/android_audio_player.kt b/composeApp/src/androidMain/kotlin/ubi2/sonodrill/audio/android_audio_player.kt @@ -1,6 +1,9 @@ package ubi2.sonodrill.audio import android.content.Context +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioTrack import android.media.MediaCodec import android.media.MediaExtractor import android.media.MediaFormat @@ -8,18 +11,30 @@ import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.audio.DefaultAudioSink import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import ubi2.sonodrill.data.resolve_audio_path +import ubi2.sonodrill.model.ChannelMode import java.io.File import java.nio.ByteOrder import kotlin.math.abs +import kotlin.math.pow +private const val CLICK_SAMPLE_RATE = 44100 + +@androidx.annotation.OptIn(UnstableApi::class) class AndroidAudioPlayer(private val context: Context) : AudioPlayer { private var exo: ExoPlayer? = null + private val sound_filter = SoundFilterProcessor() + private var pitch_semitones = 0 + private var click_tracks: Pair<AudioTrack, AudioTrack>? = null private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val _position = MutableStateFlow(0L) @@ -39,7 +54,12 @@ class AndroidAudioPlayer(private val context: Context) : AudioPlayer { .setUri(android.net.Uri.fromFile(File(resolve_audio_path(uri)))) .setMediaMetadata(MediaMetadata.Builder().setTitle(title).build()) .build() - exo = ExoPlayer.Builder(context).build().apply { + // The sound filter runs before ExoPlayer's own processing (speed, pitch) + val renderers = object : DefaultRenderersFactory(context) { + override fun buildAudioSink(context: Context, float_output: Boolean, audio_track_playback_params: Boolean): AudioSink = + DefaultAudioSink.Builder(context).setAudioProcessors(arrayOf(sound_filter)).build() + } + exo = ExoPlayer.Builder(context, renderers).build().apply { addListener(object : Player.Listener { override fun onPlaybackStateChanged(state: Int) { when (state) { @@ -53,7 +73,7 @@ class AndroidAudioPlayer(private val context: Context) : AudioPlayer { } }) setMediaItem(item) - playbackParameters = PlaybackParameters(_speed.value) + playbackParameters = playback_parameters() prepare() } } @@ -74,7 +94,35 @@ class AndroidAudioPlayer(private val context: Context) : AudioPlayer { _position.value = target } - override fun set_speed(rate: Float) { _speed.value = rate; exo?.playbackParameters = PlaybackParameters(rate) } + override fun set_speed(rate: Float) { _speed.value = rate; exo?.playbackParameters = playback_parameters() } + + override fun set_pitch(semitones: Int) { pitch_semitones = semitones; exo?.playbackParameters = playback_parameters() } + + override fun set_sound(mode: ChannelMode, low_cut: Boolean) { + sound_filter.mode = mode + sound_filter.low_cut = low_cut + } + + override fun click(accent: Boolean) { + val tracks = click_tracks ?: (make_click_track(accent = true) to make_click_track(accent = false)).also { click_tracks = it } + val track = if (accent) tracks.first else tracks.second + track.stop() + track.reloadStaticData() + track.play() + } + + private fun playback_parameters() = PlaybackParameters(_speed.value, 2.0.pow(pitch_semitones / 12.0).toFloat()) + + private fun make_click_track(accent: Boolean): AudioTrack { + val pcm = click_samples(CLICK_SAMPLE_RATE.toDouble(), accent).map { (it * 32767).toInt().toShort() }.toShortArray() + return AudioTrack.Builder() + .setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA).setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build()) + .setAudioFormat(AudioFormat.Builder().setEncoding(AudioFormat.ENCODING_PCM_16BIT).setSampleRate(CLICK_SAMPLE_RATE).setChannelMask(AudioFormat.CHANNEL_OUT_MONO).build()) + .setTransferMode(AudioTrack.MODE_STATIC) + .setBufferSizeInBytes(pcm.size * 2) + .build() + .apply { write(pcm, 0, pcm.size) } + } override fun unload() { stop_position_updates() @@ -84,7 +132,12 @@ class AndroidAudioPlayer(private val context: Context) : AudioPlayer { _duration.value = 0 } - override fun release() { unload(); scope.cancel() } + override fun release() { + unload() + click_tracks?.let { it.first.release(); it.second.release() } + click_tracks = null + scope.cancel() + } private fun start_position_updates() { position_job?.cancel(); position_job = scope.launch { while (isActive) { _position.value = exo?.currentPosition ?: 0; delay(100) } } } private fun stop_position_updates() { position_job?.cancel(); position_job = null } diff --git a/composeApp/src/androidMain/kotlin/ubi2/sonodrill/audio/sound_filter_processor.kt b/composeApp/src/androidMain/kotlin/ubi2/sonodrill/audio/sound_filter_processor.kt @@ -0,0 +1,66 @@ +package ubi2.sonodrill.audio + +import androidx.media3.common.C +import androidx.media3.common.audio.AudioProcessor +import androidx.media3.common.audio.BaseAudioProcessor +import androidx.media3.common.util.UnstableApi +import ubi2.sonodrill.model.ChannelMode +import java.nio.ByteBuffer +import java.nio.ByteOrder + +// Applies SoundFilter to ExoPlayer's 16-bit PCM output (mono or stereo) +@UnstableApi +class SoundFilterProcessor : BaseAudioProcessor() { + @Volatile var mode = ChannelMode.STEREO + @Volatile var low_cut = false + + private var filter: SoundFilter? = null + private var channels = 0 + private var left = FloatArray(0) + private var right = FloatArray(0) + + override fun onConfigure(input: AudioProcessor.AudioFormat): AudioProcessor.AudioFormat { + if (input.encoding != C.ENCODING_PCM_16BIT || input.channelCount !in 1..2) { + throw AudioProcessor.UnhandledAudioFormatException(input) + } + channels = input.channelCount + filter = SoundFilter(input.sampleRate.toDouble()) + return input + } + + override fun queueInput(input: ByteBuffer) { + val bytes = input.remaining() + if (bytes == 0) return + val output = replaceOutputBuffer(bytes) + val f = filter + f?.mode = mode; f?.low_cut = low_cut + if (f == null || !f.is_active) { + output.put(input) + output.flip() + return + } + val frames = bytes / (2 * channels) + if (left.size < frames) { left = FloatArray(frames); right = FloatArray(frames) } + val shorts = input.order(ByteOrder.nativeOrder()).asShortBuffer() + for (i in 0 until frames) { + left[i] = shorts.get(i * channels) / 32768f + if (channels == 2) right[i] = shorts.get(i * 2 + 1) / 32768f + } + input.position(input.limit()) + f.process(left, if (channels == 2) right else null, frames) + output.order(ByteOrder.nativeOrder()) + for (i in 0 until frames) { + output.putShort(to_pcm(left[i])) + if (channels == 2) output.putShort(to_pcm(right[i])) + } + // Odd trailing bytes, if any, are passed through + while (output.position() < bytes) output.put(0) + output.flip() + } + + override fun onFlush() { filter?.reset() } + + override fun onReset() { filter = null } + + private fun to_pcm(v: Float): Short = (v * 32767f).coerceIn(-32768f, 32767f).toInt().toShort() +} diff --git a/composeApp/src/androidMain/kotlin/ubi2/sonodrill/data/android_audio_storage.kt b/composeApp/src/androidMain/kotlin/ubi2/sonodrill/data/android_audio_storage.kt @@ -17,3 +17,5 @@ actual fun delete_audio_file(uri: String) { actual fun list_stored_audio(): List<String> = audio_dir().listFiles().orEmpty().filter { it.isFile && !it.name.startsWith(".") }.map { "$AUDIO_DIR/${it.name}" } + +actual fun audio_file_size(uri: String): Long? = File(resolve_audio_path(uri)).takeIf { it.exists() }?.length() diff --git a/composeApp/src/androidMain/kotlin/ubi2/sonodrill/data/android_cloud_store.kt b/composeApp/src/androidMain/kotlin/ubi2/sonodrill/data/android_cloud_store.kt @@ -0,0 +1,4 @@ +package ubi2.sonodrill.data + +// No sync on Android +actual fun create_cloud_store(): CloudStore? = null diff --git a/composeApp/src/androidMain/kotlin/ubi2/sonodrill/model/android_platform.kt b/composeApp/src/androidMain/kotlin/ubi2/sonodrill/model/android_platform.kt @@ -1,6 +1,9 @@ package ubi2.sonodrill.model import android.app.Activity +import android.content.Intent +import androidx.core.content.FileProvider +import java.io.File import android.view.WindowManager import java.lang.ref.WeakReference @@ -15,3 +18,20 @@ actual fun set_idle_timer_disabled(disabled: Boolean) { if (disabled) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } + +actual fun preferred_language(): String = java.util.Locale.getDefault().language + +// Authority of the FileProvider declared in the app's manifest +const val FILE_PROVIDER_AUTHORITY = "ubi2.sonodrill.files" + +actual fun share_text_file(file_name: String, text: String) { + val activity = current_activity?.get() ?: return + val file = File(activity.cacheDir, "shared").apply { mkdirs() }.resolve(file_name) + file.writeText(text) + val uri = FileProvider.getUriForFile(activity, FILE_PROVIDER_AUTHORITY, file) + val send = Intent(Intent.ACTION_SEND) + .setType("application/octet-stream") + .putExtra(Intent.EXTRA_STREAM, uri) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + activity.startActivity(Intent.createChooser(send, null)) +} diff --git a/composeApp/src/androidUnitTest/kotlin/ubi2/sonodrill/PlayerViewModelTest.kt b/composeApp/src/androidUnitTest/kotlin/ubi2/sonodrill/PlayerViewModelTest.kt @@ -0,0 +1,247 @@ +package ubi2.sonodrill + +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import ubi2.sonodrill.audio.AudioPlayer +import ubi2.sonodrill.data.AudioRepository +import ubi2.sonodrill.data.CloudStore +import ubi2.sonodrill.data.MarkEntry +import ubi2.sonodrill.data.MarksFile +import ubi2.sonodrill.data.SonodrillDatabase +import ubi2.sonodrill.data.ZoneEntry +import ubi2.sonodrill.model.ChannelMode +import ubi2.sonodrill.model.MarkInfo +import ubi2.sonodrill.model.PracticeSettings +import ubi2.sonodrill.model.SkipZone +import ubi2.sonodrill.ui.player.PlayerViewModel +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +// Stands for the platform player: the test moves the position by hand +private class FakePlayer : AudioPlayer { + override val current_position_ms = MutableStateFlow(0L) + override val duration_ms = MutableStateFlow(0L) + override val is_playing = MutableStateFlow(false) + override val playback_speed = MutableStateFlow(1f) + val clicks = mutableListOf<Boolean>() + + override fun load(uri: String, title: String) { duration_ms.value = 60_000 } + override fun play() { is_playing.value = true } + override fun pause() { is_playing.value = false } + override fun toggle() { is_playing.value = !is_playing.value } + override fun seek(position_ms: Long) { current_position_ms.value = position_ms } + override fun set_speed(rate: Float) { playback_speed.value = rate } + override fun set_pitch(semitones: Int) {} + override fun set_sound(mode: ChannelMode, low_cut: Boolean) {} + override fun click(accent: Boolean) { clicks += accent } + override fun unload() {} + override fun release() {} +} + +private class FakeCloud : CloudStore { + val values = mutableMapOf<String, String>() + override fun get(key: String) = values[key] + override fun put(key: String, value: String) { values[key] = value } + override fun observe(on_change: () -> Unit) {} +} + +private fun new_repo(cloud: CloudStore? = null): AudioRepository { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + SonodrillDatabase.Schema.create(driver) + return AudioRepository(SonodrillDatabase(driver), cloud, file_size = { 1234L }) +} + +@OptIn(ExperimentalCoroutinesApi::class) +class PlayerViewModelTest { + private val scheduler = TestCoroutineScheduler() + private val player = FakePlayer() + private val repo = new_repo() + private lateinit var vm: PlayerViewModel + private lateinit var file_id: String + + @BeforeTest + fun setup() { + Dispatchers.setMain(StandardTestDispatcher(scheduler)) + file_id = repo.register_file("audio/song.mp3") + listOf(10_000L, 20_000L, 30_000L).forEachIndexed { i, pos -> + repo.insert_mark(MarkInfo(id = "m$i", file_id = file_id, position_ms = pos)) + } + vm = PlayerViewModel(player, repo) + vm.load_file(repo.get_file(file_id)!!) + run(100) + } + + @AfterTest + fun teardown() { + vm.release() + Dispatchers.resetMain() + } + + private fun run(ms: Long) { + scheduler.advanceTimeBy(ms) + scheduler.runCurrent() + } + + // Loop between the first two marks, playing + private fun start_loop() { + player.seek(15_000) + vm.toggle_mark_loop() + run(100) + vm.toggle_playback() + run(100) + } + + private fun reach_loop_end() { + player.seek(20_000) + run(100) + } + + @Test + fun passes_are_counted_and_the_set_stops_after_the_repetitions() { + vm.set_practice(PracticeSettings(repetitions = 2)) + start_loop() + assertEquals(1, vm.pass.value) + reach_loop_end() + assertEquals(2, vm.pass.value) + assertEquals(10_000, player.current_position_ms.value) + assertTrue(player.is_playing.value) + reach_loop_end() + assertFalse(player.is_playing.value, "stops after 2 passes") + assertEquals(10_000, player.current_position_ms.value) + assertEquals(1, vm.pass.value) + } + + @Test + fun progressive_speed_goes_up_each_pass_up_to_the_target() { + vm.set_practice(PracticeSettings(ramp_enabled = true, ramp_from = 0.7f, ramp_to = 0.9f, ramp_step = 0.1f)) + start_loop() + assertEquals(0.7f, player.playback_speed.value, 0.001f) + reach_loop_end() + assertEquals(0.8f, player.playback_speed.value, 0.001f) + reach_loop_end() + assertEquals(0.9f, player.playback_speed.value, 0.001f) + reach_loop_end() + assertEquals(0.9f, player.playback_speed.value, 0.001f) + } + + @Test + fun pause_between_passes() { + vm.set_practice(PracticeSettings(pause_between_s = 2)) + start_loop() + reach_loop_end() + assertFalse(player.is_playing.value) + assertEquals(10_000, player.current_position_ms.value) + assertEquals(2, vm.countdown.value) + run(1000) + assertEquals(1, vm.countdown.value) + run(1000) + assertTrue(player.is_playing.value) + assertEquals(0, vm.countdown.value) + } + + @Test + fun count_in_clicks_then_plays() { + vm.set_practice(PracticeSettings(count_in_beats = 4, tempo_bpm = 120)) + vm.toggle_playback() + run(0) + assertEquals(listOf(true), player.clicks) + assertFalse(player.is_playing.value) + run(1500) + assertEquals(listOf(true, false, false, false), player.clicks) + assertFalse(player.is_playing.value) + run(500) + assertTrue(player.is_playing.value) + } + + @Test + fun count_in_before_each_pass() { + vm.set_practice(PracticeSettings(count_in_beats = 2, tempo_bpm = 60, count_in_each_pass = true)) + start_loop() + run(2000) + player.clicks.clear() + reach_loop_end() + assertFalse(player.is_playing.value) + assertEquals(listOf(true), player.clicks) + run(2000) + assertEquals(listOf(true, false), player.clicks) + assertTrue(player.is_playing.value) + } + + @Test + fun a_moved_mark_takes_its_zones_and_loop_along() { + player.seek(25_000) + vm.add_skip_zone() + start_loop() + vm.move_mark("m1", 22_000) + assertEquals(listOf(SkipZone(22_000, 30_000)), repo.get_skip_zones(file_id)) + assertEquals(22_000, vm.loop_state.value.mark_b?.position_ms) + assertEquals(22_000, repo.get_marks(file_id).first { it.id == "m1" }.position_ms) + run(100) + assertEquals(1, vm.pass.value, "moving a bound doesn't restart the passes") + } + + @Test + fun a_mark_stays_between_its_neighbours() { + vm.move_mark("m1", 50_000) + assertEquals(29_950, repo.get_marks(file_id).first { it.id == "m1" }.position_ms) + vm.move_mark("m1", 0) + assertEquals(10_050, repo.get_marks(file_id).first { it.id == "m1" }.position_ms) + } + + @Test + fun dragging_only_saves_at_the_end() { + vm.move_mark("m1", 21_000, persist = false) + assertEquals(21_000, vm.marks.value.first { it.id == "m1" }.position_ms) + assertEquals(20_000, repo.get_marks(file_id).first { it.id == "m1" }.position_ms) + vm.move_mark("m1", 21_000, persist = true) + assertEquals(21_000, repo.get_marks(file_id).first { it.id == "m1" }.position_ms) + } +} + +class RepositoryTest { + @Test + fun shared_marks_are_merged() { + val repo = new_repo() + val id = repo.register_file("audio/song.mp3") + repo.insert_mark(MarkInfo(id = "a", file_id = id, position_ms = 1000)) + val added = repo.merge_marks(id, MarksFile( + title = "song", + marks = listOf(MarkEntry(1000, "Intro"), MarkEntry(5000, "Verse")), + skip_zones = listOf(ZoneEntry(1000, 5000), ZoneEntry(5000, 9000)), + )) + assertEquals(1, added) + assertEquals(listOf(1000L to "Intro", 5000L to "Verse"), repo.get_marks(id).map { it.position_ms to it.label }) + // The zone whose end mark isn't there is dropped + assertEquals(listOf(SkipZone(1000, 5000)), repo.get_skip_zones(id)) + } + + @Test + fun marks_go_through_icloud_to_another_device() { + val cloud = FakeCloud() + val phone = new_repo(cloud) + val ipad = new_repo(cloud) + val on_phone = phone.register_file("audio/song.mp3") + phone.insert_mark(MarkInfo(id = "a", file_id = on_phone, position_ms = 1000, label = "Intro")) + + // Same track imported on the iPad: its marks come along + val on_ipad = ipad.register_file("audio/song.mp3") + assertEquals(listOf(1000L to "Intro"), ipad.get_marks(on_ipad).map { it.position_ms to it.label }) + + // A later change on the iPad wins on the phone + Thread.sleep(5) + ipad.insert_mark(MarkInfo(id = "b", file_id = on_ipad, position_ms = 2000)) + assertEquals(setOf(on_phone), phone.sync_cloud()) + assertEquals(listOf(1000L, 2000L), phone.get_marks(on_phone).map { it.position_ms }) + assertEquals(emptySet(), phone.sync_cloud(), "nothing new the second time") + } +} diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/audio/audio_player.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/audio/audio_player.kt @@ -1,6 +1,7 @@ package ubi2.sonodrill.audio import kotlinx.coroutines.flow.StateFlow +import ubi2.sonodrill.model.ChannelMode // Platform players are driven from the main thread only. interface AudioPlayer { @@ -16,6 +17,11 @@ interface AudioPlayer { fun toggle() fun seek(position_ms: Long) fun set_speed(rate: Float) + // Transposition, without changing the speed + fun set_pitch(semitones: Int) + fun set_sound(mode: ChannelMode, low_cut: Boolean) + // One metronome click, over whatever is playing + fun click(accent: Boolean) // Stops and forgets the current file; the player can load another one afterwards fun unload() // Final teardown, the player can't be used anymore diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/audio/loop_controller.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/audio/loop_controller.kt @@ -14,6 +14,12 @@ class LoopController(private val player: AudioPlayer) { 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 } + // What happens at the end of a pass: restart_ms is where the next pass starts, + // track_ended tells that the player stopped at the end of the track + var on_pass_end: (restart_ms: Long, track_ended: Boolean) -> Unit = { restart_ms, track_ended -> + player.seek(restart_ms) + if (track_ended) player.play() + } private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) fun set_loop(a: MarkInfo, b: MarkInfo, seek_to_start: Boolean = true) { @@ -23,6 +29,13 @@ class LoopController(private val player: AudioPlayer) { start_monitoring() } + // A bound moved (mark dragged): the loop goes on where it is + fun update_bounds(a: MarkInfo, b: MarkInfo) { + if (!_state.value.enabled) return + val (start, end) = if (a.position_ms <= b.position_ms) a to b else b to a + _state.value = _state.value.copy(mark_a = start, mark_b = end) + } + fun clear() { stop_monitoring() _state.value = LoopState() @@ -38,8 +51,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(resolve_target(start)) - if (track_ended) player.play() + on_pass_end(resolve_target(start), track_ended) } delay(50) } diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/audio/sound_filter.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/audio/sound_filter.kt @@ -0,0 +1,107 @@ +package ubi2.sonodrill.audio + +import ubi2.sonodrill.model.ChannelMode +import kotlin.concurrent.Volatile +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +// NO_CENTER keeps the middle of the mix below this: the bass and kick, usually +// centered too, stay +private const val CENTER_KEEP_HZ = 200.0 +private const val LOW_CUT_HZ = 250.0 + +// Channel isolation and bass cut, applied by the platform players to decoded audio. +// Called from a single audio thread; mode and low_cut may be set from another one. +class SoundFilter(private val sample_rate: Double) { + @Volatile var mode: ChannelMode = ChannelMode.STEREO + @Volatile var low_cut: Boolean = false + + private val center_lp = Biquad.low_pass(sample_rate, CENTER_KEEP_HZ) + private val low_cut_l = Biquad.high_pass(sample_rate, LOW_CUT_HZ) + private val low_cut_r = Biquad.high_pass(sample_rate, LOW_CUT_HZ) + + val is_active get() = mode != ChannelMode.STEREO || low_cut + + // After a seek: the filters must not carry the previous position's state + fun reset() { + center_lp.reset(); low_cut_l.reset(); low_cut_r.reset() + } + + // In place; right is null for mono audio (channel modes then don't apply) + fun process(left: FloatArray, right: FloatArray?, frames: Int) { + val mode = mode + val low_cut = low_cut + if (right != null) { + when (mode) { + ChannelMode.STEREO -> {} + ChannelMode.LEFT -> left.copyInto(right, 0, 0, frames) + ChannelMode.RIGHT -> right.copyInto(left, 0, 0, frames) + // The sides, plus the low end of the middle (adding it rather than + // subtracting the filtered middle: the filter's phase shift would leave + // part of the voice) + ChannelMode.NO_CENTER -> for (i in 0 until frames) { + val mid = (left[i] + right[i]) * 0.5f + val keep = center_lp.next(mid) - mid + left[i] += keep + right[i] += keep + } + } + } + if (low_cut) { + for (i in 0 until frames) left[i] = low_cut_l.next(left[i]) + if (right != null) for (i in 0 until frames) right[i] = low_cut_r.next(right[i]) + } + } +} + +// Second-order filter (RBJ audio EQ cookbook) +class Biquad private constructor( + private val b0: Double, private val b1: Double, private val b2: Double, + private val a1: Double, private val a2: Double, +) { + private var x1 = 0.0; private var x2 = 0.0; private var y1 = 0.0; private var y2 = 0.0 + + fun next(x: Float): Float { + val y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2 + x2 = x1; x1 = x.toDouble(); y2 = y1; y1 = y + return y.toFloat() + } + + fun reset() { x1 = 0.0; x2 = 0.0; y1 = 0.0; y2 = 0.0 } + + companion object { + fun low_pass(sample_rate: Double, cutoff_hz: Double, q: Double = 0.7071): Biquad { + val w0 = 2 * PI * cutoff_hz / sample_rate + val alpha = sin(w0) / (2 * q) + val cw = cos(w0) + val a0 = 1 + alpha + return Biquad( + b0 = (1 - cw) / 2 / a0, b1 = (1 - cw) / a0, b2 = (1 - cw) / 2 / a0, + a1 = -2 * cw / a0, a2 = (1 - alpha) / a0, + ) + } + + fun high_pass(sample_rate: Double, cutoff_hz: Double, q: Double = 0.7071): Biquad { + val w0 = 2 * PI * cutoff_hz / sample_rate + val alpha = sin(w0) / (2 * q) + val cw = cos(w0) + val a0 = 1 + alpha + return Biquad( + b0 = (1 + cw) / 2 / a0, b1 = -(1 + cw) / a0, b2 = (1 + cw) / 2 / a0, + a1 = -2 * cw / a0, a2 = (1 - alpha) / a0, + ) + } + } +} + +// A short decaying tone for the metronome, as samples in [-1, 1] +fun click_samples(sample_rate: Double, accent: Boolean): FloatArray { + val freq = if (accent) 1600.0 else 1000.0 + val length = (sample_rate * 0.04).toInt() + return FloatArray(length) { i -> + val t = i / sample_rate + val envelope = kotlin.math.exp(-t * 120) + (sin(2 * PI * freq * t) * envelope * 0.8).toFloat() + } +} diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/data/audio_storage.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/data/audio_storage.kt @@ -27,3 +27,5 @@ fun choose_import_name(name: String, size: Long, stored_size: (String) -> Long?) n++ } } + +expect fun audio_file_size(uri: String): Long? diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/data/cloud_store.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/data/cloud_store.kt @@ -0,0 +1,23 @@ +package ubi2.sonodrill.data + +// Small key-value store shared by the user's devices (iCloud key-value storage +// on iOS, none on Android). Values are limited in size: one key per track. +interface CloudStore { + fun get(key: String): String? + fun put(key: String, value: String) + // on_change runs on the main thread when another device changed values + fun observe(on_change: () -> Unit) +} + +expect fun create_cloud_store(): CloudStore? + +// The same track on two devices: same stored name and same size. Keys are +// limited to 64 bytes, hence the hash. +fun cloud_key(uri: String, size: Long): String { + var hash = -0x340d631b7bdddcdbL // FNV-1a 64-bit offset basis + for (b in "${uri.substringAfterLast('/')}:$size".encodeToByteArray()) { + hash = hash xor (b.toLong() and 0xff) + hash *= 0x100000001b3L + } + return "marks:" + hash.toULong().toString(16) +} diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/data/marks_file.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/data/marks_file.kt @@ -0,0 +1,39 @@ +package ubi2.sonodrill.data + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +// Extension of the files exchanged with "Share marks" (JSON inside) +const val MARKS_EXTENSION = "sonodrill" +private const val MARKS_FORMAT = "sonodrill-marks" + +// The marks and zones of a track, as shared with other people or synced through iCloud +@Serializable +data class MarksFile( + val format: String = MARKS_FORMAT, + val version: Int = 1, + val title: String, + val duration_ms: Long? = null, + val updated_at: Long = 0, + val marks: List<MarkEntry> = emptyList(), + val skip_zones: List<ZoneEntry> = emptyList(), + val loop_zones: List<ZoneEntry> = emptyList(), +) + +@Serializable +data class MarkEntry(val position_ms: Long, val label: String? = null) + +@Serializable +data class ZoneEntry(val start_ms: Long, val end_ms: Long) + +private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + +fun encode_marks(file: MarksFile): String = json.encodeToString(MarksFile.serializer(), file) + +// null when the text is not a Sonodrill marks file +fun decode_marks(text: String): MarksFile? = + runCatching { json.decodeFromString(MarksFile.serializer(), text) }.getOrNull()?.takeIf { it.format == MARKS_FORMAT } + +// File name for sharing: the title without characters that file systems refuse +fun marks_file_name(title: String): String = + title.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim().ifEmpty { "marks" } + ".$MARKS_EXTENSION" diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/data/repository.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/data/repository.kt @@ -1,8 +1,10 @@ package ubi2.sonodrill.data import ubi2.sonodrill.model.AudioFileInfo +import ubi2.sonodrill.model.ChannelMode import ubi2.sonodrill.model.LoopZone import ubi2.sonodrill.model.MarkInfo +import ubi2.sonodrill.model.PracticeSettings import ubi2.sonodrill.model.SkipZone import ubi2.sonodrill.model.current_time_ms import ubi2.sonodrill.model.generate_uuid @@ -10,10 +12,15 @@ import ubi2.sonodrill.model.generate_uuid private const val ZONE_SKIP = "skip" private const val ZONE_LOOP = "loop" -class AudioRepository(private val db: SonodrillDatabase) { +class AudioRepository( + private val db: SonodrillDatabase, + private val cloud: CloudStore? = null, + private val file_size: (uri: String) -> Long? = ::audio_file_size, +) { private val queries get() = db.audioFileQueries private val mark_queries get() = db.markQueries private val zone_queries get() = db.zoneQueries + private val pref_queries get() = db.preferenceQueries fun get_all_files(): List<AudioFileInfo> = queries.select_all().executeAsList().map { it.to_info() } @@ -22,16 +29,21 @@ class AudioRepository(private val db: SonodrillDatabase) { queries.select_by_id(id).executeAsOneOrNull()?.to_info() // Registers a stored audio file; importing the same file again keeps its entry (and marks). - fun register_file(uri: String) { + // Returns the entry's id. + fun register_file(uri: String): String { val existing = queries.select_by_uri(uri).executeAsOneOrNull() if (existing != null) { queries.touch(current_time_ms(), existing.id) - } else { - queries.insert( - id = generate_uuid(), uri = uri, title = title_from_uri(uri), duration_ms = null, - last_position_ms = 0, last_speed = 1.0, updated_at = current_time_ms(), - ) + return existing.id } + val id = generate_uuid() + queries.insert( + id = id, uri = uri, title = title_from_uri(uri), duration_ms = null, + last_position_ms = 0, last_speed = 1.0, updated_at = current_time_ms(), + ) + // Marks made on another device for the same track + pull_from_cloud(id) + return id } fun rename_file(id: String, title: String) = queries.update_title(title, current_time_ms(), id) @@ -41,6 +53,9 @@ class AudioRepository(private val db: SonodrillDatabase) { fun update_position(id: String, position_ms: Long, speed: Float) = queries.update_position(position_ms, speed.toDouble(), current_time_ms(), id) + fun update_sound(id: String, pitch_semitones: Int, channel_mode: ChannelMode, low_cut: Boolean) = + queries.update_sound(pitch_semitones.toLong(), channel_mode.key, if (low_cut) 1 else 0, id) + // Removes the entry with its marks and zones, and the stored audio unless another entry uses it. fun delete_file(file: AudioFileInfo) { db.transaction { @@ -55,6 +70,7 @@ class AudioRepository(private val db: SonodrillDatabase) { fun sync_library(): List<AudioFileInfo> { val known = get_all_files().map { it.uri }.toSet() list_stored_audio().filter { it !in known }.forEach { register_file(it) } + sync_cloud() return get_all_files().map { it.copy(missing = !audio_file_exists(it.uri)) } } @@ -63,12 +79,25 @@ class AudioRepository(private val db: SonodrillDatabase) { MarkInfo(id = it.id, file_id = it.file_id, position_ms = it.position_ms, label = it.label, created_at = it.created_at) } - fun insert_mark(mark: MarkInfo) = + fun insert_mark(mark: MarkInfo) { mark_queries.insert(mark.id, mark.file_id, mark.position_ms, mark.label, mark.created_at) + marks_changed(mark.file_id) + } - fun rename_mark(id: String, label: String?) = mark_queries.update_label(label, id) + fun rename_mark(file_id: String, id: String, label: String?) { + mark_queries.update_label(label, id) + marks_changed(file_id) + } + + fun move_mark(file_id: String, id: String, position_ms: Long) { + mark_queries.update_position(position_ms, id) + marks_changed(file_id) + } - fun delete_mark(id: String) = mark_queries.delete_by_id(id) + fun delete_mark(file_id: String, id: String) { + mark_queries.delete_by_id(id) + marks_changed(file_id) + } fun get_skip_zones(file_id: String): List<SkipZone> = zone_queries.select_by_file(file_id).executeAsList().filter { it.kind == ZONE_SKIP }.map { SkipZone(it.start_ms, it.end_ms) } @@ -76,11 +105,15 @@ class AudioRepository(private val db: SonodrillDatabase) { fun get_loop_zones(file_id: String): List<LoopZone> = zone_queries.select_by_file(file_id).executeAsList().filter { it.kind == ZONE_LOOP }.map { LoopZone(it.start_ms, it.end_ms) } - fun save_skip_zones(file_id: String, zones: List<SkipZone>) = + fun save_skip_zones(file_id: String, zones: List<SkipZone>) { save_zones(file_id, ZONE_SKIP, zones.map { it.start_ms to it.end_ms }) + marks_changed(file_id) + } - fun save_loop_zones(file_id: String, zones: List<LoopZone>) = + fun save_loop_zones(file_id: String, zones: List<LoopZone>) { save_zones(file_id, ZONE_LOOP, zones.map { it.start_ms to it.end_ms }) + marks_changed(file_id) + } private fun save_zones(file_id: String, kind: String, zones: List<Pair<Long, Long>>) { db.transaction { @@ -88,9 +121,129 @@ class AudioRepository(private val db: SonodrillDatabase) { zones.forEach { (start, end) -> zone_queries.insert(file_id, kind, start, end) } } } + + fun export_marks(file_id: String): MarksFile? { + val file = get_file(file_id) ?: return null + return MarksFile( + title = file.title, + duration_ms = file.duration_ms, + updated_at = file.marks_updated_at, + marks = get_marks(file_id).map { MarkEntry(it.position_ms, it.label) }, + skip_zones = get_skip_zones(file_id).map { ZoneEntry(it.start_ms, it.end_ms) }, + loop_zones = get_loop_zones(file_id).map { ZoneEntry(it.start_ms, it.end_ms) }, + ) + } + + // Adds the shared marks and zones to a track; returns the number of marks added. + // A mark at the same position as an existing one only gives it a name it lacked. + fun merge_marks(file_id: String, shared: MarksFile): Int { + val existing = get_marks(file_id) + var added = 0 + db.transaction { + for (entry in shared.marks) { + val same = existing.firstOrNull { it.position_ms == entry.position_ms } + if (same == null) { + mark_queries.insert(generate_uuid(), file_id, entry.position_ms, entry.label, current_time_ms()) + added++ + } else if (same.label.isNullOrEmpty() && !entry.label.isNullOrEmpty()) { + mark_queries.update_label(entry.label, same.id) + } + } + } + val positions = get_marks(file_id).map { it.position_ms }.toSet() + fun bounded(z: ZoneEntry) = z.start_ms in positions && z.end_ms in positions + save_zones(file_id, ZONE_SKIP, (get_skip_zones(file_id).map { it.start_ms to it.end_ms } + + shared.skip_zones.filter(::bounded).map { it.start_ms to it.end_ms }).distinct()) + save_zones(file_id, ZONE_LOOP, (get_loop_zones(file_id).map { it.start_ms to it.end_ms } + + shared.loop_zones.filter(::bounded).map { it.start_ms to it.end_ms }).distinct()) + marks_changed(file_id) + return added + } + + // Replaces the marks and zones of a track by those of another device + private fun replace_marks(file_id: String, remote: MarksFile) { + db.transaction { + mark_queries.delete_by_file(file_id) + zone_queries.delete_by_file(file_id) + for (entry in remote.marks) mark_queries.insert(generate_uuid(), file_id, entry.position_ms, entry.label, current_time_ms()) + for (z in remote.skip_zones) zone_queries.insert(file_id, ZONE_SKIP, z.start_ms, z.end_ms) + for (z in remote.loop_zones) zone_queries.insert(file_id, ZONE_LOOP, z.start_ms, z.end_ms) + queries.update_marks_time(remote.updated_at, file_id) + } + } + + private fun marks_changed(file_id: String) { + queries.update_marks_time(current_time_ms(), file_id) + push_to_cloud(file_id) + } + + // iCloud: the most recent version of a track's marks wins. + + private fun cloud_key_of(file: AudioFileInfo): String? = file_size(file.uri)?.let { cloud_key(file.uri, it) } + + private fun push_to_cloud(file_id: String) { + val store = cloud ?: return + val file = get_file(file_id) ?: return + val key = cloud_key_of(file) ?: return + export_marks(file_id)?.let { store.put(key, encode_marks(it)) } + } + + // Returns true when the local marks were replaced + private fun pull_from_cloud(file_id: String): Boolean { + val store = cloud ?: return false + val file = get_file(file_id) ?: return false + val key = cloud_key_of(file) ?: return false + val remote = store.get(key)?.let { decode_marks(it) } + val remote_time = remote?.updated_at ?: 0 + return when { + remote != null && remote_time > file.marks_updated_at -> { replace_marks(file_id, remote); true } + file.marks_updated_at > remote_time -> { push_to_cloud(file_id); false } + else -> false + } + } + + // Brings every track up to date with iCloud; returns the ids of the tracks whose marks changed + fun sync_cloud(): Set<String> { + if (cloud == null) return emptySet() + return get_all_files().filter { pull_from_cloud(it.id) }.map { it.id }.toSet() + } + + fun observe_cloud(on_change: (Set<String>) -> Unit) { + cloud?.observe { on_change(sync_cloud()) } + } + + fun get_practice(): PracticeSettings { + val p = pref_queries.select_all().executeAsList().associate { it.key to it.value_ } + val d = PracticeSettings() + return PracticeSettings( + delay_s = p["delay_s"]?.toIntOrNull() ?: d.delay_s, + count_in_beats = p["count_in_beats"]?.toIntOrNull() ?: d.count_in_beats, + tempo_bpm = p["tempo_bpm"]?.toIntOrNull() ?: d.tempo_bpm, + count_in_each_pass = p["count_in_each_pass"]?.toBooleanStrictOrNull() ?: d.count_in_each_pass, + pause_between_s = p["pause_between_s"]?.toIntOrNull() ?: d.pause_between_s, + repetitions = p["repetitions"]?.toIntOrNull() ?: d.repetitions, + ramp_enabled = p["ramp_enabled"]?.toBooleanStrictOrNull() ?: d.ramp_enabled, + ramp_from = p["ramp_from"]?.toFloatOrNull() ?: d.ramp_from, + ramp_to = p["ramp_to"]?.toFloatOrNull() ?: d.ramp_to, + ramp_step = p["ramp_step"]?.toFloatOrNull() ?: d.ramp_step, + ) + } + + fun save_practice(s: PracticeSettings) { + db.transaction { + mapOf( + "delay_s" to s.delay_s, "count_in_beats" to s.count_in_beats, "tempo_bpm" to s.tempo_bpm, + "count_in_each_pass" to s.count_in_each_pass, "pause_between_s" to s.pause_between_s, + "repetitions" to s.repetitions, "ramp_enabled" to s.ramp_enabled, "ramp_from" to s.ramp_from, + "ramp_to" to s.ramp_to, "ramp_step" to s.ramp_step, + ).forEach { (k, v) -> pref_queries.upsert(k, v.toString()) } + } + } } private fun Audio_file.to_info() = AudioFileInfo( id = id, uri = uri, title = title, duration_ms = duration_ms, last_position_ms = last_position_ms, last_speed = last_speed.toFloat(), updated_at = updated_at, + pitch_semitones = pitch_semitones.toInt(), channel_mode = ChannelMode.from_key(channel_mode), + low_cut = low_cut != 0L, marks_updated_at = marks_updated_at, ) diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/model/models.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/model/models.kt @@ -9,6 +9,11 @@ data class AudioFileInfo( val last_position_ms: Long = 0, val last_speed: Float = 1f, val updated_at: Long = 0, + val pitch_semitones: Int = 0, + val channel_mode: ChannelMode = ChannelMode.STEREO, + val low_cut: Boolean = false, + // Last change to the marks or zones, to sync them through iCloud + val marks_updated_at: Long = 0, // Not stored: set when the audio file can't be found on disk val missing: Boolean = false, ) @@ -36,3 +41,33 @@ data class LoopZone( val start_ms: Long, val end_ms: Long, ) + +enum class ChannelMode(val key: String) { + STEREO("stereo"), + // One channel on both sides + LEFT("left"), + RIGHT("right"), + // What is panned in the middle (usually the voice) is lowered + NO_CENTER("no_center"); + + companion object { + fun from_key(key: String) = entries.firstOrNull { it.key == key } ?: STEREO + } +} + +// App-wide practice options, see PlayerViewModel +data class PracticeSettings( + // Silent wait before playing, 0 = none + val delay_s: Int = 0, + // Metronome beats before playing, 0 = none + val count_in_beats: Int = 0, + val tempo_bpm: Int = 100, + val count_in_each_pass: Boolean = false, + val pause_between_s: Int = 0, + // Loop passes before stopping, 0 = unlimited + val repetitions: Int = 0, + val ramp_enabled: Boolean = false, + val ramp_from: Float = 0.7f, + val ramp_to: Float = 1f, + val ramp_step: Float = 0.05f, +) diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/model/platform.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/model/platform.kt @@ -3,3 +3,7 @@ package ubi2.sonodrill.model expect fun current_time_ms(): Long expect fun generate_uuid(): String expect fun set_idle_timer_disabled(disabled: Boolean) +// ISO 639 code of the user's preferred language (e.g. "fr") +expect fun preferred_language(): String +// Offers a text file to other apps (share sheet) +expect fun share_text_file(file_name: String, text: String) diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/app.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/app.kt @@ -1,17 +1,26 @@ package ubi2.sonodrill.ui +import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import ubi2.sonodrill.audio.AudioPlayer import ubi2.sonodrill.data.AudioRepository +import ubi2.sonodrill.data.MarksFile import ubi2.sonodrill.model.AudioFileInfo import ubi2.sonodrill.ui.library.LibraryScreen import ubi2.sonodrill.ui.player.PlayerScreen import ubi2.sonodrill.ui.player.PlayerViewModel +import ubi2.sonodrill.ui.player.format_time +import kotlin.math.abs private val light_scheme = lightColorScheme( primary = Color(0xFF1A73E8), primaryContainer = Color(0xFFD2E3FC), secondary = Color(0xFFFF6B35), @@ -28,21 +37,49 @@ fun SonodrillApp( is_dark: Boolean = isSystemInDarkTheme(), // Opens this file in the player at launch (used to take App Store screenshots) initial_file_id: String? = null, + // With initial_file_id, opens a panel of the player: "sound", "practice" or "mark" (screenshots) + initial_panel: String? = null, ) { val scheme = if (is_dark) dark_scheme else light_scheme var screen by remember { mutableStateOf(Screen.LIBRARY) } var player_vm by remember { mutableStateOf<PlayerViewModel?>(null) } var files by remember { mutableStateOf(emptyList<AudioFileInfo>()) } + // Shared marks waiting for the user to pick their track + var pending_marks by remember { mutableStateOf<MarksFile?>(null) } + var message by remember { mutableStateOf<String?>(null) } fun refresh_files() { files = repo.sync_library() } fun open(file: AudioFileInfo) { + player_vm?.release() val vm = PlayerViewModel(player, repo); vm.load_file(file); player_vm = vm; screen = Screen.PLAYER } + fun close_player() { + player_vm?.release(); player_vm = null; refresh_files(); screen = Screen.LIBRARY + } + LaunchedEffect(Unit) { refresh_files() files.firstOrNull { it.id == initial_file_id && !it.missing }?.let { open(it) } + // Marks changed on another device + repo.observe_cloud { changed -> + files = repo.get_all_files().map { f -> f.copy(missing = files.firstOrNull { it.id == f.id }?.missing ?: false) } + player_vm?.let { vm -> if (vm.current_file.value?.id in changed) vm.reload_marks() } + } + for (incoming in incoming_files) { + when (incoming) { + is Incoming.Audio -> { + refresh_files() + files.firstOrNull { it.id == incoming.file_id && !it.missing }?.let { open(it) } + } + is Incoming.Marks -> { + refresh_files() + if (files.none { !it.missing }) message = S.no_tracks else pending_marks = incoming.marks + } + Incoming.InvalidMarks -> message = S.invalid_marks_file + } + } } MaterialTheme(colorScheme = scheme) { @@ -54,8 +91,67 @@ fun SonodrillApp( on_delete = { file -> repo.delete_file(file); refresh_files() }, ) Screen.PLAYER -> player_vm?.let { vm -> - PlayerScreen(vm = vm, on_back = { vm.release(); player_vm = null; refresh_files(); screen = Screen.LIBRARY }) + PlayerScreen(vm = vm, on_back = { close_player() }, initial_panel = initial_panel.takeIf { vm.current_file.value?.id == initial_file_id }) } } + + pending_marks?.let { marks -> + MarksTargetDialog( + marks = marks, + files = files.filter { !it.missing }, + on_pick = { file -> + pending_marks = null + val added = repo.merge_marks(file.id, marks) + player_vm?.let { vm -> if (vm.current_file.value?.id == file.id) vm.reload_marks() } + if (player_vm?.current_file?.value?.id != file.id) open(file) + message = S.marks_added(added) + }, + on_dismiss = { pending_marks = null }, + ) + } + + message?.let { text -> + AlertDialog( + onDismissRequest = { message = null }, + text = { Text(text) }, + confirmButton = { TextButton(onClick = { message = null }) { Text(S.ok) } }, + ) + } + } +} + +// The track the shared marks belong to; the closest match (title, then duration) comes first +@Composable +private fun MarksTargetDialog( + marks: MarksFile, files: List<AudioFileInfo>, on_pick: (AudioFileInfo) -> Unit, on_dismiss: () -> Unit, +) { + val sorted = remember(marks, files) { + files.sortedWith(compareBy<AudioFileInfo>( + { !it.title.equals(marks.title, ignoreCase = true) }, + { if (marks.duration_ms != null && it.duration_ms != null) abs(it.duration_ms - marks.duration_ms) else Long.MAX_VALUE }, + )) } + AlertDialog( + onDismissRequest = on_dismiss, + title = { Text(S.apply_marks_to) }, + text = { + Column { + Text(S.marks_from(marks.title), style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.height(8.dp)) + LazyColumn(Modifier.heightIn(max = 320.dp)) { + items(sorted, key = { it.id }) { file -> + Row(Modifier.fillMaxWidth().clickable { on_pick(file) }.padding(vertical = 10.dp)) { + Text( + file.title, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis, + fontWeight = if (file == sorted.first()) FontWeight.Bold else FontWeight.Normal, + ) + file.duration_ms?.let { Text(format_time(it), style = MaterialTheme.typography.bodySmall) } + } + } + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = on_dismiss) { Text(S.cancel) } }, + ) } diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/incoming.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/incoming.kt @@ -0,0 +1,22 @@ +package ubi2.sonodrill.ui + +import kotlinx.coroutines.channels.Channel +import ubi2.sonodrill.data.MarksFile +import ubi2.sonodrill.data.decode_marks + +// Files opened from other apps (share sheet, "Open in…") or picked in the +// library, after the platform code stored them +sealed interface Incoming { + // An audio file, imported: open it + data class Audio(val file_id: String) : Incoming + // Shared marks: ask which track they belong to + data class Marks(val marks: MarksFile) : Incoming + data object InvalidMarks : Incoming +} + +val incoming_files = Channel<Incoming>(Channel.UNLIMITED) + +fun receive_marks_text(text: String?) { + val marks = text?.let { decode_marks(it) } + incoming_files.trySend(if (marks != null) Incoming.Marks(marks) else Incoming.InvalidMarks) +} diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/library/library_screen.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/library/library_screen.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import ubi2.sonodrill.model.AudioFileInfo +import ubi2.sonodrill.ui.S import ubi2.sonodrill.ui.player.format_time @OptIn(ExperimentalMaterial3Api::class) @@ -34,20 +35,20 @@ fun LibraryScreen( topBar = { TopAppBar(title = { Text("Sonodrill") }) }, - floatingActionButton = { FloatingActionButton(onClick = on_import) { Icon(Icons.Default.Add, "Import audio") } }, + floatingActionButton = { FloatingActionButton(onClick = on_import) { Icon(Icons.Default.Add, S.import_audio) } }, ) { padding -> Column(Modifier.fillMaxSize().padding(padding)) { OutlinedTextField( value = search, onValueChange = { search = it }, - placeholder = { Text("Search...") }, leadingIcon = { Icon(Icons.Default.Search, null) }, + placeholder = { Text(S.search) }, leadingIcon = { Icon(Icons.Default.Search, null) }, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), singleLine = true, ) if (filtered.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Icon(Icons.Default.MusicNote, null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)) - Spacer(Modifier.height(16.dp)); Text("No audio files", style = MaterialTheme.typography.titleMedium) - Spacer(Modifier.height(8.dp)); Text("Tap + to import", color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(16.dp)); Text(S.no_audio_files, style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)); Text(S.tap_to_import, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } else { @@ -63,13 +64,13 @@ fun LibraryScreen( to_delete?.let { file -> AlertDialog( onDismissRequest = { to_delete = null }, - title = { Text("Delete \"${file.title}\"?") }, - text = { Text("The audio file, its marks and its zones will be removed.") }, + title = { Text(S.delete_title(file.title)) }, + text = { Text(S.delete_text) }, confirmButton = { - TextButton(onClick = { on_delete(file); to_delete = null }) { Text("Delete", color = MaterialTheme.colorScheme.error) } + TextButton(onClick = { on_delete(file); to_delete = null }) { Text(S.delete, color = MaterialTheme.colorScheme.error) } }, dismissButton = { - TextButton(onClick = { to_delete = null }) { Text("Cancel") } + TextButton(onClick = { to_delete = null }) { Text(S.cancel) } }, ) } @@ -86,12 +87,12 @@ private fun AudioFileItem(file: AudioFileInfo, on_click: () -> Unit, on_delete: Spacer(Modifier.width(12.dp)) Column(Modifier.weight(1f)) { Text(file.title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyLarge) - if (file.missing) Text("File not found", fontSize = 13.sp, color = MaterialTheme.colorScheme.error) + if (file.missing) Text(S.file_not_found, fontSize = 13.sp, color = MaterialTheme.colorScheme.error) else file.duration_ms?.let { Text(format_time(it), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } } if (file.last_position_ms > 0 && file.duration_ms != null && file.duration_ms > 0) LinearProgressIndicator(progress = { file.last_position_ms.toFloat() / file.duration_ms }, modifier = Modifier.width(40.dp).padding(end = 8.dp)) - IconButton(onClick = on_delete) { Icon(Icons.Default.Delete, "Delete", modifier = Modifier.size(20.dp)) } + IconButton(onClick = on_delete) { Icon(Icons.Default.Delete, S.delete, modifier = Modifier.size(20.dp)) } } } } 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 @@ -3,16 +3,20 @@ package ubi2.sonodrill.ui.player import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.TrendingUp import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -26,16 +30,20 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlin.math.roundToInt +import ubi2.sonodrill.model.ChannelMode import ubi2.sonodrill.model.LoopState import ubi2.sonodrill.model.LoopZone import ubi2.sonodrill.model.MarkInfo +import ubi2.sonodrill.model.PracticeSettings import ubi2.sonodrill.model.SkipZone import ubi2.sonodrill.model.set_idle_timer_disabled import ubi2.sonodrill.ui.PlatformBackHandler +import ubi2.sonodrill.ui.S @OptIn(ExperimentalMaterial3Api::class) @Composable -fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { +fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit, initial_panel: String? = null) { val position by vm.position.collectAsState() val duration by vm.duration.collectAsState() val playing by vm.is_playing.collectAsState() @@ -47,18 +55,18 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { 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() + val practice by vm.practice.collectAsState() + val pass by vm.pass.collectAsState() val countdown by vm.countdown.collectAsState() PlatformBackHandler(on_back) LaunchedEffect(playing) { set_idle_timer_disabled(playing) } DisposableEffect(Unit) { onDispose { set_idle_timer_disabled(false) } } - var show_speed_sheet by remember { mutableStateOf(false) } + var show_speed_sheet by remember { mutableStateOf(initial_panel == "sound") } var show_rename by remember { mutableStateOf(false) } - var show_delay_picker by remember { mutableStateOf(false) } - var mark_to_rename by remember { mutableStateOf<MarkInfo?>(null) } + var show_practice_sheet by remember { mutableStateOf(initial_panel == "practice") } + var mark_to_edit_id by remember { mutableStateOf(if (initial_panel == "mark") marks.getOrNull(1)?.id else null) } Scaffold( topBar = { @@ -70,7 +78,12 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { ) }, navigationIcon = { - IconButton(onClick = on_back) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } + IconButton(onClick = on_back) { Icon(Icons.AutoMirrored.Filled.ArrowBack, S.back) } + }, + actions = { + IconButton(onClick = { vm.share_marks() }, enabled = marks.isNotEmpty()) { + Icon(Icons.Default.Share, S.share_marks) + } }, ) }, @@ -89,7 +102,8 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { loop_zones = loop_zones, loop_state = loop_state, on_seek = { tap_ms -> vm.tap_timeline(tap_ms) }, - on_mark_label_click = { mark -> mark_to_rename = mark }, + on_mark_label_click = { mark -> mark_to_edit_id = mark.id }, + on_mark_drag = { mark, target_ms, done -> vm.move_mark(mark.id, target_ms, persist = done) }, ) } @@ -99,6 +113,12 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { horizontalArrangement = Arrangement.SpaceBetween, ) { Text(format_time(position), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) + if (loop_state.enabled) { + Text( + S.pass_of(pass, practice.repetitions.takeIf { it > 0 }), + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary, + ) + } Text(format_time(duration), style = MaterialTheme.typography.bodyMedium) } @@ -111,12 +131,18 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { verticalAlignment = Alignment.CenterVertically, ) { // Speed button - OutlinedButton(onClick = { show_speed_sheet = true }) { - Text("${format_speed(speed)}x") + val pitch = file?.pitch_semitones ?: 0 + val filtered = file?.channel_mode != ChannelMode.STEREO || file?.low_cut == true + OutlinedButton(onClick = { show_speed_sheet = true }, contentPadding = PaddingValues(horizontal = 12.dp)) { + Text("${format_speed(speed)}x" + if (pitch != 0) " ${if (pitch > 0) "+" else ""}$pitch" else "") + if (filtered) { + Spacer(Modifier.width(2.dp)) + Icon(Icons.Default.GraphicEq, null, modifier = Modifier.size(14.dp)) + } } IconButton(onClick = { vm.prev_mark() }) { - Icon(Icons.Default.SkipPrevious, "Previous mark") + Icon(Icons.Default.SkipPrevious, S.previous_mark) } FilledIconButton( onClick = { vm.toggle_playback() }, @@ -127,7 +153,7 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { } else { Icon( if (playing) Icons.Default.Pause else Icons.Default.PlayArrow, - contentDescription = if (playing) "Pause" else "Play", + contentDescription = if (playing) S.pause else S.play, modifier = Modifier.size(32.dp), ) } @@ -136,20 +162,25 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { onClick = { vm.next_mark() }, enabled = marks.any { it.position_ms > position + 500 }, ) { - Icon(Icons.Default.SkipNext, "Next mark") + Icon(Icons.Default.SkipNext, S.next_mark) } - // Delay button - OutlinedButton(onClick = { - if (play_delay_enabled) vm.toggle_play_delay() - else show_delay_picker = true - }) { - Icon(Icons.Default.Timer, null, modifier = Modifier.size(16.dp)) + // Practice options: delay, count-in, loop passes + val lead_in = listOfNotNull( + practice.delay_s.takeIf { it > 0 }?.let { "${it}s" }, + practice.count_in_beats.takeIf { it > 0 }?.let { "$it♩" }, + ).joinToString(" ") + val practice_on = lead_in.isNotEmpty() || practice.ramp_enabled || practice.pause_between_s > 0 || practice.repetitions > 0 + OutlinedButton(onClick = { show_practice_sheet = true }, contentPadding = PaddingValues(horizontal = 12.dp)) { + Icon( + if (practice.ramp_enabled) Icons.AutoMirrored.Filled.TrendingUp else Icons.Default.Timer, S.practice, modifier = Modifier.size(16.dp), + tint = if (practice_on) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + ) Spacer(Modifier.width(4.dp)) Text( - if (play_delay_enabled) "${play_delay_s}s" else "off", + lead_in.ifEmpty { if (practice_on) "" else S.off }, fontSize = 12.sp, - color = if (play_delay_enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + color = if (practice_on) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), ) } } @@ -204,7 +235,7 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { } OutlinedIconButton(onClick = { vm.add_mark() }) { - Icon(Icons.Default.BookmarkAdd, "Add mark", modifier = Modifier.size(22.dp)) + Icon(Icons.Default.BookmarkAdd, S.add_mark, modifier = Modifier.size(22.dp)) } OutlinedIconButton(onClick = { vm.delete_prev_mark() }, enabled = marks.isNotEmpty()) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy((-4).dp)) { @@ -222,11 +253,11 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { val in_skip = skip_zones.any { position >= it.start_ms && position < it.end_ms } if (in_skip) { FilledIconButton(onClick = { vm.remove_skip_zone_at_position() }) { - Icon(Icons.Default.NotInterested, "Remove skip zone", modifier = Modifier.size(22.dp)) + Icon(Icons.Default.NotInterested, S.remove_skip_zone, modifier = Modifier.size(22.dp)) } } else { OutlinedIconButton(onClick = { vm.add_skip_zone() }, enabled = marks.size >= 2) { - Icon(Icons.Default.NotInterested, "Add skip zone", modifier = Modifier.size(22.dp)) + Icon(Icons.Default.NotInterested, S.add_skip_zone, modifier = Modifier.size(22.dp)) } } } @@ -235,10 +266,25 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { } } - // Speed bottom sheet + // Speed, pitch and sound bottom sheet if (show_speed_sheet) { ModalBottomSheet(onDismissRequest = { show_speed_sheet = false }) { - SpeedControl(speed) { vm.set_speed(it) } + SoundControl( + speed = speed, + pitch = file?.pitch_semitones ?: 0, + mode = file?.channel_mode ?: ChannelMode.STEREO, + low_cut = file?.low_cut ?: false, + on_speed = { vm.set_speed(it) }, + on_pitch = { vm.set_pitch(it) }, + on_sound = { mode, low_cut -> vm.set_sound(mode, low_cut) }, + ) + Spacer(Modifier.height(32.dp)) + } + } + + if (show_practice_sheet) { + ModalBottomSheet(onDismissRequest = { show_practice_sheet = false }) { + PracticeControl(practice) { vm.set_practice(it) } Spacer(Modifier.height(32.dp)) } } @@ -248,7 +294,7 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { var name by remember { mutableStateOf(file?.title ?: "") } AlertDialog( onDismissRequest = { show_rename = false }, - title = { Text("Rename") }, + title = { Text(S.rename) }, text = { OutlinedTextField( value = name, @@ -258,67 +304,50 @@ fun PlayerScreen(vm: PlayerViewModel, on_back: () -> Unit) { ) }, confirmButton = { - TextButton(onClick = { vm.rename_file(name); show_rename = false }) { Text("OK") } + TextButton(onClick = { vm.rename_file(name); show_rename = false }) { Text(S.ok) } }, dismissButton = { - TextButton(onClick = { show_rename = false }) { Text("Cancel") } + TextButton(onClick = { show_rename = false }) { Text(S.cancel) } }, ) } - // Mark rename dialog - mark_to_rename?.let { mark -> + // Mark dialog: name, fine position, delete + marks.firstOrNull { it.id == mark_to_edit_id }?.let { mark -> var label by remember(mark.id) { mutableStateOf(mark.label ?: "") } AlertDialog( - onDismissRequest = { mark_to_rename = null }, - title = { Text("Name mark at ${format_time_centis(mark.position_ms)}") }, + onDismissRequest = { mark_to_edit_id = null }, + title = { Text(S.mark_at(format_time_centis(mark.position_ms))) }, text = { - OutlinedTextField( - value = label, - onValueChange = { label = it }, - singleLine = true, - placeholder = { Text("e.g. Chorus, Bridge...") }, - modifier = Modifier.fillMaxWidth(), - ) - }, - confirmButton = { - TextButton(onClick = { - vm.rename_mark(mark.id, label.ifBlank { null }) - mark_to_rename = null - }) { Text("OK") } - }, - dismissButton = { - TextButton(onClick = { mark_to_rename = null }) { Text("Cancel") } - }, - ) - } - - // Delay picker dialog - if (show_delay_picker) { - val delays = listOf(2, 5, 15, 30) - AlertDialog( - onDismissRequest = { show_delay_picker = false }, - title = { Text("Play delay") }, - text = { - Row(horizontalArrangement = Arrangement.SpaceEvenly, modifier = Modifier.fillMaxWidth()) { - delays.forEach { d -> - FilterChip( - selected = play_delay_s == d, - onClick = { vm.set_play_delay(d) }, - label = { Text("${d}s", maxLines = 1) }, - modifier = Modifier.padding(horizontal = 2.dp), - ) + Column { + OutlinedTextField( + value = label, + onValueChange = { label = it }, + singleLine = true, + placeholder = { Text(S.mark_placeholder) }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + listOf(-1000L to "−1 s", -100L to "−0.1", 100L to "+0.1", 1000L to "+1 s").forEach { (delta, text) -> + OutlinedButton( + onClick = { vm.move_mark(mark.id, mark.position_ms + delta) }, + contentPadding = PaddingValues(horizontal = 8.dp), + ) { Text(text, fontSize = 13.sp, maxLines = 1) } + } } } }, confirmButton = { TextButton(onClick = { - if (!play_delay_enabled) vm.toggle_play_delay() - show_delay_picker = false - }) { Text("Enable") } + vm.rename_mark(mark.id, label.ifBlank { null }) + mark_to_edit_id = null + }) { Text(S.ok) } }, dismissButton = { - TextButton(onClick = { show_delay_picker = false }) { Text("Cancel") } + TextButton(onClick = { vm.delete_mark(mark); mark_to_edit_id = null }) { + Text(S.delete, color = MaterialTheme.colorScheme.error) + } }, ) } @@ -353,6 +382,7 @@ private fun ProgressTimeline( loop_state: LoopState, on_seek: (Long) -> Unit, on_mark_label_click: (MarkInfo) -> Unit, + on_mark_drag: (MarkInfo, Long, Boolean) -> Unit, ) { val primary = MaterialTheme.colorScheme.primary val surface_variant = MaterialTheme.colorScheme.surfaceVariant @@ -409,6 +439,7 @@ private fun ProgressTimeline( 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, + on_mark_drag = on_mark_drag, modifier = Modifier.fillMaxWidth().height(ROW_HEIGHT_DP.dp), ) } @@ -430,6 +461,7 @@ private fun ProgressTimeline( 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, + on_mark_drag = on_mark_drag, modifier = Modifier.fillMaxWidth().weight(1f), ) } @@ -449,6 +481,7 @@ private fun TimelineRow( loop_zones: List<LoopZone>, loop_state: LoopState, loop_zone_color: Color, loop_zone_active_color: Color, on_mark_label_click: (MarkInfo) -> Unit, + on_mark_drag: (MarkInfo, Long, Boolean) -> Unit, modifier: Modifier, ) { val row_start_ms = row * row_duration @@ -582,11 +615,25 @@ private fun TimelineRow( val offset_x = (center_x - est_width / 2) .coerceAtLeast(0.dp) .coerceAtMost((total_width - est_width).coerceAtLeast(0.dp)) + val current_mark by rememberUpdatedState(mark) Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .width(est_width) .offset(x = offset_x, y = 1.dp) + // Dragging the label moves the mark + .pointerInput(mark.id, row_duration, total_width) { + var target = 0f + detectHorizontalDragGestures( + onDragStart = { target = current_mark.position_ms.toFloat() }, + onDragEnd = { on_mark_drag(current_mark, target.toLong(), true) }, + onDragCancel = { on_mark_drag(current_mark, target.toLong(), true) }, + ) { change, dx -> + change.consume() + target += dx * row_duration / total_width.toPx() + on_mark_drag(current_mark, target.toLong(), false) + } + } .clickable { on_mark_label_click(mark) }, ) { Text( @@ -620,17 +667,20 @@ private fun TimelineRow( } @Composable -private fun SpeedControl(speed: Float, on_change: (Float) -> Unit) { +private fun SoundControl( + speed: Float, pitch: Int, mode: ChannelMode, low_cut: Boolean, + on_speed: (Float) -> Unit, on_pitch: (Int) -> Unit, on_sound: (ChannelMode, Boolean) -> Unit, +) { val presets = listOf(0.5f, 0.75f, 1f, 1.5f, 2f) Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(horizontal = 16.dp), + modifier = Modifier.padding(horizontal = 16.dp).verticalScroll(rememberScrollState()), ) { - Text("Speed: ${format_speed(speed)}x", fontWeight = FontWeight.Bold, fontSize = 18.sp) + Text(S.speed(format_speed(speed)), fontWeight = FontWeight.Bold, fontSize = 18.sp) Spacer(Modifier.height(8.dp)) Slider( value = speed, - onValueChange = on_change, + onValueChange = { on_speed((it * 100).toInt() / 100f) }, valueRange = 0.5f..2f, modifier = Modifier.fillMaxWidth(), ) @@ -639,11 +689,106 @@ private fun SpeedControl(speed: Float, on_change: (Float) -> Unit) { presets.forEach { p -> FilterChip( selected = speed == p, - onClick = { on_change(p) }, + onClick = { on_speed(p) }, label = { Text("${p}x", fontSize = 12.sp) }, ) } } + + SectionTitle(S.pitch) + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedIconButton(onClick = { on_pitch(pitch - 1) }, enabled = pitch > -12) { Icon(Icons.Default.Remove, null) } + Text( + S.semitones(pitch), fontWeight = FontWeight.Bold, + modifier = Modifier.widthIn(min = 140.dp).padding(horizontal = 8.dp), + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + OutlinedIconButton(onClick = { on_pitch(pitch + 1) }, enabled = pitch < 12) { Icon(Icons.Default.Add, null) } + Spacer(Modifier.width(8.dp)) + TextButton(onClick = { on_pitch(0) }, enabled = pitch != 0) { Text(S.reset) } + } + + SectionTitle(S.channels) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + listOf(ChannelMode.STEREO to S.stereo, ChannelMode.LEFT to S.left, ChannelMode.RIGHT to S.right, ChannelMode.NO_CENTER to S.no_center) + .forEach { (m, text) -> + FilterChip(selected = mode == m, onClick = { on_sound(m, low_cut) }, label = { Text(text, fontSize = 12.sp, maxLines = 1) }) + } + } + if (mode == ChannelMode.NO_CENTER) Hint(S.no_center_hint) + SwitchRow(S.low_cut, low_cut) { on_sound(mode, it) } + Hint(S.low_cut_hint) + } +} + +@Composable +private fun PracticeControl(p: PracticeSettings, on_change: (PracticeSettings) -> Unit) { + Column(Modifier.padding(horizontal = 16.dp).verticalScroll(rememberScrollState())) { + Text(S.practice, fontWeight = FontWeight.Bold, fontSize = 18.sp) + + SectionTitle(S.before_playing) + Choices(S.delay, listOf(0, 2, 5, 15, 30), p.delay_s, { if (it == 0) S.off else "${it}s" }) { on_change(p.copy(delay_s = it)) } + Choices(S.count_in, listOf(0, 1, 2, 4, 8), p.count_in_beats, { if (it == 0) S.off else S.beats(it) }) { on_change(p.copy(count_in_beats = it)) } + if (p.count_in_beats > 0) { + Text(S.tempo(p.tempo_bpm), fontSize = 14.sp, modifier = Modifier.padding(top = 4.dp)) + Slider( + value = p.tempo_bpm.toFloat(), + onValueChange = { on_change(p.copy(tempo_bpm = it.toInt())) }, + valueRange = 40f..220f, + ) + } + + SectionTitle(S.loop) + if (p.count_in_beats > 0) SwitchRow(S.count_in_each_pass, p.count_in_each_pass) { on_change(p.copy(count_in_each_pass = it)) } + Choices(S.pause_between, listOf(0, 1, 2, 5, 10), p.pause_between_s, { if (it == 0) S.off else "${it}s" }) { on_change(p.copy(pause_between_s = it)) } + Choices(S.repetitions, listOf(0, 2, 4, 8, 16), p.repetitions, { if (it == 0) S.unlimited else "$it" }) { on_change(p.copy(repetitions = it)) } + + SwitchRow(S.progressive_speed, p.ramp_enabled) { on_change(p.copy(ramp_enabled = it)) } + Hint(S.progressive_hint) + if (p.ramp_enabled) { + val percent = { v: Float -> "${(v * 100).roundToInt()}%" } + Choices(S.from, listOf(0.5f, 0.6f, 0.7f, 0.8f, 0.9f), p.ramp_from, percent) { on_change(p.copy(ramp_from = it)) } + Choices(S.up_to, listOf(0.8f, 0.9f, 1f, 1.1f, 1.25f), p.ramp_to, percent) { on_change(p.copy(ramp_to = it)) } + Choices(S.step, listOf(0.02f, 0.05f, 0.1f), p.ramp_step, percent) { on_change(p.copy(ramp_step = it)) } + } + } +} + +@Composable +private fun SectionTitle(text: String) { + Text( + text, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, + modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 4.dp), + ) +} + +@Composable +private fun Hint(text: String) { + Text( + text, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), + ) +} + +@Composable +private fun SwitchRow(text: String, checked: Boolean, on_change: (Boolean) -> Unit) { + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Text(text, modifier = Modifier.weight(1f)) + Switch(checked = checked, onCheckedChange = on_change) + } +} + +// A label and a row of chips, one selected +@Composable +private fun <T> Choices(label: String, values: List<T>, selected: T, text: (T) -> String, on_pick: (T) -> Unit) { + Text(label, fontSize = 14.sp, modifier = Modifier.padding(top = 4.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + values.forEach { v -> + FilterChip( + selected = v == selected, onClick = { on_pick(v) }, + label = { Text(text(v), fontSize = 12.sp, maxLines = 1) }, + ) + } } } 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 @@ -4,20 +4,29 @@ import ubi2.sonodrill.audio.AudioPlayer import ubi2.sonodrill.audio.LoopController import ubi2.sonodrill.audio.extract_waveform import ubi2.sonodrill.data.AudioRepository +import ubi2.sonodrill.data.encode_marks +import ubi2.sonodrill.data.marks_file_name import ubi2.sonodrill.model.AudioFileInfo +import ubi2.sonodrill.model.ChannelMode import ubi2.sonodrill.model.LoopState import ubi2.sonodrill.model.LoopZone import ubi2.sonodrill.model.MarkInfo +import ubi2.sonodrill.model.PracticeSettings import ubi2.sonodrill.model.SkipZone +import ubi2.sonodrill.model.share_text_file import ubi2.sonodrill.model.current_time_ms import ubi2.sonodrill.model.generate_uuid import kotlinx.coroutines.* import kotlinx.coroutines.flow.* +import kotlin.math.roundToInt // Ids of the synthetic marks bounding a whole-track loop const val FULL_LOOP_A = "_full_a" const val FULL_LOOP_B = "_full_b" +// Closest two marks can get when one is moved +private const val MIN_MARK_GAP_MS = 50L + fun LoopState.is_mark_loop() = enabled && mark_a != null && mark_a.id != FULL_LOOP_A fun LoopState.is_full_loop() = enabled && mark_a?.id == FULL_LOOP_A fun LoopState.is_zone(zone: LoopZone) = enabled && mark_a?.position_ms == zone.start_ms && mark_b?.position_ms == zone.end_ms @@ -26,7 +35,10 @@ class PlayerViewModel( private val player: AudioPlayer, private val repo: AudioRepository, ) { - val loop = LoopController(player).also { it.resolve_target = { pos -> jump_target(pos) } } + val loop = LoopController(player).also { + it.resolve_target = { pos -> jump_target(pos) } + it.on_pass_end = { restart_ms, track_ended -> on_pass_end(restart_ms, track_ended) } + } val position = player.current_position_ms val duration = player.duration_ms val is_playing = player.is_playing @@ -48,31 +60,51 @@ class PlayerViewModel( 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) - val play_delay_enabled: StateFlow<Boolean> = _play_delay_enabled.asStateFlow() + private val _practice = MutableStateFlow(repo.get_practice()) + val practice: StateFlow<PracticeSettings> = _practice.asStateFlow() + // Seconds or beats left before playback starts (delay, count-in, pause between passes) private val _countdown = MutableStateFlow(0) val countdown: StateFlow<Int> = _countdown.asStateFlow() - fun set_play_delay(seconds: Int) { _play_delay_s.value = seconds } - fun toggle_play_delay() { _play_delay_enabled.value = !_play_delay_enabled.value } + // Number of the current pass of the loop + private val _pass = MutableStateFlow(1) + val pass: StateFlow<Int> = _pass.asStateFlow() + + fun set_practice(settings: PracticeSettings) { + val before = _practice.value + _practice.value = settings + repo.save_practice(settings) + if (settings.ramp_enabled && (!before.ramp_enabled || before.ramp_from != settings.ramp_from)) start_ramp() + } // Main thread: the platform players must not be driven from elsewhere private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private var save_job: Job? = null private var skip_job: Job? = null private var delay_job: Job? = null + private var loop_bounds: Pair<String?, String?>? = null fun load_file(file: AudioFileInfo) { _current_file.value = file player.load(file.uri, file.title) - _marks.value = repo.get_marks(file.id) - _skip_zones.value = repo.get_skip_zones(file.id) - _loop_zones.value = repo.get_loop_zones(file.id) + player.set_pitch(file.pitch_semitones) + player.set_sound(file.channel_mode, file.low_cut) + reload_marks() if (file.last_position_ms > 0) player.seek(file.last_position_ms) - if (file.last_speed != 1f) player.set_speed(file.last_speed) + player.set_speed(file.last_speed) + // A new loop starts its passes (and its progressive speed) over + scope.launch { + loop_state.collect { ls -> + // By mark: moving a bound keeps the passes going + val bounds = ls.mark_a?.id to ls.mark_b?.id + if (ls.enabled && bounds != loop_bounds) { + _pass.value = 1 + if (_practice.value.ramp_enabled) start_ramp() + } + loop_bounds = if (ls.enabled) bounds else null + } + } scope.launch { val dur = duration.first { it > 0 } if (file.duration_ms != dur) repo.update_duration(file.id, dur) @@ -84,6 +116,44 @@ class PlayerViewModel( start_skip_monitoring() } + // Marks and zones as stored (they can change on another device) + fun reload_marks() { + val file = _current_file.value ?: return + _marks.value = repo.get_marks(file.id) + _skip_zones.value = repo.get_skip_zones(file.id) + _loop_zones.value = repo.get_loop_zones(file.id) + val ls = loop_state.value + if (ls.enabled && !ls.is_full_loop()) { + val positions = _marks.value.map { it.position_ms } + if (ls.mark_a?.position_ms !in positions || ls.mark_b?.position_ms !in positions) loop.clear() + } + } + + fun share_marks() { + val file = _current_file.value ?: return + val marks = repo.export_marks(file.id) ?: return + share_text_file(marks_file_name(file.title), encode_marks(marks)) + } + + fun set_pitch(semitones: Int) { + val file = _current_file.value ?: return + val updated = file.copy(pitch_semitones = semitones.coerceIn(-12, 12)) + player.set_pitch(updated.pitch_semitones) + save_sound(updated) + } + + fun set_sound(mode: ChannelMode, low_cut: Boolean) { + val file = _current_file.value ?: return + val updated = file.copy(channel_mode = mode, low_cut = low_cut) + player.set_sound(mode, low_cut) + save_sound(updated) + } + + private fun save_sound(file: AudioFileInfo) { + _current_file.value = file + repo.update_sound(file.id, file.pitch_semitones, file.channel_mode, file.low_cut) + } + fun rename_file(new_title: String) { val file = _current_file.value ?: return val title = new_title.trim().ifEmpty { return } @@ -100,20 +170,62 @@ class PlayerViewModel( player.pause() return } - if (_play_delay_enabled.value && _play_delay_s.value > 0) { - delay_job = scope.launch { - for (i in _play_delay_s.value downTo 1) { - _countdown.value = i - delay(1000) - } - _countdown.value = 0 - player.play() + val p = _practice.value + start_lead_in(p.delay_s, p.count_in_beats) + } + + // Silent wait, then metronome count-in, then playback + private fun start_lead_in(seconds: Int, beats: Int) { + if (seconds <= 0 && beats <= 0) { + player.play() + return + } + delay_job?.cancel() + delay_job = scope.launch { + for (i in seconds downTo 1) { + _countdown.value = i + delay(1000) } - } else { + val beat_ms = 60_000L / _practice.value.tempo_bpm.coerceIn(30, 300) + for (beat in 1..beats) { + _countdown.value = beats - beat + 1 + player.click(accent = beat == 1) + delay(beat_ms) + } + _countdown.value = 0 player.play() } } + // End of a loop pass: practice options (repetitions, progressive speed, pause, count-in) + private fun on_pass_end(restart_ms: Long, track_ended: Boolean) { + val p = _practice.value + if (p.repetitions > 0 && _pass.value >= p.repetitions) { + // Set done: stop at the start of the loop, ready for the next one + player.pause() + player.seek(restart_ms) + _pass.value = 1 + if (p.ramp_enabled) start_ramp() + return + } + _pass.value += 1 + if (p.ramp_enabled) { + val next = ((speed.value + p.ramp_step) * 100).roundToInt() / 100f + player.set_speed(next.coerceAtMost(maxOf(p.ramp_to, p.ramp_from))) + } + val beats = if (p.count_in_each_pass) p.count_in_beats else 0 + if (p.pause_between_s <= 0 && beats <= 0) { + player.seek(restart_ms) + if (track_ended) player.play() + return + } + player.pause() + player.seek(restart_ms) + start_lead_in(p.pause_between_s, beats) + } + + private fun start_ramp() = player.set_speed(_practice.value.ramp_from) + private fun cancel_countdown() { delay_job?.cancel() delay_job = null @@ -163,13 +275,45 @@ class PlayerViewModel( fun rename_mark(id: String, label: String?) { val file = _current_file.value ?: return - repo.rename_mark(id, label) + repo.rename_mark(file.id, id, label) _marks.value = repo.get_marks(file.id) } - private fun delete_mark(mark: MarkInfo) { + // Moves a mark, between its neighbours; the zones and the loop it bounds follow. + // While dragging (persist = false) only the screen is updated. + fun move_mark(mark_id: String, target_ms: Long, persist: Boolean = true) { + val file = _current_file.value ?: return + val sorted = _marks.value.sortedBy { it.position_ms } + val index = sorted.indexOfFirst { it.id == mark_id } + if (index < 0) return + val mark = sorted[index] + val min = sorted.getOrNull(index - 1)?.let { it.position_ms + MIN_MARK_GAP_MS } ?: 0L + val max = sorted.getOrNull(index + 1)?.let { it.position_ms - MIN_MARK_GAP_MS } + ?: duration.value.takeIf { it > 0 } ?: Long.MAX_VALUE + if (min > max) return + val old = mark.position_ms + val pos = target_ms.coerceIn(min, max) + if (pos != old) { + val moved = mark.copy(position_ms = pos) + _marks.value = _marks.value.map { if (it.id == mark_id) moved else it } + fun shift(p: Long) = if (p == old) pos else p + _skip_zones.value = _skip_zones.value.map { SkipZone(shift(it.start_ms), shift(it.end_ms)) } + _loop_zones.value = _loop_zones.value.map { LoopZone(shift(it.start_ms), shift(it.end_ms)) } + val ls = loop_state.value + if (ls.enabled && ls.mark_a != null && ls.mark_b != null && (ls.mark_a.position_ms == old || ls.mark_b.position_ms == old)) { + loop.update_bounds(ls.mark_a.copy(position_ms = shift(ls.mark_a.position_ms)), ls.mark_b.copy(position_ms = shift(ls.mark_b.position_ms))) + } + } + if (persist) { + repo.move_mark(file.id, mark_id, pos) + repo.save_skip_zones(file.id, _skip_zones.value) + repo.save_loop_zones(file.id, _loop_zones.value) + } + } + + fun delete_mark(mark: MarkInfo) { val file = _current_file.value ?: return - repo.delete_mark(mark.id) + repo.delete_mark(file.id, mark.id) _marks.value = repo.get_marks(file.id) if (loop_state.value.mark_a?.id == mark.id || loop_state.value.mark_b?.id == mark.id) loop.clear() cleanup_zones() @@ -356,7 +500,11 @@ class PlayerViewModel( delay(80) if (!is_playing.value) continue val pos = position.value - if (_skip_zones.value.any { pos >= it.start_ms && pos < it.end_ms }) player.seek(jump_target(pos)) + if (_skip_zones.value.none { pos >= it.start_ms && pos < it.end_ms }) continue + val target = jump_target(pos) + // Skipping to the end of the loop ends a pass + if (loop_state.value.enabled && target < pos) on_pass_end(target, track_ended = false) + else player.seek(target) } } } diff --git a/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/strings.kt b/composeApp/src/commonMain/kotlin/ubi2/sonodrill/ui/strings.kt @@ -0,0 +1,124 @@ +package ubi2.sonodrill.ui + +import ubi2.sonodrill.model.preferred_language + +// Interface texts, in the system language (French or English) +class Strings( + val back: String, + val ok: String, + val cancel: String, + val delete: String, + val enable: String, + val off: String, + // Library + val search: String, + val import_audio: String, + val no_audio_files: String, + val tap_to_import: String, + val file_not_found: String, + val delete_title: (String) -> String, + val delete_text: String, + // Player + val play: String, + val pause: String, + val previous_mark: String, + val next_mark: String, + val add_mark: String, + val add_skip_zone: String, + val remove_skip_zone: String, + val rename: String, + val more: String, + val share_marks: String, + val mark_at: (String) -> String, + val mark_placeholder: String, + val pass_of: (Int, Int?) -> String, + // Sound sheet + val speed: (String) -> String, + val pitch: String, + val semitones: (Int) -> String, + val reset: String, + val channels: String, + val stereo: String, + val left: String, + val right: String, + val no_center: String, + val no_center_hint: String, + val low_cut: String, + val low_cut_hint: String, + // Practice sheet + val practice: String, + val before_playing: String, + val delay: String, + val count_in: String, + val beats: (Int) -> String, + val tempo: (Int) -> String, + val loop: String, + val count_in_each_pass: String, + val pause_between: String, + val repetitions: String, + val unlimited: String, + val progressive_speed: String, + val progressive_hint: String, + val from: String, + val up_to: String, + val step: String, + // Marks import + val apply_marks_to: String, + val marks_from: (String) -> String, + val marks_added: (Int) -> String, + val invalid_marks_file: String, + val no_tracks: String, +) + +private val en = Strings( + back = "Back", ok = "OK", cancel = "Cancel", delete = "Delete", enable = "Enable", off = "off", + search = "Search...", import_audio = "Import audio", no_audio_files = "No audio files", tap_to_import = "Tap + to import", + file_not_found = "File not found", delete_title = { "Delete \"$it\"?" }, + delete_text = "The audio file, its marks and its zones will be removed.", + play = "Play", pause = "Pause", previous_mark = "Previous mark", next_mark = "Next mark", add_mark = "Add mark", + add_skip_zone = "Add skip zone", remove_skip_zone = "Remove skip zone", rename = "Rename", more = "More", + share_marks = "Share marks", mark_at = { "Mark at $it" }, mark_placeholder = "e.g. Chorus, Bridge...", + pass_of = { n, total -> if (total == null) "Pass $n" else "Pass $n/$total" }, + speed = { "Speed: ${it}x" }, pitch = "Pitch", semitones = { n -> (if (n > 0) "+$n" else "$n") + if (n == 1 || n == -1) " semitone" else " semitones" }, + reset = "Reset", channels = "Channels", stereo = "Stereo", left = "Left", right = "Right", no_center = "No center", + no_center_hint = "Lowers what is in the middle of the mix (often the voice)", + low_cut = "Cut the bass", low_cut_hint = "Removes the low end, to hear the other instruments better", + practice = "Practice", before_playing = "Before playing", delay = "Delay", count_in = "Count-in", + beats = { if (it == 1) "1 beat" else "$it beats" }, tempo = { "Tempo: $it BPM" }, + loop = "Loop", count_in_each_pass = "Count-in before each pass", pause_between = "Pause between passes", + repetitions = "Repetitions", unlimited = "∞", progressive_speed = "Progressive speed", + progressive_hint = "The speed goes up one step at each pass, up to the target", + from = "From", up_to = "Up to", step = "Step", + apply_marks_to = "Apply the marks to", marks_from = { "Marks of \"$it\"" }, + marks_added = { if (it == 1) "1 mark added" else "$it marks added" }, + invalid_marks_file = "This file doesn't contain Sonodrill marks", no_tracks = "Import the track first", +) + +private val fr = Strings( + back = "Retour", ok = "OK", cancel = "Annuler", delete = "Supprimer", enable = "Activer", off = "non", + search = "Rechercher…", import_audio = "Importer un fichier audio", no_audio_files = "Aucun fichier audio", + tap_to_import = "Touchez + pour importer", file_not_found = "Fichier introuvable", + delete_title = { "Supprimer « $it » ?" }, + delete_text = "Le fichier audio, ses repères et ses zones seront supprimés.", + play = "Lecture", pause = "Pause", previous_mark = "Repère précédent", next_mark = "Repère suivant", + add_mark = "Ajouter un repère", add_skip_zone = "Ajouter une zone de saut", remove_skip_zone = "Retirer la zone de saut", + rename = "Renommer", more = "Plus", share_marks = "Partager les repères", mark_at = { "Repère à $it" }, + mark_placeholder = "ex. Refrain, Pont…", + pass_of = { n, total -> if (total == null) "Passage $n" else "Passage $n/$total" }, + speed = { "Vitesse : ${it.replace('.', ',')}×" }, pitch = "Hauteur", + semitones = { n -> (if (n > 0) "+$n" else "$n") + if (n == 1 || n == -1) " demi-ton" else " demi-tons" }, + reset = "Rétablir", channels = "Canaux", stereo = "Stéréo", left = "Gauche", right = "Droite", no_center = "Sans centre", + no_center_hint = "Atténue ce qui est au milieu du mixage (souvent la voix)", + low_cut = "Couper les basses", low_cut_hint = "Retire le grave, pour mieux entendre les autres instruments", + practice = "Entraînement", before_playing = "Avant la lecture", delay = "Délai", count_in = "Décompte", + beats = { if (it == 1) "1 temps" else "$it temps" }, tempo = { "Tempo : $it BPM" }, + loop = "Boucle", count_in_each_pass = "Décompte avant chaque passage", pause_between = "Pause entre les passages", + repetitions = "Répétitions", unlimited = "∞", progressive_speed = "Vitesse progressive", + progressive_hint = "La vitesse monte d'un palier à chaque passage, jusqu'à la cible", + from = "De", up_to = "Jusqu'à", step = "Palier", + apply_marks_to = "Appliquer les repères à", marks_from = { "Repères de « $it »" }, + marks_added = { if (it <= 1) "$it repère ajouté" else "$it repères ajoutés" }, + invalid_marks_file = "Ce fichier ne contient pas de repères Sonodrill", no_tracks = "Importez d'abord le morceau", +) + +val S: Strings by lazy { if (preferred_language() == "fr") fr else en } diff --git a/composeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/2.sqm b/composeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/2.sqm @@ -0,0 +1,15 @@ +-- v2 -> v3: sound settings per file, time of the last change to marks and zones +-- (iCloud sync), and app-wide preferences. +ALTER TABLE audio_file ADD COLUMN pitch_semitones INTEGER NOT NULL DEFAULT 0; +ALTER TABLE audio_file ADD COLUMN channel_mode TEXT NOT NULL DEFAULT 'stereo'; +ALTER TABLE audio_file ADD COLUMN low_cut INTEGER NOT NULL DEFAULT 0; +ALTER TABLE audio_file ADD COLUMN marks_updated_at INTEGER NOT NULL DEFAULT 0; + +CREATE TABLE preference ( + key TEXT NOT NULL PRIMARY KEY, + value TEXT NOT NULL +); + +-- Existing marks count as changed now, so that they go to iCloud +UPDATE audio_file SET marks_updated_at = updated_at +WHERE id IN (SELECT file_id FROM mark UNION SELECT file_id FROM zone); diff --git a/composeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/AudioFile.sq b/composeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/AudioFile.sq @@ -5,7 +5,11 @@ CREATE TABLE audio_file ( duration_ms INTEGER, last_position_ms INTEGER NOT NULL DEFAULT 0, last_speed REAL NOT NULL DEFAULT 1.0, - updated_at INTEGER NOT NULL + updated_at INTEGER NOT NULL, + pitch_semitones INTEGER NOT NULL DEFAULT 0, + channel_mode TEXT NOT NULL DEFAULT 'stereo', + low_cut INTEGER NOT NULL DEFAULT 0, + marks_updated_at INTEGER NOT NULL DEFAULT 0 ); select_all: @@ -33,6 +37,12 @@ UPDATE audio_file SET duration_ms = ? WHERE id = ?; update_position: UPDATE audio_file SET last_position_ms = ?, last_speed = ?, updated_at = ? WHERE id = ?; +update_sound: +UPDATE audio_file SET pitch_semitones = ?, channel_mode = ?, low_cut = ? WHERE id = ?; + +update_marks_time: +UPDATE audio_file SET marks_updated_at = ? WHERE id = ?; + touch: UPDATE audio_file SET updated_at = ? WHERE id = ?; diff --git a/composeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/Mark.sq b/composeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/Mark.sq @@ -18,6 +18,9 @@ VALUES (?, ?, ?, ?, ?); update_label: UPDATE mark SET label = ? WHERE id = ?; +update_position: +UPDATE mark SET position_ms = ? WHERE id = ?; + delete_by_id: DELETE FROM mark WHERE id = ?; diff --git a/composeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/Preference.sq b/composeApp/src/commonMain/sqldelight/ubi2/sonodrill/data/Preference.sq @@ -0,0 +1,11 @@ +-- App-wide settings (practice options), as text. +CREATE TABLE preference ( + key TEXT NOT NULL PRIMARY KEY, + value TEXT NOT NULL +); + +select_all: +SELECT * FROM preference; + +upsert: +INSERT OR REPLACE INTO preference(key, value) VALUES (?, ?); diff --git a/composeApp/src/commonTest/kotlin/ubi2/sonodrill/MarksFileTest.kt b/composeApp/src/commonTest/kotlin/ubi2/sonodrill/MarksFileTest.kt @@ -0,0 +1,47 @@ +package ubi2.sonodrill + +import ubi2.sonodrill.data.MarkEntry +import ubi2.sonodrill.data.MarksFile +import ubi2.sonodrill.data.ZoneEntry +import ubi2.sonodrill.data.cloud_key +import ubi2.sonodrill.data.decode_marks +import ubi2.sonodrill.data.encode_marks +import ubi2.sonodrill.data.marks_file_name +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MarksFileTest { + @Test + fun round_trip() { + val file = MarksFile( + title = "Song", duration_ms = 180_000, updated_at = 42, + marks = listOf(MarkEntry(1000, "Intro"), MarkEntry(5000)), + skip_zones = listOf(ZoneEntry(1000, 5000)), + ) + assertEquals(file, decode_marks(encode_marks(file))) + } + + @Test + fun other_json_is_rejected() { + assertNull(decode_marks("""{"format": "other", "title": "y"}""")) + assertNull(decode_marks("not json")) + } + + @Test + fun file_name_is_safe() { + assertEquals("AC_DC _ Back.sonodrill", marks_file_name("AC/DC | Back")) + assertEquals("marks.sonodrill", marks_file_name(" ")) + } + + @Test + fun cloud_key_depends_on_name_and_size() { + val key = cloud_key("audio/song.mp3", 1234) + assertTrue(key.length <= 64) + assertEquals(key, cloud_key("other/song.mp3", 1234)) + assertNotEquals(key, cloud_key("audio/song.mp3", 1235)) + assertNotEquals(key, cloud_key("audio/song-2.mp3", 1234)) + } +} diff --git a/composeApp/src/commonTest/kotlin/ubi2/sonodrill/SoundFilterTest.kt b/composeApp/src/commonTest/kotlin/ubi2/sonodrill/SoundFilterTest.kt @@ -0,0 +1,74 @@ +package ubi2.sonodrill + +import ubi2.sonodrill.audio.SoundFilter +import ubi2.sonodrill.model.ChannelMode +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.sin +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +private const val RATE = 44100.0 + +private fun tone(freq: Double, frames: Int) = FloatArray(frames) { (sin(2 * PI * freq * it / RATE) * 0.5).toFloat() } + +// Peak over the second half, once the filters have settled +private fun peak(a: FloatArray) = a.drop(a.size / 2).maxOf { abs(it) } + +class SoundFilterTest { + @Test + fun left_only_copies_left_to_both_sides() { + val f = SoundFilter(RATE).apply { mode = ChannelMode.LEFT } + val l = floatArrayOf(0.1f, 0.2f); val r = floatArrayOf(0.5f, 0.6f) + f.process(l, r, 2) + assertEquals(listOf(0.1f, 0.2f), r.toList()) + } + + @Test + fun no_center_removes_centered_voice() { + val f = SoundFilter(RATE).apply { mode = ChannelMode.NO_CENTER } + val n = 8820 + val l = tone(1000.0, n); val r = tone(1000.0, n) + f.process(l, r, n) + assertTrue(peak(l) < 0.05f && peak(r) < 0.05f, "voice peaks ${peak(l)} ${peak(r)}") + } + + @Test + fun no_center_keeps_sides() { + // A guitar on the left only comes out at half level on both sides + val f = SoundFilter(RATE).apply { mode = ChannelMode.NO_CENTER } + val n = 8820 + val l = tone(700.0, n); val r = FloatArray(n) + f.process(l, r, n) + assertTrue(peak(l) > 0.2f, "guitar peak ${peak(l)}") + } + + @Test + fun no_center_keeps_centered_bass() { + val f = SoundFilter(RATE).apply { mode = ChannelMode.NO_CENTER } + val n = 44100 + val l = tone(50.0, n); val r = tone(50.0, n) + f.process(l, r, n) + assertTrue(peak(l) > 0.35f, "bass should stay, peak ${peak(l)}") + } + + @Test + fun low_cut_removes_bass_and_keeps_treble() { + val bass = tone(60.0, 44100) + SoundFilter(RATE).apply { low_cut = true }.process(bass, null, bass.size) + assertTrue(peak(bass) < 0.1f, "60 Hz peak ${peak(bass)}") + val treble = tone(2000.0, 8820) + SoundFilter(RATE).apply { low_cut = true }.process(treble, null, treble.size) + assertTrue(peak(treble) > 0.45f, "2 kHz peak ${peak(treble)}") + } + + @Test + fun stereo_without_low_cut_is_untouched() { + val f = SoundFilter(RATE) + val l = floatArrayOf(0.1f, -0.3f); val r = floatArrayOf(0.2f, 0.4f) + f.process(l, r, 2) + assertEquals(listOf(0.1f, -0.3f), l.toList()) + assertEquals(listOf(0.2f, 0.4f), r.toList()) + } +} 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 @@ -14,14 +14,36 @@ import platform.Foundation.NSNumber import platform.Foundation.NSOperationQueue import platform.Foundation.NSURL import platform.MediaPlayer.* +import platform.darwin.dispatch_async +import platform.darwin.dispatch_get_main_queue import ubi2.sonodrill.data.resolve_audio_path +import ubi2.sonodrill.model.ChannelMode + +// Audio is read and scheduled in chunks, a few ahead, so that it can be filtered (SoundFilter) +private const val CHUNK_SECONDS = 0.25 +private const val CHUNKS_AHEAD = 4 class IosAudioPlayer : AudioPlayer { private var engine: AVAudioEngine? = null private var player_node: AVAudioPlayerNode? = null private var time_pitch: AVAudioUnitTimePitch? = null private var audio_file: AVAudioFile? = null + private var click_node: AVAudioPlayerNode? = null + private var click_buffers: Pair<AVAudioPCMBuffer, AVAudioPCMBuffer>? = null private var title: String = "" + private var pitch_semitones = 0 + private var channel_mode = ChannelMode.STEREO + private var low_cut = false + + // Chunked reading: next frame to read, chunks scheduled and not played yet, and a + // generation number so that completions of chunks dropped by a seek are ignored + private var filter: SoundFilter? = null + private var read_frame: Long = 0 + private var chunks_queued = 0 + private var generation = 0 + private var chunk_frames = 0 + private var scratch_left = FloatArray(0) + private var scratch_right = FloatArray(0) private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val _position = MutableStateFlow(0L) @@ -71,13 +93,23 @@ class IosAudioPlayer : AudioPlayer { val eng = AVAudioEngine() val node = AVAudioPlayerNode() val pitch = AVAudioUnitTimePitch() - eng.attachNode(node); eng.attachNode(pitch) + val clicks = AVAudioPlayerNode() + val click_format = AVAudioFormat(standardFormatWithSampleRate = format.sampleRate, channels = 1u) + eng.attachNode(node); eng.attachNode(pitch); eng.attachNode(clicks) eng.connect(node, to = pitch, format = format) eng.connect(pitch, to = eng.mainMixerNode, format = format) - engine = eng; player_node = node; time_pitch = pitch; pitch.rate = _speed.value + eng.connect(clicks, to = eng.mainMixerNode, format = click_format) + engine = eng; player_node = node; time_pitch = pitch; click_node = clicks + pitch.rate = _speed.value + pitch.pitch = pitch_semitones * 100f + click_buffers = make_click_buffer(click_format, accent = true) to make_click_buffer(click_format, accent = false) + chunk_frames = (format.sampleRate * CHUNK_SECONDS).toInt() + scratch_left = FloatArray(chunk_frames) + scratch_right = FloatArray(chunk_frames) + filter = SoundFilter(format.sampleRate).also { it.mode = channel_mode; it.low_cut = low_cut } eng.prepare() start_engine() - node.scheduleFile(file, atTime = null, completionHandler = null) + schedule_from(0) update_now_playing() } @@ -107,12 +139,9 @@ class IosAudioPlayer : AudioPlayer { val file = audio_file ?: return; val node = player_node ?: return val target = position_ms.coerceIn(0, _duration.value) val was_playing = _playing.value + generation++ node.stop() - val frame = (target.toDouble() / 1000.0 * file.processingFormat.sampleRate).toLong() - val remaining = file.length - frame - if (remaining > 0) { - node.scheduleSegment(file, startingFrame = frame, frameCount = remaining.toUInt(), atTime = null, completionHandler = null) - } + schedule_from((target.toDouble() / 1000.0 * file.processingFormat.sampleRate).toLong()) _position.value = target play_start_mark = kotlin.time.TimeSource.Monotonic.markNow() play_start_position_ms = target @@ -133,10 +162,98 @@ class IosAudioPlayer : AudioPlayer { update_now_playing() } + override fun set_pitch(semitones: Int) { + pitch_semitones = semitones + time_pitch?.pitch = semitones * 100f + } + + override fun set_sound(mode: ChannelMode, low_cut: Boolean) { + if (mode == channel_mode && low_cut == this.low_cut) return + channel_mode = mode; this.low_cut = low_cut + filter?.let { it.mode = mode; it.low_cut = low_cut } + // Chunks already scheduled were filtered the old way: schedule again from here + if (audio_file != null) { + update_current_position() + seek(_position.value) + } + } + + override fun click(accent: Boolean) { + val node = click_node ?: return + val buffer = click_buffers?.let { if (accent) it.first else it.second } ?: return + activate_session() + if (engine?.running != true && !start_engine()) return + node.scheduleBuffer(buffer, atTime = null, options = AVAudioPlayerNodeBufferInterrupts, completionHandler = null) + node.play() + } + + private fun make_click_buffer(format: AVAudioFormat, accent: Boolean): AVAudioPCMBuffer { + val samples = click_samples(format.sampleRate, accent) + val buffer = AVAudioPCMBuffer(pCMFormat = format, frameCapacity = samples.size.toUInt())!! + buffer.frameLength = samples.size.toUInt() + val data = buffer.floatChannelData!![0]!! + samples.forEachIndexed { i, v -> data[i] = v } + return buffer + } + + // Drops what was scheduled (the caller stopped the node) and reads on from frame + private fun schedule_from(frame: Long) { + read_frame = frame.coerceAtLeast(0) + chunks_queued = 0 + filter?.reset() + schedule_more() + } + + private fun schedule_more() { + val file = audio_file ?: return; val node = player_node ?: return + val format = file.processingFormat + while (chunks_queued < CHUNKS_AHEAD && read_frame < file.length) { + val count = minOf(chunk_frames.toLong(), file.length - read_frame).toInt() + val buffer = AVAudioPCMBuffer(pCMFormat = format, frameCapacity = count.toUInt()) ?: return + file.framePosition = read_frame + val ok = memScoped { + val err = alloc<ObjCObjectVar<NSError?>>() + file.readIntoBuffer(buffer, frameCount = count.toUInt(), error = err.ptr) + } + val read = buffer.frameLength.toInt() + if (!ok || read == 0) { + read_frame = file.length + return + } + read_frame += read + apply_filter(buffer, read, format.channelCount.toInt()) + chunks_queued++ + val gen = generation + node.scheduleBuffer(buffer) { + dispatch_async(dispatch_get_main_queue()) { + if (gen == generation) { + chunks_queued-- + schedule_more() + } + } + } + } + } + + private fun apply_filter(buffer: AVAudioPCMBuffer, frames: Int, channels: Int) { + val f = filter ?: return + if (!f.is_active) return + val data = buffer.floatChannelData ?: return + val left_ptr = data[0] ?: return + val right_ptr = if (channels >= 2) data[1] else null + for (i in 0 until frames) scratch_left[i] = left_ptr[i] + if (right_ptr != null) for (i in 0 until frames) scratch_right[i] = right_ptr[i] + f.process(scratch_left, if (right_ptr != null) scratch_right else null, frames) + for (i in 0 until frames) left_ptr[i] = scratch_left[i] + if (right_ptr != null) for (i in 0 until frames) right_ptr[i] = scratch_right[i] + } + override fun unload() { stop_position_updates() - player_node?.stop(); engine?.stop() + generation++ + player_node?.stop(); click_node?.stop(); engine?.stop() engine = null; player_node = null; time_pitch = null; audio_file = null + click_node = null; click_buffers = null; filter = null _playing.value = false _position.value = 0 _duration.value = 0 diff --git a/composeApp/src/iosMain/kotlin/ubi2/sonodrill/data/ios_audio_storage.kt b/composeApp/src/iosMain/kotlin/ubi2/sonodrill/data/ios_audio_storage.kt @@ -31,3 +31,5 @@ fun file_size(path: String): Long? { val attrs = NSFileManager.defaultManager.attributesOfItemAtPath(path, error = null) ?: return null return (attrs[NSFileSize] as? NSNumber)?.longLongValue } + +actual fun audio_file_size(uri: String): Long? = file_size(resolve_audio_path(uri)) diff --git a/composeApp/src/iosMain/kotlin/ubi2/sonodrill/data/ios_cloud_store.kt b/composeApp/src/iosMain/kotlin/ubi2/sonodrill/data/ios_cloud_store.kt @@ -0,0 +1,31 @@ +package ubi2.sonodrill.data + +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSOperationQueue +import platform.Foundation.NSUbiquitousKeyValueStore +import platform.Foundation.NSUbiquitousKeyValueStoreDidChangeExternallyNotification + +// iCloud key-value storage: needs the ubiquity-kvstore entitlement, and an iCloud +// account on the device (otherwise it only keeps values locally) +private class IosCloudStore : CloudStore { + private val store = NSUbiquitousKeyValueStore.defaultStore + private var observer: Any? = null + + init { store.synchronize() } + + override fun get(key: String): String? = store.stringForKey(key) + + override fun put(key: String, value: String) { + store.setString(value, forKey = key) + store.synchronize() + } + + override fun observe(on_change: () -> Unit) { + observer?.let { NSNotificationCenter.defaultCenter.removeObserver(it) } + observer = NSNotificationCenter.defaultCenter.addObserverForName( + NSUbiquitousKeyValueStoreDidChangeExternallyNotification, `object` = store, queue = NSOperationQueue.mainQueue, + ) { _ -> on_change() } + } +} + +actual fun create_cloud_store(): CloudStore? = IosCloudStore() diff --git a/composeApp/src/iosMain/kotlin/ubi2/sonodrill/main_ios.kt b/composeApp/src/iosMain/kotlin/ubi2/sonodrill/main_ios.kt @@ -4,6 +4,7 @@ package ubi2.sonodrill import androidx.compose.ui.window.ComposeUIViewController import platform.Foundation.* import platform.UIKit.* +import platform.UniformTypeIdentifiers.UTType import platform.UniformTypeIdentifiers.UTTypeAudio import platform.darwin.NSObject import ubi2.sonodrill.audio.IosAudioPlayer @@ -12,54 +13,98 @@ import ubi2.sonodrill.audio.waveform_provider import ubi2.sonodrill.data.AUDIO_DIR import ubi2.sonodrill.data.AudioRepository import ubi2.sonodrill.data.DriverFactory +import ubi2.sonodrill.data.MARKS_EXTENSION +import ubi2.sonodrill.data.create_cloud_store import ubi2.sonodrill.data.audio_dir_path import ubi2.sonodrill.data.choose_import_name import ubi2.sonodrill.data.create_database import ubi2.sonodrill.data.file_size +import ubi2.sonodrill.model.top_view_controller +import ubi2.sonodrill.ui.Incoming import ubi2.sonodrill.ui.SonodrillApp +import ubi2.sonodrill.ui.incoming_files +import ubi2.sonodrill.ui.receive_marks_text // prevent delegate from being garbage collected private var active_delegate: AudioPickerDelegate? = null +private var app_repo: AudioRepository? = null +// Opened before the app was ready +private val pending_urls = mutableListOf<NSURL>() + +private const val MARKS_TYPE = "ubi2.sonodrill.marks" + fun register_waveform_provider(provider: WaveformProvider) { waveform_provider = provider } fun main_view_controller(): UIViewController { val db = create_database(DriverFactory()) - val repo = AudioRepository(db) + val repo = AudioRepository(db, create_cloud_store()) + app_repo = repo + pending_urls.forEach { open_url(it) } + pending_urls.clear() val player = IosAudioPlayer() // Launch argument "-open-file <id>": open that file in the player (App Store screenshots) val args = NSProcessInfo.processInfo.arguments.map { it.toString() } val initial_file_id = args.indexOf("-open-file").takeIf { it >= 0 }?.let { args.getOrNull(it + 1) } + // "-show <panel>" (debug builds only): a player panel open too + @OptIn(kotlin.experimental.ExperimentalNativeApi::class) + val initial_panel = if (Platform.isDebugBinary) args.indexOf("-show").takeIf { it >= 0 }?.let { args.getOrNull(it + 1) } else null return ComposeUIViewController { SonodrillApp( player = player, repo = repo, - on_import = { on_done -> show_audio_picker(repo, on_done) }, + on_import = { on_done -> show_audio_picker(on_done) }, initial_file_id = initial_file_id, + initial_panel = initial_panel, ) } } -private fun top_view_controller(): UIViewController? { - val window = UIApplication.sharedApplication.connectedScenes - .mapNotNull { it as? UIWindowScene } - .flatMap { scene -> scene.windows.mapNotNull { it as? UIWindow } } - .firstOrNull { it.isKeyWindow() } - var vc = window?.rootViewController - while (vc?.presentedViewController != null) vc = vc.presentedViewController - return vc +// A file given by another app (Swift's onOpenURL) or picked: audio or shared marks +fun open_url(url: NSURL) { + val repo = app_repo ?: run { pending_urls += url; return } + val accessing = url.startAccessingSecurityScopedResource() + try { + val name = url.lastPathComponent ?: return + if (name.endsWith(".$MARKS_EXTENSION", ignoreCase = true)) { + receive_marks_text(NSString.stringWithContentsOfURL(url, encoding = NSUTF8StringEncoding, error = null)) + } else { + val uri = import_audio(url) ?: return + incoming_files.trySend(Incoming.Audio(repo.register_file(uri))) + } + } finally { + if (accessing) url.stopAccessingSecurityScopedResource() + // Files opened from other apps are copied by the system into Documents/Inbox + if (url.path?.contains("/Documents/Inbox/") == true) NSFileManager.defaultManager.removeItemAtURL(url, error = null) + } } -private fun show_audio_picker(repo: AudioRepository, on_done: () -> Unit) { +// Copies an audio file into the app's storage; returns its uri (see audio_storage.kt) +private fun import_audio(url: NSURL): String? { + val file_manager = NSFileManager.defaultManager + val audio_dir = audio_dir_path() + file_manager.createDirectoryAtPath(audio_dir, withIntermediateDirectories = true, attributes = null, error = null) + val name = url.lastPathComponent ?: return null + val size = url.path?.let { file_size(it) } ?: -1 + val (target, already_stored) = choose_import_name(name, size) { file_size("$audio_dir/$it") } + if (!already_stored && !file_manager.copyItemAtURL(url, toURL = NSURL.fileURLWithPath("$audio_dir/$target"), error = null)) { + println("Sonodrill: failed to import $name") + return null + } + return "$AUDIO_DIR/$target" +} + +private fun show_audio_picker(on_done: () -> Unit) { val root = top_view_controller() ?: return - val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeAudio)) + val types = listOfNotNull(UTTypeAudio, UTType.typeWithIdentifier(MARKS_TYPE)) + val picker = UIDocumentPickerViewController(forOpeningContentTypes = types) picker.allowsMultipleSelection = true - val delegate = AudioPickerDelegate(repo, on_done) + val delegate = AudioPickerDelegate(on_done) active_delegate = delegate picker.delegate = delegate @@ -67,32 +112,22 @@ private fun show_audio_picker(repo: AudioRepository, on_done: () -> Unit) { } internal class AudioPickerDelegate( - private val repo: AudioRepository, private val on_done: () -> Unit, ) : NSObject(), UIDocumentPickerDelegateProtocol { override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) { - val file_manager = NSFileManager.defaultManager - val audio_dir = audio_dir_path() - file_manager.createDirectoryAtPath(audio_dir, withIntermediateDirectories = true, attributes = null, error = null) - - for (url in didPickDocumentsAtURLs) { - val nsurl = url as? NSURL ?: continue - val accessing = nsurl.startAccessingSecurityScopedResource() + val repo = app_repo + for (url in didPickDocumentsAtURLs.mapNotNull { it as? NSURL }) { + // Shared marks go through open_url (the user picks their track); audio is just added + if (url.lastPathComponent?.endsWith(".$MARKS_EXTENSION", ignoreCase = true) == true) { + open_url(url) + continue + } + val accessing = url.startAccessingSecurityScopedResource() try { - val name = nsurl.lastPathComponent ?: continue - val size = nsurl.path?.let { file_size(it) } ?: -1 - val (target, already_stored) = choose_import_name(name, size) { file_size("$audio_dir/$it") } - if (!already_stored) { - val copied = file_manager.copyItemAtURL(nsurl, toURL = NSURL.fileURLWithPath("$audio_dir/$target"), error = null) - if (!copied) { - println("Sonodrill: failed to import $name") - continue - } - } - repo.register_file("$AUDIO_DIR/$target") + import_audio(url)?.let { repo?.register_file(it) } } finally { - if (accessing) nsurl.stopAccessingSecurityScopedResource() + if (accessing) url.stopAccessingSecurityScopedResource() } } active_delegate = null diff --git a/composeApp/src/iosMain/kotlin/ubi2/sonodrill/model/ios_platform.kt b/composeApp/src/iosMain/kotlin/ubi2/sonodrill/model/ios_platform.kt @@ -1,5 +1,21 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class, kotlinx.cinterop.BetaInteropApi::class) package ubi2.sonodrill.model +import kotlinx.cinterop.useContents +import platform.CoreGraphics.CGRectMake +import platform.Foundation.NSLocale +import platform.Foundation.NSString +import platform.Foundation.preferredLanguages +import platform.Foundation.NSTemporaryDirectory +import platform.Foundation.NSURL +import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.create +import platform.Foundation.writeToFile +import platform.UIKit.UIActivityViewController +import platform.UIKit.UIViewController +import platform.UIKit.UIWindow +import platform.UIKit.UIWindowScene +import platform.UIKit.popoverPresentationController import platform.Foundation.NSDate import platform.Foundation.NSUUID import platform.Foundation.timeIntervalSince1970 @@ -11,3 +27,31 @@ actual fun generate_uuid(): String = NSUUID().UUIDString() actual fun set_idle_timer_disabled(disabled: Boolean) { UIApplication.sharedApplication.idleTimerDisabled = disabled } + +actual fun preferred_language(): String = + (NSLocale.preferredLanguages.firstOrNull() as? String)?.substringBefore('-') ?: "en" + +fun top_view_controller(): UIViewController? { + val window = UIApplication.sharedApplication.connectedScenes + .mapNotNull { it as? UIWindowScene } + .flatMap { scene -> scene.windows.mapNotNull { it as? UIWindow } } + .firstOrNull { it.isKeyWindow() } + var vc = window?.rootViewController + while (vc?.presentedViewController != null) vc = vc.presentedViewController + return vc +} + +actual fun share_text_file(file_name: String, text: String) { + val root = top_view_controller() ?: return + val path = NSTemporaryDirectory() + file_name + val written = NSString.create(string = text).writeToFile(path, atomically = true, encoding = NSUTF8StringEncoding, error = null) + if (!written) return + val sheet = UIActivityViewController(activityItems = listOf(NSURL.fileURLWithPath(path)), applicationActivities = null) + // iPad: the sheet is a popover, anchored at the middle of the screen + sheet.popoverPresentationController?.let { popover -> + popover.sourceView = root.view + root.view.bounds.useContents { popover.sourceRect = CGRectMake(size.width / 2, size.height / 2, 0.0, 0.0) } + popover.permittedArrowDirections = 0u + } + root.presentViewController(sheet, animated = true, completion = null) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml @@ -6,6 +6,7 @@ sqldelight = "2.0.2" exoplayer = "1.5.0" coroutines = "1.9.0" activity-compose = "1.9.3" +serialization = "1.7.3" [libraries] kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } @@ -14,11 +15,14 @@ kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutine sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" } sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } sqldelight-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } +sqldelight-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } sqldelight-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" } exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "exoplayer" } activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } @@ -26,4 +30,5 @@ androidLibrary = { id = "com.android.library", version.ref = "agp" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } diff --git a/iosApp/ContentView.swift b/iosApp/ContentView.swift @@ -5,6 +5,8 @@ struct ContentView: View { var body: some View { ComposeViewControllerRepresentable() .ignoresSafeArea() + // Audio or shared marks given by another app + .onOpenURL { url in Main_iosKt.open_url(url: url) } } } diff --git a/iosApp/project.yml b/iosApp/project.yml @@ -21,10 +21,38 @@ targets: - "ExportOptions.plist" - "*.mobileprovision" - "*.xcodeproj" + - "iosApp.entitlements" info: path: Info.plist properties: - CFBundleShortVersionString: "1.1" + CFBundleShortVersionString: "1.2" + CFBundleDevelopmentRegion: en + CFBundleLocalizations: + - en + - fr + LSSupportsOpeningDocumentsInPlace: false + # Audio from other apps ("Open in", share sheet) and shared marks + CFBundleDocumentTypes: + - CFBundleTypeName: Audio + CFBundleTypeRole: Viewer + LSHandlerRank: Alternate + LSItemContentTypes: + - public.audio + - CFBundleTypeName: Sonodrill marks + CFBundleTypeRole: Editor + LSHandlerRank: Owner + LSItemContentTypes: + - ubi2.sonodrill.marks + UTExportedTypeDeclarations: + - UTTypeIdentifier: ubi2.sonodrill.marks + UTTypeDescription: Sonodrill marks + UTTypeConformsTo: + - public.json + UTTypeTagSpecification: + public.filename-extension: + - sonodrill + public.mime-type: + - application/vnd.sonodrill+json ITSAppUsesNonExemptEncryption: false LSRequiresIPhoneOS: true CADisableMinimumFrameDuration: true @@ -39,6 +67,11 @@ targets: - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight + # iCloud key-value storage: marks synced between devices + entitlements: + path: iosApp.entitlements + properties: + com.apple.developer.ubiquity-kvstore-identifier: "$(TeamIdentifierPrefix)$(CFBundleIdentifier)" settings: base: PRODUCT_BUNDLE_IDENTIFIER: ubi2.sonodrill