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.
@@ -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
+ }