Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added AudioTap functionality #80

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
2 changes: 2 additions & 0 deletions Sources/SwiftAudioEx/AVPlayerWrapper/AVPlayerWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class AVPlayerWrapper: AVPlayerWrapperProtocol {
// MARK: - Properties

fileprivate var avPlayer = AVPlayer()
internal var audioTap: AudioTap? = nil
private let playerObserver = AVPlayerObserver()
internal let playerTimeObserver: AVPlayerTimeObserver
private let playerItemNotificationObserver = AVPlayerItemNotificationObserver()
Expand Down Expand Up @@ -385,6 +386,7 @@ class AVPlayerWrapper: AVPlayerWrapperProtocol {
private func startObservingAVPlayer(item: AVPlayerItem) {
playerItemObserver.startObserving(item: item)
playerItemNotificationObserver.startObserving(item: item)
attachTap(audioTap, to: item)
}

private func stopObservingAVPlayerItem() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ protocol AVPlayerWrapperProtocol: AnyObject {

var state: AVPlayerWrapperState { get set }

var audioTap: AudioTap? { get set }

var playWhenReady: Bool { get set }

var currentItem: AVPlayerItem? { get }
Expand Down
8 changes: 8 additions & 0 deletions Sources/SwiftAudioEx/AudioPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ public typealias AudioPlayerState = AVPlayerWrapperState
public class AudioPlayer: AVPlayerWrapperDelegate {
/// The wrapper around the underlying AVPlayer
let wrapper: AVPlayerWrapperProtocol = AVPlayerWrapper()

/**
Set an instance of AudioTap, to receive frame information and audio buffer access during playback.
*/
public var audioTap: AudioTap? {
get { return wrapper.audioTap }
set(value) { wrapper.audioTap = value }
}

public let nowPlayingInfoController: NowPlayingInfoControllerProtocol
public let remoteCommandController: RemoteCommandController
Expand Down
98 changes: 98 additions & 0 deletions Sources/SwiftAudioEx/AudioTap.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//
// AudioTap.swift
//
//
// Created by Brandon Sneed on 3/31/24.
//

import Foundation
import AVFoundation

/**
Subclass this and set the AudioPlayer's `audioTap` property to start receiving the
audio stream.
*/
open class AudioTap {
// Called at tap initialization for a given player item. Use this to setup anything you might need.
open func initialize() { print("audioTap: initialize") }
// Called at teardown of the internal tap. Use this to reset any memory buffers you have created, etc.
open func finalize() { print("audioTap: finalize") }
// Called just before playback so you can perform setup based on the stream description.
open func prepare(description: AudioStreamBasicDescription) { print("audioTap: prepare") }
// Called just before finalize.
open func unprepare() { print("audioTap: unprepare") }
/**
Called periodically during audio stream playback.

Example:

```
func process(numberOfFrames: Int, buffer: UnsafeMutableAudioBufferListPointer) {
for channel in buffer {
// process audio samples here
//memset(channel.mData, 0, Int(channel.mDataByteSize))
}
}
```
*/
open func process(numberOfFrames: Int, buffer: UnsafeMutableAudioBufferListPointer) { print("audioTap: process") }
}

extension AVPlayerWrapper {
internal func attachTap(_ tap: AudioTap?, to item: AVPlayerItem) {
guard let tap else { return }
guard let track = item.asset.tracks(withMediaType: .audio).first else {
return
}

let audioMix = AVMutableAudioMix()
let params = AVMutableAudioMixInputParameters(track: track)

// we need to retain this pointer so it doesn't disappear out from under us.
// we'll then let it go after we finalize. If the tap changed upstream, we
// aren't going to pick up the new one until after this player item goes away.
let client = UnsafeMutableRawPointer(Unmanaged.passRetained(tap).toOpaque())

var callbacks = MTAudioProcessingTapCallbacks(version: kMTAudioProcessingTapCallbacksVersion_0, clientInfo: client)
{ tapRef, clientInfo, tapStorageOut in
// initial tap setup
guard let clientInfo else { return }
tapStorageOut.pointee = clientInfo
let audioTap = Unmanaged<AudioTap>.fromOpaque(clientInfo).takeUnretainedValue()
audioTap.initialize()
} finalize: { tapRef in
// clean up
let audioTap = Unmanaged<AudioTap>.fromOpaque(MTAudioProcessingTapGetStorage(tapRef)).takeUnretainedValue()
audioTap.finalize()
// we're done, we can let go of the pointer we retained.
Unmanaged.passUnretained(audioTap).release()
} prepare: { tapRef, maxFrames, processingFormat in
// allocate memory for sound processing
let audioTap = Unmanaged<AudioTap>.fromOpaque(MTAudioProcessingTapGetStorage(tapRef)).takeUnretainedValue()
audioTap.prepare(description: processingFormat.pointee)
} unprepare: { tapRef in
// deallocate memory for sound processing
let audioTap = Unmanaged<AudioTap>.fromOpaque(MTAudioProcessingTapGetStorage(tapRef)).takeUnretainedValue()
audioTap.unprepare()
} process: { tapRef, numberFrames, flags, bufferListInOut, numberFramesOut, flagsOut in
guard noErr == MTAudioProcessingTapGetSourceAudio(tapRef, numberFrames, bufferListInOut, flagsOut, nil, numberFramesOut) else {
return
}

// process sound data
let audioTap = Unmanaged<AudioTap>.fromOpaque(MTAudioProcessingTapGetStorage(tapRef)).takeUnretainedValue()
audioTap.process(numberOfFrames: numberFrames, buffer: UnsafeMutableAudioBufferListPointer(bufferListInOut))
}

var tapRef: Unmanaged<MTAudioProcessingTap>?
let error = MTAudioProcessingTapCreate(kCFAllocatorDefault, &callbacks, kMTAudioProcessingTapCreationFlag_PreEffects, &tapRef)
assert(error == noErr)

params.audioTapProcessor = tapRef?.takeUnretainedValue()
tapRef?.release()

audioMix.inputParameters = [params]
item.audioMix = audioMix
}
}

4 changes: 4 additions & 0 deletions Sources/SwiftAudioEx/Observer/AVPlayerItemObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class AVPlayerItemObserver: NSObject {

self.isObserving = true
self.observingItem = item

item.addObserver(self, forKeyPath: AVPlayerItemKeyPath.duration, options: [.new], context: &AVPlayerItemObserver.context)
item.addObserver(self, forKeyPath: AVPlayerItemKeyPath.loadedTimeRanges, options: [.new], context: &AVPlayerItemObserver.context)
item.addObserver(self, forKeyPath: AVPlayerItemKeyPath.playbackLikelyToKeepUp, options: [.new], context: &AVPlayerItemObserver.context)
Expand All @@ -79,6 +80,9 @@ class AVPlayerItemObserver: NSObject {
return
}

// BKS: remove a tap if we had one.
observingItem.audioMix = nil

observingItem.removeObserver(self, forKeyPath: AVPlayerItemKeyPath.duration, context: &AVPlayerItemObserver.context)
observingItem.removeObserver(self, forKeyPath: AVPlayerItemKeyPath.loadedTimeRanges, context: &AVPlayerItemObserver.context)
observingItem.removeObserver(self, forKeyPath: AVPlayerItemKeyPath.playbackLikelyToKeepUp, context: &AVPlayerItemObserver.context)
Expand Down
8 changes: 8 additions & 0 deletions Sources/SwiftAudioEx/Utils/Devices.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//
// File.swift
//
//
// Created by Brandon Sneed on 4/1/24.
//

import Foundation
32 changes: 32 additions & 0 deletions Tests/SwiftAudioExTests/AudioPlayerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,38 @@ class AudioPlayerTests: XCTestCase {
XCTAssertEqual(audioPlayer.duration, 0)
}

// MARK: - Audio Tap testing

func testAudioTapSwitching() {
listener.onSecondsElapse = { position in
if position > 4 {
// swap it out part-way through the first track.
self.audioPlayer.audioTap = DummyAudioTap(tapIndex: 2)
}
}

audioPlayer.audioTap = DummyAudioTap(tapIndex: 1)
audioPlayer.load(item: FiveSecondSource.getAudioItem())
audioPlayer.play()

RunLoop.current.run(until: Date(timeIntervalSinceNow: 6))

audioPlayer.load(item: FiveSecondSource.getAudioItem())
audioPlayer.play()

RunLoop.current.run(until: Date(timeIntervalSinceNow: 6))

let tap1Active = DummyAudioTap.outputs.contains { output in
return output.contains("audioTap 1: process")
}

let tap2Active = DummyAudioTap.outputs.contains { output in
return output.contains("audioTap 2: process")
}
XCTAssertTrue(tap1Active)
XCTAssertTrue(tap2Active)
}

// MARK: - Failure

func testFailEventOnLoadWithNonMalformedURL() {
Expand Down
40 changes: 40 additions & 0 deletions Tests/SwiftAudioExTests/Mocks/DummyAudioTap.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//
// File.swift
//
//
// Created by Brandon Sneed on 4/1/24.
//

import Foundation
import CoreAudio
@testable import SwiftAudioEx

class DummyAudioTap: AudioTap {
static var outputs = [String]()

let tapIndex: Int

init(tapIndex: Int) {
self.tapIndex = tapIndex
}

override func initialize() {
Self.outputs.append("audioTap \(tapIndex): initialize")
}

override func finalize() {
Self.outputs.append("audioTap \(tapIndex): finalize")
}

override func prepare(description: AudioStreamBasicDescription) {
Self.outputs.append("audioTap \(tapIndex): prepare")
}

override func unprepare() {
Self.outputs.append("audioTap \(tapIndex): unprepare")
}

override func process(numberOfFrames: Int, buffer: UnsafeMutableAudioBufferListPointer) {
Self.outputs.append("audioTap \(tapIndex): process")
}
}
Loading