diff --git a/Example/DSWaveformImageExample-iOS/SwiftUIExample/SwiftUIExampleView.swift b/Example/DSWaveformImageExample-iOS/SwiftUIExample/SwiftUIExampleView.swift index 98770d1..1e60f58 100644 --- a/Example/DSWaveformImageExample-iOS/SwiftUIExample/SwiftUIExampleView.swift +++ b/Example/DSWaveformImageExample-iOS/SwiftUIExample/SwiftUIExampleView.swift @@ -4,6 +4,10 @@ import SwiftUI @available(iOS 14.0, *) struct SwiftUIExampleView: View { + private enum ActiveTab: Hashable { + case recorder, shape, overview + } + private static let colors = [UIColor.systemPink, UIColor.systemBlue, UIColor.systemGreen] private static var randomColor: UIColor { colors.randomElement()! } @@ -11,23 +15,23 @@ struct SwiftUIExampleView: View { Bundle.main.url(forResource: "example_sound", withExtension: "m4a"), Bundle.main.url(forResource: "example_sound_2", withExtension: "m4a") ] - private static var randomURL: URL? { audioURLs.randomElement()! } + private static func randomURL(_ current: URL?) -> URL? { audioURLs.filter { $0 != current }.randomElement()! } @StateObject private var audioRecorder: AudioRecorder = AudioRecorder() - @State private var audioURL: URL? = Self.randomURL - - @State var configuration: Waveform.Configuration = Waveform.Configuration( - style: .outlined(.blue, 3), - verticalScalingFactor: 0.5 + @State private var configuration: Waveform.Configuration = Waveform.Configuration( + style: .striped(Waveform.Style.StripeConfig(color: Self.randomColor)), + verticalScalingFactor: 0.9 ) - @State var liveConfiguration: Waveform.Configuration = Waveform.Configuration( + @State private var liveConfiguration: Waveform.Configuration = Waveform.Configuration( style: .striped(.init(color: randomColor, width: 3, spacing: 3)) ) - @State var silence: Bool = true - @State var selection: Bool = true + @State private var audioURL: URL? = audioURLs.first! + @State private var samples: [Float] = [] + @State private var silence: Bool = true + @State private var selection: ActiveTab = .overview var body: some View { VStack { @@ -36,16 +40,17 @@ struct SwiftUIExampleView: View { if #available(iOS 15.0, *) { Picker("Hey", selection: $selection) { - Text("Recorder Example").tag(true) - Text("Overview").tag(false) + Text("Recorder").tag(ActiveTab.recorder) + Text("Shape").tag(ActiveTab.shape) + Text("Overview").tag(ActiveTab.overview) } .pickerStyle(.segmented) .padding(.horizontal) - if selection { - recordingExample - } else { - overview + switch selection { + case .recorder: recordingExample + case .shape: shape + case .overview: overview } } else { Text("WaveformView & WaveformLiveCanvas require iOS 15.0") @@ -57,58 +62,97 @@ struct SwiftUIExampleView: View { @available(iOS 15.0, *) @ViewBuilder private var recordingExample: some View { - HStack { - Button { - configuration = configuration.with(style: .filled(Self.randomColor)) - liveConfiguration = liveConfiguration.with(style: .striped(.init(color: Self.randomColor, width: 3, spacing: 3))) - } label: { - Label("color", systemImage: "arrow.triangle.2.circlepath") - .frame(maxWidth: .infinity) - } - .font(.body.bold()) - .padding() - .background(Color(.systemGray6)) - .cornerRadius(10) - - Button { - audioURL = Self.randomURL - print("will draw \(audioURL!)") - } label: { - Label("waveform", systemImage: "arrow.triangle.2.circlepath") - .frame(maxWidth: .infinity) - } - .font(.body.bold()) - .padding() - .background(Color(.systemGray6)) - .cornerRadius(10) - } - .padding() - - if let audioURL { - WaveformView( - audioURL: audioURL, - configuration: configuration, - renderer: CircularWaveformRenderer(kind: .ring(0.7)) - ) - } - VStack { - Toggle("draw silence", isOn: $silence).padding() - WaveformLiveCanvas( samples: audioRecorder.samples, configuration: liveConfiguration, renderer: CircularWaveformRenderer(kind: .circle), shouldDrawSilencePadding: silence ) + + Toggle("draw silence", isOn: $silence) + .controlSize(.mini) + .padding(.horizontal) + + RecordingIndicatorView( + samples: audioRecorder.samples, + duration: audioRecorder.recordingTime, + isRecording: $audioRecorder.isRecording + ) + .padding(.horizontal) } + } + + @available(iOS 15.0, *) + @ViewBuilder + private var shape: some View { + VStack { + Text("WaveformView").font(.monospaced(.title.bold())()) - RecordingIndicatorView( - samples: audioRecorder.samples, - duration: audioRecorder.recordingTime, - isRecording: $audioRecorder.isRecording - ) - .padding() + HStack { + Button { + configuration = configuration.with(style: .striped(Waveform.Style.StripeConfig(color: Self.randomColor))) + liveConfiguration = liveConfiguration.with(style: .striped(.init(color: Self.randomColor, width: 3, spacing: 3))) + } label: { + Label("color", systemImage: "dice") + .frame(maxWidth: .infinity) + } + .font(.body.bold()) + .padding(8) + .background(Color(.systemGray6)) + .cornerRadius(10) + + Button { + audioURL = Self.randomURL(audioURL) + print("will draw \(audioURL!)") + } label: { + Label("waveform", systemImage: "dice") + .frame(maxWidth: .infinity) + } + .font(.body.bold()) + .padding(8) + .background(Color(.systemGray6)) + .cornerRadius(10) + } + .padding(.horizontal) + + // the if let is left here intentionally to illustrate how to deal with optional URLs + // as this was asked in an older GitHub issue + if let audioURL { + WaveformView(audioURL: audioURL, configuration: configuration) + + WaveformView( + audioURL: audioURL, + configuration: configuration, + renderer: CircularWaveformRenderer(kind: .ring(0.7)) + ) { shape in + // you may completely override the shape styling this way + shape + .stroke(LinearGradient(colors: [.red, Color(Self.randomColor)], startPoint: .zero, endPoint: .topTrailing), lineWidth: 3) + } + + Divider() + Text("WaveformShape").font(.monospaced(.title.bold())()) + + /// **Note:** It's possible, but discouraged to use WaveformShape directly. + /// As Shapes should not do any expensive computations, the analyzing should happen outside, + /// hence making the API a tiny bit clumsy if used directly, since we do require to know the size, + /// even though the Shape of course intrinsically knows its size already. + GeometryReader { geometry in + WaveformShape(samples: samples) + .fill(Color.orange) + .task { + do { + let samplesNeeded = Int(geometry.size.width * configuration.scale) + let samples = try await WaveformAnalyzer(audioAssetURL: audioURL)!.samples(count: samplesNeeded) + await MainActor.run { self.samples = samples } + } catch { + assertionFailure(error.localizedDescription) + } + } + } + } + } } @ViewBuilder @@ -117,26 +161,41 @@ struct SwiftUIExampleView: View { HStack { VStack { WaveformView(audioURL: audioURL, configuration: .init(style: .filled(.red))) - WaveformView(audioURL: audioURL, configuration: .init(style: .outlined(.black, 0.5))) + WaveformView(audioURL: audioURL, configuration: .init(style: .outlined(.blue, 0.5))) WaveformView(audioURL: audioURL, configuration: .init(style: .gradient([.yellow, .orange]))) WaveformView(audioURL: audioURL, configuration: .init(style: .gradientOutlined([.yellow, .orange], 1))) WaveformView(audioURL: audioURL, configuration: .init(style: .striped(.init(color: .red, width: 2, spacing: 1)))) + + WaveformView(audioURL: audioURL, configuration: .init(style: .striped(.init(color: .black)))) { shape in + shape // override the shape styling + .stroke(LinearGradient(colors: [.blue, .pink], startPoint: .bottom, endPoint: .top), lineWidth: 3) + } } VStack { WaveformView(audioURL: audioURL, configuration: .init(style: .filled(.red)), renderer: CircularWaveformRenderer()) - WaveformView(audioURL: audioURL, configuration: .init(style: .outlined(.black, 0.5)), renderer: CircularWaveformRenderer()) + WaveformView(audioURL: audioURL, configuration: .init(style: .outlined(.blue, 0.5)), renderer: CircularWaveformRenderer()) WaveformView(audioURL: audioURL, configuration: .init(style: .gradient([.yellow, .orange])), renderer: CircularWaveformRenderer()) WaveformView(audioURL: audioURL, configuration: .init(style: .gradientOutlined([.yellow, .orange], 1)), renderer: CircularWaveformRenderer()) WaveformView(audioURL: audioURL, configuration: .init(style: .striped(.init(color: .red, width: 2, spacing: 2))), renderer: CircularWaveformRenderer()) + + WaveformView(audioURL: audioURL, configuration: .init(style: .striped(.init(color: .black))), renderer: CircularWaveformRenderer()) { shape in + shape // override the shape styling + .stroke(LinearGradient(colors: [.blue, .pink], startPoint: .bottom, endPoint: .top), lineWidth: 3) + } } VStack { WaveformView(audioURL: audioURL, configuration: .init(style: .filled(.red)), renderer: CircularWaveformRenderer(kind: .ring(0.5))) - WaveformView(audioURL: audioURL, configuration: .init(style: .outlined(.black, 0.5)), renderer: CircularWaveformRenderer(kind: .ring(0.5))) + WaveformView(audioURL: audioURL, configuration: .init(style: .outlined(.blue, 0.5)), renderer: CircularWaveformRenderer(kind: .ring(0.5))) WaveformView(audioURL: audioURL, configuration: .init(style: .gradient([.yellow, .orange])), renderer: CircularWaveformRenderer(kind: .ring(0.5))) WaveformView(audioURL: audioURL, configuration: .init(style: .gradientOutlined([.yellow, .orange], 1)), renderer: CircularWaveformRenderer(kind: .ring(0.5))) WaveformView(audioURL: audioURL, configuration: .init(style: .striped(.init(color: .red, width: 2, spacing: 2))), renderer: CircularWaveformRenderer(kind: .ring(0.5))) + + WaveformView(audioURL: audioURL, configuration: .init(style: .striped(.init(color: .black))), renderer: CircularWaveformRenderer(kind: .ring(0.5))) { shape in + shape // override the shape styling + .stroke(LinearGradient(colors: [.blue, .pink], startPoint: .bottom, endPoint: .top), lineWidth: 3) + } } } } @@ -144,7 +203,7 @@ struct SwiftUIExampleView: View { } @available(iOS 15.0, *) -struct LiveRecordingView_Previews: PreviewProvider { +struct SwiftUIExampleView_Previews: PreviewProvider { static var previews: some View { SwiftUIExampleView() } diff --git a/Sources/DSWaveformImage/Renderers/CircularWaveformRenderer.swift b/Sources/DSWaveformImage/Renderers/CircularWaveformRenderer.swift index b734fba..12e86ef 100644 --- a/Sources/DSWaveformImage/Renderers/CircularWaveformRenderer.swift +++ b/Sources/DSWaveformImage/Renderers/CircularWaveformRenderer.swift @@ -10,7 +10,7 @@ import CoreGraphics */ public struct CircularWaveformRenderer: WaveformRenderer { - public enum Kind { + public enum Kind: Sendable { /// Draws waveform as a circular amplitude envelope. case circle @@ -25,11 +25,16 @@ public struct CircularWaveformRenderer: WaveformRenderer { self.kind = kind } - public func render(samples: [Float], on context: CGContext, with configuration: Waveform.Configuration, lastOffset: Int) { + public func path(samples: [Float], with configuration: Waveform.Configuration, lastOffset: Int) -> CGPath { switch kind { - case .circle: drawCircle(samples: samples, on: context, with: configuration, lastOffset: lastOffset) - case .ring: drawRing(samples: samples, on: context, with: configuration, lastOffset: lastOffset) + case .circle: return circlePath(samples: samples, with: configuration, lastOffset: lastOffset) + case .ring: return ringPath(samples: samples, with: configuration, lastOffset: lastOffset) } + } + + public func render(samples: [Float], on context: CGContext, with configuration: Waveform.Configuration, lastOffset: Int) { + let path = path(samples: samples, with: configuration, lastOffset: lastOffset) + context.addPath(path) style(context: context, with: configuration) } @@ -49,7 +54,7 @@ public struct CircularWaveformRenderer: WaveformRenderer { } } - private func drawCircle(samples: [Float], on context: CGContext, with configuration: Waveform.Configuration, lastOffset: Int) { + private func circlePath(samples: [Float], with configuration: Waveform.Configuration, lastOffset: Int) -> CGPath { let graphRect = CGRect(origin: .zero, size: configuration.size) let maxRadius = CGFloat(min(graphRect.maxX, graphRect.maxY) / 2.0) * configuration.verticalScalingFactor let center = CGPoint(x: graphRect.maxX * 0.5, y: graphRect.maxY * 0.5) @@ -78,12 +83,12 @@ public struct CircularWaveformRenderer: WaveformRenderer { } path.closeSubpath() - context.addPath(path) + return path } - private func drawRing(samples: [Float], on context: CGContext, with configuration: Waveform.Configuration, lastOffset: Int) { + private func ringPath(samples: [Float], with configuration: Waveform.Configuration, lastOffset: Int) -> CGPath { guard case let .ring(config) = kind else { - return + fatalError("called with wrong kind") } let graphRect = CGRect(origin: .zero, size: configuration.size) @@ -121,7 +126,7 @@ public struct CircularWaveformRenderer: WaveformRenderer { } path.closeSubpath() - context.addPath(path) + return path } private func stripeBucket(_ configuration: Waveform.Configuration) -> Int { diff --git a/Sources/DSWaveformImage/Renderers/LinearWaveformRenderer.swift b/Sources/DSWaveformImage/Renderers/LinearWaveformRenderer.swift index 06af47d..7cc9a51 100644 --- a/Sources/DSWaveformImage/Renderers/LinearWaveformRenderer.swift +++ b/Sources/DSWaveformImage/Renderers/LinearWaveformRenderer.swift @@ -9,7 +9,7 @@ import CoreGraphics public struct LinearWaveformRenderer: WaveformRenderer { public init() {} - public func render(samples: [Float], on context: CGContext, with configuration: Waveform.Configuration, lastOffset: Int) { + public func path(samples: [Float], with configuration: Waveform.Configuration, lastOffset: Int) -> CGPath { let graphRect = CGRect(origin: CGPoint.zero, size: configuration.size) let positionAdjustedGraphCenter = 0.5 * graphRect.size.height var path = CGMutablePath() @@ -17,15 +17,18 @@ public struct LinearWaveformRenderer: WaveformRenderer { path.move(to: CGPoint(x: 0, y: positionAdjustedGraphCenter)) if case .striped = configuration.style { - path = draw(samples: samples, on: context, path: path, with: configuration, lastOffset: lastOffset, sides: .both) + path = draw(samples: samples, path: path, with: configuration, lastOffset: lastOffset, sides: .both) } else { - path = draw(samples: samples, on: context, path: path, with: configuration, lastOffset: lastOffset, sides: .up) - path = draw(samples: samples.reversed(), on: context, path: path, with: configuration, lastOffset: lastOffset, sides: .down) + path = draw(samples: samples, path: path, with: configuration, lastOffset: lastOffset, sides: .up) + path = draw(samples: samples.reversed(), path: path, with: configuration, lastOffset: lastOffset, sides: .down) } path.closeSubpath() - context.addPath(path) + return path + } + public func render(samples: [Float], on context: CGContext, with configuration: Waveform.Configuration, lastOffset: Int) { + context.addPath(path(samples: samples, with: configuration, lastOffset: lastOffset)) defaultStyle(context: context, with: configuration) } @@ -41,7 +44,7 @@ public struct LinearWaveformRenderer: WaveformRenderer { case up, down, both } - private func draw(samples: [Float], on context: CGContext, path: CGMutablePath, with configuration: Waveform.Configuration, lastOffset: Int, sides: Sides) -> CGMutablePath { + private func draw(samples: [Float], path: CGMutablePath, with configuration: Waveform.Configuration, lastOffset: Int, sides: Sides) -> CGMutablePath { let graphRect = CGRect(origin: CGPoint.zero, size: configuration.size) let positionAdjustedGraphCenter = 0.5 * graphRect.size.height let drawMappingFactor = 0.5 * graphRect.size.height * configuration.verticalScalingFactor // we always draw in the center now diff --git a/Sources/DSWaveformImage/WaveformImageTypes.swift b/Sources/DSWaveformImage/WaveformImageTypes.swift index f4f52c7..37bbdab 100644 --- a/Sources/DSWaveformImage/WaveformImageTypes.swift +++ b/Sources/DSWaveformImage/WaveformImageTypes.swift @@ -24,9 +24,21 @@ import AVFoundation Default implementations are `LinearWaveformRenderer` and `CircularWaveformRenderer`. Check out those if you'd like to implement your own custom renderer. */ -public protocol WaveformRenderer { +public protocol WaveformRenderer: Sendable { + + /** + Calculates a CGPath from the waveform samples. + + - Parameters: + - samples: `[Float]` of the amplitude envelope to be drawn, normalized to interval `(0...1)`. `0` is maximum (typically `0dB`). + `1` is the noise floor, typically `-50dB`, as defined in `WaveformAnalyzer.noiseFloorDecibelCutoff`. + - lastOffset: You can typtically leave this `0`. **Required for live rendering**, where it is needed to keep track of the last drawing cycle. Setting it avoids 'flickering' as samples are being added + continuously and the waveform moves across the view. + */ + func path(samples: [Float], with configuration: Waveform.Configuration, lastOffset: Int) -> CGPath + /** - Renders the waveformsamples on the provided `CGContext`. + Renders the waveform samples on the provided `CGContext`. - Parameters: - samples: `[Float]` of the amplitude envelope to be drawn, normalized to interval `(0...1)`. `0` is maximum (typically `0dB`). @@ -47,8 +59,8 @@ public enum Waveform { - **gradientOutlined**: Use gradient based on color for the waveform. Draws the envelope as an outline with the provided thickness. - **striped**: Use striped filling based on color for the waveform. */ - public enum Style: Equatable { - public struct StripeConfig: Equatable { + public enum Style: Equatable, Sendable { + public struct StripeConfig: Equatable, Sendable { /// Color of the waveform stripes. Default is clear. public let color: DSColor @@ -79,8 +91,8 @@ public enum Waveform { /** Defines the damping attributes of the waveform. */ - public struct Damping: Equatable { - public enum Sides: Equatable { + public struct Damping: Equatable, Sendable { + public enum Sides: Equatable, Sendable { case left case right case both @@ -97,9 +109,9 @@ public enum Waveform { public let sides: Sides /// Easing function to be used. Default is `pow(x, 2)`. - public let easing: (Float) -> Float + public let easing: @Sendable (Float) -> Float - public init(percentage: Float = 0.125, sides: Sides = .both, easing: @escaping (Float) -> Float = { x in pow(x, 2) }) { + public init(percentage: Float = 0.125, sides: Sides = .both, easing: @escaping @Sendable (Float) -> Float = { x in pow(x, 2) }) { guard (0...0.5).contains(percentage) else { preconditionFailure("dampingPercentage must be within (0..<0.5)") } @@ -110,7 +122,7 @@ public enum Waveform { } /// Build a new `Waveform.Damping` with only the given parameters replaced. - public func with(percentage: Float? = nil, sides: Sides? = nil, easing: ((Float) -> Float)? = nil) -> Damping { + public func with(percentage: Float? = nil, sides: Sides? = nil, easing: (@Sendable (Float) -> Float)? = nil) -> Damping { .init(percentage: percentage ?? self.percentage, sides: sides ?? self.sides, easing: easing ?? self.easing) } @@ -122,7 +134,7 @@ public enum Waveform { } /// Allows customization of the waveform output image. - public struct Configuration: Equatable { + public struct Configuration: Equatable, Sendable { /// Desired output size of the waveform image, works together with scale. Default is `.zero`. public let size: CGSize @@ -153,7 +165,7 @@ public enum Waveform { /// Waveform antialiasing. If enabled, may reduce overall opacity. Default is `false`. public let shouldAntialias: Bool - var shouldDamp: Bool { + public var shouldDamp: Bool { damping != nil } diff --git a/Sources/DSWaveformImageViews/SwiftUI/DefaultShapeStyler.swift b/Sources/DSWaveformImageViews/SwiftUI/DefaultShapeStyler.swift new file mode 100644 index 0000000..550dd98 --- /dev/null +++ b/Sources/DSWaveformImageViews/SwiftUI/DefaultShapeStyler.swift @@ -0,0 +1,44 @@ +import Foundation +import DSWaveformImage +import SwiftUI + +struct DefaultShapeStyler { + @ViewBuilder + func style(shape: WaveformShape, with configuration: Waveform.Configuration) -> some View { + switch configuration.style { + case let .filled(color): + shape.fill(Color(color)) + + case let .outlined(color, lineWidth): + shape.stroke( + Color(color), + style: StrokeStyle( + lineWidth: lineWidth, + lineCap: .round + ) + ) + + case let .gradient(colors): + shape + .fill(LinearGradient(colors: colors.map(Color.init), startPoint: .bottom, endPoint: .top)) + + case let .gradientOutlined(colors, lineWidth): + shape.stroke( + LinearGradient(colors: colors.map(Color.init), startPoint: .bottom, endPoint: .top), + style: StrokeStyle( + lineWidth: lineWidth, + lineCap: .round + ) + ) + + case let .striped(config): + shape.stroke( + Color(config.color), + style: StrokeStyle( + lineWidth: config.width, + lineCap: config.lineCap + ) + ) + } + } +} diff --git a/Sources/DSWaveformImageViews/SwiftUI/WaveformShape.swift b/Sources/DSWaveformImageViews/SwiftUI/WaveformShape.swift new file mode 100644 index 0000000..976aa3f --- /dev/null +++ b/Sources/DSWaveformImageViews/SwiftUI/WaveformShape.swift @@ -0,0 +1,57 @@ +import Foundation +import SwiftUI +import DSWaveformImage + +/// A waveform SwiftUI `Shape` object for generating a shape path from component(s) of the waveform. +/// **Note:** The Shape does *not* style itself. Use `WaveformView` for that purpose and only use the Shape directly if needed. +@available(iOS 15.0, macOS 12.0, *) +public struct WaveformShape: Shape { + private let samples: [Float] + private let configuration: Waveform.Configuration + private let renderer: WaveformRenderer + + public init( + samples: [Float], + configuration: Waveform.Configuration = Waveform.Configuration(), + renderer: WaveformRenderer = LinearWaveformRenderer() + ) { + self.samples = samples + self.configuration = configuration + self.renderer = renderer + } + + public func path(in rect: CGRect) -> Path { + let size = CGSize(width: rect.maxX, height: rect.maxY) + let dampedSamples = configuration.shouldDamp ? damp(samples, with: configuration) : samples + let path = renderer.path(samples: dampedSamples, with: configuration.with(size: size), lastOffset: 0) + + return Path(path) + } +} + +private extension WaveformShape { + private func damp(_ samples: [Float], with configuration: Waveform.Configuration) -> [Float] { + guard let damping = configuration.damping, damping.percentage > 0 else { + return samples + } + + let count = Float(samples.count) + return samples.enumerated().map { x, value -> Float in + 1 - ((1 - value) * dampFactor(x: Float(x), count: count, with: damping)) + } + } + + private func dampFactor(x: Float, count: Float, with damping: Waveform.Damping) -> Float { + if (damping.sides == .left || damping.sides == .both) && x < count * damping.percentage { + // increasing linear damping within the left 8th (default) + // basically (x : 1/8) with x in (0..<1/8) + return damping.easing(x / (count * damping.percentage)) + } else if (damping.sides == .right || damping.sides == .both) && x > ((1 / damping.percentage) - 1) * (count * damping.percentage) { + // decaying linear damping within the right 8th + // basically also (x : 1/8), but since x in (7/8>...1) x is "inverted" as x = x - 7/8 + return damping.easing(1 - (x - (((1 / damping.percentage) - 1) * (count * damping.percentage))) / (count * damping.percentage)) + } + return 1 + } +} + diff --git a/Sources/DSWaveformImageViews/SwiftUI/WaveformView.swift b/Sources/DSWaveformImageViews/SwiftUI/WaveformView.swift index bdff663..afe9988 100644 --- a/Sources/DSWaveformImageViews/SwiftUI/WaveformView.swift +++ b/Sources/DSWaveformImageViews/SwiftUI/WaveformView.swift @@ -3,16 +3,16 @@ import SwiftUI @available(iOS 14.0, *) /// Renders and displays a waveform for the audio at `audioURL`. -public struct WaveformView: View { - public static let defaultConfiguration = Waveform.Configuration(damping: .init(percentage: 0.125, sides: .both)) - +public struct WaveformView: View { private let audioURL: URL private let configuration: Waveform.Configuration private let renderer: WaveformRenderer private let priority: TaskPriority + private let content: ((WaveformShape) -> Content)? + + @State private var samples: [Float] = [] - @StateObject private var waveformDrawer = WaveformImageDrawer() - @State private var waveformImage: DSImage = DSImage() + private let defaultStyler = DefaultShapeStyler() /** Creates a new WaveformView which displays a waveform for the audio at `audioURL`. @@ -22,24 +22,58 @@ public struct WaveformView: View { - configuration: The `Waveform.Configuration` to be used for rendering. - renderer: The `WaveformRenderer` implementation to be used. Defaults to `LinearWaveformRenderer`. Also comes with `CircularWaveformRenderer`. - priority: The `TaskPriority` used during analyzing. Defaults to `.userInitiated`. + - content: ViewBuilder with the WaveformShape to be customized. */ public init( audioURL: URL, - configuration: Waveform.Configuration = defaultConfiguration, + configuration: Waveform.Configuration = Waveform.Configuration(damping: .init(percentage: 0.125, sides: .both)), renderer: WaveformRenderer = LinearWaveformRenderer(), - priority: TaskPriority = .userInitiated + priority: TaskPriority = .userInitiated, + @ViewBuilder content: @escaping (WaveformShape) -> Content ) { self.audioURL = audioURL self.configuration = configuration self.renderer = renderer self.priority = priority + self.content = content + } + + /** + Creates a new WaveformView which displays a waveform for the audio at `audioURL`. + + - Parameters: + - audioURL: The `URL` of the audio asset to be rendered. + - configuration: The `Waveform.Configuration` to be used for rendering. + - renderer: The `WaveformRenderer` implementation to be used. Defaults to `LinearWaveformRenderer`. Also comes with `CircularWaveformRenderer`. + - priority: The `TaskPriority` used during analyzing. Defaults to `.userInitiated`. + */ + public init( + audioURL: URL, + configuration: Waveform.Configuration = Waveform.Configuration(damping: .init(percentage: 0.125, sides: .both)), + renderer: WaveformRenderer = LinearWaveformRenderer(), + priority: TaskPriority = .userInitiated + ) where Content == _ConditionalContent { + self.audioURL = audioURL + self.configuration = configuration + self.renderer = renderer + self.priority = priority + self.content = nil } public var body: some View { GeometryReader { geometry in - image + Group { + if let content = content { + content(WaveformShape(samples: samples, configuration: configuration, renderer: renderer)) + } else { + defaultStyler.style( + shape: WaveformShape(samples: samples, configuration: configuration, renderer: renderer), + with: configuration + ) + } + } .onAppear { - guard waveformImage.size == .zero else { return } + guard samples.isEmpty else { return } update(size: geometry.size, url: audioURL, configuration: configuration) } .onChange(of: geometry.size) { update(size: $0, url: audioURL, configuration: configuration) } @@ -48,19 +82,12 @@ public struct WaveformView: View { } } - private var image: some View { - #if os(macOS) - Image(nsImage: waveformImage).resizable() - #else - Image(uiImage: waveformImage).resizable() - #endif - } - private func update(size: CGSize, url: URL, configuration: Waveform.Configuration) { Task(priority: priority) { do { - let image = try await waveformDrawer.waveformImage(fromAudioAt: url, with: configuration.with(size: size), renderer: renderer) - await MainActor.run { waveformImage = image } + let samplesNeeded = Int(size.width * configuration.scale) + let samples = try await WaveformAnalyzer(audioAssetURL: audioURL)!.samples(count: samplesNeeded) + await MainActor.run { self.samples = samples } } catch { assertionFailure(error.localizedDescription) }