@siteed/expo-audio-studio 2.8.6 → 2.10.0

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.
Files changed (70) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/android/build.gradle +9 -0
  3. package/android/src/androidTest/assets/chorus.wav +0 -0
  4. package/android/src/androidTest/assets/jfk.wav +0 -0
  5. package/android/src/androidTest/assets/osr_us_000_0010_8k.wav +0 -0
  6. package/android/src/androidTest/assets/recorder_hello_world.wav +0 -0
  7. package/android/src/androidTest/java/net/siteed/audiostream/AudioProcessorInstrumentedTest.kt +197 -0
  8. package/android/src/androidTest/java/net/siteed/audiostream/AudioRecorderInstrumentedTest.kt +541 -0
  9. package/android/src/androidTest/java/net/siteed/audiostream/integration/BufferDurationIntegrationTest.kt +324 -0
  10. package/android/src/androidTest/java/net/siteed/audiostream/integration/OutputControlIntegrationTest.kt +340 -0
  11. package/android/src/androidTest/java/net/siteed/audiostream/integration/README.md +95 -0
  12. package/android/src/androidTest/java/net/siteed/audiostream/integration/run_integration_tests.sh +28 -0
  13. package/android/src/main/java/net/siteed/audiostream/AudioFormatUtils.kt +264 -13
  14. package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +3 -15
  15. package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +118 -55
  16. package/android/src/main/java/net/siteed/audiostream/LogUtils.kt +32 -4
  17. package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +50 -15
  18. package/android/src/test/java/net/siteed/audiostream/AudioFileHandlerTest.kt +279 -0
  19. package/android/src/test/java/net/siteed/audiostream/AudioFormatUtilsTest.kt +273 -0
  20. package/android/src/test/resources/chorus.wav +0 -0
  21. package/android/src/test/resources/generate_test_audio.py +94 -0
  22. package/android/src/test/resources/jfk.wav +0 -0
  23. package/android/src/test/resources/osr_us_000_0010_8k.wav +0 -0
  24. package/android/src/test/resources/recorder_hello_world.wav +0 -0
  25. package/build/cjs/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  26. package/build/cjs/ExpoAudioStream.types.js.map +1 -1
  27. package/build/cjs/ExpoAudioStream.web.js +38 -35
  28. package/build/cjs/ExpoAudioStream.web.js.map +1 -1
  29. package/build/cjs/WebRecorder.web.js +122 -102
  30. package/build/cjs/WebRecorder.web.js.map +1 -1
  31. package/build/esm/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  32. package/build/esm/ExpoAudioStream.types.js.map +1 -1
  33. package/build/esm/ExpoAudioStream.web.js +38 -35
  34. package/build/esm/ExpoAudioStream.web.js.map +1 -1
  35. package/build/esm/WebRecorder.web.js +122 -102
  36. package/build/esm/WebRecorder.web.js.map +1 -1
  37. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts +3 -1
  38. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -1
  39. package/build/types/ExpoAudioStream.types.d.ts +54 -22
  40. package/build/types/ExpoAudioStream.types.d.ts.map +1 -1
  41. package/build/types/ExpoAudioStream.web.d.ts.map +1 -1
  42. package/build/types/WebRecorder.web.d.ts +19 -3
  43. package/build/types/WebRecorder.web.d.ts.map +1 -1
  44. package/ios/AudioNotificationManager.swift +2 -6
  45. package/ios/AudioStreamManager.swift +116 -50
  46. package/ios/ExpoAudioStream.podspec +6 -0
  47. package/ios/ExpoAudioStreamModule.swift +11 -8
  48. package/ios/ExpoAudioStudioTests/AudioFileHandlerTests.swift +338 -0
  49. package/ios/ExpoAudioStudioTests/AudioFormatUtilsTests.swift +331 -0
  50. package/ios/ExpoAudioStudioTests/AudioTestHelpers.swift +130 -0
  51. package/ios/ExpoAudioStudioTests/Info.plist +22 -0
  52. package/ios/ExpoAudioStudioTests/SimpleAudioTest.swift +98 -0
  53. package/ios/ExpoAudioStudioTests/TestAudioGenerator.swift +75 -0
  54. package/ios/RecordingSettings.swift +53 -22
  55. package/ios/tests/integration/buffer_duration_test.swift +185 -0
  56. package/ios/tests/integration/output_control_test.swift +322 -0
  57. package/ios/tests/integration/run_integration_tests.sh +27 -0
  58. package/ios/tests/standalone/audio_processing_test.swift +144 -0
  59. package/ios/tests/standalone/audio_recording_test.swift +277 -0
  60. package/ios/tests/standalone/audio_streaming_test.swift +249 -0
  61. package/ios/tests/standalone/standalone_test.swift +144 -0
  62. package/package.json +140 -133
  63. package/src/AudioAnalysis/AudioAnalysis.types.ts +8 -1
  64. package/src/ExpoAudioStream.types.ts +66 -22
  65. package/src/ExpoAudioStream.web.ts +45 -39
  66. package/src/WebRecorder.web.ts +164 -130
  67. package/android/src/main/test/java/net/siteed/audiostream/AudioProcessorTest.kt +0 -56
  68. package/ios/siteedexpoaudiostudio.xcodeproj/project.xcworkspace/contents.xcworkspacedata +0 -7
  69. package/ios/siteedexpoaudiostudio.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +0 -8
  70. /package/plugin/build/{index.d.ts → index.d.cts} +0 -0
@@ -552,8 +552,8 @@ class AudioRecorderManager(
552
552
 
553
553
  if (!initializeAudioRecord(promise)) return
554
554
 
555
- if (recordingConfig.enableCompressedOutput && !initializeCompressedRecorder(
556
- if (recordingConfig.compressedFormat == "aac") "aac" else "opus",
555
+ if (recordingConfig.output.compressed.enabled && !initializeCompressedRecorder(
556
+ if (recordingConfig.output.compressed.format == "aac") "aac" else "opus",
557
557
  promise
558
558
  )) return
559
559
 
@@ -593,9 +593,9 @@ class AudioRecorderManager(
593
593
  "sampleRate" to recordingConfig.sampleRate,
594
594
  "mimeType" to mimeType,
595
595
  "compression" to if (compressedFile != null) bundleOf(
596
- "mimeType" to if (recordingConfig.compressedFormat == "aac") "audio/aac" else "audio/opus",
597
- "bitrate" to recordingConfig.compressedBitRate,
598
- "format" to recordingConfig.compressedFormat,
596
+ "mimeType" to if (recordingConfig.output.compressed.format == "aac") "audio/aac" else "audio/opus",
597
+ "bitrate" to recordingConfig.output.compressed.bitrate,
598
+ "format" to recordingConfig.output.compressed.format,
599
599
  "size" to 0,
600
600
  "compressedFileUri" to compressedFile?.toURI().toString()
601
601
  ) else null
@@ -712,11 +712,25 @@ class AudioRecorderManager(
712
712
  AudioFormat.CHANNEL_IN_STEREO
713
713
  }
714
714
 
715
- bufferSizeInBytes = AudioRecord.getMinBufferSize(
715
+ val minBufferSize = AudioRecord.getMinBufferSize(
716
716
  recordingConfig.sampleRate,
717
717
  channelConfig,
718
718
  audioFormat
719
719
  )
720
+
721
+ // Calculate buffer size based on bufferDurationSeconds if provided
722
+ bufferSizeInBytes = recordingConfig.bufferDurationSeconds?.let { bufferDuration ->
723
+ val bytesPerSample = when (recordingConfig.encoding) {
724
+ "pcm_8bit" -> 1
725
+ "pcm_16bit" -> 2
726
+ "pcm_32bit" -> 4
727
+ else -> 2
728
+ }
729
+ val requestedSize = (bufferDuration * recordingConfig.sampleRate *
730
+ bytesPerSample * recordingConfig.channels).toInt()
731
+ // Use the larger of requested size or minimum buffer size
732
+ maxOf(requestedSize, minBufferSize)
733
+ } ?: minBufferSize
720
734
 
721
735
  when {
722
736
  bufferSizeInBytes == AudioRecord.ERROR -> {
@@ -818,16 +832,24 @@ class AudioRecorderManager(
818
832
  private fun initializeRecordingResources(fileExtension: String, promise: Promise): Boolean {
819
833
  try {
820
834
  streamUuid = java.util.UUID.randomUUID().toString()
821
- audioFile = createRecordingFile(recordingConfig)
822
835
  totalDataSize = 0
823
-
824
- FileOutputStream(audioFile, true).use { fos ->
825
- audioFileHandler.writeWavHeader(
826
- fos,
827
- recordingConfig.sampleRate,
828
- recordingConfig.channels,
829
- AudioFormatUtils.getBitDepth(recordingConfig.encoding)
830
- )
836
+
837
+ // Only create file if primary output is enabled
838
+ if (recordingConfig.output.primary.enabled) {
839
+ audioFile = createRecordingFile(recordingConfig)
840
+
841
+ FileOutputStream(audioFile, true).use { fos ->
842
+ audioFileHandler.writeWavHeader(
843
+ fos,
844
+ recordingConfig.sampleRate,
845
+ recordingConfig.channels,
846
+ AudioFormatUtils.getBitDepth(recordingConfig.encoding)
847
+ )
848
+ }
849
+ } else {
850
+ // Set audioFile to null when primary output is disabled
851
+ audioFile = null
852
+ LogUtils.d(CLASS_NAME, "Skipping primary file creation - primary output is disabled")
831
853
  }
832
854
 
833
855
  if (recordingConfig.showNotification) {
@@ -860,16 +882,18 @@ class AudioRecorderManager(
860
882
  - Sample Rate: ${recordingConfig.sampleRate} Hz
861
883
  - Channels: ${recordingConfig.channels}
862
884
  - Encoding: ${recordingConfig.encoding}
885
+ - Buffer Duration: ${recordingConfig.bufferDurationSeconds?.let { "${it}s" } ?: "default"}
886
+ - Primary Output: ${recordingConfig.output.primary.enabled}
863
887
  - Data Emission Interval: ${recordingConfig.interval}ms
864
888
  - Analysis Interval: ${recordingConfig.intervalAnalysis}ms
865
889
  - Processing Enabled: ${recordingConfig.enableProcessing}
866
890
  - Keep Awake: ${recordingConfig.keepAwake}
867
891
  - Show Notification: ${recordingConfig.showNotification}
868
892
  - Show Waveform: ${recordingConfig.showWaveformInNotification}
869
- - Compressed Output: ${recordingConfig.enableCompressedOutput}
870
- ${if (recordingConfig.enableCompressedOutput) """
871
- - Compressed Format: ${recordingConfig.compressedFormat}
872
- - Compressed Bitrate: ${recordingConfig.compressedBitRate}
893
+ - Compressed Output: ${recordingConfig.output.compressed.enabled}
894
+ ${if (recordingConfig.output.compressed.enabled) """
895
+ - Compressed Format: ${recordingConfig.output.compressed.format}
896
+ - Compressed Bitrate: ${recordingConfig.output.compressed.bitrate}
873
897
  """.trimIndent() else ""}
874
898
  - Auto Resume: ${recordingConfig.autoResumeAfterInterruption}
875
899
  - Output Directory: ${recordingConfig.outputDirectory ?: "default"}
@@ -974,29 +998,45 @@ class AudioRecorderManager(
974
998
  compressedRecorder = null
975
999
 
976
1000
  // Log compressed file status if enabled
977
- if (recordingConfig.enableCompressedOutput) {
1001
+ if (recordingConfig.output.compressed.enabled) {
978
1002
  val compressedSize = compressedFile?.length() ?: 0
979
1003
  LogUtils.d(CLASS_NAME, "Compressed File validation - Size: $compressedSize bytes, Path: ${compressedFile?.absolutePath}")
980
1004
  }
981
1005
 
982
- val result = bundleOf(
983
- "fileUri" to audioFile?.toURI().toString(),
984
- "filename" to audioFile?.name,
985
- "durationMs" to duration,
986
- "channels" to recordingConfig.channels,
987
- "bitDepth" to AudioFormatUtils.getBitDepth(recordingConfig.encoding),
988
- "sampleRate" to recordingConfig.sampleRate,
989
- "size" to fileSize,
990
- "mimeType" to mimeType,
991
- "createdAt" to System.currentTimeMillis(),
992
- "compression" to if (compressedFile != null) bundleOf(
993
- "size" to compressedFile?.length(),
994
- "mimeType" to if (recordingConfig.compressedFormat == "aac") "audio/aac" else "audio/opus",
995
- "bitrate" to recordingConfig.compressedBitRate,
996
- "format" to recordingConfig.compressedFormat,
997
- "compressedFileUri" to compressedFile?.toURI().toString()
998
- ) else null
999
- )
1006
+ val result = if (!recordingConfig.output.primary.enabled) {
1007
+ // When primary output is disabled, return minimal info
1008
+ bundleOf(
1009
+ "fileUri" to "",
1010
+ "filename" to "stream-only",
1011
+ "durationMs" to duration,
1012
+ "channels" to recordingConfig.channels,
1013
+ "bitDepth" to AudioFormatUtils.getBitDepth(recordingConfig.encoding),
1014
+ "sampleRate" to recordingConfig.sampleRate,
1015
+ "size" to totalDataSize,
1016
+ "mimeType" to mimeType,
1017
+ "createdAt" to System.currentTimeMillis(),
1018
+ "compression" to null
1019
+ )
1020
+ } else {
1021
+ bundleOf(
1022
+ "fileUri" to audioFile?.toURI().toString(),
1023
+ "filename" to audioFile?.name,
1024
+ "durationMs" to duration,
1025
+ "channels" to recordingConfig.channels,
1026
+ "bitDepth" to AudioFormatUtils.getBitDepth(recordingConfig.encoding),
1027
+ "sampleRate" to recordingConfig.sampleRate,
1028
+ "size" to fileSize,
1029
+ "mimeType" to mimeType,
1030
+ "createdAt" to System.currentTimeMillis(),
1031
+ "compression" to if (compressedFile != null) bundleOf(
1032
+ "size" to compressedFile?.length(),
1033
+ "mimeType" to if (recordingConfig.output.compressed.format == "aac") "audio/aac" else "audio/opus",
1034
+ "bitrate" to recordingConfig.output.compressed.bitrate,
1035
+ "format" to recordingConfig.output.compressed.format,
1036
+ "compressedFileUri" to compressedFile?.toURI().toString()
1037
+ ) else null
1038
+ )
1039
+ }
1000
1040
  promise.resolve(result)
1001
1041
 
1002
1042
  // Reset the timing variables
@@ -1155,12 +1195,12 @@ class AudioRecorderManager(
1155
1195
  else -> totalRecordedTime
1156
1196
  }
1157
1197
 
1158
- val compressionBundle = if (recordingConfig.enableCompressedOutput) {
1198
+ val compressionBundle = if (recordingConfig.output.compressed.enabled) {
1159
1199
  bundleOf(
1160
1200
  "size" to (compressedFile?.length() ?: 0),
1161
- "mimeType" to if (recordingConfig.compressedFormat == "aac") "audio/aac" else "audio/opus",
1162
- "bitrate" to recordingConfig.compressedBitRate,
1163
- "format" to recordingConfig.compressedFormat
1201
+ "mimeType" to if (recordingConfig.output.compressed.format == "aac") "audio/aac" else "audio/opus",
1202
+ "bitrate" to recordingConfig.output.compressed.bitrate,
1203
+ "format" to recordingConfig.output.compressed.format
1164
1204
  )
1165
1205
  } else null
1166
1206
 
@@ -1257,8 +1297,16 @@ class AudioRecorderManager(
1257
1297
  private fun recordingProcess() {
1258
1298
  try {
1259
1299
  LogUtils.i(CLASS_NAME, "Starting recording process...")
1260
- FileOutputStream(audioFile, true).use { fos ->
1261
- // Write audio data directly to the file
1300
+
1301
+ // Only use FileOutputStream if primary output is enabled
1302
+ val fos = if (recordingConfig.output.primary.enabled && audioFile != null) {
1303
+ FileOutputStream(audioFile, true)
1304
+ } else {
1305
+ null
1306
+ }
1307
+
1308
+ try {
1309
+ // Write audio data directly to the file (if not skipping)
1262
1310
  val audioData = ByteArray(bufferSizeInBytes)
1263
1311
  LogUtils.d(CLASS_NAME, "Entering recording loop")
1264
1312
 
@@ -1318,7 +1366,10 @@ class AudioRecorderManager(
1318
1366
  }
1319
1367
 
1320
1368
  if (bytesRead > 0) {
1321
- fos.write(audioData, 0, bytesRead)
1369
+ // Only write to file if primary output is enabled
1370
+ if (fos != null) {
1371
+ fos.write(audioData, 0, bytesRead)
1372
+ }
1322
1373
  totalDataSize += bytesRead
1323
1374
 
1324
1375
  accumulatedAudioData.write(audioData, 0, bytesRead)
@@ -1377,10 +1428,16 @@ class AudioRecorderManager(
1377
1428
  }
1378
1429
  }
1379
1430
  }
1431
+ } finally {
1432
+ // Close the file output stream if it was opened
1433
+ fos?.close()
1380
1434
  }
1381
- // Update the WAV header to reflect the actual data size
1382
- audioFile?.let { file ->
1383
- audioFileHandler.updateWavHeader(file)
1435
+
1436
+ // Update the WAV header to reflect the actual data size (only if file was created)
1437
+ if (recordingConfig.output.primary.enabled) {
1438
+ audioFile?.let { file ->
1439
+ audioFileHandler.updateWavHeader(file)
1440
+ }
1384
1441
  }
1385
1442
 
1386
1443
  } catch (e: Exception) {
@@ -1403,7 +1460,7 @@ class AudioRecorderManager(
1403
1460
  val positionInMs =
1404
1461
  (from * 1000) / (recordingConfig.sampleRate * recordingConfig.channels * (if (recordingConfig.encoding == "pcm_8bit") 8 else 16) / 8)
1405
1462
 
1406
- val compressionBundle = if (recordingConfig.enableCompressedOutput) {
1463
+ val compressionBundle = if (recordingConfig.output.compressed.enabled) {
1407
1464
  val compressedSize = compressedFile?.length() ?: 0
1408
1465
  val eventDataSize = compressedSize - lastEmittedCompressedSize
1409
1466
 
@@ -1563,6 +1620,12 @@ class AudioRecorderManager(
1563
1620
 
1564
1621
  @RequiresApi(Build.VERSION_CODES.Q)
1565
1622
  private fun initializeCompressedRecorder(fileExtension: String, promise: Promise): Boolean {
1623
+ // Skip compressed recording if compressed output is not enabled
1624
+ if (!recordingConfig.output.compressed.enabled) {
1625
+ LogUtils.d(CLASS_NAME, "Skipping compressed recorder initialization - compressed output is disabled")
1626
+ return true
1627
+ }
1628
+
1566
1629
  try {
1567
1630
  // Pass true to indicate this is a compressed file
1568
1631
  compressedFile = createRecordingFile(recordingConfig, isCompressed = true)
@@ -1576,15 +1639,15 @@ class AudioRecorderManager(
1576
1639
 
1577
1640
  compressedRecorder?.apply {
1578
1641
  setAudioSource(MediaRecorder.AudioSource.MIC)
1579
- setOutputFormat(if (recordingConfig.compressedFormat == "aac")
1642
+ setOutputFormat(if (recordingConfig.output.compressed.format == "aac")
1580
1643
  MediaRecorder.OutputFormat.AAC_ADTS
1581
1644
  else MediaRecorder.OutputFormat.OGG)
1582
- setAudioEncoder(if (recordingConfig.compressedFormat == "aac")
1645
+ setAudioEncoder(if (recordingConfig.output.compressed.format == "aac")
1583
1646
  MediaRecorder.AudioEncoder.AAC
1584
1647
  else MediaRecorder.AudioEncoder.OPUS)
1585
1648
  setAudioChannels(recordingConfig.channels)
1586
1649
  setAudioSamplingRate(recordingConfig.sampleRate)
1587
- setAudioEncodingBitRate(recordingConfig.compressedBitRate)
1650
+ setAudioEncodingBitRate(recordingConfig.output.compressed.bitrate)
1588
1651
  setOutputFile(compressedFile?.absolutePath)
1589
1652
  prepare()
1590
1653
  }
@@ -1687,7 +1750,7 @@ class AudioRecorderManager(
1687
1750
 
1688
1751
  // Choose extension based on whether this is a compressed file
1689
1752
  val extension = if (isCompressed) {
1690
- config.compressedFormat.lowercase()
1753
+ config.output.compressed.format.lowercase()
1691
1754
  } else {
1692
1755
  "wav"
1693
1756
  }
@@ -1752,8 +1815,8 @@ class AudioRecorderManager(
1752
1815
  if (!initializeBufferSize(dummyPromise)) return false
1753
1816
  if (!initializeAudioRecord(dummyPromise)) return false
1754
1817
 
1755
- if (recordingConfig.enableCompressedOutput && !initializeCompressedRecorder(
1756
- if (recordingConfig.compressedFormat == "aac") "aac" else "opus",
1818
+ if (recordingConfig.output.compressed.enabled && !initializeCompressedRecorder(
1819
+ if (recordingConfig.output.compressed.format == "aac") "aac" else "opus",
1757
1820
  dummyPromise
1758
1821
  )) return false
1759
1822
 
@@ -10,6 +10,16 @@ object LogUtils {
10
10
  // Format: [ExpoAudioStudio:ClassName]
11
11
  private const val TAG_PREFIX = "ExpoAudioStudio"
12
12
 
13
+ // Check if we're running in a test environment
14
+ private val isInTest: Boolean by lazy {
15
+ try {
16
+ Class.forName("org.junit.Test")
17
+ true
18
+ } catch (e: ClassNotFoundException) {
19
+ false
20
+ }
21
+ }
22
+
13
23
  /**
14
24
  * Logs a debug message with a consistent format.
15
25
  *
@@ -17,7 +27,11 @@ object LogUtils {
17
27
  * @param message The message to log
18
28
  */
19
29
  fun d(className: String, message: String) {
20
- Log.d("$TAG_PREFIX:$className", message)
30
+ if (isInTest) {
31
+ println("D/$TAG_PREFIX:$className: $message")
32
+ } else {
33
+ Log.d("$TAG_PREFIX:$className", message)
34
+ }
21
35
  }
22
36
 
23
37
  /**
@@ -28,7 +42,12 @@ object LogUtils {
28
42
  * @param throwable Optional throwable to include in the log
29
43
  */
30
44
  fun e(className: String, message: String, throwable: Throwable? = null) {
31
- Log.e("$TAG_PREFIX:$className", message, throwable)
45
+ if (isInTest) {
46
+ println("E/$TAG_PREFIX:$className: $message")
47
+ throwable?.printStackTrace()
48
+ } else {
49
+ Log.e("$TAG_PREFIX:$className", message, throwable)
50
+ }
32
51
  }
33
52
 
34
53
  /**
@@ -39,7 +58,12 @@ object LogUtils {
39
58
  * @param throwable Optional throwable to include in the log
40
59
  */
41
60
  fun w(className: String, message: String, throwable: Throwable? = null) {
42
- Log.w("$TAG_PREFIX:$className", message, throwable)
61
+ if (isInTest) {
62
+ println("W/$TAG_PREFIX:$className: $message")
63
+ throwable?.printStackTrace()
64
+ } else {
65
+ Log.w("$TAG_PREFIX:$className", message, throwable)
66
+ }
43
67
  }
44
68
 
45
69
  /**
@@ -49,7 +73,11 @@ object LogUtils {
49
73
  * @param message The message to log
50
74
  */
51
75
  fun i(className: String, message: String) {
52
- Log.i("$TAG_PREFIX:$className", message)
76
+ if (isInTest) {
77
+ println("I/$TAG_PREFIX:$className: $message")
78
+ } else {
79
+ Log.i("$TAG_PREFIX:$className", message)
80
+ }
53
81
  }
54
82
 
55
83
  /**
@@ -4,6 +4,45 @@ import android.media.AudioFormat
4
4
  import android.os.Build
5
5
  import java.io.File
6
6
 
7
+ // New output configuration structure
8
+ data class OutputConfig(
9
+ val primary: PrimaryOutput = PrimaryOutput(),
10
+ val compressed: CompressedOutput = CompressedOutput()
11
+ ) {
12
+ data class PrimaryOutput(
13
+ val enabled: Boolean = true,
14
+ val format: String = "wav"
15
+ )
16
+
17
+ data class CompressedOutput(
18
+ val enabled: Boolean = false,
19
+ val format: String = "aac",
20
+ val bitrate: Int = 128000
21
+ )
22
+
23
+ companion object {
24
+ fun fromMap(map: Map<String, Any?>?): OutputConfig {
25
+ if (map == null) return OutputConfig()
26
+
27
+ val primaryMap = map.getTypedMap<Any?>("primary") { true }
28
+ val compressedMap = map.getTypedMap<Any?>("compressed") { true }
29
+
30
+ val primary = PrimaryOutput(
31
+ enabled = primaryMap.getBooleanOrDefault("enabled", true),
32
+ format = primaryMap.getStringOrDefault("format", "wav")
33
+ )
34
+
35
+ val compressed = CompressedOutput(
36
+ enabled = compressedMap.getBooleanOrDefault("enabled", false),
37
+ format = compressedMap.getStringOrDefault("format", "aac").lowercase(),
38
+ bitrate = compressedMap.getNumberOrDefault("bitrate", 128000)
39
+ )
40
+
41
+ return OutputConfig(primary = primary, compressed = compressed)
42
+ }
43
+ }
44
+ }
45
+
7
46
  data class RecordingConfig(
8
47
  val sampleRate: Int = Constants.DEFAULT_SAMPLE_RATE,
9
48
  val channels: Int = 1,
@@ -17,14 +56,13 @@ data class RecordingConfig(
17
56
  val showWaveformInNotification: Boolean = false,
18
57
  val notification: NotificationConfig = NotificationConfig(),
19
58
  val features: Map<String, Boolean> = emptyMap(),
20
- val enableCompressedOutput: Boolean = false,
21
- val compressedFormat: String = "opus",
22
- val compressedBitRate: Int = 24000,
59
+ val output: OutputConfig = OutputConfig(),
23
60
  val autoResumeAfterInterruption: Boolean = false,
24
61
  val outputDirectory: String? = null,
25
62
  val filename: String? = null,
26
63
  val deviceId: String? = null,
27
64
  val deviceDisconnectionBehavior: String? = null,
65
+ val bufferDurationSeconds: Double? = null,
28
66
  ) {
29
67
  companion object {
30
68
  fun fromMap(options: Map<String, Any?>?): Result<Pair<RecordingConfig, AudioFormatInfo>> {
@@ -45,19 +83,17 @@ data class RecordingConfig(
45
83
  val notificationMap = options.getTypedMap<Any?>("notification") { true }
46
84
  val notificationConfig = NotificationConfig.fromMap(notificationMap)
47
85
 
48
- // Parse compression config
49
- val compressionMap = options.getTypedMap<Any?>("compression") { true }
50
- val enableCompressedOutput = compressionMap["enabled"] as? Boolean ?: false
51
- val compressedFormat = (compressionMap["format"] as? String)?.lowercase() ?: "aac"
52
- val compressedBitRate = (compressionMap["bitrate"] as? Number)?.toInt() ?: 128000
86
+ // Parse output config
87
+ val outputMap = options.getTypedMap<Any?>("output") { true }
88
+ val outputConfig = OutputConfig.fromMap(outputMap)
53
89
 
54
90
  // Validate bitrate if compression is enabled
55
- if (enableCompressedOutput) {
91
+ if (outputConfig.compressed.enabled) {
56
92
  when {
57
- compressedBitRate < 8000 -> return Result.failure(
93
+ outputConfig.compressed.bitrate < 8000 -> return Result.failure(
58
94
  IllegalArgumentException("Bitrate must be at least 8000 bps")
59
95
  )
60
- compressedBitRate > 960000 -> return Result.failure(
96
+ outputConfig.compressed.bitrate > 960000 -> return Result.failure(
61
97
  IllegalArgumentException("Bitrate cannot exceed 960000 bps")
62
98
  )
63
99
  }
@@ -102,9 +138,7 @@ data class RecordingConfig(
102
138
  showWaveformInNotification = options.getBooleanOrDefault("showWaveformInNotification", false),
103
139
  notification = notificationConfig,
104
140
  features = features,
105
- enableCompressedOutput = enableCompressedOutput,
106
- compressedFormat = compressedFormat,
107
- compressedBitRate = compressedBitRate,
141
+ output = outputConfig,
108
142
  autoResumeAfterInterruption = options.getBooleanOrDefault("autoResumeAfterInterruption", false),
109
143
  outputDirectory = outputDirectory?.let {
110
144
  it.replace(Regex("^file://"), "")
@@ -113,7 +147,8 @@ data class RecordingConfig(
113
147
  },
114
148
  filename = options["filename"] as? String,
115
149
  deviceId = deviceId,
116
- deviceDisconnectionBehavior = deviceDisconnectionBehavior
150
+ deviceDisconnectionBehavior = deviceDisconnectionBehavior,
151
+ bufferDurationSeconds = (options["bufferDurationSeconds"] as? Number)?.toDouble(),
117
152
  )
118
153
 
119
154
  // Validate sample rate and channels