| 1 | import Foundation |
| 2 | import AVFoundation |
| 3 | |
| 4 | /// Voice timing and samples come from the shared core; the native host only |
| 5 | /// schedules bounded buffers against AVAudioEngine's presentation clock. |
| 6 | @MainActor public final class PetAudioOutput { |
| 7 | private let engine = AVAudioEngine() |
| 8 | private var active: [UInt64: AVAudioPlayerNode] = [:] |
| 9 | private var nextVoiceID: UInt64 = 0 |
| 10 | private let format = AVAudioFormat(standardFormatWithSampleRate: 48_000, channels: 2)! |
| 11 | public private(set) var enabled = false |
| 12 | private var epoch: UInt64 = 0 |
| 13 | private var simulationStart = 0.0 |
| 14 | |
| 15 | public init() {} |
| 16 | public func setEnabled(_ value: Bool, simulationTime: Double) throws { |
| 17 | stop() |
| 18 | if value { |
| 19 | guard simulationTime.isFinite && simulationTime >= 0 else { throw PetCoreError.invalid("Invalid audio presentation time.") } |
| 20 | // Materialize the output graph before starting. With no voices yet, |
| 21 | // AVAudioEngine otherwise raises an Objective-C exception (not Error). |
| 22 | _ = engine.mainMixerNode; _ = engine.outputNode |
| 23 | engine.prepare() |
| 24 | try engine.start(); epoch = mach_absolute_time(); simulationStart = simulationTime |
| 25 | } |
| 26 | enabled = value |
| 27 | } |
| 28 | private func discardVoices() { |
| 29 | for node in active.values { node.stop(); engine.detach(node) } |
| 30 | active.removeAll() |
| 31 | } |
| 32 | public func stop() { |
| 33 | discardVoices(); engine.stop(); enabled = false |
| 34 | } |
| 35 | public func present(_ voices: [PetVoice], core: PetNativeCore) throws { |
| 36 | guard enabled else { return } |
| 37 | guard engine.isRunning else { throw PetCoreError.invalid("The audio output device stopped.") } |
| 38 | let now = mach_absolute_time(), simulationTime = core.frame.timeMs / 1000 |
| 39 | let drift = AVAudioTime.seconds(forHostTime: now - epoch) - (simulationTime - simulationStart) |
| 40 | // Device time continues through a stalled UI. Re-anchor presentation to |
| 41 | // the current world instead of playing an ever-growing delayed score. |
| 42 | // Small corrections let current buffers finish; long gaps discard them. |
| 43 | if abs(drift) > 0.05 { |
| 44 | if abs(drift) > 0.25 { discardVoices() } |
| 45 | epoch = now; simulationStart = simulationTime |
| 46 | } |
| 47 | // A stopped/replaced replay cannot leave an old voice attached. |
| 48 | active = active.filter { _, node in |
| 49 | if !node.isPlaying { engine.detach(node); return false }; return true |
| 50 | } |
| 51 | for voice in voices { |
| 52 | if active.count >= 32 { break } |
| 53 | let start = Int((voice.start * 48_000).rounded(.down)), length = Int((voice.duration * 48_000).rounded(.up)) + 2 |
| 54 | let samples = try core.pcm(voice: voice, startSample: start, length: length, rate: 48_000) |
| 55 | guard samples.count == 2, samples.allSatisfy({ $0.count == length }), |
| 56 | let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(length)), let channels = buffer.floatChannelData |
| 57 | else { throw PetCoreError.invalid("Invalid pet PCM buffer.") } |
| 58 | buffer.frameLength = AVAudioFrameCount(length) |
| 59 | for c in 0..<2 { samples[c].withUnsafeBufferPointer { channels[c].update(from: $0.baseAddress!, count: length) } } |
| 60 | let node = AVAudioPlayerNode(); engine.attach(node); engine.connect(node, to: engine.mainMixerNode, format: format) |
| 61 | let elapsed = max(0, Double(start) / 48_000 - simulationStart + 0.1) |
| 62 | let when = AVAudioTime(hostTime: epoch + AVAudioTime.hostTime(forSeconds: elapsed)) |
| 63 | // A numeric ticket crosses the callback boundary; AVAudio nodes |
| 64 | // stay on the main actor and address reuse cannot retire a new voice. |
| 65 | nextVoiceID += 1; let identity = nextVoiceID |
| 66 | node.scheduleBuffer(buffer, at: nil, options: [], completionCallbackType: .dataPlayedBack) { [weak self] _ in |
| 67 | Task { @MainActor in |
| 68 | guard let self, let node = self.active.removeValue(forKey: identity) else { return } |
| 69 | node.stop(); self.engine.detach(node) |
| 70 | } |
| 71 | } |
| 72 | active[identity] = node; node.play(at: when) |
| 73 | } |
| 74 | } |
| 75 | } |
| 76 |