AVAudioEngine with Real-Time Pitch Shifting
For iOS audio output, similar to the approach used in our Music Theory Learning app, one can create a powerful note player using just a few base audio files by shifting their pitch with `AVAudioUnitTimePitch`. This approach calculates the semitone difference between the desired note and the nearest available file, letting one play any note on the fly while keeping the app lightweight.
Using `AVAudioEngine`, a `AVAudioPlayerNode` feeds into a pitch node, then to the mixer, with pitch adjusted in cents (100 per semitone). It’s a smart way to handle dynamic music playback and demonstrates advanced AVFoundation audio graph techniques.
// Simplified concept
func playNote(note: String) -> KeyPlayer? {
let audioEngine = AVAudioEngine()
let playerNode = AVAudioPlayerNode()
let pitch = AVAudioUnitTimePitch()
// Not every note has its own audio file. Instead, a small set of base notes
// is stored, and notes in between are reached by shifting the pitch.
// The spec maps each note to the number of semitones it is above the nearest
// available base note file:
// "A:0" → file "A" exists, no shift needed
// "A#:1" → no file for A#, use file "A" and shift up 1 semitone
// "C#:1" → no file for C#, use file "C" and shift up 1 semitone
// "D:2" → no file for D, use file "C" and shift up 2 semitones
let spec = "A:0,A#:1,B:0,C:0,C#:1,D:2,D#:0,E:1,F:2,F#:0,G:1,G#:2"
var names: [String] = []
var shifts: [Int] = []
for part in spec.split(separator: ",") {
let els = part.split(separator: ":")
names.append(String(els[0]))
shifts.append(Int(els[1])!)
}
guard let index = names.firstIndex(of: note) else { return nil }
let semitoneShift = shifts[index] // semitones above the base-note file
let baseNoteName = names[index - semitoneShift] // step back to the file that exists
let url = Bundle.main.url(forResource: baseNoteName, withExtension: "wav")!
let audioFile = try AVAudioFile(forReading: url)
audioEngine.attach(playerNode)
audioEngine.attach(pitch)
audioEngine.connect(playerNode, to: pitch, format: audioFile.processingFormat)
audioEngine.connect(pitch, to: audioEngine.mainMixerNode, format: audioFile.processingFormat)
// AVAudioUnitTimePitch.pitch is expressed in **cents** (100 cents = 1 semitone).
// Multiplying the semitone shift by 100 converts it to the unit the API expects.
pitch.pitch = 100 * Float(semitoneShift)
try audioEngine.start()
playerNode.scheduleFile(audioFile, at: nil)
playerNode.play()
}
