sezo-audio-engine 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +51 -0
- package/android/build.gradle +44 -0
- package/android/cpp/CMakeLists.txt +23 -0
- package/android/cpp/NativeBridge.cpp +6 -0
- package/android/settings.gradle +12 -0
- package/android/src/main/java/expo/modules/audioengine/ExpoAudioEngineModule.kt +549 -0
- package/dist/AudioEngineModule.d.ts +2 -0
- package/dist/AudioEngineModule.js +46 -0
- package/dist/AudioEngineModule.types.d.ts +108 -0
- package/dist/AudioEngineModule.types.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/expo-module.config.json +9 -0
- package/ios/AudioEngine/AudioEngineConfig.swift +13 -0
- package/ios/AudioEngine/AudioSessionManager.swift +61 -0
- package/ios/AudioEngine/AudioTrack.swift +72 -0
- package/ios/AudioEngine/NativeAudioEngine.swift +1180 -0
- package/ios/ExpoAudioEngine.podspec +26 -0
- package/ios/ExpoAudioEngineModule.swift +156 -0
- package/package.json +60 -0
|
@@ -0,0 +1,1180 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import MediaPlayer
|
|
3
|
+
import UIKit
|
|
4
|
+
|
|
5
|
+
/// Swift wrapper around `AVAudioEngine` that backs the Expo module API.
|
|
6
|
+
/// Keeps state on a single serial queue to avoid thread-safety issues.
|
|
7
|
+
final class NativeAudioEngine {
|
|
8
|
+
/// Serial queue to protect engine state and avoid races with audio callbacks.
|
|
9
|
+
private let queue = DispatchQueue(label: "sezo.audioengine.state")
|
|
10
|
+
/// Core iOS audio graph.
|
|
11
|
+
private let engine = AVAudioEngine()
|
|
12
|
+
/// Manages the shared `AVAudioSession` setup.
|
|
13
|
+
private let sessionManager = AudioSessionManager()
|
|
14
|
+
/// Loaded tracks keyed by their JS `id`.
|
|
15
|
+
private var tracks: [String: AudioTrack] = [:]
|
|
16
|
+
/// Master controls and global effects.
|
|
17
|
+
private var masterVolume: Double = 1.0
|
|
18
|
+
private var globalPitch: Double = 0.0
|
|
19
|
+
private var globalSpeed: Double = 1.0
|
|
20
|
+
/// Transport and playback state.
|
|
21
|
+
private var isPlayingFlag = false
|
|
22
|
+
private var isRecordingFlag = false
|
|
23
|
+
private var currentPositionMs: Double = 0.0
|
|
24
|
+
private var durationMs: Double = 0.0
|
|
25
|
+
/// Recording state and simple metering.
|
|
26
|
+
private var recordingVolume: Double = 1.0
|
|
27
|
+
private var isInitialized = false
|
|
28
|
+
private var playbackStartHostTime: UInt64?
|
|
29
|
+
private var playbackStartPositionMs: Double = 0.0
|
|
30
|
+
private var activePlaybackCount = 0
|
|
31
|
+
private var playbackToken = UUID()
|
|
32
|
+
private var recordingState: RecordingState?
|
|
33
|
+
private var inputLevel: Double = 0.0
|
|
34
|
+
private var backgroundPlaybackEnabled = false
|
|
35
|
+
private var nowPlayingMetadata: [String: Any] = [:]
|
|
36
|
+
private var playCommandTarget: Any?
|
|
37
|
+
private var pauseCommandTarget: Any?
|
|
38
|
+
private var toggleCommandTarget: Any?
|
|
39
|
+
private var lastConfig = AudioEngineConfig(dictionary: [:])
|
|
40
|
+
|
|
41
|
+
/// Bookkeeping for a live recording session.
|
|
42
|
+
private struct RecordingState {
|
|
43
|
+
let url: URL
|
|
44
|
+
let file: AVAudioFile
|
|
45
|
+
let format: String
|
|
46
|
+
let sampleRate: Double
|
|
47
|
+
let channels: Int
|
|
48
|
+
let startTimeMs: Double
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Initializes the audio session and prepares the engine.
|
|
52
|
+
func initialize(config: [String: Any]) {
|
|
53
|
+
let parsedConfig = AudioEngineConfig(dictionary: config)
|
|
54
|
+
queue.sync {
|
|
55
|
+
lastConfig = parsedConfig
|
|
56
|
+
sessionManager.configure(with: parsedConfig)
|
|
57
|
+
engine.mainMixerNode.outputVolume = Float(masterVolume)
|
|
58
|
+
engine.prepare()
|
|
59
|
+
isInitialized = true
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// Stops audio, clears state, and releases resources.
|
|
64
|
+
func releaseResources() {
|
|
65
|
+
queue.sync {
|
|
66
|
+
stopEngineIfRunning()
|
|
67
|
+
stopAllPlayers()
|
|
68
|
+
stopRecordingInternal()
|
|
69
|
+
detachAllTracks()
|
|
70
|
+
tracks.removeAll()
|
|
71
|
+
isPlayingFlag = false
|
|
72
|
+
isRecordingFlag = false
|
|
73
|
+
currentPositionMs = 0.0
|
|
74
|
+
durationMs = 0.0
|
|
75
|
+
playbackStartHostTime = nil
|
|
76
|
+
playbackStartPositionMs = 0.0
|
|
77
|
+
activePlaybackCount = 0
|
|
78
|
+
playbackToken = UUID()
|
|
79
|
+
inputLevel = 0.0
|
|
80
|
+
isInitialized = false
|
|
81
|
+
sessionManager.deactivate()
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// Loads and attaches tracks into the engine graph.
|
|
86
|
+
func loadTracks(_ trackInputs: [[String: Any]]) {
|
|
87
|
+
queue.sync {
|
|
88
|
+
ensureInitializedIfNeeded()
|
|
89
|
+
stopEngineIfRunning()
|
|
90
|
+
stopAllPlayers()
|
|
91
|
+
for input in trackInputs {
|
|
92
|
+
guard let track = AudioTrack(input: input) else {
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
if let existing = tracks[track.id] {
|
|
96
|
+
detachTrack(existing)
|
|
97
|
+
}
|
|
98
|
+
attachTrack(track)
|
|
99
|
+
tracks[track.id] = track
|
|
100
|
+
}
|
|
101
|
+
applyMixingForAllTracks()
|
|
102
|
+
applyPitchSpeedForAllTracks()
|
|
103
|
+
recalculateDuration()
|
|
104
|
+
engine.prepare()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// Unloads a single track by ID.
|
|
109
|
+
func unloadTrack(_ trackId: String) {
|
|
110
|
+
queue.sync {
|
|
111
|
+
if let track = tracks.removeValue(forKey: trackId) {
|
|
112
|
+
detachTrack(track)
|
|
113
|
+
}
|
|
114
|
+
applyMixingForAllTracks()
|
|
115
|
+
recalculateDuration()
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// Unloads all tracks and clears the graph.
|
|
120
|
+
func unloadAllTracks() {
|
|
121
|
+
queue.sync {
|
|
122
|
+
stopAllPlayers()
|
|
123
|
+
detachAllTracks()
|
|
124
|
+
tracks.removeAll()
|
|
125
|
+
recalculateDuration()
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// Returns track metadata for JS.
|
|
130
|
+
func getLoadedTracks() -> [[String: Any]] {
|
|
131
|
+
return queue.sync {
|
|
132
|
+
return tracks.values.map { $0.asDictionary() }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// Starts playback from the current position.
|
|
137
|
+
func play() {
|
|
138
|
+
queue.sync { playInternal() }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// Pauses playback and stores the current position.
|
|
142
|
+
func pause() {
|
|
143
|
+
queue.sync { pauseInternal() }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/// Stops playback and resets position to zero.
|
|
147
|
+
func stop() {
|
|
148
|
+
queue.sync { stopInternal() }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/// Seeks to a position in milliseconds.
|
|
152
|
+
func seek(positionMs: Double) {
|
|
153
|
+
queue.sync { seekInternal(positionMs: positionMs) }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/// Returns whether playback is active.
|
|
157
|
+
func isPlaying() -> Bool {
|
|
158
|
+
return queue.sync { isPlayingFlag }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/// Returns the current playback position in milliseconds.
|
|
162
|
+
func getCurrentPosition() -> Double {
|
|
163
|
+
return queue.sync {
|
|
164
|
+
if isPlayingFlag {
|
|
165
|
+
return currentPlaybackPositionMs()
|
|
166
|
+
}
|
|
167
|
+
return currentPositionMs
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// Returns the total duration across tracks in milliseconds.
|
|
172
|
+
func getDuration() -> Double {
|
|
173
|
+
return queue.sync { durationMs }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/// Per-track volume (0.0 - 2.0).
|
|
177
|
+
func setTrackVolume(trackId: String, volume: Double) {
|
|
178
|
+
queue.sync {
|
|
179
|
+
guard let track = tracks[trackId] else { return }
|
|
180
|
+
track.volume = volume
|
|
181
|
+
applyMixingForAllTracks()
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/// Per-track mute toggle.
|
|
186
|
+
func setTrackMuted(trackId: String, muted: Bool) {
|
|
187
|
+
queue.sync {
|
|
188
|
+
guard let track = tracks[trackId] else { return }
|
|
189
|
+
track.muted = muted
|
|
190
|
+
applyMixingForAllTracks()
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/// Per-track solo toggle.
|
|
195
|
+
func setTrackSolo(trackId: String, solo: Bool) {
|
|
196
|
+
queue.sync {
|
|
197
|
+
guard let track = tracks[trackId] else { return }
|
|
198
|
+
track.solo = solo
|
|
199
|
+
applyMixingForAllTracks()
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/// Per-track stereo pan (-1.0 left, 1.0 right).
|
|
204
|
+
func setTrackPan(trackId: String, pan: Double) {
|
|
205
|
+
queue.sync {
|
|
206
|
+
guard let track = tracks[trackId] else { return }
|
|
207
|
+
track.pan = pan
|
|
208
|
+
applyMixingForAllTracks()
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/// Per-track pitch in semitones.
|
|
213
|
+
func setTrackPitch(trackId: String, semitones: Double) {
|
|
214
|
+
queue.sync {
|
|
215
|
+
guard let track = tracks[trackId] else { return }
|
|
216
|
+
track.pitch = semitones
|
|
217
|
+
applyPitchSpeed(for: track)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/// Reads the current pitch for a track.
|
|
222
|
+
func getTrackPitch(trackId: String) -> Double {
|
|
223
|
+
return queue.sync {
|
|
224
|
+
return tracks[trackId]?.pitch ?? 0.0
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/// Per-track time-stretch rate (1.0 = normal).
|
|
229
|
+
func setTrackSpeed(trackId: String, rate: Double) {
|
|
230
|
+
queue.sync {
|
|
231
|
+
guard let track = tracks[trackId] else { return }
|
|
232
|
+
track.speed = rate
|
|
233
|
+
applyPitchSpeed(for: track)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/// Reads the current speed for a track.
|
|
238
|
+
func getTrackSpeed(trackId: String) -> Double {
|
|
239
|
+
return queue.sync {
|
|
240
|
+
return tracks[trackId]?.speed ?? 1.0
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/// Master volume for the whole engine.
|
|
245
|
+
func setMasterVolume(_ volume: Double) {
|
|
246
|
+
queue.sync {
|
|
247
|
+
masterVolume = volume
|
|
248
|
+
engine.mainMixerNode.outputVolume = Float(volume)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/// Reads the master volume.
|
|
253
|
+
func getMasterVolume() -> Double {
|
|
254
|
+
return queue.sync { masterVolume }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/// Global pitch applied to all tracks.
|
|
258
|
+
func setPitch(_ semitones: Double) {
|
|
259
|
+
queue.sync {
|
|
260
|
+
globalPitch = semitones
|
|
261
|
+
applyPitchSpeedForAllTracks()
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/// Reads global pitch.
|
|
266
|
+
func getPitch() -> Double {
|
|
267
|
+
return queue.sync { globalPitch }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/// Global speed applied to all tracks.
|
|
271
|
+
func setSpeed(_ rate: Double) {
|
|
272
|
+
queue.sync {
|
|
273
|
+
globalSpeed = rate
|
|
274
|
+
applyPitchSpeedForAllTracks()
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/// Reads global speed.
|
|
279
|
+
func getSpeed() -> Double {
|
|
280
|
+
return queue.sync { globalSpeed }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/// Sets global tempo (speed) and pitch together.
|
|
284
|
+
func setTempoAndPitch(tempo: Double, pitch: Double) {
|
|
285
|
+
queue.sync {
|
|
286
|
+
globalSpeed = tempo
|
|
287
|
+
globalPitch = pitch
|
|
288
|
+
applyPitchSpeedForAllTracks()
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/// Starts recording from the input node into AAC or WAV.
|
|
293
|
+
func startRecording(config: [String: Any]?) {
|
|
294
|
+
queue.sync {
|
|
295
|
+
guard !isRecordingFlag else { return }
|
|
296
|
+
ensureInitializedIfNeeded()
|
|
297
|
+
|
|
298
|
+
let inputNode = engine.inputNode
|
|
299
|
+
let inputFormat = inputNode.outputFormat(forBus: 0)
|
|
300
|
+
let requestedFormat = config?["format"] as? String ?? "aac"
|
|
301
|
+
let channels = config?["channels"] as? Int ?? Int(inputFormat.channelCount)
|
|
302
|
+
let sampleRate = config?["sampleRate"] as? Double ?? inputFormat.sampleRate
|
|
303
|
+
let bitrate = resolveBitrate(config: config)
|
|
304
|
+
let formatInfo = resolveRecordingFormat(requestedFormat: requestedFormat)
|
|
305
|
+
let outputURL = resolveOutputURL(
|
|
306
|
+
prefix: "recording",
|
|
307
|
+
fileExtension: formatInfo.fileExtension,
|
|
308
|
+
outputDir: config?["outputDir"] as? String
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
let settings: [String: Any]
|
|
312
|
+
if formatInfo.format == "wav" {
|
|
313
|
+
settings = [
|
|
314
|
+
AVFormatIDKey: kAudioFormatLinearPCM,
|
|
315
|
+
AVSampleRateKey: sampleRate,
|
|
316
|
+
AVNumberOfChannelsKey: channels,
|
|
317
|
+
AVLinearPCMBitDepthKey: 16,
|
|
318
|
+
AVLinearPCMIsBigEndianKey: false,
|
|
319
|
+
AVLinearPCMIsFloatKey: false
|
|
320
|
+
]
|
|
321
|
+
} else {
|
|
322
|
+
settings = [
|
|
323
|
+
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
324
|
+
AVSampleRateKey: sampleRate,
|
|
325
|
+
AVNumberOfChannelsKey: channels,
|
|
326
|
+
AVEncoderBitRateKey: bitrate
|
|
327
|
+
]
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
do {
|
|
331
|
+
let file = try AVAudioFile(forWriting: outputURL, settings: settings)
|
|
332
|
+
let startTimeMs = isPlayingFlag ? currentPlaybackPositionMs() : 0.0
|
|
333
|
+
recordingState = RecordingState(
|
|
334
|
+
url: outputURL,
|
|
335
|
+
file: file,
|
|
336
|
+
format: formatInfo.format,
|
|
337
|
+
sampleRate: sampleRate,
|
|
338
|
+
channels: channels,
|
|
339
|
+
startTimeMs: startTimeMs
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
guard startEngineIfNeeded() else {
|
|
343
|
+
recordingState = nil
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
inputNode.removeTap(onBus: 0)
|
|
348
|
+
inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { [weak self] buffer, _ in
|
|
349
|
+
guard let self = self else { return }
|
|
350
|
+
self.queue.async {
|
|
351
|
+
guard let state = self.recordingState else { return }
|
|
352
|
+
self.applyRecordingGain(buffer: buffer)
|
|
353
|
+
self.updateInputLevel(buffer: buffer)
|
|
354
|
+
do {
|
|
355
|
+
try state.file.write(from: buffer)
|
|
356
|
+
} catch {
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
isRecordingFlag = true
|
|
363
|
+
} catch {
|
|
364
|
+
recordingState = nil
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/// Stops recording and returns metadata about the output file.
|
|
370
|
+
func stopRecording() -> [String: Any] {
|
|
371
|
+
return queue.sync {
|
|
372
|
+
guard let state = recordingState else {
|
|
373
|
+
isRecordingFlag = false
|
|
374
|
+
return [
|
|
375
|
+
"uri": "",
|
|
376
|
+
"duration": 0,
|
|
377
|
+
"startTimeMs": 0,
|
|
378
|
+
"sampleRate": 44100,
|
|
379
|
+
"channels": 1,
|
|
380
|
+
"format": "aac",
|
|
381
|
+
"fileSize": 0
|
|
382
|
+
]
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
stopRecordingInternal()
|
|
386
|
+
|
|
387
|
+
let durationMs = (Double(state.file.length) / state.sampleRate) * 1000.0
|
|
388
|
+
let fileSize = resolveFileSize(url: state.url)
|
|
389
|
+
return [
|
|
390
|
+
"uri": state.url.absoluteString,
|
|
391
|
+
"duration": durationMs,
|
|
392
|
+
"startTimeMs": state.startTimeMs,
|
|
393
|
+
"startTimeSamples": Int64(state.startTimeMs / 1000.0 * state.sampleRate),
|
|
394
|
+
"sampleRate": state.sampleRate,
|
|
395
|
+
"channels": state.channels,
|
|
396
|
+
"format": state.format,
|
|
397
|
+
"fileSize": fileSize
|
|
398
|
+
]
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/// Returns whether recording is active.
|
|
403
|
+
func isRecording() -> Bool {
|
|
404
|
+
return queue.sync { isRecordingFlag }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/// Applies a simple gain to recorded input before writing.
|
|
408
|
+
func setRecordingVolume(_ volume: Double) {
|
|
409
|
+
queue.sync {
|
|
410
|
+
recordingVolume = volume
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/// Offline export for a single track.
|
|
415
|
+
func extractTrack(trackId: String, config: [String: Any]?) -> [String: Any] {
|
|
416
|
+
return queue.sync {
|
|
417
|
+
guard let track = tracks[trackId] else {
|
|
418
|
+
return [
|
|
419
|
+
"trackId": trackId,
|
|
420
|
+
"uri": "",
|
|
421
|
+
"duration": 0,
|
|
422
|
+
"format": "aac",
|
|
423
|
+
"fileSize": 0
|
|
424
|
+
]
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
let durationMs = track.startTimeMs + track.durationMs
|
|
428
|
+
return renderOffline(
|
|
429
|
+
tracksToRender: [track],
|
|
430
|
+
totalDurationMs: durationMs,
|
|
431
|
+
config: config,
|
|
432
|
+
trackId: trackId
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/// Offline export for all tracks (one file per track).
|
|
438
|
+
func extractAllTracks(config: [String: Any]?) -> [[String: Any]] {
|
|
439
|
+
return queue.sync {
|
|
440
|
+
var results: [[String: Any]] = []
|
|
441
|
+
for track in tracks.values {
|
|
442
|
+
let durationMs = track.startTimeMs + track.durationMs
|
|
443
|
+
let result = renderOffline(
|
|
444
|
+
tracksToRender: [track],
|
|
445
|
+
totalDurationMs: durationMs,
|
|
446
|
+
config: config,
|
|
447
|
+
trackId: track.id
|
|
448
|
+
)
|
|
449
|
+
results.append(result)
|
|
450
|
+
}
|
|
451
|
+
return results
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/// Placeholder for cancelation (not implemented yet).
|
|
456
|
+
func cancelExtraction(jobId: Double?) -> Bool {
|
|
457
|
+
_ = jobId
|
|
458
|
+
return false
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/// Returns the last computed input RMS level.
|
|
462
|
+
func getInputLevel() -> Double {
|
|
463
|
+
return inputLevel
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/// Output metering placeholder.
|
|
467
|
+
func getOutputLevel() -> Double {
|
|
468
|
+
return 0.0
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/// Per-track metering placeholder.
|
|
472
|
+
func getTrackLevel(trackId: String) -> Double {
|
|
473
|
+
_ = trackId
|
|
474
|
+
return 0.0
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/// Background playback placeholder (to be implemented).
|
|
478
|
+
func enableBackgroundPlayback(metadata: [String: Any]) {
|
|
479
|
+
queue.sync {
|
|
480
|
+
backgroundPlaybackEnabled = true
|
|
481
|
+
nowPlayingMetadata.merge(metadata) { _, new in new }
|
|
482
|
+
sessionManager.enableBackgroundPlayback(with: lastConfig)
|
|
483
|
+
configureRemoteCommandsIfNeeded()
|
|
484
|
+
updateNowPlayingInfoInternal()
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/// Now Playing updates placeholder (to be implemented).
|
|
489
|
+
func updateNowPlayingInfo(metadata: [String: Any]) {
|
|
490
|
+
queue.sync {
|
|
491
|
+
nowPlayingMetadata.merge(metadata) { _, new in new }
|
|
492
|
+
updateNowPlayingInfoInternal()
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/// Background playback teardown placeholder.
|
|
497
|
+
func disableBackgroundPlayback() {
|
|
498
|
+
queue.sync {
|
|
499
|
+
backgroundPlaybackEnabled = false
|
|
500
|
+
nowPlayingMetadata.removeAll()
|
|
501
|
+
removeRemoteCommands()
|
|
502
|
+
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
|
503
|
+
sessionManager.configure(with: lastConfig)
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/// Updates cached total duration based on track offsets.
|
|
508
|
+
private func recalculateDuration() {
|
|
509
|
+
durationMs = tracks.values.reduce(0.0) { current, track in
|
|
510
|
+
let endMs = track.startTimeMs + track.durationMs
|
|
511
|
+
return max(current, endMs)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/// Ensures the engine and session are initialized before use.
|
|
516
|
+
private func ensureInitializedIfNeeded() {
|
|
517
|
+
if isInitialized {
|
|
518
|
+
return
|
|
519
|
+
}
|
|
520
|
+
let config = lastConfig
|
|
521
|
+
if backgroundPlaybackEnabled {
|
|
522
|
+
sessionManager.enableBackgroundPlayback(with: config)
|
|
523
|
+
} else {
|
|
524
|
+
sessionManager.configure(with: config)
|
|
525
|
+
}
|
|
526
|
+
engine.mainMixerNode.outputVolume = Float(masterVolume)
|
|
527
|
+
engine.prepare()
|
|
528
|
+
isInitialized = true
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/// Schedules all tracks from a given position with shared host time.
|
|
532
|
+
private func schedulePlayback(at positionMs: Double) {
|
|
533
|
+
stopAllPlayers()
|
|
534
|
+
playbackToken = UUID()
|
|
535
|
+
activePlaybackCount = 0
|
|
536
|
+
|
|
537
|
+
let baseHostTime = mach_absolute_time() + AVAudioTime.hostTime(forSeconds: 0.05)
|
|
538
|
+
let token = playbackToken
|
|
539
|
+
|
|
540
|
+
for track in tracks.values {
|
|
541
|
+
scheduleTrack(
|
|
542
|
+
track,
|
|
543
|
+
baseHostTime: baseHostTime,
|
|
544
|
+
positionMs: positionMs,
|
|
545
|
+
token: token
|
|
546
|
+
)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
playbackStartHostTime = baseHostTime
|
|
550
|
+
playbackStartPositionMs = positionMs
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/// Schedules a single track into the realtime engine.
|
|
554
|
+
private func scheduleTrack(
|
|
555
|
+
_ track: AudioTrack,
|
|
556
|
+
baseHostTime: UInt64,
|
|
557
|
+
positionMs: Double,
|
|
558
|
+
token: UUID
|
|
559
|
+
) {
|
|
560
|
+
let localStartMs = positionMs - track.startTimeMs
|
|
561
|
+
if localStartMs >= track.durationMs {
|
|
562
|
+
return
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
let fileOffsetMs = max(0.0, localStartMs)
|
|
566
|
+
let delayMs = max(0.0, -localStartMs)
|
|
567
|
+
let sampleRate = track.file.processingFormat.sampleRate
|
|
568
|
+
if sampleRate <= 0 {
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
let startFrame = AVAudioFramePosition((fileOffsetMs / 1000.0) * sampleRate)
|
|
573
|
+
let framesRemaining = track.file.length - startFrame
|
|
574
|
+
if framesRemaining <= 0 {
|
|
575
|
+
return
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
let frameCount = AVAudioFrameCount(framesRemaining)
|
|
579
|
+
activePlaybackCount += 1
|
|
580
|
+
|
|
581
|
+
track.playerNode.scheduleSegment(
|
|
582
|
+
track.file,
|
|
583
|
+
startingFrame: startFrame,
|
|
584
|
+
frameCount: frameCount,
|
|
585
|
+
at: nil
|
|
586
|
+
) { [weak self] in
|
|
587
|
+
self?.queue.async {
|
|
588
|
+
guard let self = self else { return }
|
|
589
|
+
if self.playbackToken != token {
|
|
590
|
+
return
|
|
591
|
+
}
|
|
592
|
+
self.activePlaybackCount -= 1
|
|
593
|
+
if self.activePlaybackCount <= 0 {
|
|
594
|
+
self.handlePlaybackCompleted()
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
let hostTimeDelay = AVAudioTime.hostTime(forSeconds: delayMs / 1000.0)
|
|
600
|
+
let hostTime = baseHostTime + hostTimeDelay
|
|
601
|
+
track.playerNode.play(at: AVAudioTime(hostTime: hostTime))
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/// Schedules a single track for offline rendering.
|
|
605
|
+
private func scheduleTrackForManualRendering(
|
|
606
|
+
_ track: AudioTrack,
|
|
607
|
+
positionMs: Double,
|
|
608
|
+
sampleRate: Double
|
|
609
|
+
) -> Bool {
|
|
610
|
+
let localStartMs = positionMs - track.startTimeMs
|
|
611
|
+
if localStartMs >= track.durationMs {
|
|
612
|
+
return false
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
let fileOffsetMs = max(0.0, localStartMs)
|
|
616
|
+
let delayMs = max(0.0, -localStartMs)
|
|
617
|
+
let trackSampleRate = track.file.processingFormat.sampleRate
|
|
618
|
+
if trackSampleRate <= 0 {
|
|
619
|
+
return false
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
let startFrame = AVAudioFramePosition((fileOffsetMs / 1000.0) * trackSampleRate)
|
|
623
|
+
let framesRemaining = track.file.length - startFrame
|
|
624
|
+
if framesRemaining <= 0 {
|
|
625
|
+
return false
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
let frameCount = AVAudioFrameCount(framesRemaining)
|
|
629
|
+
let startSample = AVAudioFramePosition((delayMs / 1000.0) * sampleRate)
|
|
630
|
+
let startTime = AVAudioTime(sampleTime: startSample, atRate: sampleRate)
|
|
631
|
+
|
|
632
|
+
track.playerNode.scheduleSegment(
|
|
633
|
+
track.file,
|
|
634
|
+
startingFrame: startFrame,
|
|
635
|
+
frameCount: frameCount,
|
|
636
|
+
at: startTime,
|
|
637
|
+
completionHandler: nil
|
|
638
|
+
)
|
|
639
|
+
return true
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/// Handles end-of-playback cleanup once all tracks finish.
|
|
643
|
+
private func handlePlaybackCompleted() {
|
|
644
|
+
if !isPlayingFlag {
|
|
645
|
+
return
|
|
646
|
+
}
|
|
647
|
+
isPlayingFlag = false
|
|
648
|
+
currentPositionMs = durationMs
|
|
649
|
+
playbackStartHostTime = nil
|
|
650
|
+
playbackStartPositionMs = 0.0
|
|
651
|
+
stopAllPlayers()
|
|
652
|
+
stopEngineIfRunning()
|
|
653
|
+
updateNowPlayingInfoInternal()
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/// Computes playback position from host time.
|
|
657
|
+
private func currentPlaybackPositionMs() -> Double {
|
|
658
|
+
guard let startHostTime = playbackStartHostTime else {
|
|
659
|
+
return currentPositionMs
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
let nowSeconds = AVAudioTime.seconds(forHostTime: mach_absolute_time())
|
|
663
|
+
let startSeconds = AVAudioTime.seconds(forHostTime: startHostTime)
|
|
664
|
+
let elapsedMs = max(0.0, (nowSeconds - startSeconds) * 1000.0)
|
|
665
|
+
let position = playbackStartPositionMs + elapsedMs
|
|
666
|
+
return min(position, durationMs)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/// Stops the input tap and clears recording state.
|
|
670
|
+
private func stopRecordingInternal() {
|
|
671
|
+
engine.inputNode.removeTap(onBus: 0)
|
|
672
|
+
recordingState = nil
|
|
673
|
+
isRecordingFlag = false
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/// Maps requested output formats to supported formats.
|
|
677
|
+
private func resolveRecordingFormat(requestedFormat: String) -> (format: String, fileExtension: String) {
|
|
678
|
+
if requestedFormat == "wav" {
|
|
679
|
+
return ("wav", "wav")
|
|
680
|
+
}
|
|
681
|
+
if requestedFormat == "mp3" {
|
|
682
|
+
return ("aac", "m4a")
|
|
683
|
+
}
|
|
684
|
+
return ("aac", "m4a")
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/// Resolves output bitrate from config or quality preset.
|
|
688
|
+
private func resolveBitrate(config: [String: Any]?) -> Int {
|
|
689
|
+
if let bitrate = config?["bitrate"] as? Int {
|
|
690
|
+
return bitrate
|
|
691
|
+
}
|
|
692
|
+
if let quality = config?["quality"] as? String {
|
|
693
|
+
switch quality {
|
|
694
|
+
case "low":
|
|
695
|
+
return 64_000
|
|
696
|
+
case "high":
|
|
697
|
+
return 192_000
|
|
698
|
+
default:
|
|
699
|
+
return 128_000
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return 128_000
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/// Builds a file URL for recording/extraction output.
|
|
706
|
+
private func resolveOutputURL(prefix: String, fileExtension: String, outputDir: String?) -> URL {
|
|
707
|
+
let baseURL: URL
|
|
708
|
+
if let outputDir = outputDir {
|
|
709
|
+
baseURL = URL(fileURLWithPath: outputDir, isDirectory: true)
|
|
710
|
+
} else {
|
|
711
|
+
baseURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
712
|
+
.appendingPathComponent("sezo-audio-output", isDirectory: true)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
try? FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
|
|
716
|
+
let filename = "\(prefix)-\(UUID().uuidString).\(fileExtension)"
|
|
717
|
+
return baseURL.appendingPathComponent(filename)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/// Returns file size for output metadata.
|
|
721
|
+
private func resolveFileSize(url: URL) -> Int {
|
|
722
|
+
let path = url.path
|
|
723
|
+
let attributes = try? FileManager.default.attributesOfItem(atPath: path)
|
|
724
|
+
return attributes?[.size] as? Int ?? 0
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/// Applies a simple pre-recording gain to the input buffer.
|
|
728
|
+
private func applyRecordingGain(buffer: AVAudioPCMBuffer) {
|
|
729
|
+
let gain = Float(recordingVolume)
|
|
730
|
+
if gain == 1.0 {
|
|
731
|
+
return
|
|
732
|
+
}
|
|
733
|
+
let frameLength = Int(buffer.frameLength)
|
|
734
|
+
let channelCount = Int(buffer.format.channelCount)
|
|
735
|
+
|
|
736
|
+
switch buffer.format.commonFormat {
|
|
737
|
+
case .pcmFormatFloat32:
|
|
738
|
+
guard let channelData = buffer.floatChannelData else { return }
|
|
739
|
+
for channel in 0..<channelCount {
|
|
740
|
+
let samples = channelData[channel]
|
|
741
|
+
for i in 0..<frameLength {
|
|
742
|
+
samples[i] *= gain
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
case .pcmFormatInt16:
|
|
746
|
+
guard let channelData = buffer.int16ChannelData else { return }
|
|
747
|
+
for channel in 0..<channelCount {
|
|
748
|
+
let samples = channelData[channel]
|
|
749
|
+
for i in 0..<frameLength {
|
|
750
|
+
let scaled = Float(samples[i]) * gain
|
|
751
|
+
let clamped = max(Float(Int16.min), min(Float(Int16.max), scaled))
|
|
752
|
+
samples[i] = Int16(clamped)
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
default:
|
|
756
|
+
break
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/// Updates `inputLevel` using RMS from the first channel.
|
|
761
|
+
private func updateInputLevel(buffer: AVAudioPCMBuffer) {
|
|
762
|
+
guard let channelData = buffer.floatChannelData else { return }
|
|
763
|
+
let frameLength = Int(buffer.frameLength)
|
|
764
|
+
if frameLength == 0 {
|
|
765
|
+
return
|
|
766
|
+
}
|
|
767
|
+
let samples = channelData[0]
|
|
768
|
+
var sum: Float = 0.0
|
|
769
|
+
for i in 0..<frameLength {
|
|
770
|
+
let value = samples[i]
|
|
771
|
+
sum += value * value
|
|
772
|
+
}
|
|
773
|
+
let rms = sqrt(sum / Float(frameLength))
|
|
774
|
+
inputLevel = Double(rms)
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/// Renders one or more tracks offline into a file.
|
|
778
|
+
private func renderOffline(
|
|
779
|
+
tracksToRender: [AudioTrack],
|
|
780
|
+
totalDurationMs: Double,
|
|
781
|
+
config: [String: Any]?,
|
|
782
|
+
trackId: String
|
|
783
|
+
) -> [String: Any] {
|
|
784
|
+
ensureInitializedIfNeeded()
|
|
785
|
+
stopEngineIfRunning()
|
|
786
|
+
stopAllPlayers()
|
|
787
|
+
|
|
788
|
+
let renderFormat = engine.mainMixerNode.outputFormat(forBus: 0)
|
|
789
|
+
let formatInfo = resolveRecordingFormat(requestedFormat: config?["format"] as? String ?? "aac")
|
|
790
|
+
let outputURL = resolveOutputURL(
|
|
791
|
+
prefix: "extract-\(trackId)",
|
|
792
|
+
fileExtension: formatInfo.fileExtension,
|
|
793
|
+
outputDir: config?["outputDir"] as? String
|
|
794
|
+
)
|
|
795
|
+
let bitrate = resolveBitrate(config: config)
|
|
796
|
+
|
|
797
|
+
let settings: [String: Any]
|
|
798
|
+
if formatInfo.format == "wav" {
|
|
799
|
+
settings = [
|
|
800
|
+
AVFormatIDKey: kAudioFormatLinearPCM,
|
|
801
|
+
AVSampleRateKey: renderFormat.sampleRate,
|
|
802
|
+
AVNumberOfChannelsKey: renderFormat.channelCount,
|
|
803
|
+
AVLinearPCMBitDepthKey: 16,
|
|
804
|
+
AVLinearPCMIsBigEndianKey: false,
|
|
805
|
+
AVLinearPCMIsFloatKey: false
|
|
806
|
+
]
|
|
807
|
+
} else {
|
|
808
|
+
settings = [
|
|
809
|
+
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
810
|
+
AVSampleRateKey: renderFormat.sampleRate,
|
|
811
|
+
AVNumberOfChannelsKey: renderFormat.channelCount,
|
|
812
|
+
AVEncoderBitRateKey: bitrate
|
|
813
|
+
]
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
let includeEffects = config?["includeEffects"] as? Bool ?? true
|
|
817
|
+
let originalState = snapshotTrackState()
|
|
818
|
+
applyExtractionMixOverrides(tracksToRender: tracksToRender, includeEffects: includeEffects)
|
|
819
|
+
|
|
820
|
+
do {
|
|
821
|
+
try engine.enableManualRenderingMode(
|
|
822
|
+
.offline,
|
|
823
|
+
format: renderFormat,
|
|
824
|
+
maximumFrameCount: 4096
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
var scheduledTracks: [AudioTrack] = []
|
|
828
|
+
for track in tracksToRender {
|
|
829
|
+
let scheduled = scheduleTrackForManualRendering(
|
|
830
|
+
track,
|
|
831
|
+
positionMs: 0.0,
|
|
832
|
+
sampleRate: renderFormat.sampleRate
|
|
833
|
+
)
|
|
834
|
+
if scheduled {
|
|
835
|
+
scheduledTracks.append(track)
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
try engine.start()
|
|
840
|
+
for track in scheduledTracks {
|
|
841
|
+
track.playerNode.play()
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
let outputFile = try AVAudioFile(forWriting: outputURL, settings: settings)
|
|
845
|
+
let totalFrames = AVAudioFramePosition((totalDurationMs / 1000.0) * renderFormat.sampleRate)
|
|
846
|
+
|
|
847
|
+
while engine.manualRenderingSampleTime < totalFrames {
|
|
848
|
+
let remaining = totalFrames - engine.manualRenderingSampleTime
|
|
849
|
+
let frameCount = AVAudioFrameCount(min(Int(4096), Int(remaining)))
|
|
850
|
+
guard let buffer = AVAudioPCMBuffer(
|
|
851
|
+
pcmFormat: renderFormat,
|
|
852
|
+
frameCapacity: frameCount
|
|
853
|
+
) else {
|
|
854
|
+
break
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
let status = try engine.renderOffline(frameCount, to: buffer)
|
|
858
|
+
switch status {
|
|
859
|
+
case .success:
|
|
860
|
+
try outputFile.write(from: buffer)
|
|
861
|
+
case .insufficientDataFromInputNode:
|
|
862
|
+
continue
|
|
863
|
+
case .cannotDoInCurrentContext:
|
|
864
|
+
continue
|
|
865
|
+
case .error:
|
|
866
|
+
break
|
|
867
|
+
@unknown default:
|
|
868
|
+
break
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
engine.stop()
|
|
873
|
+
engine.disableManualRenderingMode()
|
|
874
|
+
} catch {
|
|
875
|
+
engine.disableManualRenderingMode()
|
|
876
|
+
restoreTrackState(originalState)
|
|
877
|
+
return [
|
|
878
|
+
"trackId": trackId,
|
|
879
|
+
"uri": "",
|
|
880
|
+
"duration": 0,
|
|
881
|
+
"format": formatInfo.format,
|
|
882
|
+
"fileSize": 0
|
|
883
|
+
]
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
restoreTrackState(originalState)
|
|
887
|
+
let fileSize = resolveFileSize(url: outputURL)
|
|
888
|
+
return [
|
|
889
|
+
"trackId": trackId,
|
|
890
|
+
"uri": outputURL.absoluteString,
|
|
891
|
+
"duration": totalDurationMs,
|
|
892
|
+
"format": formatInfo.format,
|
|
893
|
+
"bitrate": bitrate,
|
|
894
|
+
"fileSize": fileSize
|
|
895
|
+
]
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/// Snapshot of track parameters so offline render can override safely.
|
|
899
|
+
private func snapshotTrackState() -> [String: (Double, Double, Bool, Bool, Double, Double)] {
|
|
900
|
+
var snapshot: [String: (Double, Double, Bool, Bool, Double, Double)] = [:]
|
|
901
|
+
for (trackId, track) in tracks {
|
|
902
|
+
snapshot[trackId] = (track.volume, track.pan, track.muted, track.solo, track.pitch, track.speed)
|
|
903
|
+
}
|
|
904
|
+
return snapshot
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/// Restores track parameters after offline render.
|
|
908
|
+
private func restoreTrackState(_ snapshot: [String: (Double, Double, Bool, Bool, Double, Double)]) {
|
|
909
|
+
for (trackId, values) in snapshot {
|
|
910
|
+
guard let track = tracks[trackId] else { continue }
|
|
911
|
+
track.volume = values.0
|
|
912
|
+
track.pan = values.1
|
|
913
|
+
track.muted = values.2
|
|
914
|
+
track.solo = values.3
|
|
915
|
+
track.pitch = values.4
|
|
916
|
+
track.speed = values.5
|
|
917
|
+
}
|
|
918
|
+
applyMixingForAllTracks()
|
|
919
|
+
applyPitchSpeedForAllTracks()
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/// Applies extraction-specific overrides (mute others, optional effects).
|
|
923
|
+
private func applyExtractionMixOverrides(tracksToRender: [AudioTrack], includeEffects: Bool) {
|
|
924
|
+
let allowedIds = Set(tracksToRender.map { $0.id })
|
|
925
|
+
for track in tracks.values {
|
|
926
|
+
if !allowedIds.contains(track.id) {
|
|
927
|
+
track.volume = 0.0
|
|
928
|
+
track.muted = true
|
|
929
|
+
} else {
|
|
930
|
+
track.muted = false
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
if !includeEffects {
|
|
934
|
+
track.pitch = 0.0
|
|
935
|
+
track.speed = 1.0
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
applyMixingForAllTracks()
|
|
939
|
+
applyPitchSpeedForAllTracks()
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/// Attaches a track's nodes into the engine graph.
|
|
943
|
+
private func attachTrack(_ track: AudioTrack) {
|
|
944
|
+
if track.isAttached {
|
|
945
|
+
return
|
|
946
|
+
}
|
|
947
|
+
engine.attach(track.playerNode)
|
|
948
|
+
engine.attach(track.timePitch)
|
|
949
|
+
engine.connect(track.playerNode, to: track.timePitch, format: track.file.processingFormat)
|
|
950
|
+
engine.connect(track.timePitch, to: engine.mainMixerNode, format: track.file.processingFormat)
|
|
951
|
+
track.isAttached = true
|
|
952
|
+
applyMixing(for: track, soloActive: isSoloActive())
|
|
953
|
+
applyPitchSpeed(for: track)
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/// Detaches a track's nodes from the engine graph.
|
|
957
|
+
private func detachTrack(_ track: AudioTrack) {
|
|
958
|
+
if !track.isAttached {
|
|
959
|
+
return
|
|
960
|
+
}
|
|
961
|
+
engine.detach(track.playerNode)
|
|
962
|
+
engine.detach(track.timePitch)
|
|
963
|
+
track.isAttached = false
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/// Detaches all tracks from the engine.
|
|
967
|
+
private func detachAllTracks() {
|
|
968
|
+
for track in tracks.values {
|
|
969
|
+
detachTrack(track)
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/// Stops all player nodes.
|
|
974
|
+
private func stopAllPlayers() {
|
|
975
|
+
for track in tracks.values {
|
|
976
|
+
track.playerNode.stop()
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/// Starts the AVAudioEngine if it is not already running.
|
|
981
|
+
@discardableResult
|
|
982
|
+
private func startEngineIfNeeded() -> Bool {
|
|
983
|
+
if !isInitialized {
|
|
984
|
+
return false
|
|
985
|
+
}
|
|
986
|
+
if !engine.isRunning {
|
|
987
|
+
do {
|
|
988
|
+
try engine.start()
|
|
989
|
+
} catch {
|
|
990
|
+
return false
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
return engine.isRunning
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/// Stops the AVAudioEngine if running.
|
|
997
|
+
private func stopEngineIfRunning() {
|
|
998
|
+
if engine.isRunning {
|
|
999
|
+
engine.stop()
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/// Applies volume/pan/solo rules to every track.
|
|
1004
|
+
private func applyMixingForAllTracks() {
|
|
1005
|
+
let soloActive = isSoloActive()
|
|
1006
|
+
for track in tracks.values {
|
|
1007
|
+
applyMixing(for: track, soloActive: soloActive)
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/// Applies volume/pan to a single track.
|
|
1012
|
+
private func applyMixing(for track: AudioTrack, soloActive: Bool) {
|
|
1013
|
+
let shouldMute = track.muted || (soloActive && !track.solo)
|
|
1014
|
+
let volume = shouldMute ? 0.0 : track.volume
|
|
1015
|
+
track.playerNode.volume = Float(volume)
|
|
1016
|
+
track.playerNode.pan = Float(track.pan)
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/// Applies pitch/speed updates to all tracks.
|
|
1020
|
+
private func applyPitchSpeedForAllTracks() {
|
|
1021
|
+
for track in tracks.values {
|
|
1022
|
+
applyPitchSpeed(for: track)
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/// Combines per-track and global pitch/speed and applies to the unit.
|
|
1027
|
+
private func applyPitchSpeed(for track: AudioTrack) {
|
|
1028
|
+
let combinedPitch = track.pitch + globalPitch
|
|
1029
|
+
let combinedSpeed = track.speed * globalSpeed
|
|
1030
|
+
track.timePitch.pitch = Float(combinedPitch * 100.0)
|
|
1031
|
+
track.timePitch.rate = Float(combinedSpeed)
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/// Returns true when any track is soloed.
|
|
1035
|
+
private func isSoloActive() -> Bool {
|
|
1036
|
+
return tracks.values.contains { $0.solo }
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/// Internal play logic (expects to run on the queue).
|
|
1040
|
+
private func playInternal() {
|
|
1041
|
+
guard !tracks.isEmpty else { return }
|
|
1042
|
+
ensureInitializedIfNeeded()
|
|
1043
|
+
if isPlayingFlag {
|
|
1044
|
+
return
|
|
1045
|
+
}
|
|
1046
|
+
guard startEngineIfNeeded() else { return }
|
|
1047
|
+
schedulePlayback(at: currentPositionMs)
|
|
1048
|
+
isPlayingFlag = true
|
|
1049
|
+
updateNowPlayingInfoInternal()
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/// Internal pause logic (expects to run on the queue).
|
|
1053
|
+
private func pauseInternal() {
|
|
1054
|
+
guard isPlayingFlag else { return }
|
|
1055
|
+
currentPositionMs = currentPlaybackPositionMs()
|
|
1056
|
+
isPlayingFlag = false
|
|
1057
|
+
stopAllPlayers()
|
|
1058
|
+
stopEngineIfRunning()
|
|
1059
|
+
updateNowPlayingInfoInternal()
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/// Internal stop logic (expects to run on the queue).
|
|
1063
|
+
private func stopInternal() {
|
|
1064
|
+
isPlayingFlag = false
|
|
1065
|
+
currentPositionMs = 0.0
|
|
1066
|
+
playbackStartHostTime = nil
|
|
1067
|
+
playbackStartPositionMs = 0.0
|
|
1068
|
+
playbackToken = UUID()
|
|
1069
|
+
stopAllPlayers()
|
|
1070
|
+
stopEngineIfRunning()
|
|
1071
|
+
updateNowPlayingInfoInternal()
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/// Internal seek logic (expects to run on the queue).
|
|
1075
|
+
private func seekInternal(positionMs: Double) {
|
|
1076
|
+
currentPositionMs = max(0.0, positionMs)
|
|
1077
|
+
if isPlayingFlag {
|
|
1078
|
+
guard startEngineIfNeeded() else { return }
|
|
1079
|
+
schedulePlayback(at: currentPositionMs)
|
|
1080
|
+
}
|
|
1081
|
+
updateNowPlayingInfoInternal()
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/// Updates Now Playing info based on stored metadata and playback state.
|
|
1085
|
+
private func updateNowPlayingInfoInternal() {
|
|
1086
|
+
guard backgroundPlaybackEnabled else { return }
|
|
1087
|
+
|
|
1088
|
+
var info: [String: Any] = [:]
|
|
1089
|
+
if let title = nowPlayingMetadata["title"] as? String {
|
|
1090
|
+
info[MPMediaItemPropertyTitle] = title
|
|
1091
|
+
}
|
|
1092
|
+
if let artist = nowPlayingMetadata["artist"] as? String {
|
|
1093
|
+
info[MPMediaItemPropertyArtist] = artist
|
|
1094
|
+
}
|
|
1095
|
+
if let album = nowPlayingMetadata["album"] as? String {
|
|
1096
|
+
info[MPMediaItemPropertyAlbumTitle] = album
|
|
1097
|
+
}
|
|
1098
|
+
if let artwork = resolveArtwork(metadata: nowPlayingMetadata) {
|
|
1099
|
+
info[MPMediaItemPropertyArtwork] = artwork
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
let elapsedSeconds = (isPlayingFlag ? currentPlaybackPositionMs() : currentPositionMs) / 1000.0
|
|
1103
|
+
info[MPMediaItemPropertyPlaybackDuration] = durationMs / 1000.0
|
|
1104
|
+
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = elapsedSeconds
|
|
1105
|
+
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlayingFlag ? 1.0 : 0.0
|
|
1106
|
+
|
|
1107
|
+
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/// Registers basic play/pause remote command handlers.
|
|
1111
|
+
private func configureRemoteCommandsIfNeeded() {
|
|
1112
|
+
guard playCommandTarget == nil else { return }
|
|
1113
|
+
let commandCenter = MPRemoteCommandCenter.shared()
|
|
1114
|
+
|
|
1115
|
+
playCommandTarget = commandCenter.playCommand.addTarget { [weak self] _ in
|
|
1116
|
+
self?.queue.async { self?.playInternal() }
|
|
1117
|
+
return .success
|
|
1118
|
+
}
|
|
1119
|
+
pauseCommandTarget = commandCenter.pauseCommand.addTarget { [weak self] _ in
|
|
1120
|
+
self?.queue.async { self?.pauseInternal() }
|
|
1121
|
+
return .success
|
|
1122
|
+
}
|
|
1123
|
+
toggleCommandTarget = commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
|
|
1124
|
+
self?.queue.async {
|
|
1125
|
+
guard let self = self else { return }
|
|
1126
|
+
if self.isPlayingFlag {
|
|
1127
|
+
self.pauseInternal()
|
|
1128
|
+
} else {
|
|
1129
|
+
self.playInternal()
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
return .success
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
commandCenter.playCommand.isEnabled = true
|
|
1136
|
+
commandCenter.pauseCommand.isEnabled = true
|
|
1137
|
+
commandCenter.togglePlayPauseCommand.isEnabled = true
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/// Removes remote command handlers.
|
|
1141
|
+
private func removeRemoteCommands() {
|
|
1142
|
+
let commandCenter = MPRemoteCommandCenter.shared()
|
|
1143
|
+
if let target = playCommandTarget {
|
|
1144
|
+
commandCenter.playCommand.removeTarget(target)
|
|
1145
|
+
}
|
|
1146
|
+
if let target = pauseCommandTarget {
|
|
1147
|
+
commandCenter.pauseCommand.removeTarget(target)
|
|
1148
|
+
}
|
|
1149
|
+
if let target = toggleCommandTarget {
|
|
1150
|
+
commandCenter.togglePlayPauseCommand.removeTarget(target)
|
|
1151
|
+
}
|
|
1152
|
+
playCommandTarget = nil
|
|
1153
|
+
pauseCommandTarget = nil
|
|
1154
|
+
toggleCommandTarget = nil
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/// Resolves artwork from a local file path or file URL.
|
|
1158
|
+
private func resolveArtwork(metadata: [String: Any]) -> MPMediaItemArtwork? {
|
|
1159
|
+
guard let artworkValue = metadata["artwork"] as? String else {
|
|
1160
|
+
return nil
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
let url: URL?
|
|
1164
|
+
if artworkValue.hasPrefix("file://") {
|
|
1165
|
+
url = URL(string: artworkValue)
|
|
1166
|
+
} else if artworkValue.hasPrefix("/") {
|
|
1167
|
+
url = URL(fileURLWithPath: artworkValue)
|
|
1168
|
+
} else {
|
|
1169
|
+
url = nil
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
guard let imageURL = url,
|
|
1173
|
+
let data = try? Data(contentsOf: imageURL),
|
|
1174
|
+
let image = UIImage(data: data) else {
|
|
1175
|
+
return nil
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
return MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
|
1179
|
+
}
|
|
1180
|
+
}
|