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 ebd7c8b9e33ca96f47ad30c8f58cfab44e3e6b9d
parent 636d1dbfcf4c4016720b27e5a17e055a98648f1a
Author: Jean Schurger <jean@schurger.org>
Date:   Wed,  1 Apr 2026 09:01:57 -0400

Add waveform display via Swift-Kotlin bridge

AVAudioPCMBuffer float pointer APIs aren't exposed in Kotlin/Native
cinterop, so waveform extraction is done in Swift (WaveformExtractor)
and passed to Kotlin via a WaveformProvider interface.

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

Diffstat:
McomposeApp/src/iosMain/kotlin/ubi2/sonodrill/audio/ios_audio_player.kt | 6+++---
AcomposeApp/src/iosMain/kotlin/ubi2/sonodrill/audio/waveform_provider.kt | 8++++++++
McomposeApp/src/iosMain/kotlin/ubi2/sonodrill/main_ios.kt | 6++++++
MiosApp/ContentView.swift | 3++-
AiosApp/WaveformExtractor.swift | 36++++++++++++++++++++++++++++++++++++
MiosApp/iosApp.xcodeproj/project.pbxproj | 21++++++++++++++-------
6 files changed, 69 insertions(+), 11 deletions(-)

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 @@ -103,7 +103,7 @@ class IosAudioPlayer : AudioPlayer { actual fun create_audio_player(): AudioPlayer = IosAudioPlayer() actual fun extract_waveform(uri: String, num_samples: Int): FloatArray { - // AVAudioPCMBuffer float pointer APIs not available in Kotlin/Native cinterop. - // Waveform will use fallback progress display. - return FloatArray(0) + val provider = waveform_provider ?: return FloatArray(0) + val list = provider.extractWaveform(uri, num_samples) + return FloatArray(list.size) { (list[it] as? Number)?.toFloat() ?: 0f } } diff --git a/composeApp/src/iosMain/kotlin/ubi2/sonodrill/audio/waveform_provider.kt b/composeApp/src/iosMain/kotlin/ubi2/sonodrill/audio/waveform_provider.kt @@ -0,0 +1,8 @@ +package ubi2.sonodrill.audio + +// ObjC-compatible interface that Swift can implement +interface WaveformProvider { + fun extractWaveform(path: String, numSamples: Int): List<*> +} + +var waveform_provider: WaveformProvider? = null diff --git a/composeApp/src/iosMain/kotlin/ubi2/sonodrill/main_ios.kt b/composeApp/src/iosMain/kotlin/ubi2/sonodrill/main_ios.kt @@ -6,7 +6,9 @@ import platform.Foundation.* import platform.UIKit.* import platform.UniformTypeIdentifiers.UTTypeAudio import platform.darwin.NSObject +import ubi2.sonodrill.audio.WaveformProvider import ubi2.sonodrill.audio.create_audio_player +import ubi2.sonodrill.audio.waveform_provider import ubi2.sonodrill.data.AudioRepository import ubi2.sonodrill.data.DriverFactory import ubi2.sonodrill.data.create_database @@ -18,6 +20,10 @@ import ubi2.sonodrill.ui.SonodrillApp // prevent delegate from being garbage collected private var active_delegate: AudioPickerDelegate? = null +fun register_waveform_provider(provider: WaveformProvider) { + waveform_provider = provider +} + fun main_view_controller(): UIViewController { val db = create_database(DriverFactory()) val repo = AudioRepository(db) diff --git a/iosApp/ContentView.swift b/iosApp/ContentView.swift @@ -10,7 +10,8 @@ struct ContentView: View { struct ComposeViewControllerRepresentable: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UIViewController { - Main_iosKt.main_view_controller() + Main_iosKt.register_waveform_provider(provider: SwiftWaveformProvider()) + return Main_iosKt.main_view_controller() } func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} } diff --git a/iosApp/WaveformExtractor.swift b/iosApp/WaveformExtractor.swift @@ -0,0 +1,36 @@ +import AVFoundation +import ComposeApp + +class SwiftWaveformProvider: WaveformProvider { + func extractWaveform(path: String, numSamples: Int32) -> [Any] { + let url = URL(fileURLWithPath: path) + guard let file = try? AVAudioFile(forReading: url) else { return [] } + let totalFrames = file.length + guard totalFrames > 0 else { return [] } + let format = file.processingFormat + let framesPerSample = max(AVAudioFrameCount(totalFrames / Int64(numSamples)), 1) + guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: framesPerSample) else { return [] } + + var result: [NSNumber] = [] + result.reserveCapacity(Int(numSamples)) + + for i in 0..<Int(numSamples) { + file.framePosition = Int64(i) * Int64(framesPerSample) + do { + try file.read(into: buffer) + } catch { + break + } + guard let channelData = buffer.floatChannelData?[0] else { break } + let count = Int(buffer.frameLength) + guard count > 0 else { break } + var maxVal: Float = 0 + for j in 0..<count { + let v = abs(channelData[j]) + if v > maxVal { maxVal = v } + } + result.append(NSNumber(value: maxVal)) + } + return result + } +} diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 22EA1CB33A17DCC6FC82406D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F543015C899423D4B01CEB2E /* Assets.xcassets */; }; 2E8A151C3EFF8D2645EFFBFA /* SonodrillApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E74C38E65621F5192E1B68D2 /* SonodrillApp.swift */; }; + 47808750E34BFBC08FE160DA /* WaveformExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BCF6AAC471512DFD0FA06EE /* WaveformExtractor.swift */; }; 56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */; }; 5ADAC7381145BCE241DC42A4 /* iosApp.xcodeproj in Resources */ = {isa = PBXBuildFile; fileRef = 8395CD5B070D5A2FB485F0DA /* iosApp.xcodeproj */; }; F7C33B50A94DA01F2E82C280 /* libsqlite3.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 245434685F34A9A15B1AC406 /* libsqlite3.tbd */; }; @@ -16,8 +17,9 @@ /* Begin PBXFileReference section */ 245434685F34A9A15B1AC406 /* libsqlite3.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libsqlite3.tbd; path = usr/lib/libsqlite3.tbd; sourceTree = SDKROOT; }; + 2BCF6AAC471512DFD0FA06EE /* WaveformExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformExtractor.swift; sourceTree = "<group>"; }; 8395CD5B070D5A2FB485F0DA /* iosApp.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; path = iosApp.xcodeproj; sourceTree = "<group>"; }; - 86038AE1032E284E79B32AD6 /* Sonodrill.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sonodrill.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 86038AE1032E284E79B32AD6 /* iosApp.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; name = iosApp.app; path = Sonodrill.app; sourceTree = BUILT_PRODUCTS_DIR; }; CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; E74C38E65621F5192E1B68D2 /* SonodrillApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SonodrillApp.swift; sourceTree = "<group>"; }; F543015C899423D4B01CEB2E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; @@ -42,6 +44,7 @@ CE8509E5A49E4A5CAAEB3ADB /* ContentView.swift */, 8395CD5B070D5A2FB485F0DA /* iosApp.xcodeproj */, E74C38E65621F5192E1B68D2 /* SonodrillApp.swift */, + 2BCF6AAC471512DFD0FA06EE /* WaveformExtractor.swift */, ); name = iosApp; path = .; @@ -64,7 +67,7 @@ ); sourceTree = "<group>"; }; - 5FF97F622F7C9DF20050BA4C /* Products */ = { + 5FF97F652F7D4E980050BA4C /* Products */ = { isa = PBXGroup; name = Products; sourceTree = "<group>"; @@ -72,7 +75,7 @@ 94C98B8B3A2BFC1966447269 /* Products */ = { isa = PBXGroup; children = ( - 86038AE1032E284E79B32AD6 /* Sonodrill.app */, + 86038AE1032E284E79B32AD6 /* iosApp.app */, ); name = Products; sourceTree = "<group>"; @@ -97,7 +100,7 @@ packageProductDependencies = ( ); productName = iosApp; - productReference = 86038AE1032E284E79B32AD6 /* Sonodrill.app */; + productReference = 86038AE1032E284E79B32AD6 /* iosApp.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -108,6 +111,11 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1430; + TargetAttributes = { + 78B53CC79860CBFC2F5543C6 = { + DevelopmentTeam = ""; + }; + }; }; buildConfigurationList = FF7CA1CA754D017066F1822F /* Build configuration list for PBXProject "iosApp" */; compatibilityVersion = "Xcode 14.0"; @@ -122,7 +130,7 @@ projectDirPath = ""; projectReferences = ( { - ProductGroup = 5FF97F622F7C9DF20050BA4C /* Products */; + ProductGroup = 5FF97F652F7D4E980050BA4C /* Products */; ProjectRef = 8395CD5B070D5A2FB485F0DA /* iosApp.xcodeproj */; }, ); @@ -174,6 +182,7 @@ files = ( 56B0305B5D3847FC6B326502 /* ContentView.swift in Sources */, 2E8A151C3EFF8D2645EFFBFA /* SonodrillApp.swift in Sources */, + 47808750E34BFBC08FE160DA /* WaveformExtractor.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -306,7 +315,6 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = 8CH9CCT4MH; FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; INFOPLIST_FILE = Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -326,7 +334,6 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = 8CH9CCT4MH; FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; INFOPLIST_FILE = Info.plist; LD_RUNPATH_SEARCH_PATHS = (