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 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.6
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
@@ -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.6"
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 Exception("Failed to initialize audio engine")
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 = audioEngine ?: throw Exception("Engine not initialized")
202
+ val engine = requireEngine()
198
203
 
199
204
  tracks.forEach { track ->
200
- val id = track["id"] as? String ?: throw Exception("Missing track id")
201
- val uri = track["uri"] as? String ?: throw Exception("Missing track uri")
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 Exception("Failed to load track: $id from $filePath")
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 = audioEngine ?: throw Exception("Engine not initialized")
228
+ val engine = requireEngine()
218
229
 
219
230
  if (!engine.unloadTrack(trackId)) {
220
- throw Exception("Failed to unload track: $trackId")
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 = audioEngine ?: throw Exception("Engine not initialized")
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 = audioEngine ?: throw Exception("Engine not initialized")
250
+ val engine = requireEngine()
240
251
  if (!startPlaybackInternal(engine, fromSystem = false)) {
241
- throw Exception("Failed to start playback")
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 = audioEngine ?: throw Exception("Engine not initialized")
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 = audioEngine ?: throw Exception("Engine not initialized")
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 = audioEngine ?: throw Exception("Engine not initialized")
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 = audioEngine ?: throw Exception("Engine not initialized")
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 = audioEngine ?: throw Exception("Engine not initialized")
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 = audioEngine ?: throw Exception("Engine not initialized")
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 = audioEngine ?: throw Exception("Engine not initialized")
303
+ val engine = requireEngine()
293
304
  engine.setTrackPan(trackId, pan.toFloat())
294
305
  }
295
306
 
296
307
  Function("setMasterVolume") { volume: Double ->
297
- val engine = audioEngine ?: throw Exception("Engine not initialized")
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 = audioEngine ?: throw Exception("Engine not initialized")
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 = audioEngine ?: throw Exception("Engine not initialized")
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 = audioEngine ?: throw Exception("Engine not initialized")
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 = audioEngine ?: throw Exception("Engine not initialized")
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 = audioEngine ?: throw Exception("Engine not initialized")
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 = audioEngine ?: throw Exception("Engine not initialized")
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 Exception("Failed to start recording")
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 = audioEngine ?: throw Exception("Engine not initialized")
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 Exception("Failed to stop recording: ${result.errorMessage}")
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 = audioEngine ?: throw Exception("Engine not initialized")
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 = audioEngine ?: throw Exception("Engine not initialized")
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 = audioEngine ?: throw Exception("Engine not initialized")
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 = audioEngine ?: throw Exception("Engine not initialized")
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 = audioEngine ?: throw Exception("Engine not initialized")
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 Exception("content:// URIs not yet supported. Use file:// or absolute paths.")
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 Exception("asset:// URIs not yet supported. Use file:// or absolute paths.")
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 Exception("Unsupported URI scheme: $uri. Use file:// or absolute paths.")
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, .allowBluetooth]
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
- .playback,
57
+ .playAndRecord,
56
58
  mode: .default,
57
- options: [.allowBluetooth, .allowAirPlay]
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
- return [
493
- "trackId": trackId,
494
- "uri": "",
495
- "duration": 0,
496
- "format": "aac",
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
- _ = sessionManager.enableBackgroundPlayback(with: config)
628
+ configured = sessionManager.enableBackgroundPlayback(with: config)
597
629
  } else {
598
- _ = sessionManager.configure(with: config)
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
- break
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
- break
1086
+ throw AudioEngineError(
1087
+ "EXTRACTION_FAILED",
1088
+ "Offline render failed.",
1089
+ details: errorContext
1090
+ )
1038
1091
  @unknown default:
1039
- break
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
- return [
1049
- "trackId": trackId,
1050
- "uri": "",
1051
- "duration": 0,
1052
- "format": formatInfo.format,
1053
- "fileSize": 0
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 { return }
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
- commandCenter.playCommand.isEnabled = true
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sezo-audio-engine",
3
- "version": "0.0.13",
3
+ "version": "0.0.14",
4
4
  "description": "Cross-platform Expo module for the Sezo Audio Engine with iOS implementation and background playback.",
5
5
  "license": "MIT",
6
6
  "author": "Sezo",