Skip to content

Commit 833c981

Browse files
committed
Use image renderer
1 parent 6bb9f26 commit 833c981

40 files changed

Lines changed: 99 additions & 38 deletions

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ Snapshot audio tests are snapshot tested itself. Please find many examples in: [
6767
- [ ] Multi level comparison (first hash, then data, then image)
6868
- [ ] Use accelerate in downsampling
6969
- [x] Add file strategy
70+
- [ ] Use smaller images to not bloat the size of repo
71+
- [ ] Use sparklines
7072

7173
## Contributing
7274

Sources/AudioSnapshotTesting/AudioSnapshotTesting.swift

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,9 @@ public extension Snapshotting where Format == PlatformImage, Value == (AVAudioPC
7272
/// - height: The height of the resulting image.
7373
/// - strategy: The strategy to use when generating the waveform. Defaults to `.joinedLines`.
7474
/// - mono: A boolean indicating whether to mix down to a mono signal before generating the waveform. Defaults to `true`.
75+
@available(macOS 13.0, iOS 16.0, *)
7576
static func waveform(width: Int, height: Int, strategy: WaveformStrategy = .joinedLines, mono: Bool = true) -> Snapshotting {
76-
Snapshotting<PlatformView, PlatformImage>.image(size: .init(width: width, height: height))
77+
SimplySnapshotting<PlatformImage>.image()
7778
.pullback { buffer1, buffer2 in
7879
let verticalPadding: CGFloat = 4
7980
let waveformHeight = CGFloat(height) - (verticalPadding * 2)
@@ -95,7 +96,8 @@ public extension Snapshotting where Format == PlatformImage, Value == (AVAudioPC
9596
}
9697
.padding(.vertical, verticalPadding)
9798
.background(Color.black)
98-
return PlatformHostingView(rootView: waveform.environment(\.colorScheme, .light))
99+
100+
return renderViewToImage(waveform, width: width, height: height)
99101
}
100102
}
101103
}
@@ -108,8 +110,14 @@ public extension Snapshotting where Format == PlatformImage, Value == AVAudioPCM
108110
/// - height: The height of the resulting image.
109111
/// - strategy: The strategy to use when generating the waveform. Defaults to `.joinedLines`.
110112
/// - mono: A boolean indicating whether to mix down to a mono signal before generating the waveform. Defaults to `true`.
111-
static func waveform(width: Int, height: Int, strategy: WaveformStrategy = .joinedLines, mono: Bool = true) -> Snapshotting {
112-
Snapshotting<PlatformView, PlatformImage>.image(size: .init(width: width, height: height))
113+
@available(macOS 13.0, iOS 16.0, *)
114+
static func waveform(
115+
width: Int,
116+
height: Int,
117+
strategy: WaveformStrategy = .joinedLines,
118+
mono: Bool = true
119+
) -> Snapshotting {
120+
SimplySnapshotting<PlatformImage>.image()
113121
.pullback { buffer in
114122
let verticalPadding: CGFloat = 4
115123
let waveformHeight = CGFloat(height) - (verticalPadding * 2)
@@ -121,7 +129,8 @@ public extension Snapshotting where Format == PlatformImage, Value == AVAudioPCM
121129
)
122130
.padding(.vertical, verticalPadding)
123131
.background(Color.black)
124-
return PlatformHostingView(rootView: waveform.environment(\.colorScheme, .light))
132+
133+
return renderViewToImage(waveform, width: width, height: height)
125134
}
126135
}
127136

@@ -138,14 +147,14 @@ public extension Snapshotting where Format == PlatformImage, Value == AVAudioPCM
138147
window: [Float]? = nil,
139148
threshold: Float = 0.005
140149
) -> Snapshotting {
141-
Snapshotting<PlatformView, PlatformImage>.image(size: .init(width: width, height: height))
150+
SimplySnapshotting<PlatformImage>.image()
142151
.pullback { buffer in
143152
let effectiveWindow = window ?? createHannWindow(size: Int(buffer.frameLength))
144153
let data = buffer
145154
.spectrum(window: effectiveWindow)
146155
.filter { $0.amplitude > threshold }
147156
let spectrum = SpectrumView(data: data, height: CGFloat(height))
148-
return PlatformHostingView(rootView: spectrum.environment(\.colorScheme, .light))
157+
return renderViewToImage(spectrum, width: width, height: height)
149158
}
150159
}
151160

@@ -165,7 +174,7 @@ public extension Snapshotting where Format == PlatformImage, Value == AVAudioPCM
165174
imageWidth: Int = 1000
166175
) -> Snapshotting {
167176
let height = frequencyCount
168-
return Snapshotting<PlatformView, PlatformImage>.image(size: .init(width: imageWidth, height: height))
177+
return SimplySnapshotting<PlatformImage>.image()
169178
.pullback { buffer in
170179
let fftSize = frequencyCount * 2
171180
let lastBucketStart = Int(buffer.frameLength) - fftSize
@@ -187,11 +196,32 @@ public extension Snapshotting where Format == PlatformImage, Value == AVAudioPCM
187196
let duration = Double(buffer.frameLength) / buffer.format.sampleRate
188197

189198
let spectrogram = SpectrogramView(data: data, width: width, height: height, maxFrequency: maxFrequency, duration: duration)
190-
return PlatformHostingView(rootView: spectrogram.environment(\.colorScheme, .light))
199+
return renderViewToImage(spectrogram, width: imageWidth, height: height)
191200
}
192201
}
193202
}
194203

204+
/// Renders a SwiftUI view to a platform image using ImageRenderer
205+
/// - Parameters:
206+
/// - view: The SwiftUI view to render
207+
/// - width: The width of the resulting image
208+
/// - height: The height of the resulting image
209+
/// - Returns: A platform-specific image (NSImage on macOS, UIImage on iOS)
210+
@available(iOS 16.0, macOS 13.0, *)
211+
@MainActor
212+
private func renderViewToImage<Content: View>(_ view: Content, width: Int, height: Int) -> PlatformImage {
213+
let renderer = ImageRenderer(
214+
content: view.environment(\.colorScheme, .light)
215+
)
216+
renderer.proposedSize = .init(width: CGFloat(width), height: CGFloat(height))
217+
renderer.scale = 2
218+
#if os(iOS)
219+
return renderer.uiImage!
220+
#else
221+
return renderer.nsImage!
222+
#endif
223+
}
224+
195225
/// Creates a normalized Hann window of the specified size
196226
/// - Parameter size: The number of samples in the window
197227
/// - Returns: An array of Float values representing the Hann window function

Sources/AudioSnapshotTesting/CGImageExtensions.swift

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,9 @@ extension CGImage {
77
let rgbImageFormat = vImage_CGImageFormat(
88
bitsPerComponent: 32,
99
bitsPerPixel: 32 * 3,
10-
colorSpace: CGColorSpaceCreateDeviceRGB(),
11-
bitmapInfo: CGBitmapInfo(
12-
rawValue: kCGBitmapByteOrder32Host.rawValue |
13-
CGBitmapInfo.floatComponents.rawValue |
14-
CGImageAlphaInfo.none.rawValue))!
10+
colorSpace: CGColorSpace(name: CGColorSpace.sRGB)!,
11+
bitmapInfo: CGBitmapInfo.floatComponents
12+
)!
1513

1614
/// RGB vImage buffer that contains a vertical representation of the audio spectrogram.
1715
let redBuffer = vImage.PixelBuffer<vImage.PlanarF>(width: height, height: width)
@@ -42,29 +40,29 @@ extension CGImage {
4240
}
4341
}
4442

43+
// Lookup table resolution: 32 entries provides good color accuracy while keeping memory usage low
44+
// Higher values (64, 128) would provide smoother gradients but increase memory and initialization time
45+
// Lower values (16, 8) would be faster but produce visible banding in spectrograms
46+
let entriesPerChannel = UInt8(32)
47+
48+
4549
/// Returns the RGB values from a blue -> red -> green color map for a specified value.
4650
///
4751
/// Values near zero return dark blue, `0.5` returns red, and `1.0` returns full-brightness green.
4852
@available(macOS 13, iOS 16, *)
4953
nonisolated(unsafe) var multidimensionalLookupTable: vImage.MultidimensionalLookupTable = {
50-
// Lookup table resolution: 32 entries provides good color accuracy while keeping memory usage low
51-
// Higher values (64, 128) would provide smoother gradients but increase memory and initialization time
52-
// Lower values (16, 8) would be faster but produce visible banding in spectrograms
53-
let entriesPerChannel = UInt8(32)
5454
let srcChannelCount = 1
5555
let destChannelCount = 3
5656

5757
let lookupTableElementCount = Int(pow(Float(entriesPerChannel), Float(srcChannelCount))) * Int(destChannelCount)
5858

59-
let tableData = [UInt16](unsafeUninitializedCapacity: lookupTableElementCount) {
60-
buffer,
61-
count in
59+
let tableData = [UInt16](unsafeUninitializedCapacity: lookupTableElementCount) { buffer, count in
6260
/// Supply the samples in the range `0...65535`. The transform function
6361
/// interpolates these to the range `0...1`.
6462
let multiplier = CGFloat(UInt16.max)
6563
var bufferIndex = 0
6664

67-
for gray in ( 0 ..< entriesPerChannel) {
65+
for gray in 0 ..< entriesPerChannel {
6866
/// Create normalized red, green, and blue values in the range `0...1`.
6967
let normalizedValue = CGFloat(gray) / CGFloat(entriesPerChannel - 1)
7068

@@ -85,11 +83,11 @@ nonisolated(unsafe) var multidimensionalLookupTable: vImage.MultidimensionalLook
8583

8684
color.getRed(&r, green: &g, blue: &b, alpha: nil)
8785

88-
buffer[ bufferIndex ] = UInt16(g * multiplier)
86+
buffer[bufferIndex] = UInt16(g * multiplier)
8987
bufferIndex += 1
90-
buffer[ bufferIndex ] = UInt16(r * multiplier)
88+
buffer[bufferIndex] = UInt16(r * multiplier)
9189
bufferIndex += 1
92-
buffer[ bufferIndex ] = UInt16(b * multiplier)
90+
buffer[bufferIndex] = UInt16(b * multiplier)
9391
bufferIndex += 1
9492
}
9593
count = lookupTableElementCount

Sources/AudioSnapshotTesting/SpectrogramView.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ struct SpectrogramView: View {
3939
}
4040

4141
var body: some View {
42+
image
43+
.resizable()
44+
.padding(.vertical, SpectrogramLayout.verticalPadding)
45+
/*
4246
VStack(spacing: 0) {
4347
HStack(spacing: SpectrogramLayout.labelSpacing) {
4448
FrequencyAxisView(maxFrequency: maxFrequency)
@@ -53,6 +57,7 @@ struct SpectrogramView: View {
5357
.padding(.horizontal, SpectrogramLayout.horizontalPadding)
5458
}
5559
}
60+
*/
5661
}
5762
}
5863

@@ -123,7 +128,7 @@ private struct FrequencyLabel: View {
123128

124129
var body: some View {
125130
Text(Self.frequencyFormatter.string(from: NSNumber(value: frequency)) ?? "\(frequency)")
126-
.font(.caption)
131+
.font(.system(size: 10, weight: .regular, design: .monospaced))
127132
.foregroundColor(.secondary)
128133
.frame(height: SpectrogramLayout.frequencyLabelHeight)
129134
}
@@ -183,7 +188,7 @@ private struct TimeLabel: View {
183188

184189
var body: some View {
185190
Text(formatTime(time, totalDuration: duration))
186-
.font(.caption)
191+
.font(.system(size: 10, weight: .regular, design: .monospaced))
187192
.foregroundColor(.secondary)
188193
}
189194

Sources/AudioSnapshotTesting/SpectrumView.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,31 @@ struct SpectrumView: View {
1717
.chartYAxis {
1818
AxisMarks(position: .leading) { _ in
1919
AxisValueLabel()
20+
.font(.system(size: 10, weight: .regular, design: .monospaced))
2021
.foregroundStyle(.white)
2122
AxisGridLine()
2223
.foregroundStyle(.white.opacity(0.3))
2324
}
2425
}
25-
.chartYAxisLabel(position: .leading) { Text("Amplitude").foregroundStyle(.white) }
26+
.chartYAxisLabel(position: .leading) {
27+
Text("Amplitude")
28+
.font(.system(size: 12, weight: .regular, design: .monospaced))
29+
.foregroundStyle(.white)
30+
}
2631
.chartXAxis {
2732
AxisMarks(preset: .aligned, values: xAxisValues) { _ in
2833
AxisValueLabel()
34+
.font(.system(size: 10, weight: .regular, design: .monospaced))
2935
.foregroundStyle(.white)
3036
AxisGridLine()
3137
.foregroundStyle(.white.opacity(0.3))
3238
}
3339
}
34-
.chartXAxisLabel(position: .bottom) { Text("Frequency (Hz)").foregroundStyle(.white) }
40+
.chartXAxisLabel(position: .bottom) {
41+
Text("Frequency (Hz)")
42+
.font(.system(size: 12, weight: .regular, design: .monospaced))
43+
.foregroundStyle(.white)
44+
}
3545
.padding(16)
3646
.frame(height: height)
3747
.background(Color.black)

Tests/AudioSnapshotTestingTests/AudioSnapshotTestingTests.swift

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,21 @@ import Testing
22
import AVFAudio
33
@testable import AudioSnapshotTesting
44

5+
@available(iOS 16.0, *)
56
@Test(
67
.snapshots(record: false, diffTool: .ksdiff),
78
arguments: ["sine", "triangle", "square", "sawtooth", "brown", "pink", "white"]
89
)
910
@MainActor
1011
func fileWaveform(wave: String) async throws {
11-
// TODO: account for retina
12-
1312
assertSnapshot(
1413
of: try AVAudioPCMBuffer.read(wave: wave),
1514
as: .waveform(width: 3000, height: 800),
1615
named: wave
1716
)
1817
}
1918

19+
@available(iOS 16.0, *)
2020
@Test(.snapshots(record: false, diffTool: .ksdiff))
2121
@MainActor
2222
func fileWaveformMetronome() async throws {
@@ -27,6 +27,7 @@ func fileWaveformMetronome() async throws {
2727
)
2828
}
2929

30+
@available(iOS 16.0, *)
3031
@Test(.snapshots(record: false, diffTool: .ksdiff))
3132
@MainActor
3233
func stereoFileWaveform() async throws {
@@ -36,6 +37,7 @@ func stereoFileWaveform() async throws {
3637
)
3738
}
3839

40+
@available(iOS 16, *)
3941
@Test(.snapshots(record: false, diffTool: .ksdiff))
4042
@MainActor
4143
func fileWaveformOverlay() async throws {
@@ -102,28 +104,42 @@ func spectrumSynthesised() async throws {
102104

103105
@available(iOS 16, *)
104106
@Test(
105-
"Generates color spectrum spectrogram",
106-
.snapshots(record: false, diffTool: .ksdiff)
107+
.snapshots(record: true, diffTool: .ksdiff)
107108
)
108109
@MainActor
109110
func spectrogramColors() async throws {
110-
let frequencyCount = 1024
111+
let frequencyCount = 32
111112
let signal = synthesizeSignal(
112113
frequencyAmplitudePairs: (0..<frequencyCount).map { (Float($0), Float($0) / Float(frequencyCount)) },
113-
count: 2048
114+
count: frequencyCount * 2
114115
)
115-
let buffer = createBuffer(from: signal + signal + signal + signal)
116+
let buffer = createBuffer(from: signal)
116117

118+
#if os(macOS)
117119
assertSnapshot(
118120
of: buffer,
119121
as: .spectrogram(
120122
hopSize: 128,
121123
frequencyCount: frequencyCount,
122124
window: [Float](repeating: 1, count: 2048),
123125
amplitudeScale: .linear,
124-
imageWidth: 200
125-
)
126+
imageWidth: 1
127+
),
128+
named: "macOS"
129+
)
130+
#else
131+
assertSnapshot(
132+
of: buffer,
133+
as: .spectrogram(
134+
hopSize: 128,
135+
frequencyCount: frequencyCount,
136+
window: [Float](repeating: 1, count: 2048),
137+
amplitudeScale: .linear,
138+
imageWidth: 1
139+
),
140+
named: "iOS"
126141
)
142+
#endif
127143
}
128144

129145
@available(iOS 16, *)
-257 KB
Loading
-634 KB
Loading
-27.8 KB
Loading
-17.9 KB
Loading

0 commit comments

Comments
 (0)