Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions Example/WaveformScrubberExample.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -488,8 +488,8 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = KRK6S63FNN;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.lukakorica.WaveformScrubberExampleTests;
PRODUCT_NAME = "$(TARGET_NAME)";
Expand All @@ -501,7 +501,7 @@
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/WaveformScrubberExample.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/WaveformScrubberExample";
XROS_DEPLOYMENT_TARGET = 26.0;
XROS_DEPLOYMENT_TARGET = 1.0;
};
name = Debug;
};
Expand All @@ -513,8 +513,8 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = KRK6S63FNN;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.lukakorica.WaveformScrubberExampleTests;
PRODUCT_NAME = "$(TARGET_NAME)";
Expand All @@ -526,7 +526,7 @@
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/WaveformScrubberExample.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/WaveformScrubberExample";
XROS_DEPLOYMENT_TARGET = 26.0;
XROS_DEPLOYMENT_TARGET = 1.0;
};
name = Release;
};
Expand All @@ -537,8 +537,8 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = KRK6S63FNN;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.lukakorica.WaveformScrubberExampleUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
Expand All @@ -550,7 +550,7 @@
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
TEST_TARGET_NAME = WaveformScrubberExample;
XROS_DEPLOYMENT_TARGET = 26.0;
XROS_DEPLOYMENT_TARGET = 1.0;
};
name = Debug;
};
Expand All @@ -561,8 +561,8 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = KRK6S63FNN;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.lukakorica.WaveformScrubberExampleUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
Expand All @@ -574,7 +574,7 @@
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
TEST_TARGET_NAME = WaveformScrubberExample;
XROS_DEPLOYMENT_TARGET = 26.0;
XROS_DEPLOYMENT_TARGET = 1.0;
};
name = Release;
};
Expand Down
14 changes: 13 additions & 1 deletion Example/WaveformScrubberExample/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ struct ContentView: View {
Section("Audio") {
WaveformScrubber(
config: scrubberConfig,
drawer: BarDrawer(config: .init(barWidth: 2, spacing: 2)),
drawer: BarDrawer(config: .init(barWidth: 2, spacing: 2), upsampleStrategy: .smooth),
url: audioURL,
progress: $progress
) { info in
Expand Down Expand Up @@ -61,6 +61,18 @@ struct ContentView: View {
}
.frame(height: 100)

WaveformScrubber(
config: scrubberConfig,
drawer: LineDrawer(config: .init(inverted: true)),
url: audioURL,
progress: $progress
) { info in
print(info.duration)
} onGestureActive: { status in

}
.frame(height: 100)

WaveformScrubber(
config: scrubberConfig,
drawer: BarDrawer(config: .init(barWidth: 2, spacing: 2)),
Expand Down
43 changes: 37 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ A highly customizable, performant, and lightweight SwiftUI view that displays an
- **Highly Customizable:**
- **Pluggable Drawing System:** Use built-in drawers (`Bar`, `Line`, `BezierCurve`, etc.) or create your own by conforming to the `WaveformDrawing` protocol.
- **Flexible Styling:** Use the `.waveformScrubberStyle()` modifier to completely change the look and feel. Use solid colors, gradients, materials, or even combine fill and stroke styles.
- **Advanced Upsampling:** Intelligently interpolates data when the view is wider than the number of samples, ensuring a smooth, visually complete waveform. Choose from multiple strategies like `.smooth`, `.linear`, `.cosine`, `.hold`, or `.none`.
- **Performant:**
- **Asynchronous Processing:** Audio is processed on a background thread to keep the UI responsive.
- **Optimized Downsampling:** Uses Apple's Accelerate framework (vDSP) for high-speed sample processing.
- **Optimized Downsampling:** Uses Apple's Accelerate framework (vDSP) for high-speed downsampling and upsampling.
- **Built-in Caching:** Processed waveform data is automatically cached in memory, ensuring seamless scrolling in lists and preventing redundant work.
- **Responsive:** Automatically adapts and redraws the waveform when the view size changes, such as on device rotation or window resize.
- **User-Friendly:** Supports scrubbing through the audio via a simple drag gesture.
Expand All @@ -23,7 +24,7 @@ A highly customizable, performant, and lightweight SwiftUI view that displays an
- macOS 12.0+
- watchOS 8.0+
- visionOS 1.0+
- Swift 5.7+
- Swift 5.9+

## Installation

Expand Down Expand Up @@ -71,15 +72,15 @@ struct BasicExampleView: View {

`WaveformScrubber` offers two powerful customization layers: **Drawers** (the shape of the waveform) and **Styles** (the appearance/color).

### Choosing a Drawer
### 1. Choosing a Drawer

A "Drawer" defines the geometric shape of the waveform. You can choose from several built-in drawers and configure them.

#### BarDrawer
The classic bar graph style.
```swift
WaveformScrubber(
drawer: BarDrawer(config: .init(barWidth: 3, spacing: 2)),
drawer: BarDrawer(config: .init(barWidth: 3, spacing: 5)),
url: audioURL,
progress: $progress
)
Expand All @@ -89,7 +90,7 @@ WaveformScrubber(
A solid, filled line shape, like in many popular audio apps.
```swift
WaveformScrubber(
drawer: LineDrawer(),
drawer: LineDrawer(config: .init(inverted: true)), // Create a "cutout" effect
url: audioURL,
progress: $progress
)
Expand All @@ -105,7 +106,37 @@ WaveformScrubber(
)
```

### Styling the Waveform

### 2. Configuring a Drawer (Upsampling)

When the waveform view is wider than the number of available samples, the scrubber intelligently **upsamples** the data to create a smooth, continuous look. You can control this behavior via the `upsampleStrategy` in each drawer's configuration.

```swift
VStack {
Text("Smooth Interpolation (Default)")
WaveformScrubber(
drawer: BarDrawer(upsampleStrategy: .smooth),
url: shortAudioURL,
progress: $progress
)

Text("Hold Interpolation (Blocky)")
WaveformScrubber(
drawer: BarDrawer(upsampleStrategy: .hold),
url: shortAudioURL,
progress: $progress
)

Text("No Interpolation (Gaps)")
WaveformScrubber(
drawer: BarDrawer(upsampleStrategy: .none),
url: shortAudioURL,
progress: $progress
)
}
```

### 3. Styling the Waveform

Use the `.waveformScrubberStyle()` modifier to change the appearance. The default style fills the active and inactive parts of the waveform.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ extension BarDrawer {
/// - Parameters:
/// - spacing: The distance between each waveform bar. Defaults to `2`.
/// - barWidth: The width of each waveform bar. Defaults to `2`.
public init(barWidth: CGFloat = 2, spacing: CGFloat = 2) {
public init(
barWidth: CGFloat = 2,
spacing: CGFloat = 2
) {
self.barWidth = barWidth
self.spacing = spacing
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@ import SwiftUI
/// A waveform drawer that renders the waveform as a series of vertical bars.
public struct BarDrawer: WaveformDrawing {
public let config: Config
public var upsampleStrategy: UpsampleStrategy

public init(config: Config = .init()) {
public init(
config: Config = .init(),
upsampleStrategy: UpsampleStrategy = .smooth
) {
self.config = config
self.upsampleStrategy = upsampleStrategy
}

public func draw(samples: [Float], in rect: CGRect) -> Path {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,23 @@ import SwiftUI
extension BezierCurveDrawer {
/// Configuration for the `BezierCurveDrawer`.
public struct Config: Sendable {
public var upsampleStrategy: UpsampleStrategy

/// A value controlling how curvy the line is.
/// A value of 1.0 is a good starting point. 0.0 will be linear.
public let curviness: CGFloat

/// Determines the density of the samples. A lower number means more detail but more computation.
public let pixelsPerSample: CGFloat

public init(curviness: CGFloat = 1.0, pixelsPerSample: CGFloat = 3.0) {
public init(
curviness: CGFloat = 1.0,
pixelsPerSample: CGFloat = 3.0,
upsampleStrategy: UpsampleStrategy = .none
) {
self.curviness = curviness
self.pixelsPerSample = pixelsPerSample
self.upsampleStrategy = upsampleStrategy
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import SwiftUI
/// This style is excellent for a modern, fluid appearance.
public struct BezierCurveDrawer: WaveformDrawing {
public let config: Config

public init(config: Config = .init()) {
public var upsampleStrategy: UpsampleStrategy

public init(config: Config = .init(), upsampleStrategy: UpsampleStrategy = .none) {
self.config = config
self.upsampleStrategy = upsampleStrategy
}

// This drawer creates a closed, fillable shape.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,21 @@ extension DotDrawer {
public struct Config: Sendable {
public let dotRadius: CGFloat
public let spacing: CGFloat

public let upsampleStrategy: UpsampleStrategy

/// Creates a new configuration for the `BarDrawer`'s appearance.
/// - Parameters:
/// - dotRadius: The width of each waveform bar. Defaults to `2`.
/// - spacing: The distance between each waveform bar. Defaults to `2`.
public init(dotRadius: CGFloat = 2, spacing: CGFloat = 2) {
/// - upsampleStrategy: The interpolation strategy to use when upsampling. Defaults to `.cosine`.
public init(
dotRadius: CGFloat = 2,
spacing: CGFloat = 2,
upsampleStrategy: UpsampleStrategy = .cosine
) {
self.dotRadius = dotRadius
self.spacing = spacing
self.upsampleStrategy = upsampleStrategy
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import SwiftUI
/// This style renders the waveform as a series of circles, where the radius or vertical position of each circle represents the amplitude.
public struct DotDrawer: WaveformDrawing {
public let config: Config
public let upsampleStrategy: UpsampleStrategy

public init(config: Config = .init()) {
public init(config: Config = .init(), upsampleStrategy: UpsampleStrategy = .linear) {
self.config = config
self.upsampleStrategy = upsampleStrategy
}

public func draw(samples: [Float], in rect: CGRect) -> Path {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@ import SwiftUI

extension LineDrawer {
public struct Config: Sendable {
let inverted: Bool

/// Creates a new configuration for the `LineDrawer`'s appearance.
public init() {}
/// - Parameters:
/// - inverted: Defines if the inner wave should be inverted, making the outside shape represent the wave. Defaults to `false`.
public init(inverted: Bool = false) {
self.inverted = inverted
}
}
}
22 changes: 16 additions & 6 deletions Sources/WaveformScrubber/Domain/Shapes/LineDrawer/LineDrawer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import SwiftUI
/// mirroring the amplitude above and below a center line.
public struct LineDrawer: WaveformDrawing {
public let config: Config
public var upsampleStrategy: UpsampleStrategy

public init(config: Config = .init()) {
public init(config: Config = .init(), upsampleStrategy: UpsampleStrategy = .smooth) {
self.config = config
self.upsampleStrategy = upsampleStrategy
}

public func draw(samples: [Float], in rect: CGRect) -> Path {
Expand All @@ -28,15 +30,23 @@ public struct LineDrawer: WaveformDrawing {

for (index, sample) in samples.enumerated() {
let x = CGFloat(index) * stepX
let height = max(1, CGFloat(sample) * rect.height)

topPoints.append(CGPoint(x: x, y: midY - height))
bottomPoints.append(CGPoint(x: x, y: midY + height))
let isEdgePoint = (index == 0 || index == samples.count - 1)
let middleAmplitude = max(1, CGFloat(sample) * midY)
var halfHeight: CGFloat

if config.inverted {
halfHeight = isEdgePoint ? midY : middleAmplitude
} else {
halfHeight = isEdgePoint ? 0 : middleAmplitude
}

topPoints.append(CGPoint(x: x, y: midY - halfHeight))
bottomPoints.append(CGPoint(x: x, y: midY + halfHeight))
}

path.addLines(topPoints)
// Reverse the bottom points to create a single, continuous, closed shape
path.addLines(bottomPoints)
path.addLines(bottomPoints.reversed())
path.closeSubpath()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,16 @@ import SwiftUI
public struct LogarithmicBarDrawer: WaveformDrawing {
let linearDrawer: BarDrawer
let floor: Float

public init(config: BarDrawer.Config = .init(), floor: Float = 0.1) {
public var upsampleStrategy: UpsampleStrategy

public init(
config: BarDrawer.Config = .init(),
floor: Float = 0.1,
upsampleStrategy: UpsampleStrategy = .smooth
) {
self.linearDrawer = BarDrawer(config: config)
self.floor = floor
self.upsampleStrategy = upsampleStrategy
}

public func draw(samples: [Float], in rect: CGRect) -> Path {
Expand Down
5 changes: 5 additions & 0 deletions Sources/WaveformScrubber/Domain/Shapes/WaveformDrawing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,9 @@ public protocol WaveformDrawing: Sendable {
/// - Parameter size: The size of the view that will be rendering the waveform.
/// - Returns: The optimal number of samples for the drawing strategy.
func sampleCount(for size: CGSize) -> Int


/// Allows the scrubber to know which strategy to use for upsampling.
var upsampleStrategy: UpsampleStrategy { get }

}
Loading