Skip to content

Commit 7c12f27

Browse files
authored
Add spectrum analysis functionality (#2)
Add support for generating frequency spectrum snapshots of audio buffers using FFT analysis. This includes: - New spectrum view for visualizing frequency data using SwiftUI Charts - Extension for AVAudioPCMBuffer to compute spectrum data - Test coverage for various waveforms including pure tones and noise
1 parent 3a76f38 commit 7c12f27

15 files changed

Lines changed: 257 additions & 0 deletions

Sources/AudioSnapshotTesting/AVAudioPCMBufferExtensions.swift

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,132 @@ private extension AVAudioPCMBuffer {
6363
return buffer
6464
}
6565
}
66+
67+
extension AVAudioPCMBuffer {
68+
func spectrum(window: [Float]) -> [FrequencyAmplitude] {
69+
let mono = mixToMono()
70+
guard let data = mono.floatChannelData?[0] else {
71+
fatalError("Not a float audio format")
72+
}
73+
return fft(
74+
n: Int(frameLength),
75+
signal: data,
76+
window: window,
77+
sampleRate: Float(mono.format.sampleRate)
78+
)
79+
}
80+
}
81+
82+
private func fft(
83+
n: Int,
84+
signal: UnsafeMutablePointer<Float>,
85+
window: [Float],
86+
sampleRate: Float
87+
) -> [FrequencyAmplitude] {
88+
let n = vDSP_Length(n)
89+
let log2n = vDSP_Length(log2(Float(n)))
90+
91+
guard let fft = vDSP.FFT(log2n: log2n, radix: .radix2, ofType: DSPSplitComplex.self) else {
92+
fatalError("Can't create FFT Setup.")
93+
}
94+
95+
vDSP_vmul(signal, 1, window, 1, signal, 1, n)
96+
97+
let signal = UnsafeRawBufferPointer(
98+
start: signal,
99+
count: Int(n) * MemoryLayout<Float>.size
100+
)
101+
102+
let halfN = Int(n / 2)
103+
104+
var forwardInputReal = [Float](repeating: 0, count: halfN)
105+
var forwardInputImag = [Float](repeating: 0, count: halfN)
106+
var forwardOutputReal = [Float](repeating: 0, count: halfN)
107+
var forwardOutputImag = [Float](repeating: 0, count: halfN)
108+
109+
forwardInputReal.withUnsafeMutableBufferPointer { forwardInputRealPtr in
110+
forwardInputImag.withUnsafeMutableBufferPointer { forwardInputImagPtr in
111+
forwardOutputReal.withUnsafeMutableBufferPointer { forwardOutputRealPtr in
112+
forwardOutputImag.withUnsafeMutableBufferPointer { forwardOutputImagPtr in
113+
114+
// Create a `DSPSplitComplex` to contain the signal.
115+
var forwardInput = DSPSplitComplex(
116+
realp: forwardInputRealPtr.baseAddress!,
117+
imagp: forwardInputImagPtr.baseAddress!
118+
)
119+
120+
// Convert the real values in `signal` to complex numbers.
121+
vDSP.convert(
122+
interleavedComplexVector: [DSPComplex](signal.bindMemory(to: DSPComplex.self)),
123+
toSplitComplexVector: &forwardInput
124+
)
125+
126+
// Create a `DSPSplitComplex` to receive the FFT result.
127+
var forwardOutput = DSPSplitComplex(
128+
realp: forwardOutputRealPtr.baseAddress!,
129+
imagp: forwardOutputImagPtr.baseAddress!
130+
)
131+
132+
// Perform the forward FFT.
133+
fft.forward(input: forwardInput, output: &forwardOutput)
134+
}
135+
}
136+
}
137+
}
138+
139+
let autospectrum = computeAutospectrum(
140+
halfN: halfN,
141+
forwardOutputReal: &forwardOutputReal,
142+
forwardOutputImag: &forwardOutputImag
143+
)
144+
145+
return computeAmplitudes(
146+
autospectrum: autospectrum,
147+
sampleRate: sampleRate,
148+
n: Float(n)
149+
)
150+
}
151+
152+
private func computeAutospectrum(
153+
halfN: Int,
154+
forwardOutputReal: inout [Float],
155+
forwardOutputImag: inout [Float]
156+
) -> [Float] {
157+
[Float](unsafeUninitializedCapacity: halfN) { autospectrumBuffer, initializedCount in
158+
// The `vDSP_zaspec` function accumulates its output. Clear the
159+
// uninitialized `autospectrumBuffer` before computing the spectrum.
160+
vDSP.clear(&autospectrumBuffer)
161+
162+
forwardOutputReal.withUnsafeMutableBufferPointer { forwardOutputRealPtr in
163+
forwardOutputImag.withUnsafeMutableBufferPointer { forwardOutputImagPtr in
164+
var frequencyDomain = DSPSplitComplex(
165+
realp: forwardOutputRealPtr.baseAddress!,
166+
imagp: forwardOutputImagPtr.baseAddress!
167+
)
168+
169+
vDSP_zaspec(
170+
&frequencyDomain,
171+
autospectrumBuffer.baseAddress!,
172+
vDSP_Length(halfN)
173+
)
174+
}
175+
}
176+
initializedCount = halfN
177+
}
178+
}
179+
180+
private func computeAmplitudes(
181+
autospectrum: [Float],
182+
sampleRate: Float,
183+
n: Float
184+
) -> [FrequencyAmplitude] {
185+
autospectrum
186+
.enumerated()
187+
.filter { $0.element > 1 }
188+
.map { index, element in
189+
return FrequencyAmplitude(
190+
frequency: Float(index) * sampleRate / n,
191+
amplitude: sqrt(element) / n
192+
)
193+
}
194+
}

Sources/AudioSnapshotTesting/AudioSnapshotTesting.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ typealias PlatformHostingView = _UIHostingView
1717
@MainActor
1818
public extension Snapshotting where Format == PlatformImage, Value == (AVAudioPCMBuffer, AVAudioPCMBuffer) {
1919
/// Generates a overlayed waveform snapshot of the given tuple of `AVAudioPCMBuffer`s.
20+
/// - Parameters:
21+
/// - width: The width of the resulting image.
22+
/// - height: The height of the resulting image.
2023
static func waveform(width: Int, height: Int) -> Snapshotting {
2124
Snapshotting<PlatformView, PlatformImage>.image(size: .init(width: width, height: height))
2225
.pullback { buffer1, buffer2 in
@@ -52,6 +55,9 @@ public extension Snapshotting where Format == PlatformImage, Value == (AVAudioPC
5255
@MainActor
5356
public extension Snapshotting where Format == PlatformImage, Value == AVAudioPCMBuffer {
5457
/// Generates a waveform snapshot of the given `AVAudioPCMBuffer`.
58+
/// - Parameters:
59+
/// - width: The width of the resulting image.
60+
/// - height: The height of the resulting image.
5561
static func waveform(width: Int, height: Int) -> Snapshotting {
5662
Snapshotting<PlatformView, PlatformImage>.image(size: .init(width: width, height: height))
5763
.pullback { buffer in
@@ -70,4 +76,33 @@ public extension Snapshotting where Format == PlatformImage, Value == AVAudioPCM
7076
return PlatformHostingView(rootView: waveform.environment(\.colorScheme, .light))
7177
}
7278
}
79+
80+
/// Generates a frequency spectrum of the given `AVAudioPCMBuffer`.
81+
/// - Parameters:
82+
/// - width: The width of the resulting image.
83+
/// - height: The height of the resulting image.
84+
/// - window: An optional array of floats representing the window function to apply before computing the FFT. If not provided, a Hann window will be used by default.
85+
/// - threshold: A float value between 0 and 1 that determines the minimum amplitude required for a frequency bin to be included in the resulting image.
86+
@available(iOS 16, macOS 13, *)
87+
static func spectrum(
88+
width: Int,
89+
height: Int,
90+
window: [Float]? = nil,
91+
threshold: Float = 0.005
92+
) -> Snapshotting {
93+
Snapshotting<PlatformView, PlatformImage>.image(size: .init(width: width, height: height))
94+
.pullback { buffer in
95+
let fallbackWindow = vDSP.window(
96+
ofType: Float.self,
97+
usingSequence: .hanningNormalized,
98+
count: Int(buffer.frameLength),
99+
isHalfWindow: false
100+
)
101+
let data = buffer
102+
.spectrum(window: window ?? fallbackWindow)
103+
.filter { $0.amplitude > threshold }
104+
let spectrum = SpectrumView(data: data, height: CGFloat(height))
105+
return PlatformHostingView(rootView: spectrum.environment(\.colorScheme, .light))
106+
}
107+
}
73108
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
struct FrequencyAmplitude {
2+
let frequency: Float
3+
let amplitude: Float
4+
5+
var frequencyString: String { String(format: "%.1f", frequency) }
6+
}
7+
8+
extension FrequencyAmplitude: Identifiable {
9+
var id: Float { frequency }
10+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import SwiftUI
2+
import Charts
3+
4+
@available(iOS 16, macOS 13, *)
5+
struct SpectrumView: View {
6+
let data: [FrequencyAmplitude]
7+
let height: CGFloat
8+
9+
var body: some View {
10+
Chart(data) { frequency in
11+
BarMark(
12+
x: .value("Frequency", frequency.frequencyString),
13+
y: .value("Amplitude", frequency.amplitude)
14+
)
15+
.foregroundStyle(Color.red)
16+
}
17+
.chartYAxis {
18+
AxisMarks(position: .leading) { _ in
19+
AxisValueLabel()
20+
.foregroundStyle(.white)
21+
AxisGridLine()
22+
.foregroundStyle(.white.opacity(0.3))
23+
}
24+
}
25+
.chartYAxisLabel(position: .leading) { Text("Amplitude").foregroundStyle(.white) }
26+
.chartXAxis {
27+
AxisMarks(preset: .aligned, values: xAxisValues) { _ in
28+
AxisValueLabel()
29+
.foregroundStyle(.white)
30+
AxisGridLine()
31+
.foregroundStyle(.white.opacity(0.3))
32+
}
33+
}
34+
.chartXAxisLabel(position: .bottom) { Text("Frequency (Hz)").foregroundStyle(.white) }
35+
.padding(16)
36+
.frame(height: height)
37+
.background(Color.black)
38+
}
39+
40+
private var xAxisValues: [String] {
41+
let count = data.count
42+
let numberOfLabels = min(10, data.count)
43+
guard numberOfLabels > 1 else { return [data[0].frequencyString] }
44+
let spacing = Float(count - 1) / Float(numberOfLabels - 1)
45+
let points = (0..<numberOfLabels).map {
46+
let index = Int(Float($0) * spacing)
47+
return data[index].frequencyString
48+
}
49+
return points
50+
}
51+
}

Tests/AudioSnapshotTestingTests/AudioSnapshotTestingTests.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,38 @@ func fileWaveformOverlay() async throws {
3030
)
3131
}
3232

33+
@Test(
34+
.snapshots(record: false, diffTool: .ksdiff),
35+
arguments: ["1hz@32768", "1hz2hz@32768", "2hz@32768"]
36+
)
37+
@MainActor
38+
func perfectSpectrum(wave: String) async throws {
39+
let buffer = try AVAudioPCMBuffer.read(wave: wave)
40+
assertSnapshot(
41+
of: buffer,
42+
as: .spectrum(
43+
width: 500,
44+
height: 200,
45+
window: .init(repeating: 1, count: Int(buffer.frameLength))
46+
),
47+
named: wave
48+
)
49+
}
50+
51+
@Test(
52+
.snapshots(record: false, diffTool: .ksdiff),
53+
arguments: ["1hz@44100", "white", "brown", "pink", "square", "triangle", "sawtooth"]
54+
)
55+
@MainActor
56+
func windowedSpectrum(wave: String) async throws {
57+
let buffer = try AVAudioPCMBuffer.read(wave: wave)
58+
assertSnapshot(
59+
of: buffer,
60+
as: .spectrum(width: 1500, height: 400),
61+
named: wave
62+
)
63+
}
64+
3365
private extension AVAudioPCMBuffer {
3466
static func read(wave: String) throws -> AVAudioPCMBuffer {
3567
let file = try AVAudioFile(
8.46 KB
Loading
8.97 KB
Loading
8.58 KB
Loading
31.1 KB
Loading
44 KB
Loading

0 commit comments

Comments
 (0)