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:
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 = (