Skip to content

Commit f46422e

Browse files
committed
Add SnapshotFormat option for audio file vs MD5 checksum snapshots
For repositories with many audio snapshot tests, .caf files can bloat the repo. This adds a SnapshotFormat enum (.audio / .checksum) so users can opt into lightweight 32-byte .md5 checksum files instead of full ALAC audio snapshots. Default is .audio, preserving full backward compatibility.
1 parent 363f2be commit f46422e

8 files changed

Lines changed: 137 additions & 12 deletions

File tree

Sources/AudioSnapshotTesting/Core/AudioSnapshotTesting.swift

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,24 @@ private struct SnapshotContext {
6969

7070
func snapshotPath(index: Int, count: Int) -> URL {
7171
let suffix = count > 1 ? ".\(index + 1)" : ""
72-
let fileName = "\(name)\(suffix).caf"
72+
let ext: String
73+
switch trait.format {
74+
case .audio:
75+
ext = "caf"
76+
case .checksum:
77+
ext = "md5"
78+
}
79+
let fileName = "\(name)\(suffix).\(ext)"
7380
return SnapshotFileManager.snapshotPath(directory: directory, fileName: fileName)
7481
}
7582

83+
func temporaryAudioPath(index: Int, count: Int, label: String? = nil) -> URL {
84+
let suffix = count > 1 ? ".\(index + 1)" : ""
85+
let labelSuffix = label.map { ".\($0)" } ?? ""
86+
let fileName = "\(name)\(suffix)\(labelSuffix).caf"
87+
return SnapshotFileManager.temporaryFilePath(fileName: fileName)
88+
}
89+
7690
func visualizationPath() -> URL {
7791
SnapshotFileManager.temporaryFilePath(fileName: "\(name).png")
7892
}
@@ -131,7 +145,15 @@ private func recordSnapshots(
131145

132146
for index in indicesToRecord {
133147
let path = context.snapshotPath(index: index, count: bufferCount)
134-
try AudioFileWriter.write(buffer: buffers[index], to: path, bitDepth: context.trait.bitDepth)
148+
switch context.trait.format {
149+
case .audio:
150+
try AudioFileWriter.write(buffer: buffers[index], to: path, bitDepth: context.trait.bitDepth)
151+
case .checksum:
152+
let tempPath = context.temporaryAudioPath(index: index, count: bufferCount)
153+
try AudioFileWriter.write(buffer: buffers[index], to: tempPath, bitDepth: context.trait.bitDepth)
154+
let checksum = try AudioChecksumWriter.computeChecksum(of: tempPath)
155+
try AudioChecksumWriter.writeChecksum(checksum, to: path)
156+
}
135157
}
136158

137159
var message: String
@@ -168,14 +190,25 @@ private func verifySnapshots(buffers: [AVAudioPCMBuffer], context: SnapshotConte
168190

169191
private func compareSnapshots(buffers: [AVAudioPCMBuffer], context: SnapshotContext) throws -> [(index: Int, message: String)] {
170192
var diffs: [(index: Int, message: String)] = []
171-
193+
172194
for (index, buffer) in buffers.enumerated() {
173195
let path = context.snapshotPath(index: index, count: buffers.count)
174-
if let diffMessage = try AudioDataComparator.compare(expectedURL: path, actual: buffer, bitDepth: context.trait.bitDepth) {
175-
diffs.append((index, diffMessage))
196+
switch context.trait.format {
197+
case .audio:
198+
if let diffMessage = try AudioDataComparator.compare(expectedURL: path, actual: buffer, bitDepth: context.trait.bitDepth) {
199+
diffs.append((index, diffMessage))
200+
}
201+
case .checksum:
202+
let tempPath = context.temporaryAudioPath(index: index, count: buffers.count)
203+
try AudioFileWriter.write(buffer: buffer, to: tempPath, bitDepth: context.trait.bitDepth)
204+
let actualChecksum = try AudioChecksumWriter.computeChecksum(of: tempPath)
205+
let expectedChecksum = try AudioChecksumWriter.readChecksum(from: path)
206+
if actualChecksum != expectedChecksum {
207+
diffs.append((index, "Checksum mismatch: expected \(expectedChecksum), got \(actualChecksum)"))
208+
}
176209
}
177210
}
178-
211+
179212
return diffs
180213
}
181214

@@ -186,13 +219,25 @@ private func buildFailureMessage(
186219
) async throws -> String {
187220
let bufferCount = buffers.count
188221
var message = bufferCount > 1 ? "Audio snapshots differ." : diffs[0].message
189-
222+
190223
if bufferCount > 1 {
191224
for diff in diffs {
192225
message += "\nBuffer \(diff.index + 1): \(diff.message)"
193226
}
194227
}
195228

229+
for diff in diffs {
230+
let buffer = buffers[diff.index]
231+
let path = context.temporaryAudioPath(index: diff.index, count: bufferCount, label: "actual")
232+
try AudioFileWriter.write(buffer: buffer, to: path, bitDepth: context.trait.bitDepth)
233+
Attachment.record(
234+
try Data(contentsOf: path),
235+
named: path.lastPathComponent,
236+
sourceLocation: context.sourceLocation
237+
)
238+
message += "\nActual audio: file://\(path.path)"
239+
}
240+
196241
if let strategy = context.trait.strategy {
197242
let visualizationMessage = try await generateVisualization(
198243
buffers: buffers,
@@ -201,7 +246,7 @@ private func buildFailureMessage(
201246
)
202247
message += "\nFailure visualization:" + visualizationMessage
203248
}
204-
249+
205250
return message
206251
}
207252

@@ -214,7 +259,7 @@ private func generateVisualization(
214259
let tempPath = context.visualizationPath()
215260
try SnapshotFileManager.writeFile(visualData, to: tempPath)
216261
Attachment.record(visualData, named: "\(context.name).png", sourceLocation: context.sourceLocation)
217-
262+
218263
#if os(macOS)
219264
// During developemnt, it is useful to auto open
220265
// generated file for easy inspection

Sources/AudioSnapshotTesting/Core/AudioSnapshotTrait.swift

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,22 @@ public struct AudioSnapshotTrait: TestTrait, SuiteTrait, TestScoping {
2020
/// The bit depth for ALAC encoding. Defaults to 16-bit.
2121
public let bitDepth: AudioBitDepth
2222

23+
/// The format used for storing snapshot artifacts.
24+
public let format: SnapshotFormat
25+
2326
/// Creates a new audio snapshot trait.
2427
/// - Parameters:
2528
/// - record: Whether to record new snapshots. Defaults to `false`.
2629
/// - strategy: The snapshot strategy for failure visualization. Defaults to `nil`.
2730
/// - autoOpen: Whether to automatically open visualizations. Defaults to `false`.
2831
/// - bitDepth: The bit depth for ALAC encoding. Defaults to `.bits16`.
29-
public init(record: Bool = false, strategy: VisualisationStrategy? = nil, autoOpen: Bool = false, bitDepth: AudioBitDepth = .bits16) {
32+
/// - format: The format for storing snapshot artifacts. Defaults to `.audio`.
33+
public init(record: Bool = false, strategy: VisualisationStrategy? = nil, autoOpen: Bool = false, bitDepth: AudioBitDepth = .bits16, format: SnapshotFormat = .audio) {
3034
self.record = record
3135
self.strategy = strategy
3236
self.autoOpen = autoOpen
3337
self.bitDepth = bitDepth
38+
self.format = format
3439
}
3540

3641
/// Called by Swift Testing to set up the test scope.
@@ -48,14 +53,16 @@ extension Trait where Self == AudioSnapshotTrait {
4853
/// - strategy: The snapshot strategy for failure visualization. Defaults to `nil`.
4954
/// - autoOpen: Whether to automatically open visualizations. Defaults to `false`.
5055
/// - bitDepth: The bit depth for ALAC encoding. Defaults to `.bits16`.
56+
/// - format: The format for storing snapshot artifacts. Defaults to `.audio`.
5157
/// - Returns: An `AudioSnapshotTrait` configured with the specified options.
5258
public static func audioSnapshot(
5359
record: Bool = false,
5460
strategy: VisualisationStrategy? = nil,
5561
autoOpen: Bool = false,
56-
bitDepth: AudioBitDepth = .bits16
62+
bitDepth: AudioBitDepth = .bits16,
63+
format: SnapshotFormat = .audio
5764
) -> AudioSnapshotTrait {
58-
AudioSnapshotTrait(record: record, strategy: strategy, autoOpen: autoOpen, bitDepth: bitDepth)
65+
AudioSnapshotTrait(record: record, strategy: strategy, autoOpen: autoOpen, bitDepth: bitDepth, format: format)
5966
}
6067
}
6168

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/// The format used for storing audio snapshot artifacts.
2+
public enum SnapshotFormat: Sendable {
3+
/// Full ALAC-encoded .caf audio file (current behavior).
4+
case audio
5+
/// Lightweight MD5 checksum stored in a .md5 text file.
6+
case checksum
7+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import CryptoKit
2+
import Foundation
3+
4+
/// Handles MD5 checksum computation and file I/O for checksum-based snapshots.
5+
enum AudioChecksumWriter {
6+
/// Computes the MD5 checksum of a file at the given URL.
7+
/// - Parameter url: The file URL to hash.
8+
/// - Returns: A 32-character lowercase hex string.
9+
static func computeChecksum(of url: URL) throws -> String {
10+
let data = try Data(contentsOf: url)
11+
let digest = Insecure.MD5.hash(data: data)
12+
return digest.map { String(format: "%02x", $0) }.joined()
13+
}
14+
15+
/// Writes a checksum string to a `.md5` file.
16+
/// - Parameters:
17+
/// - checksum: The 32-character hex checksum string.
18+
/// - url: The destination file URL.
19+
static func writeChecksum(_ checksum: String, to url: URL) throws {
20+
try checksum.write(to: url, atomically: true, encoding: .utf8)
21+
}
22+
23+
/// Reads a checksum string from a `.md5` file.
24+
/// - Parameter url: The file URL to read from.
25+
/// - Returns: The checksum string (trimmed of whitespace).
26+
static func readChecksum(from url: URL) throws -> String {
27+
try String(contentsOf: url, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines)
28+
}
29+
}

Tests/AudioSnapshotTestingTests/AudioSnapshotTestingTests.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,40 @@ func multiChannelComparison() async throws {
169169
await assertAudioSnapshot(of: buffer, named: "multiChannelComparison.4ch")
170170
}
171171

172+
@Test(
173+
"Checksum snapshot records and verifies a deterministic buffer",
174+
.audioSnapshot(record: false, format: .checksum)
175+
)
176+
func checksumRoundTrip() async throws {
177+
let signal = synthesizeSignal(
178+
frequencyAmplitudePairs: [(440, 0.5)],
179+
count: 4410
180+
)
181+
let buffer = createBuffer(from: signal)
182+
await assertAudioSnapshot(of: buffer, named: "checksumRoundTrip.440hz")
183+
}
184+
185+
@Test(
186+
"Checksum snapshot with multiple buffers uses indexed naming",
187+
.audioSnapshot(record: false, format: .checksum)
188+
)
189+
func checksumMultiBuffer() async throws {
190+
let signal1 = synthesizeSignal(
191+
frequencyAmplitudePairs: [(440, 0.5)],
192+
count: 4410
193+
)
194+
let signal2 = synthesizeSignal(
195+
frequencyAmplitudePairs: [(880, 0.3)],
196+
count: 4410
197+
)
198+
let buffer1 = createBuffer(from: signal1)
199+
let buffer2 = createBuffer(from: signal2)
200+
await assertAudioSnapshot(
201+
of: [buffer1, buffer2],
202+
named: "checksumMultiBuffer"
203+
)
204+
}
205+
172206
private func createBuffer(from samples: [Float], sampleRate: Double = 32768) -> AVAudioPCMBuffer {
173207
createBuffer(channels: [samples], sampleRate: sampleRate)
174208
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
60e4ed9c7ca9ae8b665aab56b5f09cd4
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
15bcba203f8d88260cd8d606496a45aa
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
60e4ed9c7ca9ae8b665aab56b5f09cd4

0 commit comments

Comments
 (0)