Skip to content

Commit 1da07fc

Browse files
authored
Merge pull request #6 from pcbeard/AudioSubsystemMixer
Rewrite audio using callback
2 parents 6376de2 + 5450081 commit 1da07fc

File tree

2 files changed

+236
-29
lines changed

2 files changed

+236
-29
lines changed

Sources/Asteroids/Components/Audio.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ final class Audio: Component {
1717
}
1818

1919
extension Audio {
20-
enum Sound: String {
20+
enum Sound: String, CaseIterable {
2121
case explodeAsteroid = "asteroid.wav"
2222
case explodeShip = "ship.wav"
2323
case shootGun = "shoot.wav"

Sources/Asteroids/Systems/AudioSystem.swift

Lines changed: 235 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,177 @@
33
// FirebladeECSDemo
44
//
55
// Created by Igor Kravchenko on 18.11.2020.
6+
// Modified by @pcbeard on 29.9.2021.
67
//
78

89
import Dispatch
910
import FirebladeECS
1011
import SDL2
1112

13+
/// In-memory copy of a sound file. Currently these remain loaded forever to avoid audio dropouts.
14+
struct AudioData {
15+
var sound : Audio.Sound
16+
var spec : SDL_AudioSpec
17+
var buffer : UnsafeMutableRawPointer
18+
var length : Int
19+
}
20+
21+
typealias MixBuffer = (count: Int, format: SDL_AudioFormat, pointer: UnsafeMutableRawPointer)
22+
23+
struct MixCallbackData {
24+
var player : MixingAudioPlayer
25+
var queue : DispatchQueue
26+
var buffers : [MixBuffer] = []
27+
}
28+
29+
/// helper function to convert arrays or inout parameters into scoped pointers.
30+
@inlinable public func withPointer<R, T1>(_ p1 : UnsafePointer<T1>,
31+
_ body: (UnsafePointer<T1>) throws -> R)
32+
rethrows -> R {
33+
try body(p1)
34+
}
35+
36+
@inlinable public func withMutablePointer<R, T1>(_ p1 : UnsafeMutablePointer<T1>,
37+
_ body: (UnsafeMutablePointer<T1>) throws -> R)
38+
rethrows -> R {
39+
try body(p1)
40+
}
41+
42+
typealias MutableSamplePtr = UnsafeMutablePointer<UInt8>
43+
typealias SamplePtr = UnsafePointer<UInt8>
44+
typealias Mixer = (MutableSamplePtr, SamplePtr, SDL_AudioFormat, Int) -> Void
45+
46+
func mixAudioBuffers(_ data: inout MixCallbackData,
47+
_ audioStream: MutableSamplePtr,
48+
_ length: Int) -> Bool
49+
{
50+
var finished = false
51+
func mixBuffer(_ buffer : inout MixBuffer, mixer: Mixer) {
52+
let count = min(length, buffer.count)
53+
if count == 0 {
54+
// This buffer is empty. Schedule buffer removal.
55+
finished = true
56+
} else {
57+
// mix the next range of samples.
58+
let bufferPointer = UnsafeRawPointer(buffer.pointer).assumingMemoryBound(to: UInt8.self)
59+
mixer(audioStream, bufferPointer, buffer.format, count)
60+
buffer.pointer = buffer.pointer.advanced(by: count)
61+
buffer.count -= count
62+
}
63+
}
64+
let bufferCount = data.buffers.count
65+
withMutablePointer(&data.buffers) { buffers in
66+
if bufferCount == 1 {
67+
// special case single buffer case, no need to mix.
68+
mixBuffer(&buffers[0]) {
69+
memcpy($0, $1, $3)
70+
// but need to clear remainder.
71+
let count = $3, remaining = length - count
72+
if remaining > 0 {
73+
memset(UnsafeMutableRawPointer(audioStream).advanced(by: count), 0, remaining)
74+
}
75+
}
76+
} else {
77+
// fill stream with silence before mixing.
78+
memset(audioStream, 0, length)
79+
for i in 0..<bufferCount {
80+
mixBuffer(&buffers[i]) {
81+
SDL_MixAudioFormat($0, $1, $2, UInt32($3), SDL_MIX_MAXVOLUME)
82+
}
83+
}
84+
}
85+
}
86+
return finished
87+
}
88+
89+
func mixAudioCallback(userDataOrNil : UnsafeMutableRawPointer?,
90+
audioStreamOrNil: UnsafeMutablePointer<UInt8>?,
91+
length : Int32)
92+
{
93+
// validate that pointers aren't nil
94+
if let userData = userDataOrNil, let audioStream = audioStreamOrNil {
95+
let data = userData.assumingMemoryBound(to: MixCallbackData.self)
96+
let finished = mixAudioBuffers(&data.pointee, audioStream, Int(length))
97+
if finished {
98+
data.pointee.queue.async {
99+
let player = data.pointee.player
100+
player.buffersFinished()
101+
}
102+
}
103+
}
104+
}
105+
106+
class MixingAudioPlayer {
107+
let data : UnsafeMutablePointer<MixCallbackData>
108+
var device : SDL_AudioDeviceID
109+
var playing = false
110+
111+
init(_ queue : DispatchQueue, _ audioSpec : SDL_AudioSpec) {
112+
data = UnsafeMutablePointer<MixCallbackData>.allocate(capacity: 1)
113+
114+
var want = audioSpec
115+
want.samples = 512
116+
want.callback = mixAudioCallback
117+
want.userdata = UnsafeMutableRawPointer(data)
118+
var have = SDL_AudioSpec()
119+
device = SDL_OpenAudioDevice(nil, 0, &want, &have, 0)
120+
if device > 0 {
121+
data.initialize(to: MixCallbackData(player: self, queue: queue))
122+
}
123+
}
124+
125+
deinit {
126+
if device > 0 {
127+
SDL_CloseAudioDevice(device)
128+
}
129+
// For this to be safe, we need a guarantee that the callback will never be called
130+
// again. Testing shows that closing the audio device isn't a strong enough guarantee,
131+
// so the `CallbackData` blocks are leaked deliberately.
132+
// data.deallocate()
133+
}
134+
135+
/// Prepare the player to play audio data. In theory, this could be called
136+
/// at any moment, to interrupt the currently playing sound, but in practice
137+
/// that will cause clicks, so this is only ever called when the audio
138+
/// device is paused.
139+
/// - Parameter audioData:
140+
func prepare(_ audioData : AudioData) {
141+
SDL_LockAudioDevice(device)
142+
data.pointee.buffers.append(
143+
(count: audioData.length,
144+
format: audioData.spec.format,
145+
pointer: audioData.buffer))
146+
SDL_UnlockAudioDevice(device)
147+
}
148+
149+
func buffersFinished() {
150+
SDL_LockAudioDevice(device)
151+
data.pointee.buffers = data.pointee.buffers.filter { $0.count > 0 }
152+
SDL_UnlockAudioDevice(device)
153+
if data.pointee.buffers.isEmpty {
154+
self.pause()
155+
}
156+
}
157+
158+
func start() {
159+
if !playing {
160+
SDL_PauseAudioDevice(device, 0)
161+
playing = true
162+
}
163+
}
164+
165+
func pause() {
166+
if playing {
167+
SDL_PauseAudioDevice(device, 1)
168+
playing = false
169+
}
170+
}
171+
172+
func dump() {
173+
Swift.dump(data.pointee, maxDepth: 1)
174+
}
175+
}
176+
12177
class AudioSystem {
13178
private let queue = DispatchQueue(label: "asteroids.audio",
14179
qos: .userInteractive,
@@ -19,6 +184,12 @@ class AudioSystem {
19184

20185
init(nexus: Nexus) {
21186
family = nexus.family(requires: Audio.self)
187+
queue.async {
188+
// preload all known sounds.
189+
Audio.Sound.allCases.forEach { sound in
190+
self.prepare(sound: sound)
191+
}
192+
}
22193
}
23194

24195
func update() {
@@ -32,40 +203,76 @@ class AudioSystem {
32203
}
33204
}
34205

35-
func play(sound: Audio.Sound) {
36-
guard let path = bundleResourcesPath()?.appendingPathComponent(sound.rawValue).path else {
37-
assertionFailure("unable to find path for '\(sound.rawValue)' resource")
38-
return
206+
private var audioDataCache: [Audio.Sound : AudioData] = [:]
207+
208+
func fetchAudioData(_ sound: Audio.Sound) -> AudioData? {
209+
if let audioData = audioDataCache[sound] {
210+
return audioData
39211
}
40-
assert(sound.rawValue.hasSuffix(".wav"))
41-
var specIn = SDL_AudioSpec()
42-
var specOut = SDL_AudioSpec()
43-
let audio = SDL_OpenAudioDevice(.none, 0, &specIn, &specOut, 0)
44-
guard audio > 0, let ops = SDL_RWFromFile(path, "rb") else {
45-
print(String(cString: SDL_GetError()))
46-
return
212+
if let path = bundleResourcesPath()?.appendingPathComponent(sound.rawValue).path {
213+
if let ops = SDL_RWFromFile(path, "rb") {
214+
var spec = SDL_AudioSpec()
215+
var length: UInt32 = 0
216+
var bufferOrNil: UnsafeMutablePointer<UInt8>?
217+
if SDL_LoadWAV_RW(ops, 1, &spec, &bufferOrNil, &length) != nil, let buffer = bufferOrNil {
218+
let audioData = AudioData(sound: sound, spec: spec, buffer: UnsafeMutableRawPointer(buffer), length: Int(length))
219+
audioDataCache[sound] = audioData
220+
return audioData
221+
}
222+
}
223+
}
224+
if let error = SDL_GetError() {
225+
print(String(cString: error))
47226
}
227+
return nil
228+
}
48229

49-
var wavLength: Uint32 = 0
50-
var wavBuffer: UnsafeMutablePointer<Uint8>?
51-
if SDL_LoadWAV_RW(
52-
ops,
53-
1,
54-
&specIn,
55-
&wavBuffer,
56-
&wavLength
57-
) == nil {
58-
print(String(cString: SDL_GetError()))
230+
private var mixingPlayer : MixingAudioPlayer?
231+
232+
func getMixingPlayer(_ audioSpec : SDL_AudioSpec) -> MixingAudioPlayer? {
233+
if let player = mixingPlayer {
234+
return player
235+
}
236+
let player = MixingAudioPlayer(queue, audioSpec)
237+
mixingPlayer = player
238+
return player
239+
}
240+
241+
private func play(audio audioData : AudioData) {
242+
if let player = getMixingPlayer(audioData.spec) {
243+
player.prepare(audioData)
244+
player.start()
59245
}
246+
}
60247

61-
SDL_QueueAudio(audio, wavBuffer, wavLength)
62-
SDL_PauseAudioDevice(audio, 0)
248+
private func prepare(audio audioData : AudioData) -> Bool {
249+
getMixingPlayer(audioData.spec) != nil
250+
}
63251

64-
while SDL_GetQueuedAudioSize(audio) > 0 {
65-
SDL_Delay(1000)
252+
/**
253+
* Plays a sound asynchronously using an SDL sound device. Because closing audio devices
254+
* after playback seems to be so race-prone (causing crashes), this creates a single instance of
255+
* the class `MixAudioPlayer` which keeps the audio device paused when not in use
256+
* (to reduce CPU). The player allocates a an unsafe pointer to a `MixCallbackData` struct,
257+
* which is passed to the callback function `mixAudioCallback`, when the audio device needs
258+
* audio buffers to play. The `MixCallbackData` struct contains an array of audio buffers to be mixed.
259+
* When an audio buffer is fully consumed, `mixAudioBuffers()` returns true, and
260+
* `mixAudioCallback` calls `MixAudioPlayer.buffersFinished()` which removes
261+
* any buffers where `buffer.count == 0`.
262+
* - Parameter sound: an enum that specifies a known .wav file resource.
263+
*/
264+
func play(sound: Audio.Sound) {
265+
assert(sound.rawValue.hasSuffix(".wav"))
266+
if let audioData = fetchAudioData(sound) {
267+
play(audio: audioData)
66268
}
67-
68-
SDL_CloseAudioDevice(audio)
69-
SDL_FreeWAV(wavBuffer)
269+
}
270+
271+
/// Preloads audio data and starts the audio system so sounds will play immediately.
272+
@discardableResult func prepare(sound: Audio.Sound) -> Bool {
273+
if let audioData = fetchAudioData(sound) {
274+
return prepare(audio: audioData)
275+
}
276+
return false
70277
}
71278
}

0 commit comments

Comments
 (0)