Skip to content

Implement spectrogram strategy#5

Merged
jcavar merged 2 commits intomainfrom
feature/spectrogram
Nov 28, 2025
Merged

Implement spectrogram strategy#5
jcavar merged 2 commits intomainfrom
feature/spectrogram

Conversation

@jcavar
Copy link
Collaborator

@jcavar jcavar commented Oct 29, 2025

Implement a strategy to generate spectrogram images.

There are probably few more iterations I would like to do on this, but for now I am happy.

I've compared and tuned these spectrograms by comparing to output of sox:

440-880-1320hz_sox 500hz_sox 2000hz_sox 5000hz_sox 10000hz_sox 15000hz_sox 20000hz_sox 22000hz_sox

@jcavar jcavar force-pushed the feature/spectrogram branch 5 times, most recently from 3b85663 to 6c60add Compare October 29, 2025 09:22
@jcavar jcavar force-pushed the feature/spectrogram branch from 6c60add to 7088df6 Compare October 29, 2025 09:34
@jcavar jcavar marked this pull request as ready for review October 29, 2025 09:36
@jcavar jcavar requested a review from hhrvoic October 29, 2025 09:36
Copy link
Contributor

@hhrvoic hhrvoic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good :)

Added some minor comments about force unwrapping

var spectrogramData = [Float]()

let mono = mixToMono()
let data = mono.floatChannelData![0]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest to have safe access here without force unwrapping and .first() and handle the optional in the way you think it would be the best fit for this method (throwing, returning empty array or something else)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is that swift-snapshot-testing doesn't have a good way to handle errors. Eventually, anything that we throw out of here, will need to be ignored in the high-level handling code.

I think I would rather embrace our assumptions that these will always be as we expect them and adjust implementation if they turned out to be incorrect for some cases.

Comment on lines +6 to +42
static func spectrogramImage(data: [Float], width: Int, height: Int) -> CGImage {
let rgbImageFormat = vImage_CGImageFormat(
bitsPerComponent: 32,
bitsPerPixel: 32 * 3,
colorSpace: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGBitmapInfo(
rawValue: kCGBitmapByteOrder32Host.rawValue |
CGBitmapInfo.floatComponents.rawValue |
CGImageAlphaInfo.none.rawValue))!

/// RGB vImage buffer that contains a vertical representation of the audio spectrogram.
let redBuffer = vImage.PixelBuffer<vImage.PlanarF>(width: height, height: width)
let greenBuffer = vImage.PixelBuffer<vImage.PlanarF>(width: height, height: width)
let blueBuffer = vImage.PixelBuffer<vImage.PlanarF>(width: height, height: width)
let rgbImageBuffer = vImage.PixelBuffer<vImage.InterleavedFx3>(width: height, height: width)
var data = data
data.withUnsafeMutableBufferPointer {
let planarImageBuffer = vImage.PixelBuffer(
data: $0.baseAddress!,
width: height,
height: width,
byteCountPerRow: height * MemoryLayout<Float>.stride,
pixelFormat: vImage.PlanarF.self
)

multidimensionalLookupTable.apply(
sources: [planarImageBuffer],
destinations: [redBuffer, greenBuffer, blueBuffer],
interpolation: .half
)

rgbImageBuffer.interleave(
planarSourceBuffers: [redBuffer, greenBuffer, blueBuffer]
)
}
return rgbImageBuffer.makeCGImage(cgImageFormat: rgbImageFormat)!
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also here, maybe to think about improving force unwrapping

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to my other comment, I don't feel like we would gain much, since the error that we throw from here would have to be ignored in the upstream code

Comment on lines +50 to +52
// Lookup table resolution: 32 entries provides good color accuracy while keeping memory usage low
// Higher values (64, 128) would provide smoother gradients but increase memory and initialization time
// Lower values (16, 8) would be faster but produce visible banding in spectrograms
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

.foregroundColor(.secondary)
}

private func formatTime(_ time: Double, totalDuration: Double) -> String {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding style, could be static func (extension on String), similar to other extensions below

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, fixed!

Comment on lines +187 to +196
private func createBuffer(from samples: [Float]) -> AVAudioPCMBuffer {
let format = AVAudioFormat(standardFormatWithSampleRate: 32768, channels: 1)!
let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(samples.count))!
let channelData = buffer.floatChannelData![0]
samples.enumerated().forEach { index, sample in
channelData[index] = sample
}
buffer.frameLength = AVAudioFrameCount(samples.count)
return buffer
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be throwing func instead of force unwrapping, I can see benefit cause test methods are already throwing and we will not potentially crash the whole suite?

///
/// Values near zero return dark blue, `0.5` returns red, and `1.0` returns full-brightness green.
@available(macOS 13, iOS 16, *)
nonisolated(unsafe) var multidimensionalLookupTable: vImage.MultidimensionalLookupTable = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be a global var since I see that we use it only in this file?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks ,fixed

@jcavar jcavar merged commit 9dda34a into main Nov 28, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants