返回 CodeWhale
PetAudioOutput.swift
根目录 / pet / swift / PetAudioOutput.swift
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
76 lines Plain Text