Using AVAudioEngine to schedule sounds for low-latency metronome
Asked Answered
J

3

13

I am creating a metronome as part of a larger app and I have a few very short wav files to use as the individual sounds. I would like to use AVAudioEngine because NSTimer has significant latency problems and Core Audio seems rather daunting to implement in Swift. I'm attempting the following, but I'm currently unable to implement the first 3 steps and I'm wondering if there is a better way.

Code outline:

  1. Create an array of file URLs according to the metronome's current settings (number of beats per bar and subdivisions per beat; file A for beats, file B for subdivisions)
  2. Programmatically create a wav file with the appropriate number of frames of silence, based on the tempo and the length of the files, and insert it into the array between each of the sounds
  3. Read those files into a single AudioBuffer or AudioBufferList
  4. audioPlayer.scheduleBuffer(buffer, atTime:nil, options:.Loops, completionHandler:nil)

So far I have been able to play a looping buffer (step 4) of a single sound file, but I haven't been able to construct a buffer from an array of files or create silence programmatically, nor have I found any answers on StackOverflow that address this. So I'm guessing that this isn't the best approach.

My question is: Is it possible to schedule a sequence of sounds with low latency using AVAudioEngine and then loop that sequence? If not, which framework/approach is best suited for scheduling sounds when coding in Swift?

Jeaninejeanlouis answered 18/9, 2015 at 0:21 Comment(2)
Not sure if that helps, but try TheAmazingAudioEngine. It's written in objective c, but may be used as a framework in swiftNineteen
I've looked at TAAE briefly and it may be the best option, though I'm hoping that there is a more native approach.Jeaninejeanlouis
B
6

I was able to make a buffer containing sound from file and silence of required length. Hope this will help:

// audioFile here – an instance of AVAudioFile initialized with wav-file
func tickBuffer(forBpm bpm: Int) -> AVAudioPCMBuffer {
    audioFile.framePosition = 0 // position in file from where to read, required if you're read several times from one AVAudioFile
    let periodLength = AVAudioFrameCount(audioFile.processingFormat.sampleRate * 60 / Double(bpm)) // tick's length for given bpm (sound length + silence length)
    let buffer = AVAudioPCMBuffer(PCMFormat: audioFile.processingFormat, frameCapacity: periodLength)
    try! audioFile.readIntoBuffer(buffer) // sorry for forcing try
    buffer.frameLength = periodLength // key to success. This will append silcence to sound
    return buffer
}

// player – instance of AVAudioPlayerNode within your AVAudioEngine
func startLoop() {
    player.stop()
    let buffer = tickBuffer(forBpm: bpm)
    player.scheduleBuffer(buffer, atTime: nil, options: .Loops, completionHandler: nil)
    player.play()
}
Bibliolatry answered 28/9, 2015 at 7:53 Comment(3)
This is helpful, especially using buffer.frameLength for silence, but still doesn't allow for a different audio file for each type of beat (i.e. sound A for downbeats, sound B for beats, and sound C for subdivisions). The real trick would be to create a buffer containing an entire bar of of beats and subdivisions and then loop the entire buffer/bar. The above answer is perfect for a basic metronome or click track, but it couldn't support a "real" metronome that needs at least two different sounds.Jeaninejeanlouis
I think you can try to build graph with two instances of AVAudioPlayerNode (one for each type of "tick"). The first one plays as "basic" metronome and the other one only on strong beats.Bibliolatry
In the end, this is the approach I went with and I got the single click working. In the future, I may attempt multiple nodes and use the atTime parameter to create the needed offset.Jeaninejeanlouis
C
5

I think that one of possible ways to have sounds played at with lowest possible time error is providing audio samples directly via callback. In iOS you could do this with AudioUnit.

In this callback you could track sample count and know at what sample you are now. From sample counter you could go to time value (using sample rate) and use it for your high level tasks like metronome. If you see that it is time to play metronome sound then you just starting to copy audio samples from that sound to buffer.

This is a theoretic part without any code, but you could find many examples of AudioUnit and callback technique.

Comedienne answered 21/9, 2015 at 20:45 Comment(1)
Thank you, I will have to look into this more.Jeaninejeanlouis
U
3

To expand upon 5hrp's answer:

Take the simple case where you have two beats, an upbeat (tone1) and a downbeat (tone2), and you want them out of phase with each other so the audio will be (up, down, up, down) to a certain bpm.

You will need two instances of AVAudioPlayerNode (one for each beat), let's call them audioNode1 and audioNode2

The first beat you will want to be in phase, so setup as normal:

let buffer = tickBuffer(forBpm: bpm)
audioNode1player.scheduleBuffer(buffer, atTime: nil, options: .loops, completionHandler: nil)

then for the second beat you want it to be exactly out of phase, or to start at t=bpm/2. for this you can use an AVAudioTime variable:

audioTime2 = AVAudioTime(sampleTime: AVAudioFramePosition(AVAudioFrameCount(audioFile2.processingFormat.sampleRate * 60 / Double(bpm) * 0.5)), atRate: Double(1))

you can use this variable in the buffer like so:

audioNode2player.scheduleBuffer(buffer, atTime: audioTime2, options: .loops, completionHandler: nil)

This will play on loop your two beats, bpm/2 out of phase from each other!

It's easy to see how to generalise this to more beats, to create a whole bar. It's not the most elegant solution though, because if you want to say do 16th notes you'd have to create 16 nodes.

Uranous answered 9/12, 2017 at 10:27 Comment(1)
it was quite helpful in my use case. I wonder how to make it more scalable to avoid creating as many nodes as we have notes in one bar.Mealtime

© 2022 - 2024 — McMap. All rights reserved.