Skip to content

Commit 4335a23

Browse files
authored
Merge pull request #111 from dmrschmidt/copilot/add-stereo-channel-support
Add per-channel rendering for multi-channel audio
2 parents 7aadd49 + 2b454aa commit 4335a23

28 files changed

Lines changed: 907 additions & 307 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ DerivedData
1919

2020
build
2121
derived
22+
.build
2223

2324
#CocoaPods
2425
Pods

Example/DSWaveformImageExample-VisionOS/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Content.imageset/Contents.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"images" : [
33
{
4+
"filename" : "Icon.png",
45
"idiom" : "vision",
56
"scale" : "2x"
67
}
Loading

Example/DSWaveformImageExample-VisionOS/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Content.imageset/Contents.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"images" : [
33
{
4+
"filename" : "Icon.png",
45
"idiom" : "vision",
56
"scale" : "2x"
67
}
Loading

Example/DSWaveformImageExample-VisionOS/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Content.imageset/Contents.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"images" : [
33
{
4+
"filename" : "Icon.png",
45
"idiom" : "vision",
56
"scale" : "2x"
67
}
Loading

Example/DSWaveformImageExample-VisionOS/ContentView.swift

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,34 +3,8 @@ import DSWaveformImage
33
import DSWaveformImageViews
44

55
struct ContentView: View {
6-
private static let colors = [UIColor.systemPink, UIColor.systemBlue, UIColor.systemGreen]
7-
private static var randomColor: UIColor {
8-
colors[Int.random(in: 0..<colors.count)]
9-
}
10-
11-
@State private var audioURL: URL = Bundle.main.url(forResource: "example_sound", withExtension: "m4a")!
12-
13-
@State var configuration: Waveform.Configuration = Waveform.Configuration(
14-
style: .gradient([.red, .green])
15-
)
16-
176
var body: some View {
18-
VStack {
19-
Text("SwiftUI example")
20-
.font(.largeTitle.bold())
21-
22-
Button {
23-
configuration = configuration.with(style: .striped(.init(color: Self.randomColor)))
24-
} label: {
25-
Label("switch color randomly", systemImage: "arrow.triangle.2.circlepath")
26-
}
27-
.font(.body.bold())
28-
.padding()
29-
.background(Color(UIColor.systemGray).opacity(0.6))
30-
.cornerRadius(10)
31-
32-
WaveformView(audioURL: audioURL, configuration: configuration, renderer: CircularWaveformRenderer())
33-
}
7+
WaveformGalleryView()
348
}
359
}
3610

Example/DSWaveformImageExample-iOS/SwiftUIExample/SwiftUIExampleView.swift

Lines changed: 38 additions & 173 deletions
Original file line numberDiff line numberDiff line change
@@ -3,68 +3,61 @@ import DSWaveformImageViews
33
import SwiftUI
44

55
struct SwiftUIExampleView: View {
6-
private enum ActiveTab: Hashable {
7-
case recorder, shape, overview
6+
private enum Tab: Hashable {
7+
case gallery
8+
case recorder
89
}
910

10-
private static let colors = [UIColor.systemPink, UIColor.systemBlue, UIColor.systemGreen]
11-
private static var randomColor: UIColor { colors.randomElement()! }
12-
13-
private static var audioURLs: [URL?] = [
14-
Bundle.main.url(forResource: "example_sound", withExtension: "m4a"),
15-
Bundle.main.url(forResource: "example_sound_2", withExtension: "m4a")
16-
]
17-
private static func randomURL(_ current: URL?) -> URL? { audioURLs.filter { $0 != current }.randomElement()! }
18-
19-
@StateObject private var audioRecorder: AudioRecorder = AudioRecorder()
20-
21-
@State private var configuration: Waveform.Configuration = Waveform.Configuration(
22-
style: .striped(Waveform.Style.StripeConfig(color: Self.randomColor, width: 3, lineCap: .round)),
23-
verticalScalingFactor: 0.9
24-
)
25-
26-
@State private var liveConfiguration: Waveform.Configuration = Waveform.Configuration(
27-
style: .striped(.init(color: randomColor, width: 3, spacing: 3))
28-
)
29-
30-
@State private var audioURL: URL? = audioURLs.first!
31-
@State private var samples: [Float] = []
32-
@State private var silence: Bool = true
33-
@State private var selection: ActiveTab = .overview
11+
@State private var tab: Tab = .gallery
3412

3513
var body: some View {
36-
VStack {
37-
Text("SwiftUI examples")
38-
.font(.largeTitle.bold())
39-
40-
Picker("Hey", selection: $selection) {
41-
Text("Recorder").tag(ActiveTab.recorder)
42-
Text("Shape").tag(ActiveTab.shape)
43-
Text("Overview").tag(ActiveTab.overview)
14+
VStack(spacing: 0) {
15+
Picker("Section", selection: $tab) {
16+
Label("Gallery", systemImage: "rectangle.grid.2x2").tag(Tab.gallery)
17+
Label("Live", systemImage: "mic.circle.fill").tag(Tab.recorder)
4418
}
4519
.pickerStyle(.segmented)
20+
.labelsHidden()
4621
.padding(.horizontal)
22+
.padding(.top, 12)
4723

48-
switch selection {
49-
case .recorder: recordingExample
50-
case .shape: shape
51-
case .overview: overview
24+
switch tab {
25+
case .gallery: WaveformGalleryView()
26+
case .recorder: LiveRecordingTab()
5227
}
5328
}
54-
.padding(.vertical, 20)
5529
}
30+
}
31+
32+
private struct LiveRecordingTab: View {
33+
@StateObject private var audioRecorder = AudioRecorder()
34+
@State private var silence: Bool = true
35+
@State private var configuration: Waveform.Configuration = Waveform.Configuration(
36+
style: .striped(.init(color: .systemIndigo, width: 3, spacing: 3))
37+
)
38+
39+
var body: some View {
40+
VStack(spacing: 16) {
41+
VStack(alignment: .leading, spacing: 4) {
42+
Text("Live recording")
43+
.font(.title2.weight(.semibold))
44+
Text("WaveformLiveCanvas streams microphone amplitude into a circular renderer.")
45+
.font(.callout)
46+
.foregroundStyle(.secondary)
47+
}
48+
.frame(maxWidth: .infinity, alignment: .leading)
49+
.padding(.horizontal)
50+
.padding(.top, 16)
5651

57-
@ViewBuilder
58-
private var recordingExample: some View {
59-
VStack {
6052
WaveformLiveCanvas(
6153
samples: audioRecorder.samples,
62-
configuration: liveConfiguration,
54+
configuration: configuration,
6355
renderer: CircularWaveformRenderer(kind: .circle),
6456
shouldDrawSilencePadding: silence
6557
)
58+
.padding(.horizontal)
6659

67-
Toggle("draw silence", isOn: $silence)
60+
Toggle("Pad silence", isOn: $silence)
6861
.controlSize(.mini)
6962
.padding(.horizontal)
7063

@@ -75,131 +68,8 @@ struct SwiftUIExampleView: View {
7568
isRecording: $audioRecorder.isRecording
7669
)
7770
.padding(.horizontal)
78-
}
79-
}
80-
81-
@ViewBuilder
82-
private var shape: some View {
83-
VStack {
84-
Text("WaveformView").font(.monospaced(.title.bold())())
8571

86-
HStack {
87-
Button {
88-
configuration = configuration.with(style: .striped(Waveform.Style.StripeConfig(color: Self.randomColor, width: 3, lineCap: .round)))
89-
liveConfiguration = liveConfiguration.with(style: .striped(.init(color: Self.randomColor, width: 3, spacing: 3)))
90-
} label: {
91-
Label("color", systemImage: "dice")
92-
.frame(maxWidth: .infinity)
93-
}
94-
.font(.body.bold())
95-
.padding(8)
96-
.background(Color(.systemGray6))
97-
.cornerRadius(10)
98-
99-
Button {
100-
audioURL = Self.randomURL(audioURL)
101-
print("will draw \(audioURL!)")
102-
} label: {
103-
Label("waveform", systemImage: "dice")
104-
.frame(maxWidth: .infinity)
105-
}
106-
.font(.body.bold())
107-
.padding(8)
108-
.background(Color(.systemGray6))
109-
.cornerRadius(10)
110-
}
111-
.padding(.horizontal)
112-
113-
// the if let is left here intentionally to illustrate how to deal with optional URLs
114-
// as this was asked in an older GitHub issue
115-
if let audioURL {
116-
WaveformView(audioURL: audioURL, configuration: configuration)
117-
118-
WaveformView(
119-
audioURL: audioURL,
120-
configuration: configuration,
121-
renderer: CircularWaveformRenderer(kind: .ring(0.7))
122-
) { shape in
123-
// you may completely override the shape styling this way
124-
shape
125-
.stroke(
126-
LinearGradient(colors: [.red, Color(Self.randomColor)], startPoint: .zero, endPoint: .topTrailing),
127-
style: StrokeStyle(lineWidth: 3, lineCap: .round))
128-
}
129-
130-
Divider()
131-
Text("WaveformShape").font(.monospaced(.title.bold())())
132-
133-
/// **Note:** It's possible, but discouraged to use WaveformShape directly.
134-
/// As Shapes should not do any expensive computations, the analyzing should happen outside,
135-
/// hence making the API a tiny bit clumsy if used directly, since we do require to know the size,
136-
/// even though the Shape of course intrinsically knows its size already.
137-
GeometryReader { geometry in
138-
WaveformShape(samples: samples)
139-
.fill(Color.orange)
140-
.task {
141-
do {
142-
let samplesNeeded = Int(geometry.size.width * configuration.scale)
143-
let samples = try await WaveformAnalyzer().samples(fromAudioAt: audioURL, count: samplesNeeded)
144-
await MainActor.run { self.samples = samples }
145-
} catch {
146-
assertionFailure(error.localizedDescription)
147-
}
148-
}
149-
}
150-
}
151-
}
152-
}
153-
154-
@ViewBuilder
155-
private var overview: some View {
156-
if let audioURL {
157-
HStack {
158-
VStack {
159-
WaveformView(audioURL: audioURL, configuration: .init(style: .filled(.red)))
160-
WaveformView(audioURL: audioURL, configuration: .init(style: .outlined(.blue, 0.5)))
161-
WaveformView(audioURL: audioURL, configuration: .init(style: .gradient([.yellow, .orange])))
162-
WaveformView(audioURL: audioURL, configuration: .init(style: .gradientOutlined([.yellow, .orange], 1)))
163-
WaveformView(audioURL: audioURL, configuration: .init(style: .striped(.init(color: .red, width: 2, spacing: 1))))
164-
165-
WaveformView(audioURL: audioURL, configuration: .init(style: .striped(.init(color: .black)))) { shape in
166-
shape // override the shape styling
167-
.stroke(LinearGradient(colors: [.blue, .pink], startPoint: .bottom, endPoint: .top), lineWidth: 3)
168-
} placeholder: {
169-
ProgressView()
170-
}
171-
}
172-
173-
VStack {
174-
WaveformView(audioURL: audioURL, configuration: .init(style: .filled(.red)), renderer: CircularWaveformRenderer())
175-
WaveformView(audioURL: audioURL, configuration: .init(style: .outlined(.blue, 0.5)), renderer: CircularWaveformRenderer())
176-
WaveformView(audioURL: audioURL, configuration: .init(style: .gradient([.yellow, .orange])), renderer: CircularWaveformRenderer())
177-
WaveformView(audioURL: audioURL, configuration: .init(style: .gradientOutlined([.yellow, .orange], 1)), renderer: CircularWaveformRenderer())
178-
WaveformView(audioURL: audioURL, configuration: .init(style: .striped(.init(color: .red, width: 2, spacing: 2))), renderer: CircularWaveformRenderer())
179-
180-
WaveformView(audioURL: audioURL, configuration: .init(style: .striped(.init(color: .black))), renderer: CircularWaveformRenderer()) { shape in
181-
shape // override the shape styling
182-
.stroke(LinearGradient(colors: [.blue, .pink], startPoint: .bottom, endPoint: .top), lineWidth: 3)
183-
} placeholder: {
184-
ProgressView()
185-
}
186-
}
187-
188-
VStack {
189-
WaveformView(audioURL: audioURL, configuration: .init(style: .filled(.red)), renderer: CircularWaveformRenderer(kind: .ring(0.5)))
190-
WaveformView(audioURL: audioURL, configuration: .init(style: .outlined(.blue, 0.5)), renderer: CircularWaveformRenderer(kind: .ring(0.5)))
191-
WaveformView(audioURL: audioURL, configuration: .init(style: .gradient([.yellow, .orange])), renderer: CircularWaveformRenderer(kind: .ring(0.5)))
192-
WaveformView(audioURL: audioURL, configuration: .init(style: .gradientOutlined([.yellow, .orange], 1)), renderer: CircularWaveformRenderer(kind: .ring(0.5)))
193-
WaveformView(audioURL: audioURL, configuration: .init(style: .striped(.init(color: .red, width: 2, spacing: 2))), renderer: CircularWaveformRenderer(kind: .ring(0.5)))
194-
195-
WaveformView(audioURL: audioURL, configuration: .init(style: .striped(.init(color: .black))), renderer: CircularWaveformRenderer(kind: .ring(0.5))) { shape in
196-
shape // override the shape styling
197-
.stroke(LinearGradient(colors: [.blue, .pink], startPoint: .bottom, endPoint: .top), lineWidth: 3)
198-
} placeholder: {
199-
ProgressView()
200-
}
201-
}
202-
}
72+
Spacer(minLength: 0)
20373
}
20474
}
20575
}
@@ -224,9 +94,7 @@ private class AudioRecorder: NSObject, ObservableObject, RecordingDelegate {
22494

22595
override init() {
22696
audioManager = SCAudioManager()
227-
22897
super.init()
229-
23098
audioManager.prepareAudioRecording()
23199
audioManager.recordingDelegate = self
232100
}
@@ -245,16 +113,13 @@ private class AudioRecorder: NSObject, ObservableObject, RecordingDelegate {
245113
// MARK: - RecordingDelegate
246114

247115
func audioManager(_ manager: SCAudioManager!, didAllowRecording flag: Bool) {}
248-
249116
func audioManager(_ manager: SCAudioManager!, didFinishRecordingSuccessfully flag: Bool) {}
250117

251118
func audioManager(_ manager: SCAudioManager!, didUpdateRecordProgress progress: CGFloat) {
252119
let linear = 1 - pow(10, manager.lastAveragePower() / 20)
253-
254120
// Here we add the same sample 3 times to speed up the animation.
255121
// Usually you'd just add the sample once.
256122
recordingTime = audioManager.currentRecordingTime
257123
samples += [linear, linear, linear]
258124
}
259125
}
260-
75.9 KB
Binary file not shown.

0 commit comments

Comments
 (0)