sezo-audio-engine 0.0.13 → 0.0.14
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/README.md +1 -1
- package/android/build.gradle +1 -1
- package/android/src/main/java/expo/modules/audioengine/ExpoAudioEngineModule.kt +62 -35
- package/ios/AudioEngine/AudioSessionManager.swift +5 -3
- package/ios/AudioEngine/NativeAudioEngine.swift +214 -32
- package/ios/ExpoAudioEngineModule.swift +56 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ allprojects {
|
|
|
38
38
|
Optionally pin the engine version in `android/gradle.properties`:
|
|
39
39
|
|
|
40
40
|
```properties
|
|
41
|
-
sezoAudioEngineVersion=android-engine-v0.1.
|
|
41
|
+
sezoAudioEngineVersion=android-engine-v0.1.7
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
If you want to build from source instead, include the local engine module in
|
package/android/build.gradle
CHANGED
|
@@ -46,7 +46,7 @@ dependencies {
|
|
|
46
46
|
if (androidEngineProject != null) {
|
|
47
47
|
implementation androidEngineProject
|
|
48
48
|
} else {
|
|
49
|
-
def sezoAudioEngineVersion = project.findProperty("sezoAudioEngineVersion") ?: "android-engine-v0.1.
|
|
49
|
+
def sezoAudioEngineVersion = project.findProperty("sezoAudioEngineVersion") ?: "android-engine-v0.1.7"
|
|
50
50
|
implementation "com.github.Sepzie:SezoAudioEngine:${sezoAudioEngineVersion}"
|
|
51
51
|
}
|
|
52
52
|
}
|
|
@@ -5,6 +5,7 @@ import android.os.Bundle
|
|
|
5
5
|
import android.util.Log
|
|
6
6
|
import com.sezo.audioengine.AudioEngine
|
|
7
7
|
import expo.modules.kotlin.Promise
|
|
8
|
+
import expo.modules.kotlin.exception.CodedException
|
|
8
9
|
import expo.modules.kotlin.modules.Module
|
|
9
10
|
import expo.modules.kotlin.modules.ModuleDefinition
|
|
10
11
|
import java.util.Collections
|
|
@@ -60,6 +61,10 @@ class ExpoAudioEngineModule : Module() {
|
|
|
60
61
|
val operation: String
|
|
61
62
|
)
|
|
62
63
|
|
|
64
|
+
private fun requireEngine(): AudioEngine {
|
|
65
|
+
return audioEngine ?: throw CodedException("ENGINE_NOT_INITIALIZED", "Engine not initialized")
|
|
66
|
+
}
|
|
67
|
+
|
|
63
68
|
override fun definition() = ModuleDefinition {
|
|
64
69
|
Name("ExpoAudioEngineModule")
|
|
65
70
|
|
|
@@ -151,7 +156,7 @@ class ExpoAudioEngineModule : Module() {
|
|
|
151
156
|
val success = audioEngine?.initialize(sampleRate, maxTracks) ?: false
|
|
152
157
|
|
|
153
158
|
if (!success) {
|
|
154
|
-
throw
|
|
159
|
+
throw CodedException("ENGINE_INIT_FAILED", "Failed to initialize audio engine", null)
|
|
155
160
|
}
|
|
156
161
|
|
|
157
162
|
ensureAudioFocusManager()
|
|
@@ -194,18 +199,24 @@ class ExpoAudioEngineModule : Module() {
|
|
|
194
199
|
}
|
|
195
200
|
|
|
196
201
|
AsyncFunction("loadTracks") { tracks: List<Map<String, Any?>> ->
|
|
197
|
-
val engine =
|
|
202
|
+
val engine = requireEngine()
|
|
198
203
|
|
|
199
204
|
tracks.forEach { track ->
|
|
200
|
-
val id = track["id"] as? String
|
|
201
|
-
|
|
205
|
+
val id = track["id"] as? String
|
|
206
|
+
?: throw CodedException("TRACK_LOAD_FAILED", "Missing track id", null)
|
|
207
|
+
val uri = track["uri"] as? String
|
|
208
|
+
?: throw CodedException("TRACK_LOAD_FAILED", "Missing track uri", null)
|
|
202
209
|
val startTimeMs = (track["startTimeMs"] as? Number)?.toDouble() ?: 0.0
|
|
203
210
|
|
|
204
211
|
Log.d(TAG, "Loading track: id=$id, uri=$uri")
|
|
205
212
|
val filePath = convertUriToPath(uri)
|
|
206
213
|
|
|
207
214
|
if (!engine.loadTrack(id, filePath, startTimeMs)) {
|
|
208
|
-
throw
|
|
215
|
+
throw CodedException(
|
|
216
|
+
"TRACK_LOAD_FAILED",
|
|
217
|
+
"Failed to load track: $id from $filePath",
|
|
218
|
+
null
|
|
219
|
+
)
|
|
209
220
|
}
|
|
210
221
|
|
|
211
222
|
loadedTrackIds.add(id)
|
|
@@ -214,10 +225,10 @@ class ExpoAudioEngineModule : Module() {
|
|
|
214
225
|
}
|
|
215
226
|
|
|
216
227
|
AsyncFunction("unloadTrack") { trackId: String ->
|
|
217
|
-
val engine =
|
|
228
|
+
val engine = requireEngine()
|
|
218
229
|
|
|
219
230
|
if (!engine.unloadTrack(trackId)) {
|
|
220
|
-
throw
|
|
231
|
+
throw CodedException("TRACK_UNLOAD_FAILED", "Failed to unload track: $trackId", null)
|
|
221
232
|
}
|
|
222
233
|
|
|
223
234
|
loadedTrackIds.remove(trackId)
|
|
@@ -225,7 +236,7 @@ class ExpoAudioEngineModule : Module() {
|
|
|
225
236
|
}
|
|
226
237
|
|
|
227
238
|
AsyncFunction("unloadAllTracks") {
|
|
228
|
-
val engine =
|
|
239
|
+
val engine = requireEngine()
|
|
229
240
|
engine.unloadAllTracks()
|
|
230
241
|
loadedTrackIds.clear()
|
|
231
242
|
Log.d(TAG, "All tracks unloaded")
|
|
@@ -236,27 +247,27 @@ class ExpoAudioEngineModule : Module() {
|
|
|
236
247
|
}
|
|
237
248
|
|
|
238
249
|
AsyncFunction("play") {
|
|
239
|
-
val engine =
|
|
250
|
+
val engine = requireEngine()
|
|
240
251
|
if (!startPlaybackInternal(engine, fromSystem = false)) {
|
|
241
|
-
throw
|
|
252
|
+
throw CodedException("PLAYBACK_START_FAILED", "Failed to start playback", null)
|
|
242
253
|
}
|
|
243
254
|
Log.d(TAG, "Playback started")
|
|
244
255
|
}
|
|
245
256
|
|
|
246
257
|
Function("pause") {
|
|
247
|
-
val engine =
|
|
258
|
+
val engine = requireEngine()
|
|
248
259
|
pausePlaybackInternal(engine, fromSystem = false)
|
|
249
260
|
Log.d(TAG, "Playback paused")
|
|
250
261
|
}
|
|
251
262
|
|
|
252
263
|
Function("stop") {
|
|
253
|
-
val engine =
|
|
264
|
+
val engine = requireEngine()
|
|
254
265
|
stopPlaybackInternal(engine, fromSystem = false)
|
|
255
266
|
Log.d(TAG, "Playback stopped")
|
|
256
267
|
}
|
|
257
268
|
|
|
258
269
|
Function("seek") { positionMs: Double ->
|
|
259
|
-
val engine =
|
|
270
|
+
val engine = requireEngine()
|
|
260
271
|
engine.seek(positionMs)
|
|
261
272
|
Log.d(TAG, "Seeked to position: $positionMs ms")
|
|
262
273
|
}
|
|
@@ -274,27 +285,27 @@ class ExpoAudioEngineModule : Module() {
|
|
|
274
285
|
}
|
|
275
286
|
|
|
276
287
|
Function("setTrackVolume") { trackId: String, volume: Double ->
|
|
277
|
-
val engine =
|
|
288
|
+
val engine = requireEngine()
|
|
278
289
|
engine.setTrackVolume(trackId, volume.toFloat())
|
|
279
290
|
}
|
|
280
291
|
|
|
281
292
|
Function("setTrackMuted") { trackId: String, muted: Boolean ->
|
|
282
|
-
val engine =
|
|
293
|
+
val engine = requireEngine()
|
|
283
294
|
engine.setTrackMuted(trackId, muted)
|
|
284
295
|
}
|
|
285
296
|
|
|
286
297
|
Function("setTrackSolo") { trackId: String, solo: Boolean ->
|
|
287
|
-
val engine =
|
|
298
|
+
val engine = requireEngine()
|
|
288
299
|
engine.setTrackSolo(trackId, solo)
|
|
289
300
|
}
|
|
290
301
|
|
|
291
302
|
Function("setTrackPan") { trackId: String, pan: Double ->
|
|
292
|
-
val engine =
|
|
303
|
+
val engine = requireEngine()
|
|
293
304
|
engine.setTrackPan(trackId, pan.toFloat())
|
|
294
305
|
}
|
|
295
306
|
|
|
296
307
|
Function("setMasterVolume") { volume: Double ->
|
|
297
|
-
val engine =
|
|
308
|
+
val engine = requireEngine()
|
|
298
309
|
engine.setMasterVolume(volume.toFloat())
|
|
299
310
|
}
|
|
300
311
|
|
|
@@ -303,7 +314,7 @@ class ExpoAudioEngineModule : Module() {
|
|
|
303
314
|
}
|
|
304
315
|
|
|
305
316
|
Function("setPitch") { semitones: Double ->
|
|
306
|
-
val engine =
|
|
317
|
+
val engine = requireEngine()
|
|
307
318
|
engine.setPitch(semitones.toFloat())
|
|
308
319
|
}
|
|
309
320
|
|
|
@@ -312,7 +323,7 @@ class ExpoAudioEngineModule : Module() {
|
|
|
312
323
|
}
|
|
313
324
|
|
|
314
325
|
Function("setSpeed") { rate: Double ->
|
|
315
|
-
val engine =
|
|
326
|
+
val engine = requireEngine()
|
|
316
327
|
engine.setSpeed(rate.toFloat())
|
|
317
328
|
}
|
|
318
329
|
|
|
@@ -321,13 +332,13 @@ class ExpoAudioEngineModule : Module() {
|
|
|
321
332
|
}
|
|
322
333
|
|
|
323
334
|
Function("setTempoAndPitch") { tempo: Double, pitch: Double ->
|
|
324
|
-
val engine =
|
|
335
|
+
val engine = requireEngine()
|
|
325
336
|
engine.setSpeed(tempo.toFloat())
|
|
326
337
|
engine.setPitch(pitch.toFloat())
|
|
327
338
|
}
|
|
328
339
|
|
|
329
340
|
Function("setTrackPitch") { trackId: String, semitones: Double ->
|
|
330
|
-
val engine =
|
|
341
|
+
val engine = requireEngine()
|
|
331
342
|
engine.setTrackPitch(trackId, semitones.toFloat())
|
|
332
343
|
}
|
|
333
344
|
|
|
@@ -336,7 +347,7 @@ class ExpoAudioEngineModule : Module() {
|
|
|
336
347
|
}
|
|
337
348
|
|
|
338
349
|
Function("setTrackSpeed") { trackId: String, rate: Double ->
|
|
339
|
-
val engine =
|
|
350
|
+
val engine = requireEngine()
|
|
340
351
|
engine.setTrackSpeed(trackId, rate.toFloat())
|
|
341
352
|
}
|
|
342
353
|
|
|
@@ -345,7 +356,7 @@ class ExpoAudioEngineModule : Module() {
|
|
|
345
356
|
}
|
|
346
357
|
|
|
347
358
|
AsyncFunction("startRecording") { config: Map<String, Any?>? ->
|
|
348
|
-
val engine =
|
|
359
|
+
val engine = requireEngine()
|
|
349
360
|
|
|
350
361
|
val sampleRate = (config?.get("sampleRate") as? Number)?.toInt() ?: 44100
|
|
351
362
|
val channels = (config?.get("channels") as? Number)?.toInt() ?: 1
|
|
@@ -376,7 +387,7 @@ class ExpoAudioEngineModule : Module() {
|
|
|
376
387
|
)
|
|
377
388
|
|
|
378
389
|
if (!success) {
|
|
379
|
-
throw
|
|
390
|
+
throw CodedException("RECORDING_START_FAILED", "Failed to start recording", null)
|
|
380
391
|
}
|
|
381
392
|
|
|
382
393
|
activeRecordingFormat = format
|
|
@@ -384,13 +395,17 @@ class ExpoAudioEngineModule : Module() {
|
|
|
384
395
|
}
|
|
385
396
|
|
|
386
397
|
AsyncFunction("stopRecording") {
|
|
387
|
-
val engine =
|
|
398
|
+
val engine = requireEngine()
|
|
388
399
|
|
|
389
400
|
Log.d(TAG, "Stopping recording")
|
|
390
401
|
val result = engine.stopRecording()
|
|
391
402
|
|
|
392
403
|
if (!result.success) {
|
|
393
|
-
throw
|
|
404
|
+
throw CodedException(
|
|
405
|
+
"RECORDING_STOP_FAILED",
|
|
406
|
+
"Failed to stop recording: ${result.errorMessage}",
|
|
407
|
+
null
|
|
408
|
+
)
|
|
394
409
|
}
|
|
395
410
|
|
|
396
411
|
Log.d(TAG, "Recording stopped: ${result.fileSize} bytes, ${result.durationSamples} samples")
|
|
@@ -426,12 +441,12 @@ class ExpoAudioEngineModule : Module() {
|
|
|
426
441
|
}
|
|
427
442
|
|
|
428
443
|
Function("setRecordingVolume") { volume: Double ->
|
|
429
|
-
val engine =
|
|
444
|
+
val engine = requireEngine()
|
|
430
445
|
engine.setRecordingVolume(volume.toFloat())
|
|
431
446
|
}
|
|
432
447
|
|
|
433
448
|
AsyncFunction("extractTrack") { trackId: String, config: Map<String, Any?>?, promise: Promise ->
|
|
434
|
-
val engine =
|
|
449
|
+
val engine = requireEngine()
|
|
435
450
|
|
|
436
451
|
val format = (config?.get("format") as? String) ?: "wav"
|
|
437
452
|
val bitrate = (config?.get("bitrate") as? Number)?.toInt() ?: 128000
|
|
@@ -471,7 +486,7 @@ class ExpoAudioEngineModule : Module() {
|
|
|
471
486
|
}
|
|
472
487
|
|
|
473
488
|
AsyncFunction("extractAllTracks") { config: Map<String, Any?>?, promise: Promise ->
|
|
474
|
-
val engine =
|
|
489
|
+
val engine = requireEngine()
|
|
475
490
|
|
|
476
491
|
val format = (config?.get("format") as? String) ?: "wav"
|
|
477
492
|
val bitrate = (config?.get("bitrate") as? Number)?.toInt() ?: 128000
|
|
@@ -510,7 +525,7 @@ class ExpoAudioEngineModule : Module() {
|
|
|
510
525
|
}
|
|
511
526
|
|
|
512
527
|
Function("cancelExtraction") { jobId: Double? ->
|
|
513
|
-
val engine =
|
|
528
|
+
val engine = requireEngine()
|
|
514
529
|
val resolvedJobId = jobId?.toLong() ?: lastExtractionJobId
|
|
515
530
|
if (resolvedJobId == null) {
|
|
516
531
|
false
|
|
@@ -527,7 +542,7 @@ class ExpoAudioEngineModule : Module() {
|
|
|
527
542
|
Function("getTrackLevel") { _trackId: String -> 0.0 }
|
|
528
543
|
|
|
529
544
|
AsyncFunction("enableBackgroundPlayback") { metadata: Map<String, Any?> ->
|
|
530
|
-
val engine =
|
|
545
|
+
val engine = requireEngine()
|
|
531
546
|
backgroundPlaybackEnabled = true
|
|
532
547
|
mergeNowPlayingMetadata(metadata)
|
|
533
548
|
ensureAudioFocusManager()
|
|
@@ -940,15 +955,27 @@ class ExpoAudioEngineModule : Module() {
|
|
|
940
955
|
uri.startsWith("content://") -> {
|
|
941
956
|
// TODO: Handle content:// URIs with ContentResolver
|
|
942
957
|
// For now, throw an error - this will be implemented when needed
|
|
943
|
-
throw
|
|
958
|
+
throw CodedException(
|
|
959
|
+
"UNSUPPORTED_URI",
|
|
960
|
+
"content:// URIs not yet supported. Use file:// or absolute paths.",
|
|
961
|
+
null
|
|
962
|
+
)
|
|
944
963
|
}
|
|
945
964
|
uri.startsWith("asset://") -> {
|
|
946
965
|
// TODO: Handle asset:// URIs by copying to temp file
|
|
947
966
|
// For now, throw an error - this will be implemented when needed
|
|
948
|
-
throw
|
|
967
|
+
throw CodedException(
|
|
968
|
+
"UNSUPPORTED_URI",
|
|
969
|
+
"asset:// URIs not yet supported. Use file:// or absolute paths.",
|
|
970
|
+
null
|
|
971
|
+
)
|
|
949
972
|
}
|
|
950
973
|
else -> {
|
|
951
|
-
throw
|
|
974
|
+
throw CodedException(
|
|
975
|
+
"UNSUPPORTED_URI",
|
|
976
|
+
"Unsupported URI scheme: $uri. Use file:// or absolute paths.",
|
|
977
|
+
null
|
|
978
|
+
)
|
|
952
979
|
}
|
|
953
980
|
}
|
|
954
981
|
}
|
|
@@ -11,7 +11,7 @@ final class AudioSessionManager {
|
|
|
11
11
|
try session.setCategory(
|
|
12
12
|
.playAndRecord,
|
|
13
13
|
mode: .default,
|
|
14
|
-
options: [.defaultToSpeaker, .
|
|
14
|
+
options: [.defaultToSpeaker, .allowBluetoothHFP]
|
|
15
15
|
)
|
|
16
16
|
} catch {
|
|
17
17
|
lastError = "setCategory failed: \(error.localizedDescription)"
|
|
@@ -51,10 +51,12 @@ final class AudioSessionManager {
|
|
|
51
51
|
func enableBackgroundPlayback(with config: AudioEngineConfig) -> Bool {
|
|
52
52
|
lastError = nil
|
|
53
53
|
do {
|
|
54
|
+
// The current engine graph always includes an input-side recording mixer path.
|
|
55
|
+
// Keep an input-capable category in background mode so engine.start() remains valid.
|
|
54
56
|
try session.setCategory(
|
|
55
|
-
.
|
|
57
|
+
.playAndRecord,
|
|
56
58
|
mode: .default,
|
|
57
|
-
options: [.
|
|
59
|
+
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowAirPlay]
|
|
58
60
|
)
|
|
59
61
|
} catch {
|
|
60
62
|
lastError = "setCategory failed: \(error.localizedDescription)"
|
|
@@ -48,9 +48,13 @@ final class NativeAudioEngine {
|
|
|
48
48
|
private var playCommandTarget: Any?
|
|
49
49
|
private var pauseCommandTarget: Any?
|
|
50
50
|
private var toggleCommandTarget: Any?
|
|
51
|
+
private var changePlaybackPositionCommandTarget: Any?
|
|
52
|
+
private var skipForwardCommandTarget: Any?
|
|
53
|
+
private var skipBackwardCommandTarget: Any?
|
|
51
54
|
private var lastConfig = AudioEngineConfig(dictionary: [:])
|
|
52
55
|
var onPlaybackStateChange: ((String, Double, Double) -> Void)?
|
|
53
56
|
var onPlaybackComplete: ((Double, Double) -> Void)?
|
|
57
|
+
var onError: ((String, String, [String: Any]?) -> Void)?
|
|
54
58
|
|
|
55
59
|
/// Bookkeeping for a live recording session.
|
|
56
60
|
private struct RecordingState {
|
|
@@ -104,13 +108,15 @@ final class NativeAudioEngine {
|
|
|
104
108
|
}
|
|
105
109
|
|
|
106
110
|
/// Loads and attaches tracks into the engine graph.
|
|
107
|
-
func loadTracks(_ trackInputs: [[String: Any]]) {
|
|
108
|
-
queue.sync {
|
|
111
|
+
func loadTracks(_ trackInputs: [[String: Any]]) -> [String: Any]? {
|
|
112
|
+
return queue.sync {
|
|
109
113
|
ensureInitializedIfNeeded()
|
|
110
114
|
stopEngineIfRunning()
|
|
111
115
|
stopAllPlayers()
|
|
116
|
+
var failedInputs: [[String: Any]] = []
|
|
112
117
|
for input in trackInputs {
|
|
113
118
|
guard let track = AudioTrack(input: input) else {
|
|
119
|
+
failedInputs.append(input)
|
|
114
120
|
continue
|
|
115
121
|
}
|
|
116
122
|
if let existing = tracks[track.id] {
|
|
@@ -123,6 +129,20 @@ final class NativeAudioEngine {
|
|
|
123
129
|
applyPitchSpeedForAllTracks()
|
|
124
130
|
recalculateDuration()
|
|
125
131
|
engine.prepare()
|
|
132
|
+
updateRemoteCommandStates()
|
|
133
|
+
if failedInputs.isEmpty {
|
|
134
|
+
return nil
|
|
135
|
+
}
|
|
136
|
+
var details: [String: Any] = ["failedCount": failedInputs.count]
|
|
137
|
+
let failedIds = failedInputs.compactMap { $0["id"] as? String }
|
|
138
|
+
if !failedIds.isEmpty {
|
|
139
|
+
details["failedTrackIds"] = failedIds
|
|
140
|
+
}
|
|
141
|
+
let failedUris = failedInputs.compactMap { $0["uri"] as? String }
|
|
142
|
+
if !failedUris.isEmpty {
|
|
143
|
+
details["failedTrackUris"] = failedUris
|
|
144
|
+
}
|
|
145
|
+
return details
|
|
126
146
|
}
|
|
127
147
|
}
|
|
128
148
|
|
|
@@ -134,6 +154,7 @@ final class NativeAudioEngine {
|
|
|
134
154
|
}
|
|
135
155
|
applyMixingForAllTracks()
|
|
136
156
|
recalculateDuration()
|
|
157
|
+
updateRemoteCommandStates()
|
|
137
158
|
}
|
|
138
159
|
}
|
|
139
160
|
|
|
@@ -144,6 +165,7 @@ final class NativeAudioEngine {
|
|
|
144
165
|
detachAllTracks()
|
|
145
166
|
tracks.removeAll()
|
|
146
167
|
recalculateDuration()
|
|
168
|
+
updateRemoteCommandStates()
|
|
147
169
|
}
|
|
148
170
|
}
|
|
149
171
|
|
|
@@ -486,20 +508,18 @@ final class NativeAudioEngine {
|
|
|
486
508
|
}
|
|
487
509
|
|
|
488
510
|
/// Offline export for a single track.
|
|
489
|
-
func extractTrack(trackId: String, config: [String: Any]?) -> [String: Any] {
|
|
490
|
-
return queue.sync {
|
|
511
|
+
func extractTrack(trackId: String, config: [String: Any]?) throws -> [String: Any] {
|
|
512
|
+
return try queue.sync {
|
|
491
513
|
guard let track = tracks[trackId] else {
|
|
492
|
-
|
|
493
|
-
"
|
|
494
|
-
"
|
|
495
|
-
"
|
|
496
|
-
|
|
497
|
-
"fileSize": 0
|
|
498
|
-
]
|
|
514
|
+
throw AudioEngineError(
|
|
515
|
+
"TRACK_NOT_FOUND",
|
|
516
|
+
"Track not found: \(trackId).",
|
|
517
|
+
details: ["trackId": trackId]
|
|
518
|
+
)
|
|
499
519
|
}
|
|
500
520
|
|
|
501
521
|
let durationMs = track.startTimeMs + track.durationMs
|
|
502
|
-
return renderOffline(
|
|
522
|
+
return try renderOffline(
|
|
503
523
|
tracksToRender: [track],
|
|
504
524
|
totalDurationMs: durationMs,
|
|
505
525
|
config: config,
|
|
@@ -509,12 +529,18 @@ final class NativeAudioEngine {
|
|
|
509
529
|
}
|
|
510
530
|
|
|
511
531
|
/// Offline export for all tracks (one file per track).
|
|
512
|
-
func extractAllTracks(config: [String: Any]?) -> [[String: Any]] {
|
|
513
|
-
return queue.sync {
|
|
532
|
+
func extractAllTracks(config: [String: Any]?) throws -> [[String: Any]] {
|
|
533
|
+
return try queue.sync {
|
|
534
|
+
if tracks.isEmpty {
|
|
535
|
+
throw AudioEngineError(
|
|
536
|
+
"NO_TRACKS_LOADED",
|
|
537
|
+
"Cannot extract without loaded tracks."
|
|
538
|
+
)
|
|
539
|
+
}
|
|
514
540
|
var results: [[String: Any]] = []
|
|
515
541
|
for track in tracks.values {
|
|
516
542
|
let durationMs = track.startTimeMs + track.durationMs
|
|
517
|
-
let result = renderOffline(
|
|
543
|
+
let result = try renderOffline(
|
|
518
544
|
tracksToRender: [track],
|
|
519
545
|
totalDurationMs: durationMs,
|
|
520
546
|
config: config,
|
|
@@ -554,6 +580,7 @@ final class NativeAudioEngine {
|
|
|
554
580
|
backgroundPlaybackEnabled = true
|
|
555
581
|
nowPlayingMetadata.merge(metadata) { _, new in new }
|
|
556
582
|
_ = sessionManager.enableBackgroundPlayback(with: lastConfig)
|
|
583
|
+
setRemoteControlEventsEnabled(true)
|
|
557
584
|
configureRemoteCommandsIfNeeded()
|
|
558
585
|
updateNowPlayingInfoInternal()
|
|
559
586
|
}
|
|
@@ -574,6 +601,10 @@ final class NativeAudioEngine {
|
|
|
574
601
|
nowPlayingMetadata.removeAll()
|
|
575
602
|
removeRemoteCommands()
|
|
576
603
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
|
604
|
+
if #available(iOS 13.0, *) {
|
|
605
|
+
MPNowPlayingInfoCenter.default().playbackState = .stopped
|
|
606
|
+
}
|
|
607
|
+
setRemoteControlEventsEnabled(false)
|
|
577
608
|
_ = sessionManager.configure(with: lastConfig)
|
|
578
609
|
}
|
|
579
610
|
}
|
|
@@ -592,10 +623,18 @@ final class NativeAudioEngine {
|
|
|
592
623
|
return
|
|
593
624
|
}
|
|
594
625
|
let config = lastConfig
|
|
626
|
+
let configured: Bool
|
|
595
627
|
if backgroundPlaybackEnabled {
|
|
596
|
-
|
|
628
|
+
configured = sessionManager.enableBackgroundPlayback(with: config)
|
|
597
629
|
} else {
|
|
598
|
-
|
|
630
|
+
configured = sessionManager.configure(with: config)
|
|
631
|
+
}
|
|
632
|
+
if !configured {
|
|
633
|
+
emitError(
|
|
634
|
+
code: "AUDIO_SESSION_FAILED",
|
|
635
|
+
message: sessionManager.lastErrorDescription() ?? "Audio session configuration failed",
|
|
636
|
+
details: ["backgroundPlaybackEnabled": backgroundPlaybackEnabled]
|
|
637
|
+
)
|
|
599
638
|
}
|
|
600
639
|
_ = ensureRecordingMixerConnected(
|
|
601
640
|
sampleRate: AVAudioSession.sharedInstance().sampleRate,
|
|
@@ -951,7 +990,7 @@ final class NativeAudioEngine {
|
|
|
951
990
|
totalDurationMs: Double,
|
|
952
991
|
config: [String: Any]?,
|
|
953
992
|
trackId: String
|
|
954
|
-
) -> [String: Any] {
|
|
993
|
+
) throws -> [String: Any] {
|
|
955
994
|
ensureInitializedIfNeeded()
|
|
956
995
|
stopEngineIfRunning()
|
|
957
996
|
stopAllPlayers()
|
|
@@ -988,6 +1027,12 @@ final class NativeAudioEngine {
|
|
|
988
1027
|
let originalState = snapshotTrackState()
|
|
989
1028
|
applyExtractionMixOverrides(tracksToRender: tracksToRender, includeEffects: includeEffects)
|
|
990
1029
|
|
|
1030
|
+
let errorContext: [String: Any] = [
|
|
1031
|
+
"trackId": trackId,
|
|
1032
|
+
"format": formatInfo.format,
|
|
1033
|
+
"outputUri": outputURL.absoluteString
|
|
1034
|
+
]
|
|
1035
|
+
|
|
991
1036
|
do {
|
|
992
1037
|
try engine.enableManualRenderingMode(
|
|
993
1038
|
.offline,
|
|
@@ -1022,7 +1067,11 @@ final class NativeAudioEngine {
|
|
|
1022
1067
|
pcmFormat: renderFormat,
|
|
1023
1068
|
frameCapacity: frameCount
|
|
1024
1069
|
) else {
|
|
1025
|
-
|
|
1070
|
+
throw AudioEngineError(
|
|
1071
|
+
"EXTRACTION_FAILED",
|
|
1072
|
+
"Failed to allocate render buffer.",
|
|
1073
|
+
details: errorContext
|
|
1074
|
+
)
|
|
1026
1075
|
}
|
|
1027
1076
|
|
|
1028
1077
|
let status = try engine.renderOffline(frameCount, to: buffer)
|
|
@@ -1034,9 +1083,17 @@ final class NativeAudioEngine {
|
|
|
1034
1083
|
case .cannotDoInCurrentContext:
|
|
1035
1084
|
continue
|
|
1036
1085
|
case .error:
|
|
1037
|
-
|
|
1086
|
+
throw AudioEngineError(
|
|
1087
|
+
"EXTRACTION_FAILED",
|
|
1088
|
+
"Offline render failed.",
|
|
1089
|
+
details: errorContext
|
|
1090
|
+
)
|
|
1038
1091
|
@unknown default:
|
|
1039
|
-
|
|
1092
|
+
throw AudioEngineError(
|
|
1093
|
+
"EXTRACTION_FAILED",
|
|
1094
|
+
"Offline render failed with unknown status.",
|
|
1095
|
+
details: errorContext
|
|
1096
|
+
)
|
|
1040
1097
|
}
|
|
1041
1098
|
}
|
|
1042
1099
|
|
|
@@ -1045,13 +1102,16 @@ final class NativeAudioEngine {
|
|
|
1045
1102
|
} catch {
|
|
1046
1103
|
engine.disableManualRenderingMode()
|
|
1047
1104
|
restoreTrackState(originalState)
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1105
|
+
if let codedError = error as? AudioEngineError {
|
|
1106
|
+
throw codedError
|
|
1107
|
+
}
|
|
1108
|
+
var details = errorContext
|
|
1109
|
+
details["nativeError"] = error.localizedDescription
|
|
1110
|
+
throw AudioEngineError(
|
|
1111
|
+
"EXTRACTION_FAILED",
|
|
1112
|
+
"Failed to extract track \(trackId). \(error.localizedDescription)",
|
|
1113
|
+
details: details
|
|
1114
|
+
)
|
|
1055
1115
|
}
|
|
1056
1116
|
|
|
1057
1117
|
restoreTrackState(originalState)
|
|
@@ -1148,11 +1208,19 @@ final class NativeAudioEngine {
|
|
|
1148
1208
|
}
|
|
1149
1209
|
}
|
|
1150
1210
|
|
|
1211
|
+
private func emitError(code: String, message: String, details: [String: Any]? = nil) {
|
|
1212
|
+
onError?(code, message, details)
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1151
1215
|
/// Starts the AVAudioEngine if it is not already running.
|
|
1152
1216
|
@discardableResult
|
|
1153
1217
|
private func startEngineIfNeeded() -> Bool {
|
|
1154
1218
|
if !isInitialized {
|
|
1155
1219
|
lastEngineError = "audio engine not initialized"
|
|
1220
|
+
emitError(
|
|
1221
|
+
code: "ENGINE_NOT_INITIALIZED",
|
|
1222
|
+
message: lastEngineError ?? "Audio engine not initialized"
|
|
1223
|
+
)
|
|
1156
1224
|
return false
|
|
1157
1225
|
}
|
|
1158
1226
|
if !engine.isRunning {
|
|
@@ -1161,6 +1229,20 @@ final class NativeAudioEngine {
|
|
|
1161
1229
|
lastEngineError = nil
|
|
1162
1230
|
} catch {
|
|
1163
1231
|
lastEngineError = error.localizedDescription
|
|
1232
|
+
let session = AVAudioSession.sharedInstance()
|
|
1233
|
+
emitError(
|
|
1234
|
+
code: "ENGINE_START_FAILED",
|
|
1235
|
+
message: "Failed to start audio engine. \(error.localizedDescription)",
|
|
1236
|
+
details: [
|
|
1237
|
+
"nativeError": error.localizedDescription,
|
|
1238
|
+
"category": session.category.rawValue,
|
|
1239
|
+
"sampleRate": session.sampleRate,
|
|
1240
|
+
"inputAvailable": session.isInputAvailable,
|
|
1241
|
+
"inputChannels": session.inputNumberOfChannels,
|
|
1242
|
+
"route": describeRoute(session),
|
|
1243
|
+
"recordingMixerConnected": recordingMixerConnected
|
|
1244
|
+
]
|
|
1245
|
+
)
|
|
1164
1246
|
return false
|
|
1165
1247
|
}
|
|
1166
1248
|
}
|
|
@@ -1212,7 +1294,13 @@ final class NativeAudioEngine {
|
|
|
1212
1294
|
|
|
1213
1295
|
/// Internal play logic (expects to run on the queue).
|
|
1214
1296
|
private func playInternal() {
|
|
1215
|
-
guard !tracks.isEmpty else {
|
|
1297
|
+
guard !tracks.isEmpty else {
|
|
1298
|
+
emitError(
|
|
1299
|
+
code: "NO_TRACKS_LOADED",
|
|
1300
|
+
message: "Cannot start playback without loaded tracks."
|
|
1301
|
+
)
|
|
1302
|
+
return
|
|
1303
|
+
}
|
|
1216
1304
|
ensureInitializedIfNeeded()
|
|
1217
1305
|
if isPlayingFlag {
|
|
1218
1306
|
return
|
|
@@ -1261,6 +1349,13 @@ final class NativeAudioEngine {
|
|
|
1261
1349
|
updateNowPlayingInfoInternal()
|
|
1262
1350
|
}
|
|
1263
1351
|
|
|
1352
|
+
private func seekByIntervalInternal(deltaMs: Double) {
|
|
1353
|
+
let current = isPlayingFlag ? currentPlaybackPositionMs() : currentPositionMs
|
|
1354
|
+
let maxPosition = durationMs > 0 ? durationMs : Double.greatestFiniteMagnitude
|
|
1355
|
+
let target = min(max(0.0, current + deltaMs), maxPosition)
|
|
1356
|
+
seekInternal(positionMs: target)
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1264
1359
|
/// Updates Now Playing info based on stored metadata and playback state.
|
|
1265
1360
|
private func updateNowPlayingInfoInternal() {
|
|
1266
1361
|
guard backgroundPlaybackEnabled else { return }
|
|
@@ -1285,6 +1380,16 @@ final class NativeAudioEngine {
|
|
|
1285
1380
|
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlayingFlag ? 1.0 : 0.0
|
|
1286
1381
|
|
|
1287
1382
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
|
1383
|
+
if #available(iOS 13.0, *) {
|
|
1384
|
+
if isPlayingFlag {
|
|
1385
|
+
MPNowPlayingInfoCenter.default().playbackState = .playing
|
|
1386
|
+
} else if currentPositionMs > 0 {
|
|
1387
|
+
MPNowPlayingInfoCenter.default().playbackState = .paused
|
|
1388
|
+
} else {
|
|
1389
|
+
MPNowPlayingInfoCenter.default().playbackState = .stopped
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
updateRemoteCommandStates()
|
|
1288
1393
|
}
|
|
1289
1394
|
|
|
1290
1395
|
private func emitPlaybackState(_ state: PlaybackState, positionMs: Double? = nil) {
|
|
@@ -1316,10 +1421,35 @@ final class NativeAudioEngine {
|
|
|
1316
1421
|
}
|
|
1317
1422
|
return .success
|
|
1318
1423
|
}
|
|
1424
|
+
changePlaybackPositionCommandTarget = commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
|
|
1425
|
+
guard let self = self,
|
|
1426
|
+
let positionEvent = event as? MPChangePlaybackPositionCommandEvent else {
|
|
1427
|
+
return .commandFailed
|
|
1428
|
+
}
|
|
1429
|
+
self.queue.async {
|
|
1430
|
+
let targetMs = max(0.0, positionEvent.positionTime * 1000.0)
|
|
1431
|
+
self.seekInternal(positionMs: targetMs)
|
|
1432
|
+
}
|
|
1433
|
+
return .success
|
|
1434
|
+
}
|
|
1435
|
+
skipForwardCommandTarget = commandCenter.skipForwardCommand.addTarget { [weak self] event in
|
|
1436
|
+
guard let self = self else { return .commandFailed }
|
|
1437
|
+
let seconds = (event as? MPSkipIntervalCommandEvent)?.interval ?? 15.0
|
|
1438
|
+
self.queue.async {
|
|
1439
|
+
self.seekByIntervalInternal(deltaMs: max(0.0, seconds) * 1000.0)
|
|
1440
|
+
}
|
|
1441
|
+
return .success
|
|
1442
|
+
}
|
|
1443
|
+
skipBackwardCommandTarget = commandCenter.skipBackwardCommand.addTarget { [weak self] event in
|
|
1444
|
+
guard let self = self else { return .commandFailed }
|
|
1445
|
+
let seconds = (event as? MPSkipIntervalCommandEvent)?.interval ?? 15.0
|
|
1446
|
+
self.queue.async {
|
|
1447
|
+
self.seekByIntervalInternal(deltaMs: -max(0.0, seconds) * 1000.0)
|
|
1448
|
+
}
|
|
1449
|
+
return .success
|
|
1450
|
+
}
|
|
1319
1451
|
|
|
1320
|
-
|
|
1321
|
-
commandCenter.pauseCommand.isEnabled = true
|
|
1322
|
-
commandCenter.togglePlayPauseCommand.isEnabled = true
|
|
1452
|
+
updateRemoteCommandStates()
|
|
1323
1453
|
}
|
|
1324
1454
|
|
|
1325
1455
|
/// Removes remote command handlers.
|
|
@@ -1334,9 +1464,61 @@ final class NativeAudioEngine {
|
|
|
1334
1464
|
if let target = toggleCommandTarget {
|
|
1335
1465
|
commandCenter.togglePlayPauseCommand.removeTarget(target)
|
|
1336
1466
|
}
|
|
1467
|
+
if let target = changePlaybackPositionCommandTarget {
|
|
1468
|
+
commandCenter.changePlaybackPositionCommand.removeTarget(target)
|
|
1469
|
+
}
|
|
1470
|
+
if let target = skipForwardCommandTarget {
|
|
1471
|
+
commandCenter.skipForwardCommand.removeTarget(target)
|
|
1472
|
+
}
|
|
1473
|
+
if let target = skipBackwardCommandTarget {
|
|
1474
|
+
commandCenter.skipBackwardCommand.removeTarget(target)
|
|
1475
|
+
}
|
|
1337
1476
|
playCommandTarget = nil
|
|
1338
1477
|
pauseCommandTarget = nil
|
|
1339
1478
|
toggleCommandTarget = nil
|
|
1479
|
+
changePlaybackPositionCommandTarget = nil
|
|
1480
|
+
skipForwardCommandTarget = nil
|
|
1481
|
+
skipBackwardCommandTarget = nil
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
private func updateRemoteCommandStates() {
|
|
1485
|
+
let commandCenter = MPRemoteCommandCenter.shared()
|
|
1486
|
+
let hasTracks = !tracks.isEmpty
|
|
1487
|
+
let hasSeekableDuration = durationMs > 0
|
|
1488
|
+
let seekStepSeconds = max(1.0, resolveSeekStepMs() / 1000.0)
|
|
1489
|
+
commandCenter.playCommand.isEnabled = backgroundPlaybackEnabled && hasTracks && !isPlayingFlag
|
|
1490
|
+
commandCenter.pauseCommand.isEnabled = backgroundPlaybackEnabled && hasTracks && isPlayingFlag
|
|
1491
|
+
commandCenter.togglePlayPauseCommand.isEnabled = backgroundPlaybackEnabled && hasTracks
|
|
1492
|
+
commandCenter.changePlaybackPositionCommand.isEnabled = backgroundPlaybackEnabled && hasTracks && hasSeekableDuration
|
|
1493
|
+
commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: seekStepSeconds)]
|
|
1494
|
+
commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: seekStepSeconds)]
|
|
1495
|
+
commandCenter.skipForwardCommand.isEnabled = backgroundPlaybackEnabled && hasTracks && hasSeekableDuration
|
|
1496
|
+
commandCenter.skipBackwardCommand.isEnabled = backgroundPlaybackEnabled && hasTracks && hasSeekableDuration
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
private func resolveSeekStepMs() -> Double {
|
|
1500
|
+
guard let playbackCard = nowPlayingMetadata["playbackCard"] as? [String: Any] else {
|
|
1501
|
+
return 15000.0
|
|
1502
|
+
}
|
|
1503
|
+
if let seekStepMs = playbackCard["seekStepMs"] as? NSNumber {
|
|
1504
|
+
return max(1000.0, seekStepMs.doubleValue)
|
|
1505
|
+
}
|
|
1506
|
+
return 15000.0
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
private func setRemoteControlEventsEnabled(_ enabled: Bool) {
|
|
1510
|
+
let handler = {
|
|
1511
|
+
if enabled {
|
|
1512
|
+
UIApplication.shared.beginReceivingRemoteControlEvents()
|
|
1513
|
+
} else {
|
|
1514
|
+
UIApplication.shared.endReceivingRemoteControlEvents()
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
if Thread.isMainThread {
|
|
1518
|
+
handler()
|
|
1519
|
+
} else {
|
|
1520
|
+
DispatchQueue.main.async(execute: handler)
|
|
1521
|
+
}
|
|
1340
1522
|
}
|
|
1341
1523
|
|
|
1342
1524
|
/// Resolves artwork from a local file path or file URL.
|
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
import ExpoModulesCore
|
|
2
2
|
|
|
3
|
+
struct AudioEngineError: CodedError, CustomNSError {
|
|
4
|
+
let code: String
|
|
5
|
+
let description: String
|
|
6
|
+
let details: [String: Any]?
|
|
7
|
+
|
|
8
|
+
init(_ code: String, _ description: String, details: [String: Any]? = nil) {
|
|
9
|
+
self.code = code
|
|
10
|
+
self.description = description
|
|
11
|
+
self.details = details
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
static var errorDomain: String {
|
|
15
|
+
return "ExpoAudioEngine"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
var errorCode: Int {
|
|
19
|
+
return 0
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
var errorUserInfo: [String: Any] {
|
|
23
|
+
var info: [String: Any] = [
|
|
24
|
+
NSLocalizedDescriptionKey: description,
|
|
25
|
+
"code": code
|
|
26
|
+
]
|
|
27
|
+
if let details = details {
|
|
28
|
+
info["details"] = details
|
|
29
|
+
}
|
|
30
|
+
return info
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
3
34
|
public class ExpoAudioEngineModule: Module {
|
|
4
35
|
private let engine = NativeAudioEngine()
|
|
5
36
|
|
|
@@ -29,10 +60,23 @@ public class ExpoAudioEngineModule: Module {
|
|
|
29
60
|
)
|
|
30
61
|
}
|
|
31
62
|
}
|
|
63
|
+
engine.onError = { [weak self] code, message, details in
|
|
64
|
+
DispatchQueue.main.async {
|
|
65
|
+
var payload: [String: Any] = [
|
|
66
|
+
"code": code,
|
|
67
|
+
"message": message
|
|
68
|
+
]
|
|
69
|
+
if let details = details {
|
|
70
|
+
payload["details"] = details
|
|
71
|
+
}
|
|
72
|
+
self?.sendEvent("error", payload)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
32
75
|
}
|
|
33
76
|
OnDestroy {
|
|
34
77
|
engine.onPlaybackStateChange = nil
|
|
35
78
|
engine.onPlaybackComplete = nil
|
|
79
|
+
engine.onError = nil
|
|
36
80
|
}
|
|
37
81
|
|
|
38
82
|
AsyncFunction("initialize") { (config: [String: Any]) in
|
|
@@ -41,8 +85,14 @@ public class ExpoAudioEngineModule: Module {
|
|
|
41
85
|
AsyncFunction("release") {
|
|
42
86
|
engine.releaseResources()
|
|
43
87
|
}
|
|
44
|
-
AsyncFunction("loadTracks") { (tracks: [[String: Any]]) in
|
|
45
|
-
engine.loadTracks(tracks)
|
|
88
|
+
AsyncFunction("loadTracks") { (tracks: [[String: Any]]) throws in
|
|
89
|
+
if let failure = engine.loadTracks(tracks) {
|
|
90
|
+
let failedCount = failure["failedCount"] as? Int ?? 0
|
|
91
|
+
let message = failedCount > 0
|
|
92
|
+
? "Failed to load \(failedCount) track(s)."
|
|
93
|
+
: "Failed to load tracks."
|
|
94
|
+
throw AudioEngineError("TRACK_LOAD_FAILED", message, details: failure)
|
|
95
|
+
}
|
|
46
96
|
}
|
|
47
97
|
AsyncFunction("unloadTrack") { (trackId: String) in
|
|
48
98
|
engine.unloadTrack(trackId)
|
|
@@ -147,12 +197,12 @@ public class ExpoAudioEngineModule: Module {
|
|
|
147
197
|
engine.setRecordingVolume(volume)
|
|
148
198
|
}
|
|
149
199
|
|
|
150
|
-
AsyncFunction("extractTrack") { (trackId: String, config: [String: Any]?) in
|
|
151
|
-
return engine.extractTrack(trackId: trackId, config: config)
|
|
200
|
+
AsyncFunction("extractTrack") { (trackId: String, config: [String: Any]?) throws in
|
|
201
|
+
return try engine.extractTrack(trackId: trackId, config: config)
|
|
152
202
|
}
|
|
153
203
|
|
|
154
|
-
AsyncFunction("extractAllTracks") { (config: [String: Any]?) in
|
|
155
|
-
return engine.extractAllTracks(config: config)
|
|
204
|
+
AsyncFunction("extractAllTracks") { (config: [String: Any]?) throws in
|
|
205
|
+
return try engine.extractAllTracks(config: config)
|
|
156
206
|
}
|
|
157
207
|
|
|
158
208
|
Function("cancelExtraction") { (jobId: Double?) in
|
package/package.json
CHANGED