Skip to content

Commit

Permalink
add new circular drawing style and introduce new public protocol for …
Browse files Browse the repository at this point in the history
…extensible custom drawing
  • Loading branch information
dmrschmidt committed Nov 16, 2022
1 parent eab9f88 commit 2a325f4
Show file tree
Hide file tree
Showing 14 changed files with 304 additions and 112 deletions.
6 changes: 0 additions & 6 deletions Example/DSWaveformImageExample-iOS/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {



func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
return true
Expand All @@ -30,7 +27,4 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
}


}

Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ struct SwiftUIExampleView: View {
@State private var audioURL: URL? = Self.randomURL

@State var configuration: Waveform.Configuration = Waveform.Configuration(
style: .filled(randomColor),
position: .bottom
style: .outlined(.blue, 3),
position: .custom(CGPoint(x: 0.6, y: 0.6)),
verticalScalingFactor: 0.5
)

@State var liveConfiguration: Waveform.Configuration = Waveform.Configuration(
Expand Down Expand Up @@ -63,7 +64,11 @@ struct SwiftUIExampleView: View {
.padding()

if let audioURL {
WaveformView(audioURL: audioURL, configuration: configuration)
WaveformView(
audioURL: audioURL,
configuration: configuration,
renderer: CircularWaveformRenderer(kind: .ring(0.7))
)
}

VStack {
Expand All @@ -72,6 +77,7 @@ struct SwiftUIExampleView: View {
WaveformLiveCanvas(
samples: audioRecorder.samples,
configuration: liveConfiguration,
renderer: CircularWaveformRenderer(kind: .circle),
shouldDrawSilencePadding: silence
)
}
Expand Down
5 changes: 3 additions & 2 deletions Example/DSWaveformImageExample-iOS/ViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ class ViewController: UIViewController {
]
),
dampening: .init(percentage: 0.2, sides: .right, easing: { x in pow(x, 4) }),
position: .top,
verticalScalingFactor: 2)
position: .custom(CGPoint(x: 0.6, y: 0.6)),
verticalScalingFactor: 2),
renderer: CircularWaveformRenderer()
) { image in
// need to jump back to main queue
DispatchQueue.main.async {
Expand Down
2 changes: 1 addition & 1 deletion Example/DSWaveformImageExample-macOS/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ struct ContentView: View {
.cornerRadius(10)

if #available(macOS 12.0, *) {
WaveformView(audioURL: audioURL, configuration: configuration)
WaveformView(audioURL: audioURL, configuration: configuration, renderer: CircularWaveformRenderer())
} else {
Text("at least macOS 12 is required")
}
Expand Down
120 changes: 120 additions & 0 deletions Sources/DSWaveformImage/Renderers/CircularWaveformRenderer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import Foundation
import CoreGraphics

public struct CircularWaveformRenderer: WaveformRenderer {
public enum Kind {
case circle
case ring(CGFloat)
}

private let kind: Kind

public init(kind: Kind = .circle) {
self.kind = kind
}

public func render(samples: [Float], on context: CGContext, with configuration: Waveform.Configuration, lastOffset: Int) {
switch kind {
case .circle: drawCircle(samples: samples, on: context, with: configuration, lastOffset: lastOffset)
case .ring: drawRing(samples: samples, on: context, with: configuration, lastOffset: lastOffset)
}
}

public func style(context: CGContext, with configuration: Waveform.Configuration) {
if case let .gradient(colors) = configuration.style {
context.clip()
let colors = NSArray(array: colors.map { (color: DSColor) -> CGColor in color.cgColor }) as CFArray
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors, locations: nil)!
context.drawLinearGradient(gradient,
start: CGPoint(x: 0, y: 0),
end: CGPoint(x: 0, y: configuration.size.height),
options: .drawsAfterEndLocation)
} else {
defaultStyle(context: context, with: configuration)
}
}

private func drawCircle(samples: [Float], on context: CGContext, with configuration: Waveform.Configuration, lastOffset: Int) {
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 * configuration.position.value().x, y: graphRect.maxY * configuration.position.value().y)
let path = CGMutablePath()

path.move(to: center)

for (y, sample) in samples.enumerated() {
let angle = CGFloat.pi * 2 * (CGFloat(y) / CGFloat(samples.count))
let x = y + lastOffset

if case .striped = configuration.style, x % Int(configuration.scale) != 0 || x % stripeBucket(configuration) != 0 {
// skip sub-pixels - any x value not scale aligned
// skip any point that is not a multiple of our bucket width (width + spacing)
path.addLine(to: center)
continue
}

let invertedDbSample = 1 - CGFloat(sample) // sample is in dB, linearly normalized to [0, 1] (1 -> -50 dB)
let pointOnCircle = CGPoint(
x: center.x + maxRadius * invertedDbSample * cos(angle),
y: center.y + maxRadius * invertedDbSample * sin(angle)
)

path.addLine(to: pointOnCircle)
}

path.closeSubpath()
context.addPath(path)
}

private func drawRing(samples: [Float], on context: CGContext, with configuration: Waveform.Configuration, lastOffset: Int) {
guard case let .ring(config) = kind else {
return
}

let graphRect = CGRect(origin: .zero, size: configuration.size)
let maxRadius = CGFloat(min(graphRect.maxX, graphRect.maxY) / 2.0) * configuration.verticalScalingFactor
let innerRadius: CGFloat = maxRadius * config
let center = CGPoint(x: graphRect.maxX * configuration.position.value().x, y: graphRect.maxY * configuration.position.value().y)
let path = CGMutablePath()

path.move(to: CGPoint(
x: center.x + innerRadius * cos(0),
y: center.y + innerRadius * sin(0)
))

for (y, sample) in samples.enumerated() {
let x = y + lastOffset
let angle = CGFloat.pi * 2 * (CGFloat(y) / CGFloat(samples.count))

if case .striped = configuration.style, x % Int(configuration.scale) != 0 || x % stripeBucket(configuration) != 0 {
// skip sub-pixels - any x value not scale aligned
// skip any point that is not a multiple of our bucket width (width + spacing)
path.move(to: CGPoint(
x: center.x + innerRadius * cos(angle),
y: center.y + innerRadius * sin(angle)
))
continue
}

let invertedDbSample = 1 - CGFloat(sample) // sample is in dB, linearly normalized to [0, 1] (1 -> -50 dB)
let pointOnCircle = CGPoint(
x: center.x + innerRadius * cos(angle) + (maxRadius - innerRadius) * invertedDbSample * cos(angle),
y: center.y + innerRadius * sin(angle) + (maxRadius - innerRadius) * invertedDbSample * sin(angle)
)

path.addLine(to: pointOnCircle)
}

path.closeSubpath()
context.addPath(path)
}

private func stripeBucket(_ configuration: Waveform.Configuration) -> Int {
if case let .striped(stripeConfig) = configuration.style {
return Int(stripeConfig.width + stripeConfig.spacing) * Int(configuration.scale)
} else {
return 0
}
}
}
53 changes: 53 additions & 0 deletions Sources/DSWaveformImage/Renderers/LinearWaveformRenderer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import Foundation
import CoreGraphics

public struct LinearWaveformRenderer: WaveformRenderer {
public init() {}

public func render(samples: [Float], on context: CGContext, with configuration: Waveform.Configuration, lastOffset: Int) {
let graphRect = CGRect(origin: CGPoint.zero, size: configuration.size)
let positionAdjustedGraphCenter = CGFloat(configuration.position.value().y) * graphRect.size.height
let drawMappingFactor = graphRect.size.height * configuration.verticalScalingFactor
let minimumGraphAmplitude: CGFloat = 1 / configuration.scale // we want to see at least a 1px line for silence
let path = CGMutablePath()
var maxAmplitude: CGFloat = 0.0 // we know 1 is our max in normalized data, but we keep it 'generic'
for (index, sample) in samples.enumerated() {
var x = index + lastOffset
if case .striped = configuration.style, x % Int(configuration.scale) != 0 || x % stripeBucket(configuration) != 0 {
// skip sub-pixels - any x value not scale aligned
// skip any point that is not a multiple of our bucket width (width + spacing)
continue
} else if case let .striped(config) = configuration.style {
// ensure 1st stripe is drawn completely inside bounds and does not clip half way on the left side
x += Int(config.width / 2 * configuration.scale)
}

let samplesNeeded = Int(configuration.size.width * configuration.scale)
let xOffset = CGFloat(samplesNeeded - samples.count) / configuration.scale // When there's extra space, draw waveform on the right
let xPos = (CGFloat(x - lastOffset) / configuration.scale) + xOffset
let invertedDbSample = 1 - CGFloat(sample) // sample is in dB, linearly normalized to [0, 1] (1 -> -50 dB)
let drawingAmplitude = max(minimumGraphAmplitude, invertedDbSample * drawMappingFactor)
let drawingAmplitudeUp = positionAdjustedGraphCenter - drawingAmplitude
let drawingAmplitudeDown = positionAdjustedGraphCenter + drawingAmplitude
maxAmplitude = max(drawingAmplitude, maxAmplitude)

path.move(to: CGPoint(x: xPos, y: drawingAmplitudeUp))
path.addLine(to: CGPoint(x: xPos, y: drawingAmplitudeDown))
}

path.closeSubpath()
context.addPath(path)
}

public func style(context: CGContext, with configuration: Waveform.Configuration) {
defaultStyle(context: context, with: configuration)
}

private func stripeBucket(_ configuration: Waveform.Configuration) -> Int {
if case let .striped(stripeConfig) = configuration.style {
return Int(stripeConfig.width + stripeConfig.spacing) * Int(configuration.scale)
} else {
return 0
}
}
}
52 changes: 52 additions & 0 deletions Sources/DSWaveformImage/Renderers/WaveformRenderer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import Foundation
import CoreGraphics

public extension WaveformRenderer {
func defaultStyle(context: CGContext, with configuration: Waveform.Configuration) {
// draw pixel-perfect by default
context.setLineWidth(1.0 / configuration.scale)

switch configuration.style {
case let .filled(color):
context.setStrokeColor(color.cgColor)
context.strokePath()

case let .outlined(color, lineWidth):
context.setStrokeColor(color.cgColor)
context.setLineWidth(lineWidth)
context.setLineCap(.round)
context.strokePath()

case let .striped(config):
context.setLineWidth(config.width)
context.setLineCap(config.lineCap)
context.setStrokeColor(config.color.cgColor)
context.strokePath()

case let .gradient(colors):
context.replacePathWithStrokedPath()
context.clip()
let colors = NSArray(array: colors.map { (color: DSColor) -> CGColor in color.cgColor }) as CFArray
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors, locations: nil)!
context.drawLinearGradient(gradient,
start: CGPoint(x: 0, y: 0),
end: CGPoint(x: 0, y: configuration.size.height),
options: .drawsAfterEndLocation)

case let .gradientOutlined(colors, lineWidth):
context.setLineWidth(lineWidth)
context.replacePathWithStrokedPath()
context.setLineCap(.round)
context.setLineJoin(.round)
context.clip()
let colors = NSArray(array: colors.map { (color: DSColor) -> CGColor in color.cgColor }) as CFArray
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors, locations: nil)!
context.drawLinearGradient(gradient,
start: CGPoint(x: 0, y: 0),
end: CGPoint(x: 0, y: configuration.size.height),
options: .drawsAfterEndLocation)
}
}
}
8 changes: 4 additions & 4 deletions Sources/DSWaveformImage/WaveformImageDrawer+iOS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,19 @@ public extension WaveformImageDrawer {
/// Renders a DSImage of the provided waveform samples.
///
/// Samples need to be normalized within interval `(0...1)`.
func waveformImage(from samples: [Float], with configuration: Waveform.Configuration) -> DSImage? {
func waveformImage(from samples: [Float], with configuration: Waveform.Configuration, renderer: WaveformRenderer) -> DSImage? {
guard samples.count > 0, samples.count == Int(configuration.size.width * configuration.scale) else {
print("ERROR: samples: \(samples.count) != \(configuration.size.width) * \(configuration.scale)")
return nil
}

let format = UIGraphicsImageRendererFormat()
format.scale = configuration.scale
let renderer = UIGraphicsImageRenderer(size: configuration.size, format: format)
let imageRenderer = UIGraphicsImageRenderer(size: configuration.size, format: format)
let dampenedSamples = configuration.shouldDampen ? dampen(samples, with: configuration) : samples

return renderer.image { renderContext in
draw(on: renderContext.cgContext, from: dampenedSamples, with: configuration)
return imageRenderer.image { renderContext in
draw(on: renderContext.cgContext, from: dampenedSamples, with: configuration, renderer: renderer)
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/DSWaveformImage/WaveformImageDrawer+macOS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public extension WaveformImageDrawer {
/// Renders a DSImage of the provided waveform samples.
///
/// Samples need to be normalized within interval `(0...1)`.
func waveformImage(from samples: [Float], with configuration: Waveform.Configuration) -> DSImage? {
func waveformImage(from samples: [Float], with configuration: Waveform.Configuration, renderer: WaveformRenderer) -> DSImage? {
guard samples.count > 0, samples.count == Int(configuration.size.width * configuration.scale) else {
print("ERROR: samples: \(samples.count) != \(configuration.size.width) * \(configuration.scale)")
return nil
Expand All @@ -19,7 +19,7 @@ public extension WaveformImageDrawer {
guard let context = NSGraphicsContext.current?.cgContext else {
fatalError("Missing context")
}
self.draw(on: context, from: dampenedSamples, with: configuration)
self.draw(on: context, from: dampenedSamples, with: configuration, renderer: renderer)
return true
}
}
Expand Down
Loading

0 comments on commit 2a325f4

Please sign in to comment.