sezo-audio-engine 0.0.15 → 0.0.16

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.7
41
+ sezoAudioEngineVersion=android-engine-v0.1.8
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.7"
49
+ def sezoAudioEngineVersion = project.findProperty("sezoAudioEngineVersion") ?: "android-engine-v0.1.8"
50
50
  implementation "com.github.Sepzie:SezoAudioEngine:${sezoAudioEngineVersion}"
51
51
  }
52
52
  }
@@ -62,7 +62,14 @@ class ExpoAudioEngineModule : Module() {
62
62
  )
63
63
 
64
64
  private fun requireEngine(): AudioEngine {
65
- return audioEngine ?: throw CodedException("ENGINE_NOT_INITIALIZED", "Engine not initialized")
65
+ return audioEngine ?: run {
66
+ emitEngineError(
67
+ code = "ENGINE_NOT_INITIALIZED",
68
+ message = "Engine not initialized",
69
+ source = "engine"
70
+ )
71
+ throw CodedException("ENGINE_NOT_INITIALIZED", "Engine not initialized", null)
72
+ }
66
73
  }
67
74
 
68
75
  override fun definition() = ModuleDefinition {
@@ -70,6 +77,7 @@ class ExpoAudioEngineModule : Module() {
70
77
 
71
78
  OnActivityEntersBackground {
72
79
  Log.d(TAG, "Activity entering background")
80
+ emitDebugLog("debug", "Activity entering background")
73
81
  val engine = audioEngine ?: return@OnActivityEntersBackground
74
82
 
75
83
  if (backgroundPlaybackEnabled) {
@@ -81,31 +89,33 @@ class ExpoAudioEngineModule : Module() {
81
89
  if (wasPlayingBeforePause) {
82
90
  Log.d(TAG, "Pausing engine before background (was playing)")
83
91
  pausePlaybackInternal(engine, fromSystem = true, keepAudioFocus = false)
84
- sendEvent("engineStateChanged", mapOf(
85
- "reason" to "backgrounded",
86
- "wasPlaying" to true
87
- ))
92
+ emitEngineStateChanged(
93
+ reason = "backgrounded",
94
+ payload = mapOf("wasPlaying" to true)
95
+ )
88
96
  }
89
97
  }
90
98
 
91
99
  OnActivityEntersForeground {
92
100
  Log.d(TAG, "Activity entering foreground")
101
+ emitDebugLog("debug", "Activity entering foreground")
93
102
  val engine = audioEngine ?: return@OnActivityEntersForeground
94
103
 
95
104
  // Check stream health on resume
96
105
  if (!engine.isStreamHealthy()) {
97
106
  Log.w(TAG, "Stream unhealthy on resume, attempting restart")
98
107
  val restarted = engine.restartStream()
99
- sendEvent("engineStateChanged", mapOf(
100
- "reason" to "streamRestarted",
101
- "success" to restarted
102
- ))
108
+ emitEngineStateChanged(
109
+ reason = "streamRestarted",
110
+ payload = mapOf("success" to restarted)
111
+ )
103
112
  if (!restarted) {
104
113
  Log.e(TAG, "Failed to restart stream on resume")
105
- sendEvent("error", mapOf(
106
- "code" to "STREAM_DISCONNECTED",
107
- "message" to "Audio stream could not be recovered after returning to foreground"
108
- ))
114
+ emitEngineError(
115
+ code = "STREAM_DISCONNECTED",
116
+ message = "Audio stream could not be recovered after returning to foreground",
117
+ source = "engine"
118
+ )
109
119
  return@OnActivityEntersForeground
110
120
  }
111
121
  }
@@ -115,10 +125,9 @@ class ExpoAudioEngineModule : Module() {
115
125
  Log.d(TAG, "Resuming playback after returning to foreground")
116
126
  val resumed = startPlaybackInternal(engine, fromSystem = true)
117
127
  wasPlayingBeforePause = false
118
- sendEvent(
119
- "engineStateChanged",
120
- mapOf(
121
- "reason" to "resumed",
128
+ emitEngineStateChanged(
129
+ reason = "resumed",
130
+ payload = mapOf(
122
131
  "wasPlaying" to true,
123
132
  "success" to resumed
124
133
  )
@@ -148,6 +157,7 @@ class ExpoAudioEngineModule : Module() {
148
157
 
149
158
  AsyncFunction("initialize") { config: Map<String, Any?> ->
150
159
  Log.d(TAG, "Initialize called with config: $config")
160
+ emitDebugLog("info", "Initialize called", mapOf("config" to config))
151
161
 
152
162
  val sampleRate = (config["sampleRate"] as? Number)?.toInt() ?: 44100
153
163
  val maxTracks = (config["maxTracks"] as? Number)?.toInt() ?: 8
@@ -156,6 +166,11 @@ class ExpoAudioEngineModule : Module() {
156
166
  val success = audioEngine?.initialize(sampleRate, maxTracks) ?: false
157
167
 
158
168
  if (!success) {
169
+ emitEngineError(
170
+ code = "ENGINE_INIT_FAILED",
171
+ message = "Failed to initialize audio engine",
172
+ source = "engine"
173
+ )
159
174
  throw CodedException("ENGINE_INIT_FAILED", "Failed to initialize audio engine", null)
160
175
  }
161
176
 
@@ -171,6 +186,13 @@ class ExpoAudioEngineModule : Module() {
171
186
  "durationMs" to durationMs
172
187
  )
173
188
  )
189
+ sendEvent(
190
+ "positionUpdate",
191
+ mapOf(
192
+ "positionMs" to positionMs,
193
+ "durationMs" to durationMs
194
+ )
195
+ )
174
196
  handlePlaybackStateChange(state)
175
197
  }
176
198
 
@@ -179,6 +201,7 @@ class ExpoAudioEngineModule : Module() {
179
201
 
180
202
  AsyncFunction("release") {
181
203
  Log.d(TAG, "Release called")
204
+ emitDebugLog("info", "Release called")
182
205
  teardownBackgroundPlayback(clearMetadata = false, stopService = true)
183
206
  audioEngine?.let { engine ->
184
207
  synchronized(pendingExtractions) {
@@ -203,15 +226,47 @@ class ExpoAudioEngineModule : Module() {
203
226
 
204
227
  tracks.forEach { track ->
205
228
  val id = track["id"] as? String
206
- ?: throw CodedException("TRACK_LOAD_FAILED", "Missing track id", null)
229
+ ?: run {
230
+ emitEngineError(
231
+ code = "TRACK_LOAD_FAILED",
232
+ message = "Missing track id",
233
+ details = track,
234
+ source = "engine"
235
+ )
236
+ throw CodedException("TRACK_LOAD_FAILED", "Missing track id", null)
237
+ }
207
238
  val uri = track["uri"] as? String
208
- ?: throw CodedException("TRACK_LOAD_FAILED", "Missing track uri", null)
239
+ ?: run {
240
+ emitEngineError(
241
+ code = "TRACK_LOAD_FAILED",
242
+ message = "Missing track uri",
243
+ details = mapOf("trackId" to id),
244
+ source = "engine"
245
+ )
246
+ throw CodedException("TRACK_LOAD_FAILED", "Missing track uri", null)
247
+ }
209
248
  val startTimeMs = (track["startTimeMs"] as? Number)?.toDouble() ?: 0.0
210
249
 
211
250
  Log.d(TAG, "Loading track: id=$id, uri=$uri")
212
- val filePath = convertUriToPath(uri)
251
+ val filePath = try {
252
+ convertUriToPath(uri)
253
+ } catch (error: CodedException) {
254
+ emitEngineError(
255
+ code = "UNSUPPORTED_URI",
256
+ message = error.message ?: "Unsupported URI",
257
+ details = mapOf("trackId" to id, "uri" to uri),
258
+ source = "system"
259
+ )
260
+ throw error
261
+ }
213
262
 
214
263
  if (!engine.loadTrack(id, filePath, startTimeMs)) {
264
+ emitEngineError(
265
+ code = "TRACK_LOAD_FAILED",
266
+ message = "Failed to load track: $id from $filePath",
267
+ details = mapOf("trackId" to id, "uri" to uri),
268
+ source = "engine"
269
+ )
215
270
  throw CodedException(
216
271
  "TRACK_LOAD_FAILED",
217
272
  "Failed to load track: $id from $filePath",
@@ -221,6 +276,7 @@ class ExpoAudioEngineModule : Module() {
221
276
 
222
277
  loadedTrackIds.add(id)
223
278
  Log.d(TAG, "Track loaded successfully: $id")
279
+ sendEvent("trackLoaded", mapOf("trackId" to id))
224
280
  }
225
281
  }
226
282
 
@@ -228,18 +284,27 @@ class ExpoAudioEngineModule : Module() {
228
284
  val engine = requireEngine()
229
285
 
230
286
  if (!engine.unloadTrack(trackId)) {
287
+ emitEngineError(
288
+ code = "TRACK_UNLOAD_FAILED",
289
+ message = "Failed to unload track: $trackId",
290
+ details = mapOf("trackId" to trackId),
291
+ source = "engine"
292
+ )
231
293
  throw CodedException("TRACK_UNLOAD_FAILED", "Failed to unload track: $trackId", null)
232
294
  }
233
295
 
234
296
  loadedTrackIds.remove(trackId)
235
297
  Log.d(TAG, "Track unloaded: $trackId")
298
+ sendEvent("trackUnloaded", mapOf("trackId" to trackId))
236
299
  }
237
300
 
238
301
  AsyncFunction("unloadAllTracks") {
239
302
  val engine = requireEngine()
303
+ val trackIdsToUnload = loadedTrackIds.toList()
240
304
  engine.unloadAllTracks()
241
305
  loadedTrackIds.clear()
242
306
  Log.d(TAG, "All tracks unloaded")
307
+ trackIdsToUnload.forEach { sendEvent("trackUnloaded", mapOf("trackId" to it)) }
243
308
  }
244
309
 
245
310
  Function("getLoadedTracks") {
@@ -249,6 +314,11 @@ class ExpoAudioEngineModule : Module() {
249
314
  AsyncFunction("play") {
250
315
  val engine = requireEngine()
251
316
  if (!startPlaybackInternal(engine, fromSystem = false)) {
317
+ emitEngineError(
318
+ code = "PLAYBACK_START_FAILED",
319
+ message = "Failed to start playback",
320
+ source = "playback"
321
+ )
252
322
  throw CodedException("PLAYBACK_START_FAILED", "Failed to start playback", null)
253
323
  }
254
324
  Log.d(TAG, "Playback started")
@@ -387,11 +457,23 @@ class ExpoAudioEngineModule : Module() {
387
457
  )
388
458
 
389
459
  if (!success) {
460
+ emitEngineError(
461
+ code = "RECORDING_START_FAILED",
462
+ message = "Failed to start recording",
463
+ details = mapOf(
464
+ "format" to format,
465
+ "sampleRate" to sampleRate,
466
+ "channels" to channels
467
+ ),
468
+ source = "recording"
469
+ )
390
470
  throw CodedException("RECORDING_START_FAILED", "Failed to start recording", null)
391
471
  }
392
472
 
393
473
  activeRecordingFormat = format
394
474
  Log.d(TAG, "Recording started successfully")
475
+ sendEvent("recordingStarted", mapOf<String, Any>())
476
+ emitEngineStateChanged("recordingStarted")
395
477
  }
396
478
 
397
479
  AsyncFunction("stopRecording") {
@@ -401,6 +483,12 @@ class ExpoAudioEngineModule : Module() {
401
483
  val result = engine.stopRecording()
402
484
 
403
485
  if (!result.success) {
486
+ emitEngineError(
487
+ code = "RECORDING_STOP_FAILED",
488
+ message = "Failed to stop recording: ${result.errorMessage}",
489
+ details = mapOf("errorMessage" to result.errorMessage),
490
+ source = "recording"
491
+ )
404
492
  throw CodedException(
405
493
  "RECORDING_STOP_FAILED",
406
494
  "Failed to stop recording: ${result.errorMessage}",
@@ -423,6 +511,7 @@ class ExpoAudioEngineModule : Module() {
423
511
  "fileSize" to result.fileSize
424
512
  )
425
513
  )
514
+ emitEngineStateChanged("recordingStopped")
426
515
 
427
516
  mapOf(
428
517
  "uri" to "file://${result.outputPath}",
@@ -469,6 +558,12 @@ class ExpoAudioEngineModule : Module() {
469
558
  )
470
559
 
471
560
  if (jobId <= 0L) {
561
+ emitEngineError(
562
+ code = "EXTRACTION_FAILED",
563
+ message = "Failed to start extraction",
564
+ details = mapOf("trackId" to trackId),
565
+ source = "extraction"
566
+ )
472
567
  promise.reject("EXTRACTION_FAILED", "Failed to start extraction", null)
473
568
  return@AsyncFunction
474
569
  }
@@ -508,6 +603,11 @@ class ExpoAudioEngineModule : Module() {
508
603
  )
509
604
 
510
605
  if (jobId <= 0L) {
606
+ emitEngineError(
607
+ code = "EXTRACTION_FAILED",
608
+ message = "Failed to start extraction",
609
+ source = "extraction"
610
+ )
511
611
  promise.reject("EXTRACTION_FAILED", "Failed to start extraction", null)
512
612
  return@AsyncFunction
513
613
  }
@@ -551,7 +651,7 @@ class ExpoAudioEngineModule : Module() {
551
651
  requestAudioFocus()
552
652
  }
553
653
  syncBackgroundService(engine.isPlaying())
554
- sendEvent("engineStateChanged", mapOf("reason" to "backgroundPlaybackEnabled"))
654
+ emitEngineStateChanged("backgroundPlaybackEnabled")
555
655
  }
556
656
  Function("updateNowPlayingInfo") { metadata: Map<String, Any?> ->
557
657
  mergeNowPlayingMetadata(metadata)
@@ -568,7 +668,7 @@ class ExpoAudioEngineModule : Module() {
568
668
  abandonAudioFocus()
569
669
  }
570
670
  nowPlayingMetadata.clear()
571
- sendEvent("engineStateChanged", mapOf("reason" to "backgroundPlaybackDisabled"))
671
+ emitEngineStateChanged("backgroundPlaybackDisabled")
572
672
  }
573
673
 
574
674
  Events(
@@ -582,6 +682,7 @@ class ExpoAudioEngineModule : Module() {
582
682
  "extractionProgress",
583
683
  "extractionComplete",
584
684
  "engineStateChanged",
685
+ "debugLog",
585
686
  "error"
586
687
  )
587
688
  }
@@ -621,6 +722,21 @@ class ExpoAudioEngineModule : Module() {
621
722
 
622
723
  if (!result.success) {
623
724
  Log.e(TAG, "Extraction failed for job=$jobId: ${result.errorMessage}")
725
+ val code = if (result.errorMessage == "Extraction cancelled") {
726
+ "EXTRACTION_CANCELLED"
727
+ } else {
728
+ "EXTRACTION_FAILED"
729
+ }
730
+ emitEngineError(
731
+ code = code,
732
+ message = result.errorMessage ?: "Unknown extraction error",
733
+ details = mapOf(
734
+ "jobId" to jobId,
735
+ "trackId" to pending.trackId,
736
+ "operation" to pending.operation
737
+ ),
738
+ source = "extraction"
739
+ )
624
740
  sendEvent(
625
741
  "extractionComplete",
626
742
  mapOf(
@@ -634,11 +750,6 @@ class ExpoAudioEngineModule : Module() {
634
750
  "operation" to pending.operation
635
751
  )
636
752
  )
637
- val code = if (result.errorMessage == "Extraction cancelled") {
638
- "EXTRACTION_CANCELLED"
639
- } else {
640
- "EXTRACTION_FAILED"
641
- }
642
753
  pending.promise.reject(code, result.errorMessage, null)
643
754
  } else {
644
755
  Log.d(TAG, "Extraction successful: ${result.fileSize} bytes, ${result.durationSamples} samples")
@@ -696,12 +807,10 @@ class ExpoAudioEngineModule : Module() {
696
807
  Log.w(TAG, "Stream unhealthy before play, attempting restart")
697
808
  if (!engine.restartStream()) {
698
809
  if (!fromSystem) {
699
- sendEvent(
700
- "error",
701
- mapOf(
702
- "code" to "STREAM_DISCONNECTED",
703
- "message" to "Audio stream is disconnected and could not be recovered"
704
- )
810
+ emitEngineError(
811
+ code = "STREAM_DISCONNECTED",
812
+ message = "Audio stream is disconnected and could not be recovered",
813
+ source = "engine"
705
814
  )
706
815
  }
707
816
  return false
@@ -711,12 +820,10 @@ class ExpoAudioEngineModule : Module() {
711
820
  if (!requestAudioFocus()) {
712
821
  Log.w(TAG, "Audio focus request denied")
713
822
  if (!fromSystem) {
714
- sendEvent(
715
- "error",
716
- mapOf(
717
- "code" to "AUDIO_FOCUS_DENIED",
718
- "message" to "Could not gain audio focus for playback"
719
- )
823
+ emitEngineError(
824
+ code = "AUDIO_FOCUS_DENIED",
825
+ message = "Could not gain audio focus for playback",
826
+ source = "focus"
720
827
  )
721
828
  }
722
829
  return false
@@ -750,7 +857,7 @@ class ExpoAudioEngineModule : Module() {
750
857
  }
751
858
 
752
859
  if (fromSystem) {
753
- sendEvent("engineStateChanged", mapOf("reason" to "pausedFromSystem"))
860
+ emitEngineStateChanged("pausedFromSystem")
754
861
  }
755
862
  }
756
863
 
@@ -769,7 +876,7 @@ class ExpoAudioEngineModule : Module() {
769
876
  }
770
877
 
771
878
  if (fromSystem) {
772
- sendEvent("engineStateChanged", mapOf("reason" to "stoppedFromSystem"))
879
+ emitEngineStateChanged("stoppedFromSystem")
773
880
  }
774
881
  }
775
882
 
@@ -826,7 +933,7 @@ class ExpoAudioEngineModule : Module() {
826
933
  if (engine.isPlaying()) {
827
934
  shouldResumeAfterTransientFocusLoss = true
828
935
  pausePlaybackInternal(engine, fromSystem = true, keepAudioFocus = true)
829
- sendEvent("engineStateChanged", mapOf("reason" to "audioFocusLossTransient"))
936
+ emitEngineStateChanged("audioFocusLossTransient")
830
937
  }
831
938
  }
832
939
  AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
@@ -834,12 +941,9 @@ class ExpoAudioEngineModule : Module() {
834
941
  volumeBeforeDuck = engine.getMasterVolume()
835
942
  val duckedVolume = (volumeBeforeDuck!! * 0.3f).coerceAtLeast(0.05f)
836
943
  engine.setMasterVolume(duckedVolume)
837
- sendEvent(
838
- "engineStateChanged",
839
- mapOf(
840
- "reason" to "audioFocusDuck",
841
- "volume" to duckedVolume.toDouble()
842
- )
944
+ emitEngineStateChanged(
945
+ reason = "audioFocusDuck",
946
+ payload = mapOf("volume" to duckedVolume.toDouble())
843
947
  )
844
948
  }
845
949
  }
@@ -847,7 +951,7 @@ class ExpoAudioEngineModule : Module() {
847
951
  shouldResumeAfterTransientFocusLoss = false
848
952
  if (engine.isPlaying()) {
849
953
  pausePlaybackInternal(engine, fromSystem = true)
850
- sendEvent("engineStateChanged", mapOf("reason" to "audioFocusLoss"))
954
+ emitEngineStateChanged("audioFocusLoss")
851
955
  } else {
852
956
  abandonAudioFocus()
853
957
  }
@@ -900,6 +1004,80 @@ class ExpoAudioEngineModule : Module() {
900
1004
  abandonAudioFocus()
901
1005
  }
902
1006
 
1007
+ private fun emitEngineStateChanged(
1008
+ reason: String,
1009
+ payload: Map<String, Any?> = emptyMap()
1010
+ ) {
1011
+ val eventPayload = payload.toMutableMap()
1012
+ eventPayload["reason"] = reason
1013
+ sendEvent("engineStateChanged", eventPayload)
1014
+ }
1015
+
1016
+ private fun emitDebugLog(
1017
+ level: String,
1018
+ message: String,
1019
+ context: Map<String, Any?>? = null
1020
+ ) {
1021
+ val payload = mutableMapOf<String, Any?>(
1022
+ "level" to level,
1023
+ "message" to message
1024
+ )
1025
+ if (context != null) {
1026
+ payload["context"] = context
1027
+ }
1028
+ sendEvent("debugLog", payload)
1029
+ }
1030
+
1031
+ private fun emitEngineError(
1032
+ code: String,
1033
+ message: String,
1034
+ details: Any? = null,
1035
+ source: String? = null
1036
+ ) {
1037
+ val resolvedSource = source ?: resolveErrorSource(code)
1038
+ val (severity, recoverable) = classifyError(code)
1039
+ val payload = mutableMapOf<String, Any?>(
1040
+ "code" to code,
1041
+ "message" to message,
1042
+ "severity" to severity,
1043
+ "recoverable" to recoverable,
1044
+ "source" to resolvedSource,
1045
+ "timestampMs" to System.currentTimeMillis(),
1046
+ "platform" to "android"
1047
+ )
1048
+ if (details != null) {
1049
+ payload["details"] = details
1050
+ }
1051
+ sendEvent("error", payload)
1052
+ emitDebugLog(
1053
+ level = if (severity == "fatal") "error" else "warn",
1054
+ message = message,
1055
+ context = payload
1056
+ )
1057
+ }
1058
+
1059
+ private fun resolveErrorSource(code: String): String {
1060
+ return when {
1061
+ code == "AUDIO_SESSION_FAILED" -> "session"
1062
+ code == "AUDIO_FOCUS_DENIED" -> "focus"
1063
+ code.startsWith("PLAYBACK_") || code == "NO_TRACKS_LOADED" -> "playback"
1064
+ code.startsWith("RECORDING_") -> "recording"
1065
+ code.startsWith("EXTRACTION_") -> "extraction"
1066
+ code.startsWith("ENGINE_") || code.startsWith("TRACK_") || code == "STREAM_DISCONNECTED" -> "engine"
1067
+ else -> "system"
1068
+ }
1069
+ }
1070
+
1071
+ private fun classifyError(code: String): Pair<String, Boolean> {
1072
+ return when (code) {
1073
+ "ENGINE_INIT_FAILED",
1074
+ "ENGINE_START_FAILED",
1075
+ "AUDIO_SESSION_FAILED",
1076
+ "STREAM_DISCONNECTED" -> "fatal" to false
1077
+ else -> "warning" to true
1078
+ }
1079
+ }
1080
+
903
1081
  private fun mapToBundle(map: Map<String, Any?>): Bundle {
904
1082
  val bundle = Bundle()
905
1083
  map.forEach { (key, value) ->
@@ -42,5 +42,5 @@ export const AudioEngineModule = {
42
42
  enableBackgroundPlayback: (metadata) => NativeAudioEngineModule.enableBackgroundPlayback(metadata),
43
43
  updateNowPlayingInfo: (metadata) => NativeAudioEngineModule.updateNowPlayingInfo(metadata),
44
44
  disableBackgroundPlayback: () => NativeAudioEngineModule.disableBackgroundPlayback(),
45
- addListener: (event, callback) => NativeAudioEngineModule.addListener(event, callback)
45
+ addListener: ((event, callback) => NativeAudioEngineModule.addListener(event, callback))
46
46
  };
@@ -64,11 +64,62 @@ export interface PlaybackCardOptions {
64
64
  showStop?: boolean;
65
65
  seekStepMs?: number;
66
66
  }
67
- export type AudioEngineEvent = 'playbackStateChange' | 'positionUpdate' | 'playbackComplete' | 'trackLoaded' | 'trackUnloaded' | 'recordingStarted' | 'recordingStopped' | 'extractionProgress' | 'extractionComplete' | 'error';
67
+ export type AudioEngineEvent = 'playbackStateChange' | 'positionUpdate' | 'playbackComplete' | 'trackLoaded' | 'trackUnloaded' | 'recordingStarted' | 'recordingStopped' | 'extractionProgress' | 'extractionComplete' | 'engineStateChanged' | 'debugLog' | 'error';
68
+ export type AudioEngineErrorSeverity = 'warning' | 'fatal';
69
+ export type AudioEngineErrorSource = 'engine' | 'session' | 'playback' | 'recording' | 'extraction' | 'focus' | 'system';
68
70
  export interface AudioEngineError {
69
71
  code: string;
70
72
  message: string;
71
73
  details?: unknown;
74
+ severity: AudioEngineErrorSeverity;
75
+ recoverable: boolean;
76
+ source: AudioEngineErrorSource;
77
+ timestampMs: number;
78
+ platform: 'ios' | 'android';
79
+ }
80
+ export interface AudioEngineEventMap {
81
+ playbackStateChange: {
82
+ state: string;
83
+ positionMs: number;
84
+ durationMs: number;
85
+ };
86
+ positionUpdate: {
87
+ positionMs: number;
88
+ durationMs: number;
89
+ };
90
+ playbackComplete: {
91
+ positionMs: number;
92
+ durationMs: number;
93
+ };
94
+ trackLoaded: {
95
+ trackId: string;
96
+ };
97
+ trackUnloaded: {
98
+ trackId: string;
99
+ };
100
+ recordingStarted: Record<string, never>;
101
+ recordingStopped: {
102
+ uri: string;
103
+ duration: number;
104
+ startTimeMs: number;
105
+ startTimeSamples?: number;
106
+ sampleRate: number;
107
+ channels: number;
108
+ format: 'aac' | 'm4a' | 'mp3' | 'wav';
109
+ fileSize: number;
110
+ };
111
+ extractionProgress: Record<string, unknown>;
112
+ extractionComplete: Record<string, unknown>;
113
+ engineStateChanged: {
114
+ reason: string;
115
+ [key: string]: unknown;
116
+ };
117
+ debugLog: {
118
+ level: 'debug' | 'info' | 'warn' | 'error';
119
+ message: string;
120
+ context?: unknown;
121
+ };
122
+ error: AudioEngineError;
72
123
  }
73
124
  export interface AudioEngine {
74
125
  initialize(config: AudioEngineConfig): Promise<void>;
@@ -112,7 +163,7 @@ export interface AudioEngine {
112
163
  enableBackgroundPlayback(metadata: MediaMetadata): Promise<void>;
113
164
  updateNowPlayingInfo(metadata: Partial<MediaMetadata>): void;
114
165
  disableBackgroundPlayback(): Promise<void>;
115
- addListener(event: AudioEngineEvent, callback: Function): {
166
+ addListener<K extends keyof AudioEngineEventMap>(event: K, callback: (payload: AudioEngineEventMap[K]) => void): {
116
167
  remove: () => void;
117
168
  };
118
169
  }
@@ -1,4 +1,5 @@
1
1
  import ExpoModulesCore
2
+ import Foundation
2
3
 
3
4
  public class ExpoAudioEngineModule: Module {
4
5
  private let engine = AudioEngineFacade()
@@ -6,6 +7,7 @@ public class ExpoAudioEngineModule: Module {
6
7
  public func definition() -> ModuleDefinition {
7
8
  Name("ExpoAudioEngineModule")
8
9
  OnCreate {
10
+ emitDebugLog(level: "info", message: "ExpoAudioEngineModule created")
9
11
  engine.onPlaybackStateChange = { [weak self] state, positionMs, durationMs in
10
12
  DispatchQueue.main.async {
11
13
  self?.sendEvent(
@@ -16,6 +18,13 @@ public class ExpoAudioEngineModule: Module {
16
18
  "durationMs": durationMs
17
19
  ]
18
20
  )
21
+ self?.sendEvent(
22
+ "positionUpdate",
23
+ [
24
+ "positionMs": positionMs,
25
+ "durationMs": durationMs
26
+ ]
27
+ )
19
28
  }
20
29
  }
21
30
  engine.onPlaybackComplete = { [weak self] positionMs, durationMs in
@@ -31,14 +40,7 @@ public class ExpoAudioEngineModule: Module {
31
40
  }
32
41
  engine.onError = { [weak self] code, message, details in
33
42
  DispatchQueue.main.async {
34
- var payload: [String: Any] = [
35
- "code": code,
36
- "message": message
37
- ]
38
- if let details = details {
39
- payload["details"] = details
40
- }
41
- self?.sendEvent("error", payload)
43
+ self?.emitEngineError(code: code, message: message, details: details)
42
44
  }
43
45
  }
44
46
  }
@@ -46,13 +48,18 @@ public class ExpoAudioEngineModule: Module {
46
48
  engine.onPlaybackStateChange = nil
47
49
  engine.onPlaybackComplete = nil
48
50
  engine.onError = nil
51
+ emitDebugLog(level: "info", message: "ExpoAudioEngineModule destroyed")
49
52
  }
50
53
 
51
54
  AsyncFunction("initialize") { (config: [String: Any]) in
52
55
  engine.initialize(config: config)
56
+ emitEngineStateChanged(reason: "initialized")
57
+ emitDebugLog(level: "info", message: "Audio engine initialized", context: ["config": config])
53
58
  }
54
59
  AsyncFunction("release") {
55
60
  engine.releaseResources()
61
+ emitEngineStateChanged(reason: "released")
62
+ emitDebugLog(level: "info", message: "Audio engine released")
56
63
  }
57
64
  AsyncFunction("loadTracks") { (tracks: [[String: Any]]) throws in
58
65
  if let failure = engine.loadTracks(tracks) {
@@ -60,14 +67,25 @@ public class ExpoAudioEngineModule: Module {
60
67
  let message = failedCount > 0
61
68
  ? "Failed to load \(failedCount) track(s)."
62
69
  : "Failed to load tracks."
70
+ emitEngineError(code: "TRACK_LOAD_FAILED", message: message, details: failure, source: "engine")
63
71
  throw AudioEngineError("TRACK_LOAD_FAILED", message, details: failure)
64
72
  }
73
+ for track in tracks {
74
+ if let trackId = track["id"] as? String {
75
+ sendEvent("trackLoaded", ["trackId": trackId])
76
+ }
77
+ }
65
78
  }
66
79
  AsyncFunction("unloadTrack") { (trackId: String) in
67
80
  engine.unloadTrack(trackId)
81
+ sendEvent("trackUnloaded", ["trackId": trackId])
68
82
  }
69
83
  AsyncFunction("unloadAllTracks") {
84
+ let loadedTrackIds = engine.getLoadedTracks().compactMap { $0["id"] as? String }
70
85
  engine.unloadAllTracks()
86
+ for trackId in loadedTrackIds {
87
+ sendEvent("trackUnloaded", ["trackId": trackId])
88
+ }
71
89
  }
72
90
  Function("getLoadedTracks") {
73
91
  return engine.getLoadedTracks()
@@ -84,6 +102,13 @@ public class ExpoAudioEngineModule: Module {
84
102
  }
85
103
  Function("seek") { (positionMs: Double) in
86
104
  engine.seek(positionMs: positionMs)
105
+ sendEvent(
106
+ "positionUpdate",
107
+ [
108
+ "positionMs": engine.getCurrentPosition(),
109
+ "durationMs": engine.getDuration()
110
+ ]
111
+ )
87
112
  }
88
113
  Function("isPlaying") {
89
114
  return engine.isPlaying()
@@ -147,17 +172,23 @@ public class ExpoAudioEngineModule: Module {
147
172
  let didStart = engine.startRecording(config: config)
148
173
  if !didStart {
149
174
  let detail = engine.getLastRecordingError() ?? "audio input format is invalid or the audio session is not ready"
150
- throw NSError(
151
- domain: "ExpoAudioEngine",
152
- code: 1,
153
- userInfo: [
154
- NSLocalizedDescriptionKey: "Failed to start recording. \(detail)."
155
- ]
175
+ let message = "Failed to start recording. \(detail)."
176
+ emitEngineError(
177
+ code: "RECORDING_START_FAILED",
178
+ message: message,
179
+ details: ["detail": detail],
180
+ source: "recording"
156
181
  )
182
+ throw AudioEngineError("RECORDING_START_FAILED", message, details: ["detail": detail])
157
183
  }
184
+ sendEvent("recordingStarted", [String: Any]())
185
+ emitEngineStateChanged(reason: "recordingStarted")
158
186
  }
159
187
  AsyncFunction("stopRecording") {
160
- return engine.stopRecording()
188
+ let result = engine.stopRecording()
189
+ sendEvent("recordingStopped", result)
190
+ emitEngineStateChanged(reason: "recordingStopped")
191
+ return result
161
192
  }
162
193
  Function("isRecording") {
163
194
  return engine.isRecording()
@@ -167,11 +198,67 @@ public class ExpoAudioEngineModule: Module {
167
198
  }
168
199
 
169
200
  AsyncFunction("extractTrack") { (trackId: String, config: [String: Any]?) throws in
170
- return try engine.extractTrack(trackId: trackId, config: config)
201
+ do {
202
+ let result = try engine.extractTrack(trackId: trackId, config: config)
203
+ sendEvent("extractionProgress", ["trackId": trackId, "progress": 1.0, "operation": "track"])
204
+ var completionPayload: [String: Any] = [
205
+ "success": true,
206
+ "trackId": trackId
207
+ ]
208
+ for (key, value) in result {
209
+ completionPayload[key] = value
210
+ }
211
+ sendEvent("extractionComplete", completionPayload)
212
+ return result
213
+ } catch let error as AudioEngineError {
214
+ emitEngineError(
215
+ code: error.code,
216
+ message: error.description,
217
+ details: error.details,
218
+ source: "extraction"
219
+ )
220
+ sendEvent(
221
+ "extractionComplete",
222
+ [
223
+ "success": false,
224
+ "trackId": trackId,
225
+ "errorMessage": error.description
226
+ ]
227
+ )
228
+ throw error
229
+ }
171
230
  }
172
231
 
173
232
  AsyncFunction("extractAllTracks") { (config: [String: Any]?) throws in
174
- return try engine.extractAllTracks(config: config)
233
+ do {
234
+ let results = try engine.extractAllTracks(config: config)
235
+ sendEvent("extractionProgress", ["progress": 1.0, "operation": "mix"])
236
+ sendEvent(
237
+ "extractionComplete",
238
+ [
239
+ "success": true,
240
+ "operation": "mix",
241
+ "count": results.count
242
+ ]
243
+ )
244
+ return results
245
+ } catch let error as AudioEngineError {
246
+ emitEngineError(
247
+ code: error.code,
248
+ message: error.description,
249
+ details: error.details,
250
+ source: "extraction"
251
+ )
252
+ sendEvent(
253
+ "extractionComplete",
254
+ [
255
+ "success": false,
256
+ "operation": "mix",
257
+ "errorMessage": error.description
258
+ ]
259
+ )
260
+ throw error
261
+ }
175
262
  }
176
263
 
177
264
  Function("cancelExtraction") { (jobId: Double?) in
@@ -190,12 +277,14 @@ public class ExpoAudioEngineModule: Module {
190
277
 
191
278
  AsyncFunction("enableBackgroundPlayback") { (metadata: [String: Any]) in
192
279
  engine.enableBackgroundPlayback(metadata: metadata)
280
+ emitEngineStateChanged(reason: "backgroundPlaybackEnabled")
193
281
  }
194
282
  Function("updateNowPlayingInfo") { (metadata: [String: Any]) in
195
283
  engine.updateNowPlayingInfo(metadata: metadata)
196
284
  }
197
285
  AsyncFunction("disableBackgroundPlayback") {
198
286
  engine.disableBackgroundPlayback()
287
+ emitEngineStateChanged(reason: "backgroundPlaybackDisabled")
199
288
  }
200
289
 
201
290
  Events(
@@ -208,8 +297,85 @@ public class ExpoAudioEngineModule: Module {
208
297
  "recordingStopped",
209
298
  "extractionProgress",
210
299
  "extractionComplete",
300
+ "engineStateChanged",
211
301
  "error",
212
302
  "debugLog"
213
303
  )
214
304
  }
305
+
306
+ private func emitEngineStateChanged(reason: String, payload: [String: Any] = [:]) {
307
+ var statePayload = payload
308
+ statePayload["reason"] = reason
309
+ sendEvent("engineStateChanged", statePayload)
310
+ }
311
+
312
+ private func emitDebugLog(level: String, message: String, context: [String: Any]? = nil) {
313
+ var payload: [String: Any] = [
314
+ "level": level,
315
+ "message": message
316
+ ]
317
+ if let context = context {
318
+ payload["context"] = context
319
+ }
320
+ sendEvent("debugLog", payload)
321
+ }
322
+
323
+ private func emitEngineError(
324
+ code: String,
325
+ message: String,
326
+ details: [String: Any]? = nil,
327
+ source: String? = nil
328
+ ) {
329
+ let sourceValue = source ?? resolveErrorSource(code: code)
330
+ let classification = classifyError(code: code)
331
+ var payload: [String: Any] = [
332
+ "code": code,
333
+ "message": message,
334
+ "severity": classification.severity,
335
+ "recoverable": classification.recoverable,
336
+ "source": sourceValue,
337
+ "timestampMs": Int(Date().timeIntervalSince1970 * 1000.0),
338
+ "platform": "ios"
339
+ ]
340
+ if let details = details {
341
+ payload["details"] = details
342
+ }
343
+ sendEvent("error", payload)
344
+ emitDebugLog(level: classification.severity == "fatal" ? "error" : "warn", message: message, context: payload)
345
+ }
346
+
347
+ private func resolveErrorSource(code: String) -> String {
348
+ if code == "AUDIO_SESSION_FAILED" {
349
+ return "session"
350
+ }
351
+ if code == "AUDIO_FOCUS_DENIED" {
352
+ return "focus"
353
+ }
354
+ if code.hasPrefix("PLAYBACK_") || code == "NO_TRACKS_LOADED" {
355
+ return "playback"
356
+ }
357
+ if code.hasPrefix("RECORDING_") {
358
+ return "recording"
359
+ }
360
+ if code.hasPrefix("EXTRACTION_") {
361
+ return "extraction"
362
+ }
363
+ if code.hasPrefix("ENGINE_") || code.hasPrefix("TRACK_") || code == "STREAM_DISCONNECTED" {
364
+ return "engine"
365
+ }
366
+ return "system"
367
+ }
368
+
369
+ private func classifyError(code: String) -> (severity: String, recoverable: Bool) {
370
+ let fatalCodes: Set<String> = [
371
+ "ENGINE_INIT_FAILED",
372
+ "ENGINE_START_FAILED",
373
+ "AUDIO_SESSION_FAILED",
374
+ "STREAM_DISCONNECTED"
375
+ ]
376
+ if fatalCodes.contains(code) {
377
+ return ("fatal", false)
378
+ }
379
+ return ("warning", true)
380
+ }
215
381
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sezo-audio-engine",
3
- "version": "0.0.15",
3
+ "version": "0.0.16",
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",