Skip to content

Commit

Permalink
Implement textual waveform support on AVAudioPCMBuffer (#2941)
Browse files Browse the repository at this point in the history
* Check errors after AudioUnit interactions

I spent quite a bit of time debugging an issue where
`AudioUnitSetProperty` was returning error `OSStatus`.

Ideally, all of these would be marked as throws, so that call-site can
react appropriatelly to these errors. But I thought this is a good first
step to improve the current situation.

* Implement textual waveform support on AVAudioPCMBuffer

- Textual waveforms can be useful as a debugging (and testing) tool
- Additionally, this PR adds the ability to quick look AVAudioPCMBuffer
  inside of Xcode

For example:
- https://goq2q.net/blog/tech/using-ascii-waveforms-to-test-real-time-audio-code
- https://melatonin.dev/blog/audio-sparklines/
  • Loading branch information
jcavar authored Dec 9, 2024
1 parent ec7b290 commit e05284f
Show file tree
Hide file tree
Showing 3 changed files with 278 additions and 0 deletions.
91 changes: 91 additions & 0 deletions Sources/AudioKit/Audio Files/AVAudioPCMBuffer+Utilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,94 @@ public extension AVAudioPCMBuffer {
return editedBuffer
}
}

public extension AVAudioPCMBuffer {
/// Reduce a buffer into a specified number of buckets
/// Returns `[Float]` buckets and absolute maximum bucket value
func reduce(bucketCount: Int) -> ([Float], Float) {
let frameCount = Int(self.frameLength)
guard frameCount > 0 else { return ([], 0) }
let mono = mixToMono()
let samples = Array(UnsafeBufferPointer(start: mono.floatChannelData![0], count: frameCount))
let samplesPerBucket = max(1, Double(frameCount) / Double(bucketCount))

var buckets = [Float](repeating: 0, count: bucketCount)
var maxBucket: Float = 0
for i in 0..<bucketCount {
let bucketStart = Int(Double(i) * samplesPerBucket)
let bucketEnd = min(bucketStart + Int(samplesPerBucket), frameCount)
guard bucketStart < bucketEnd else { break }
let bucketSamples = samples[bucketStart..<bucketEnd]
let avgSample = bucketSamples.reduce(into: Float(0)) { currentMax, value in
if abs(value) > abs(currentMax) {
currentMax = value
}
}
buckets[i] = avgSample
if abs(avgSample) > maxBucket {
maxBucket = abs(avgSample)
}
}
return (buckets, maxBucket)
}
}

public extension AVAudioPCMBuffer {
func visualDescription(width: Int = 60, height: Int = 15) -> String {
assert((height - 1).isMultiple(of: 2))
let rows = [
format.stringDescription,
"Frame count \(frameLength)",
"Frame capacity \(frameCapacity)"
]
let frameCount = Int(self.frameLength)
guard self.floatChannelData != nil, frameCount > 0 else {
return rows.joined(separator: "\n")
}
let (buckets, maxBucket) = reduce(bucketCount: width)
let scaleFactor = maxBucket > 0 ? Float((height - 1) / 2) / maxBucket : 1.0
let half = Int((Double(height) / 2).rounded(.up))
let waveformRows = (0..<height).map { rowIndex in
let row = height - rowIndex
return "\(abs(row - half))" + String(
buckets.map { value in
let scaled = value * scaleFactor
let max = Int(half) + Int(scaled)
if row > Int(half) {
return (max == row && scaled > 0) ? "*" : " "
} else if row < Int(half) {
return (row == max && scaled < 0) ? "*" : " "
} else {
return (row == max) ? "*" : " "
}
}
)
}
return (rows + [""] + waveformRows + [""]).joined(separator: "\n")
}

// Allows to use Quick Look in the debugger on AVAudioPCMBuffer
// https://developer.apple.com/library/archive/documentation/IDEs/Conceptual/CustomClassDisplay_in_QuickLook/CH01-quick_look_for_custom_objects/CH01-quick_look_for_custom_objects.html
@objc func debugQuickLookObject() -> Any? {
visualDescription()
}
}

private extension AVAudioFormat {
var stringDescription: String {
"Format \(channelCount) ch, \(sampleRate) Hz, \(isInterleaved ? "interleaved" : "deinterleaved"), \(commonFormat.stringDescription)"
}
}

private extension AVAudioCommonFormat {
var stringDescription: String {
switch self {
case .otherFormat: "Other format"
case .pcmFormatFloat32: "Float32"
case .pcmFormatFloat64: "Float64"
case .pcmFormatInt16: "Int16"
case .pcmFormatInt32: "Int32"
@unknown default: "Unknown"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import AudioKit
import AVFoundation
import Foundation
import XCTest

class AVAudioPCMBufferReduceToBucketsTests: XCTestCase {
let sampleRate: Double = 44100
lazy var capacity = UInt32(sampleRate)
lazy var format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 2)!
lazy var buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: capacity)!
lazy var data = buffer.floatChannelData!

func testOneSamplePerBucket() {
for index in 0..<capacity {
data[0][Int(index)] = 1
}
buffer.frameLength = capacity

let buckets = buffer.reduce(bucketCount: 44100)

XCTAssertEqual(buckets.0.count, 44100)
XCTAssertTrue(buckets.0.allSatisfy { $0 == 1 })
}

func testTwoSamplesPerBucketHigherSampleReturned() {
for index in 0..<Int(capacity) {
data[0][index] = index.isMultiple(of: 2) ? 1 : 0.5
}
buffer.frameLength = capacity

let buckets = buffer.reduce(bucketCount: 22050)

XCTAssertEqual(buckets.0.count, 22050)
XCTAssertTrue(buckets.0.allSatisfy { $0 == 1 })
}

func testTwoSamplesPerBucketHigherAbsoluteSampleReturned() {
for index in 0..<Int(capacity) {
data[0][index] = index.isMultiple(of: 2) ? -1 : 0.5
}
buffer.frameLength = capacity

let buckets = buffer.reduce(bucketCount: 22050)

XCTAssertEqual(buckets.0.count, 22050)
XCTAssertTrue(buckets.0.allSatisfy { $0 == -1 })
}

func testLessThenOneSamplePerBucketFallbacksToOneSamplePerBucket() {
for index in 0..<Int(capacity) {
data[0][index] = 1
}
buffer.frameLength = capacity

let buckets = buffer.reduce(bucketCount: 44102)

XCTAssertEqual(buckets.0.count, 44102)
XCTAssertTrue(buckets.0[..<44100].allSatisfy { $0 == 1 })
XCTAssertEqual(buckets.0[44100..<44102], [0, 0])
}

func testAbsoluteMax() {
for index in 0..<Int(capacity) {
data[0][index] = Float(index + 1)
}
buffer.frameLength = capacity

let buckets = buffer.reduce(bucketCount: 44102)

XCTAssertEqual(buckets.1, 44100)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright AudioKit. All Rights Reserved. Revision History at http://github.com/AudioKit/AudioKit/

import AudioKit
import AVFoundation
import Foundation
import GameplayKit
import XCTest

class PlaygroundOscillatorTests: XCTestCase {
let engine = AudioEngine()

override func setUp() {
Settings.sampleRate = 44100
}

func testSine() {
let data = test(waveform: .sine)

XCTAssertEqual(
data.visualDescription(),
"""
Format 2 ch, 44100.0 Hz, deinterleaved, Float32
Frame count 44100
Frame capacity 44100
7│ *
6│ ****** *****
5│ ** **
4│ ** **
3│ * *
2│ ** **
1│ * *
0│ * ** *
1│ * *
2│ ** **
3│ * *
4│ ** **
5│ ** **
6│ ***** ******
7│ *
"""
)
}

func testTriangle() {
let data = test(waveform: .triangle)
XCTAssertEqual(
data.visualDescription(),
"""
Format 2 ch, 44100.0 Hz, deinterleaved, Float32
Frame count 44100
Frame capacity 44100
7│ **
6│ ** **
5│ ** **
4│ ** **
3│ ** **
2│ ** **
1│ ** **
0│ **** ****
1│ ** **
2│ ** **
3│ ** **
4│ ** **
5│ ** **
6│ ** **
7│ * *
"""
)
}

func testPositiveSquare() {
let data = test(waveform: .positiveSquare)
XCTAssertEqual(
data.visualDescription(),
"""
Format 2 ch, 44100.0 Hz, deinterleaved, Float32
Frame count 44100
Frame capacity 44100
7│ *******************************
6│
5│
4│
3│
2│
1│
0│ *****************************
1│
2│
3│
4│
5│
6│
7│
"""
)
}
}

private extension PlaygroundOscillatorTests {
func test(waveform: TableType) -> AVAudioPCMBuffer {
let oscillator = PlaygroundOscillator(waveform: Table(waveform), frequency: 1)
engine.output = oscillator
oscillator.start()

let data = engine.startTest(totalDuration: 1)
data.append(engine.render(duration: 1))
return data
}
}

0 comments on commit e05284f

Please sign in to comment.