@siteed/audio-studio 3.0.5 → 3.1.1

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 (63) hide show
  1. package/CHANGELOG.md +19 -1
  2. package/README.md +108 -41
  3. package/android/src/androidTest/java/net/siteed/audiostudio/AudioFinalMetadataContractInstrumentedTest.kt +190 -0
  4. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderInstrumentedTest.kt +29 -83
  5. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderPerformanceInstrumentedTest.kt +17 -1
  6. package/android/src/androidTest/java/net/siteed/audiostudio/OpusRangeDecodeRegressionInstrumentedTest.kt +186 -0
  7. package/android/src/main/java/net/siteed/audiostudio/AudioProcessor.kt +473 -380
  8. package/android/src/main/java/net/siteed/audiostudio/AudioRecorderManager.kt +74 -22
  9. package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +86 -19
  10. package/android/src/main/java/net/siteed/audiostudio/AudioTrimmer.kt +174 -212
  11. package/android/src/main/java/net/siteed/audiostudio/EventSender.kt +6 -0
  12. package/android/src/test/java/net/siteed/audiostudio/AndroidCallStateTest.kt +37 -0
  13. package/android/src/test/java/net/siteed/audiostudio/AndroidEventEmitterTest.kt +28 -0
  14. package/android/src/test/java/net/siteed/audiostudio/InterruptionAutoResumePolicyTest.kt +49 -0
  15. package/build/cjs/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  16. package/build/cjs/AudioAnalysis/extractPreview.js +92 -15
  17. package/build/cjs/AudioAnalysis/extractPreview.js.map +1 -1
  18. package/build/cjs/AudioAnalysis/extractPreviewBars.js +134 -0
  19. package/build/cjs/AudioAnalysis/extractPreviewBars.js.map +1 -0
  20. package/build/cjs/AudioStudio.types.js.map +1 -1
  21. package/build/cjs/errors/AudioExtractionError.js +127 -0
  22. package/build/cjs/errors/AudioExtractionError.js.map +1 -0
  23. package/build/cjs/index.js +6 -1
  24. package/build/cjs/index.js.map +1 -1
  25. package/build/cjs/useAudioRecorder.js +36 -18
  26. package/build/cjs/useAudioRecorder.js.map +1 -1
  27. package/build/esm/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  28. package/build/esm/AudioAnalysis/extractPreview.js +92 -15
  29. package/build/esm/AudioAnalysis/extractPreview.js.map +1 -1
  30. package/build/esm/AudioAnalysis/extractPreviewBars.js +128 -0
  31. package/build/esm/AudioAnalysis/extractPreviewBars.js.map +1 -0
  32. package/build/esm/AudioStudio.types.js.map +1 -1
  33. package/build/esm/errors/AudioExtractionError.js +122 -0
  34. package/build/esm/errors/AudioExtractionError.js.map +1 -0
  35. package/build/esm/index.js +2 -0
  36. package/build/esm/index.js.map +1 -1
  37. package/build/esm/useAudioRecorder.js +36 -18
  38. package/build/esm/useAudioRecorder.js.map +1 -1
  39. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts +79 -0
  40. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -1
  41. package/build/types/AudioAnalysis/extractPreview.d.ts +2 -2
  42. package/build/types/AudioAnalysis/extractPreview.d.ts.map +1 -1
  43. package/build/types/AudioAnalysis/extractPreviewBars.d.ts +12 -0
  44. package/build/types/AudioAnalysis/extractPreviewBars.d.ts.map +1 -0
  45. package/build/types/AudioStudio.types.d.ts +14 -1
  46. package/build/types/AudioStudio.types.d.ts.map +1 -1
  47. package/build/types/errors/AudioExtractionError.d.ts +24 -0
  48. package/build/types/errors/AudioExtractionError.d.ts.map +1 -0
  49. package/build/types/index.d.ts +3 -0
  50. package/build/types/index.d.ts.map +1 -1
  51. package/build/types/useAudioRecorder.d.ts.map +1 -1
  52. package/ios/AudioProcessor.swift +99 -0
  53. package/ios/AudioStreamManager.swift +79 -15
  54. package/ios/AudioStudioModule.swift +63 -0
  55. package/ios/AudioStudioTests/CompressedOnlyOutputTests.swift +41 -1
  56. package/package.json +7 -7
  57. package/src/AudioAnalysis/AudioAnalysis.types.ts +82 -0
  58. package/src/AudioAnalysis/extractPreview.ts +118 -17
  59. package/src/AudioAnalysis/extractPreviewBars.ts +193 -0
  60. package/src/AudioStudio.types.ts +15 -1
  61. package/src/errors/AudioExtractionError.ts +167 -0
  62. package/src/index.ts +10 -0
  63. package/src/useAudioRecorder.tsx +36 -14
@@ -15,7 +15,7 @@ import java.nio.ByteBuffer
15
15
  import kotlin.math.min
16
16
 
17
17
  class AudioTrimmer(
18
- private val context: Context,
18
+ private val context: Context,
19
19
  private val fileHandler: AudioFileHandler
20
20
  ) {
21
21
  companion object {
@@ -27,6 +27,10 @@ class AudioTrimmer(
27
27
  fun onProgress(progress: Float, bytesProcessed: Long, totalBytes: Long)
28
28
  }
29
29
 
30
+ private fun numberOption(options: Map<String, Any>, key: String): Int? {
31
+ return (options[key] as? Number)?.toInt()
32
+ }
33
+
30
34
  /**
31
35
  * Trims audio file based on the provided options
32
36
  */
@@ -48,19 +52,19 @@ class AudioTrimmer(
48
52
  try {
49
53
  // Resolve the input file URI
50
54
  val inputUri = Uri.parse(fileUri)
51
-
55
+
52
56
  // Get audio file metadata
53
57
  val retriever = MediaMetadataRetriever()
54
58
  retriever.setDataSource(context, inputUri)
55
-
59
+
56
60
  // Extract audio format information
57
61
  val audioFormat = getAudioFormat(retriever)
58
62
  Log.d(TAG, "Source audio format: $audioFormat")
59
-
63
+
60
64
  // Validate and process output format options
61
65
  val formatOptions = outputFormat ?: emptyMap()
62
66
  val outputFormatType = (formatOptions["format"] as? String)?.lowercase() ?: "wav"
63
-
67
+
64
68
  // Validate format and provide consistent fallback
65
69
  val effectiveFormatType = if (outputFormatType !in listOf("wav", "aac", "opus")) {
66
70
  Log.w(TAG, "Unsupported format '$outputFormatType'. Falling back to 'aac'")
@@ -68,38 +72,38 @@ class AudioTrimmer(
68
72
  } else {
69
73
  outputFormatType
70
74
  }
71
-
75
+
72
76
  // Validate and normalize format-specific parameters
73
- val sampleRate = (formatOptions["sampleRate"] as? Int)?.coerceIn(8000, 48000)
77
+ val sampleRate = numberOption(formatOptions, "sampleRate")?.coerceIn(8000, 48000)
74
78
  ?: audioFormat.sampleRate
75
- val channels = (formatOptions["channels"] as? Int)?.coerceIn(1, 2)
79
+ val channels = numberOption(formatOptions, "channels")?.coerceIn(1, 2)
76
80
  ?: audioFormat.channels
77
- val bitDepth = (formatOptions["bitDepth"] as? Int)?.coerceIn(8, 32)
81
+ val bitDepth = numberOption(formatOptions, "bitDepth")?.coerceIn(8, 32)
78
82
  ?: audioFormat.bitDepth
79
- val bitrate = (formatOptions["bitrate"] as? Int)?.coerceIn(8000, 320000)
83
+ val bitrate = numberOption(formatOptions, "bitrate")?.coerceIn(8000, 320000)
80
84
  ?: 128000
81
-
85
+
82
86
  Log.d(TAG, "Output format parameters: format=$effectiveFormatType, sampleRate=$sampleRate, " +
83
87
  "channels=$channels, bitDepth=$bitDepth, bitrate=$bitrate")
84
-
88
+
85
89
  // Determine the appropriate extension and format
86
90
  val extension = when (effectiveFormatType) {
87
91
  "wav" -> "wav"
88
92
  "opus" -> "opus"
89
93
  else -> "m4a" // Use m4a extension for AAC to match iOS
90
94
  }
91
-
95
+
92
96
  Log.d(TAG, "Using output extension: $extension")
93
-
97
+
94
98
  // Create output file
95
99
  val outputFile = if (outputFileName != null) {
96
100
  File(context.filesDir, "$outputFileName.$extension")
97
101
  } else {
98
102
  fileHandler.createAudioFile(extension)
99
103
  }
100
-
104
+
101
105
  Log.d(TAG, "Created output file: ${outputFile.absolutePath}")
102
-
106
+
103
107
  // Determine the time ranges to process based on the mode
104
108
  val timeRanges = when (mode) {
105
109
  "single" -> {
@@ -112,34 +116,34 @@ class AudioTrimmer(
112
116
  // For remove mode, we need to invert the ranges
113
117
  val invertedRanges = mutableListOf<Map<String, Long>>()
114
118
  var lastEndTime = 0L
115
-
119
+
116
120
  ranges?.sortedBy { it["startTimeMs"] }?.forEach { range ->
117
121
  val start = range["startTimeMs"] ?: 0L
118
122
  val end = range["endTimeMs"] ?: audioFormat.durationMs
119
-
123
+
120
124
  if (start > lastEndTime) {
121
125
  invertedRanges.add(mapOf("startTimeMs" to lastEndTime, "endTimeMs" to start))
122
126
  }
123
127
  lastEndTime = end
124
128
  }
125
-
129
+
126
130
  if (lastEndTime < audioFormat.durationMs) {
127
131
  invertedRanges.add(mapOf("startTimeMs" to lastEndTime, "endTimeMs" to audioFormat.durationMs))
128
132
  }
129
-
133
+
130
134
  invertedRanges
131
135
  }
132
136
  else -> throw IllegalArgumentException("Invalid mode: $mode")
133
137
  }
134
-
138
+
135
139
  // Check if we need format conversion
136
- val needFormatChange = formatOptions["sampleRate"] != null ||
137
- formatOptions["channels"] != null ||
140
+ val needFormatChange = formatOptions["sampleRate"] != null ||
141
+ formatOptions["channels"] != null ||
138
142
  formatOptions["bitDepth"] != null
139
-
143
+
140
144
  // Check if input is WAV format
141
145
  val isWavInput = audioFormat.mimeType == "audio/wav" || audioFormat.mimeType == "audio/x-wav"
142
-
146
+
143
147
  // Optimized approach based on input/output formats
144
148
  if (isWavInput && extension == "wav" && !needFormatChange) {
145
149
  // Fast path for WAV-to-WAV with no format changes
@@ -152,12 +156,12 @@ class AudioTrimmer(
152
156
  // Need to decode and possibly re-encode
153
157
  Log.d(TAG, "Using decode/encode path for non-WAV input or format conversion")
154
158
  val config = DecodingConfig(
155
- targetSampleRate = formatOptions["sampleRate"] as? Int,
156
- targetChannels = formatOptions["channels"] as? Int,
157
- targetBitDepth = formatOptions["bitDepth"] as? Int ?: 16,
159
+ targetSampleRate = sampleRate,
160
+ targetChannels = channels,
161
+ targetBitDepth = bitDepth,
158
162
  normalizeAudio = false
159
163
  )
160
-
164
+
161
165
  if (extension == "wav") {
162
166
  // For any format to WAV conversion
163
167
  Log.d(TAG, "Processing to WAV with possible format conversion")
@@ -172,7 +176,7 @@ class AudioTrimmer(
172
176
  // For compressed output formats (AAC, Opus)
173
177
  Log.d(TAG, "Processing to compressed format: $extension")
174
178
  val tempWavFile = File(context.filesDir, "temp_${System.currentTimeMillis()}.wav")
175
-
179
+
176
180
  try {
177
181
  // First decode to WAV
178
182
  processToWav(
@@ -186,20 +190,20 @@ class AudioTrimmer(
186
190
  }
187
191
  }
188
192
  )
189
-
193
+
190
194
  // Now encode to the target format
191
195
  if (extension == "opus") {
192
196
  val audioProcessor = AudioProcessor(context.filesDir)
193
197
  val audioData = audioProcessor.loadAudioFromAnyFormat(
194
198
  tempWavFile.absolutePath,
195
199
  DecodingConfig(
196
- targetSampleRate = formatOptions["sampleRate"] as? Int ?: 16000,
197
- targetChannels = formatOptions["channels"] as? Int ?: 1,
200
+ targetSampleRate = sampleRate,
201
+ targetChannels = channels,
198
202
  targetBitDepth = 16,
199
203
  normalizeAudio = false
200
204
  )
201
205
  ) ?: throw IOException("Failed to load WAV file")
202
-
206
+
203
207
  encodeToOpus(
204
208
  audioData,
205
209
  outputFile,
@@ -231,23 +235,23 @@ class AudioTrimmer(
231
235
  }
232
236
  }
233
237
  }
234
-
238
+
235
239
  // Get output file metadata
236
240
  val outputFileSize = outputFile.length()
237
241
  val outputDurationMs = calculateOutputDuration(timeRanges)
238
-
242
+
239
243
  // Extract audio format details
240
244
  val extractor = MediaExtractor()
241
245
  try {
242
246
  extractor.setDataSource(outputFile.absolutePath)
243
-
247
+
244
248
  // Initialize variables that will be populated from the file or user options
245
249
  val outputBitrate: Int
246
250
 
247
251
  // First try to get values from the output file
248
252
  if (extractor.trackCount > 0) {
249
253
  val format = extractor.getTrackFormat(0)
250
-
254
+
251
255
  outputBitrate = if (format.containsKey(MediaFormat.KEY_BIT_RATE)) {
252
256
  format.getInteger(MediaFormat.KEY_BIT_RATE)
253
257
  } else {
@@ -258,14 +262,14 @@ class AudioTrimmer(
258
262
  // If we can't get from the file, use user options or defaults
259
263
  outputBitrate = bitrate
260
264
  }
261
-
265
+
262
266
  // Determine the correct MIME type
263
267
  val mimeType = when (extension) {
264
268
  "m4a" -> "audio/mp4" // Use audio/mp4 for AAC to match iOS
265
269
  "opus" -> "audio/ogg" // Use audio/ogg for Opus
266
270
  else -> "audio/wav"
267
271
  }
268
-
272
+
269
273
  val result = mutableMapOf<String, Any>(
270
274
  "uri" to outputFile.absolutePath,
271
275
  "filename" to outputFile.name,
@@ -278,7 +282,7 @@ class AudioTrimmer(
278
282
  "requestedFormat" to (formatOptions["format"] as? String ?: "wav"), // Add the originally requested format
279
283
  "actualFormat" to extension // Add the actual format used
280
284
  )
281
-
285
+
282
286
  // Add compression info if not WAV
283
287
  if (extension != "wav") {
284
288
  result["compression"] = mapOf(
@@ -287,20 +291,20 @@ class AudioTrimmer(
287
291
  "size" to outputFileSize
288
292
  )
289
293
  }
290
-
294
+
291
295
  Log.d(TAG, "Audio trim completed in ${System.currentTimeMillis() - startTime}ms")
292
296
  return result
293
297
  } catch (e: Exception) {
294
298
  Log.e(TAG, "Error reading output file metadata: ${e.message}")
295
299
  // Continue with basic metadata if extractor fails
296
-
300
+
297
301
  // Determine the correct MIME type
298
302
  val mimeType = when (extension) {
299
303
  "m4a" -> "audio/mp4" // Use audio/mp4 for AAC to match iOS
300
304
  "opus" -> "audio/ogg" // Use audio/ogg for Opus
301
305
  else -> "audio/wav"
302
306
  }
303
-
307
+
304
308
  val result = mutableMapOf<String, Any>(
305
309
  "uri" to outputFile.absolutePath,
306
310
  "filename" to outputFile.name,
@@ -313,7 +317,7 @@ class AudioTrimmer(
313
317
  "requestedFormat" to (formatOptions["format"] as? String ?: "wav"),
314
318
  "actualFormat" to extension
315
319
  )
316
-
320
+
317
321
  // Add compression info if not WAV
318
322
  if (extension != "wav") {
319
323
  result["compression"] = mapOf(
@@ -322,7 +326,7 @@ class AudioTrimmer(
322
326
  "size" to outputFileSize
323
327
  )
324
328
  }
325
-
329
+
326
330
  Log.d(TAG, "Audio trim completed in ${System.currentTimeMillis() - startTime}ms")
327
331
  return result
328
332
  } finally {
@@ -332,7 +336,7 @@ class AudioTrimmer(
332
336
  // Ignore
333
337
  }
334
338
  }
335
-
339
+
336
340
  } catch (e: Exception) {
337
341
  Log.e(TAG, "Error trimming audio", e)
338
342
  throw e
@@ -348,7 +352,7 @@ class AudioTrimmer(
348
352
  }
349
353
  return totalDurationMs
350
354
  }
351
-
355
+
352
356
  /**
353
357
  * Optimized version of processWavFile that directly copies bytes from input to output
354
358
  * without decoding the entire file
@@ -362,111 +366,111 @@ class AudioTrimmer(
362
366
  // Get input file path from URI
363
367
  val inputPath = inputUri.path ?: throw IOException("Invalid input URI")
364
368
  val inputFile = File(inputPath)
365
-
369
+
366
370
  if (!inputFile.exists()) {
367
371
  throw IOException("Input file does not exist: $inputPath")
368
372
  }
369
-
373
+
370
374
  // Create output file if it doesn't exist
371
375
  if (!outputFile.exists() && !outputFile.createNewFile()) {
372
376
  throw IOException("Failed to create output file: ${outputFile.path}")
373
377
  }
374
-
378
+
375
379
  // Use AudioProcessor to determine actual WAV header length
376
380
  val audioProcessor = AudioProcessor(context.filesDir)
377
381
  val headerSize = audioProcessor.getWavHeaderSize(inputFile.absolutePath) ?: 44 // Default to 44 if we can't determine
378
-
382
+
379
383
  // Read WAV header to get format information using 'use' pattern
380
384
  val headerBuffer = FileInputStream(inputFile).use { inputStream ->
381
385
  ByteArray(headerSize).also { buffer ->
382
386
  inputStream.read(buffer)
383
387
  }
384
388
  }
385
-
389
+
386
390
  // Parse header to get format info
387
391
  val sampleRate = ByteBuffer.wrap(headerBuffer, 24, 4).order(java.nio.ByteOrder.LITTLE_ENDIAN).int
388
392
  val channels = ByteBuffer.wrap(headerBuffer, 22, 2).order(java.nio.ByteOrder.LITTLE_ENDIAN).short.toInt()
389
393
  val bitDepth = ByteBuffer.wrap(headerBuffer, 34, 2).order(java.nio.ByteOrder.LITTLE_ENDIAN).short.toInt()
390
-
394
+
391
395
  // Get file duration using MediaMetadataRetriever for consistency
392
396
  val retriever = MediaMetadataRetriever()
393
397
  retriever.setDataSource(inputFile.absolutePath)
394
398
  val durationMsStr = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
395
399
  val fileDurationMs = durationMsStr?.toLong() ?: 0
396
400
  retriever.release()
397
-
401
+
398
402
  // Create output file with WAV header
399
403
  FileOutputStream(outputFile).use { outputStream ->
400
404
  fileHandler.writeWavHeader(outputStream, sampleRate, channels, bitDepth)
401
-
405
+
402
406
  // Process each time range
403
407
  val bytesPerSample = bitDepth / 8
404
408
  val bytesPerFrame = bytesPerSample * channels
405
409
  val buffer = ByteArray(BUFFER_SIZE - (BUFFER_SIZE % bytesPerFrame)) // Ensure buffer size is multiple of frame size
406
-
410
+
407
411
  var totalBytesProcessed = 0L
408
412
  val totalRangeDuration = calculateOutputDuration(timeRanges)
409
413
  var currentRangeProcessed = 0L
410
-
414
+
411
415
  var lastUpdateTime = 0L
412
416
  val updateIntervalMs = 100L // Update progress every 100ms
413
-
417
+
414
418
  for (range in timeRanges) {
415
419
  val startTimeMs = range["startTimeMs"] ?: 0
416
420
  val endTimeMs = range["endTimeMs"] ?: fileDurationMs // Use actual file duration instead of Long.MAX_VALUE
417
-
421
+
418
422
  // Calculate byte positions
419
423
  val startByte = headerSize + ((startTimeMs * sampleRate * bytesPerFrame) / 1000)
420
424
  val endByte = headerSize + ((endTimeMs * sampleRate * bytesPerFrame) / 1000)
421
-
425
+
422
426
  val rangeSize = endByte - startByte
423
427
  val rangeDuration = endTimeMs - startTimeMs
424
-
428
+
425
429
  // Read and write the range using 'use' pattern
426
430
  FileInputStream(inputFile).use { rangeInputStream ->
427
431
  if (rangeInputStream.skip(startByte) != startByte) {
428
432
  throw IOException("Failed to skip to position $startByte in input file")
429
433
  }
430
-
434
+
431
435
  var bytesRead: Int
432
436
  var rangeProcessed = 0L
433
-
437
+
434
438
  while (rangeInputStream.read(buffer).also { bytesRead = it } > 0 && rangeProcessed < rangeSize) {
435
439
  // Ensure we don't read past the range
436
440
  val bytesToWrite = min(bytesRead.toLong(), rangeSize - rangeProcessed).toInt()
437
-
441
+
438
442
  outputStream.write(buffer, 0, bytesToWrite)
439
-
443
+
440
444
  rangeProcessed += bytesToWrite
441
445
  totalBytesProcessed += bytesToWrite
442
-
446
+
443
447
  // Calculate progress based on time for consistency with compressed audio
444
448
  val currentTimeInRange = (rangeProcessed * 1000) / (sampleRate * bytesPerFrame)
445
-
449
+
446
450
  // Calculate overall progress directly
447
451
  val overallProgress = (currentRangeProcessed + currentTimeInRange).toFloat() / totalRangeDuration
448
-
452
+
449
453
  val currentTime = System.currentTimeMillis()
450
454
  if (currentTime - lastUpdateTime >= updateIntervalMs) {
451
455
  progressCallback(overallProgress * 100, bytesToWrite.toLong(), totalRangeDuration)
452
456
  lastUpdateTime = currentTime
453
457
  }
454
-
458
+
455
459
  // Break if we've read the entire range
456
460
  if (rangeProcessed >= rangeSize) {
457
461
  break
458
462
  }
459
463
  }
460
464
  }
461
-
465
+
462
466
  currentRangeProcessed += rangeDuration
463
467
  }
464
468
  }
465
-
469
+
466
470
  // Update WAV header with correct file size
467
471
  fileHandler.updateWavHeader(outputFile)
468
472
  }
469
-
473
+
470
474
  /**
471
475
  * Optimized version of processToWav that processes audio ranges more efficiently
472
476
  */
@@ -478,74 +482,32 @@ class AudioTrimmer(
478
482
  progressListener: ProgressListener?
479
483
  ) {
480
484
  val audioProcessor = AudioProcessor(context.filesDir)
481
- val isWavInput = try {
482
- val mimeType = MediaMetadataRetriever().apply {
483
- setDataSource(context, inputUri)
484
- }.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE)
485
- mimeType == "audio/wav" || mimeType == "audio/x-wav"
486
- } catch (e: Exception) {
487
- false
488
- }
489
-
485
+
490
486
  // Create output file with WAV header
491
487
  FileOutputStream(outputFile).use { outputStream ->
492
488
  // We'll write the header at the end when we know the total size
493
489
  var totalBytes = 0L
494
490
  var totalProgress: Float
495
491
  val totalRanges = timeRanges.size
496
-
492
+
497
493
  // Process each time range
498
494
  for ((index, range) in timeRanges.withIndex()) {
499
495
  val startTimeMs = range["startTimeMs"] ?: 0
500
496
  val endTimeMs = range["endTimeMs"] ?: 0
501
497
  val rangeDuration = endTimeMs - startTimeMs
502
-
498
+
503
499
  Log.d(TAG, "Processing range $index: $startTimeMs-$endTimeMs ms")
504
-
505
- // Load just this range of audio - use the optimized method for compressed audio
506
- val audioData = if (isWavInput) {
507
- // For WAV files, use the existing method
508
- audioProcessor.loadAudioRange(
509
- fileUri = inputUri.toString(),
510
- startTimeMs = startTimeMs,
511
- endTimeMs = endTimeMs,
512
- config = config
513
- )
514
- } else {
515
- // For compressed audio, use the new optimized method
516
- audioProcessor.decodeAudioRangeToPCM(
517
- fileUri = inputUri.toString(),
518
- startTimeMs = startTimeMs,
519
- endTimeMs = endTimeMs
520
- )?.let { decodedData ->
521
- // Apply any format conversion if needed
522
- if (config.targetSampleRate != null && config.targetSampleRate != decodedData.sampleRate ||
523
- config.targetChannels != null && config.targetChannels != decodedData.channels) {
524
-
525
- // Need to resample or convert channels
526
- val resampledData = audioProcessor.processAudio(
527
- decodedData.data,
528
- decodedData.sampleRate,
529
- decodedData.channels,
530
- config.targetSampleRate ?: decodedData.sampleRate,
531
- config.targetChannels ?: decodedData.channels,
532
- config.normalizeAudio
533
- )
534
-
535
- AudioProcessor.AudioData(
536
- data = resampledData,
537
- sampleRate = config.targetSampleRate ?: decodedData.sampleRate,
538
- channels = config.targetChannels ?: decodedData.channels,
539
- bitDepth = decodedData.bitDepth,
540
- durationMs = decodedData.durationMs
541
- )
542
- } else {
543
- // No conversion needed
544
- decodedData
545
- }
546
- }
547
- } ?: throw IOException("Failed to load audio range $startTimeMs-$endTimeMs")
548
-
500
+
501
+ // Load just this range through AudioProcessor's canonical range path. It handles
502
+ // WAV and compressed inputs, target sample rate/channel/bit depth conversion, and
503
+ // final-byte metadata consistently for trimAudio and extractAudioData.
504
+ val audioData = audioProcessor.loadAudioRange(
505
+ fileUri = inputUri.toString(),
506
+ startTimeMs = startTimeMs,
507
+ endTimeMs = endTimeMs,
508
+ config = config
509
+ ) ?: throw IOException("Failed to load audio range $startTimeMs-$endTimeMs")
510
+
549
511
  // For the first range, write the WAV header
550
512
  if (index == 0) {
551
513
  fileHandler.writeWavHeader(
@@ -555,23 +517,23 @@ class AudioTrimmer(
555
517
  audioData.bitDepth
556
518
  )
557
519
  }
558
-
520
+
559
521
  // Write the PCM data for this range
560
522
  outputStream.write(audioData.data)
561
523
  totalBytes += audioData.data.size
562
-
524
+
563
525
  // Update progress
564
526
  val rangeProgress = (index + 1).toFloat() / totalRanges
565
527
  totalProgress = rangeProgress * 100
566
528
  progressListener?.onProgress(totalProgress, audioData.data.size.toLong(), rangeDuration)
567
-
529
+
568
530
  Log.d(TAG, "Range $index processed: ${audioData.data.size} bytes, ${audioData.durationMs} ms")
569
531
  }
570
532
  }
571
-
533
+
572
534
  // Update WAV header with correct file size
573
535
  fileHandler.updateWavHeader(outputFile)
574
-
536
+
575
537
  Log.d(TAG, "WAV file created successfully: ${outputFile.absolutePath}")
576
538
  }
577
539
 
@@ -586,18 +548,18 @@ class AudioTrimmer(
586
548
  ) {
587
549
  // Increase MediaCodec buffer size
588
550
  val largerInputBufferSize = 65536 // 64KB
589
-
551
+
590
552
  Log.d(TAG, "Encoding WAV to AAC: ${inputWavFile.absolutePath} -> ${outputAacFile.absolutePath}")
591
-
553
+
592
554
  // Get WAV file details
593
555
  val audioProcessor = AudioProcessor(context.filesDir)
594
556
  val audioFormat = audioProcessor.getAudioFormat(inputWavFile.absolutePath)
595
557
  ?: throw IOException("Failed to get audio format from WAV file")
596
-
597
- val sampleRate = formatOptions["sampleRate"] as? Int ?: audioFormat.sampleRate
598
- val channels = formatOptions["channels"] as? Int ?: audioFormat.channels
599
- val bitrate = formatOptions["bitrate"] as? Int ?: 128000
600
-
558
+
559
+ val sampleRate = numberOption(formatOptions, "sampleRate") ?: audioFormat.sampleRate
560
+ val channels = numberOption(formatOptions, "channels") ?: audioFormat.channels
561
+ val bitrate = numberOption(formatOptions, "bitrate") ?: 128000
562
+
601
563
  // Load the entire WAV file as PCM data
602
564
  val audioData = audioProcessor.loadAudioFromAnyFormat(
603
565
  inputWavFile.absolutePath,
@@ -608,22 +570,22 @@ class AudioTrimmer(
608
570
  normalizeAudio = false
609
571
  )
610
572
  ) ?: throw IOException("Failed to load WAV file")
611
-
573
+
612
574
  // Set up MediaCodec for AAC encoding
613
575
  val mediaFormat = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, channels)
614
576
  mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, bitrate)
615
577
  mediaFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, android.media.MediaCodecInfo.CodecProfileLevel.AACObjectLC)
616
578
  mediaFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, largerInputBufferSize)
617
-
579
+
618
580
  val encoder = android.media.MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
619
581
  encoder.configure(mediaFormat, null, null, android.media.MediaCodec.CONFIGURE_FLAG_ENCODE)
620
582
  encoder.start()
621
-
583
+
622
584
  // Set up MediaMuxer for MP4 container
623
585
  val muxer = MediaMuxer(outputAacFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
624
586
  var trackIndex = -1
625
587
  var muxerStarted = false
626
-
588
+
627
589
  try {
628
590
  val bufferInfo = android.media.MediaCodec.BufferInfo()
629
591
  val timeoutUs = 10000L
@@ -632,18 +594,18 @@ class AudioTrimmer(
632
594
  val totalBytes = audioData.data.size.toLong()
633
595
  var allInputSubmitted = false
634
596
  var encoderDone = false
635
-
597
+
636
598
  // Calculate bytes per frame
637
599
  val bytesPerSample = audioData.bitDepth / 8
638
600
  val bytesPerFrame = bytesPerSample * audioData.channels
639
601
  val frameSizeInBytes = 65536 // 64KB buffer instead of smaller chunks
640
-
602
+
641
603
  // Process the PCM data in larger chunks
642
604
  var inputOffset = 0
643
-
605
+
644
606
  var lastUpdateTime = 0L
645
607
  val updateIntervalMs = 100L
646
-
608
+
647
609
  while (!encoderDone) {
648
610
  // Submit input data if we have any left
649
611
  if (!allInputSubmitted) {
@@ -651,21 +613,21 @@ class AudioTrimmer(
651
613
  if (inputBufferIndex >= 0) {
652
614
  val inputBuffer = encoder.getInputBuffer(inputBufferIndex)
653
615
  inputBuffer?.clear()
654
-
616
+
655
617
  // Calculate how many bytes to read
656
618
  val bytesToRead = if (inputOffset + frameSizeInBytes <= audioData.data.size) {
657
619
  frameSizeInBytes
658
620
  } else {
659
621
  audioData.data.size - inputOffset
660
622
  }
661
-
623
+
662
624
  if (bytesToRead > 0) {
663
625
  // Copy data to the input buffer
664
626
  inputBuffer?.put(audioData.data, inputOffset, bytesToRead)
665
-
627
+
666
628
  // Calculate presentation time in microseconds
667
629
  val frameDurationUs = (bytesToRead * 1000000L) / (sampleRate * bytesPerFrame)
668
-
630
+
669
631
  // Submit the input buffer
670
632
  encoder.queueInputBuffer(
671
633
  inputBufferIndex,
@@ -674,12 +636,12 @@ class AudioTrimmer(
674
636
  presentationTimeUs,
675
637
  0
676
638
  )
677
-
639
+
678
640
  // Update state
679
641
  presentationTimeUs += frameDurationUs
680
642
  inputOffset += bytesToRead
681
643
  totalBytesProcessed += bytesToRead
682
-
644
+
683
645
  // Report progress
684
646
  val progress = (totalBytesProcessed * 100f) / totalBytes
685
647
  val currentTime = System.currentTimeMillis()
@@ -700,7 +662,7 @@ class AudioTrimmer(
700
662
  }
701
663
  }
702
664
  }
703
-
665
+
704
666
  // Get encoded output
705
667
  val outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, timeoutUs)
706
668
  if (outputBufferIndex == android.media.MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
@@ -716,25 +678,25 @@ class AudioTrimmer(
716
678
  } else if (outputBufferIndex >= 0) {
717
679
  // Got encoded data
718
680
  val encodedData = encoder.getOutputBuffer(outputBufferIndex)
719
-
681
+
720
682
  if (encodedData != null) {
721
683
  if ((bufferInfo.flags and android.media.MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
722
684
  // Codec config data, not actual media data
723
685
  bufferInfo.size = 0
724
686
  }
725
-
687
+
726
688
  if (bufferInfo.size > 0 && muxerStarted) {
727
689
  // Adjust buffer info offset and size for the buffer
728
690
  encodedData.position(bufferInfo.offset)
729
691
  encodedData.limit(bufferInfo.offset + bufferInfo.size)
730
-
692
+
731
693
  // Write to muxer
732
694
  muxer.writeSampleData(trackIndex, encodedData, bufferInfo)
733
695
  }
734
-
696
+
735
697
  // Release the output buffer
736
698
  encoder.releaseOutputBuffer(outputBufferIndex, false)
737
-
699
+
738
700
  // Check if we're done
739
701
  if ((bufferInfo.flags and android.media.MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
740
702
  encoderDone = true
@@ -742,10 +704,10 @@ class AudioTrimmer(
742
704
  }
743
705
  }
744
706
  }
745
-
707
+
746
708
  // Make sure we report 100% progress
747
709
  progressListener?.onProgress(100f, totalBytes, totalBytes)
748
-
710
+
749
711
  } finally {
750
712
  // Clean up resources
751
713
  try {
@@ -754,7 +716,7 @@ class AudioTrimmer(
754
716
  } catch (e: Exception) {
755
717
  Log.w(TAG, "Error releasing encoder: ${e.message}")
756
718
  }
757
-
719
+
758
720
  if (muxerStarted) {
759
721
  try {
760
722
  muxer.stop()
@@ -762,14 +724,14 @@ class AudioTrimmer(
762
724
  Log.w(TAG, "Error stopping muxer: ${e.message}")
763
725
  }
764
726
  }
765
-
727
+
766
728
  try {
767
729
  muxer.release()
768
730
  } catch (e: Exception) {
769
731
  Log.w(TAG, "Error releasing muxer: ${e.message}")
770
732
  }
771
733
  }
772
-
734
+
773
735
  Log.d(TAG, "WAV to AAC encoding completed successfully")
774
736
  }
775
737
 
@@ -783,7 +745,7 @@ class AudioTrimmer(
783
745
  progressListener: ProgressListener?
784
746
  ) {
785
747
  Log.d(TAG, "Encoding to Opus: ${outputFile.absolutePath}")
786
-
748
+
787
749
  try {
788
750
  // Check if Opus codec is available
789
751
  val codecList = android.media.MediaCodecList(android.media.MediaCodecList.REGULAR_CODECS)
@@ -791,10 +753,10 @@ class AudioTrimmer(
791
753
  .filter { it.isEncoder && it.supportedTypes.contains(MediaFormat.MIMETYPE_AUDIO_OPUS) }
792
754
  .map { it.name }
793
755
  .firstOrNull()
794
-
756
+
795
757
  if (opusCodecName == null) {
796
758
  Log.w(TAG, "Opus encoder not available, falling back to AAC")
797
-
759
+
798
760
  // Create a temporary WAV file
799
761
  val tempWavFile = File(context.filesDir, "temp_${System.currentTimeMillis()}.wav")
800
762
  try {
@@ -808,14 +770,14 @@ class AudioTrimmer(
808
770
  audioData.channels,
809
771
  audioData.bitDepth
810
772
  )
811
-
773
+
812
774
  // Write PCM data
813
775
  outputStream.write(audioData.data)
814
776
  }
815
-
777
+
816
778
  // Update WAV header with correct file size
817
779
  audioFileHandler.updateWavHeader(tempWavFile)
818
-
780
+
819
781
  // Now we can call encodeWavToAac with the temp file
820
782
  encodeWavToAac(
821
783
  tempWavFile,
@@ -831,23 +793,23 @@ class AudioTrimmer(
831
793
  }
832
794
  return
833
795
  }
834
-
796
+
835
797
  // Set up MediaCodec for Opus encoding
836
- val sampleRate = formatOptions["sampleRate"] as? Int ?: audioData.sampleRate
837
- val channels = formatOptions["channels"] as? Int ?: audioData.channels
838
-
798
+ val sampleRate = numberOption(formatOptions, "sampleRate") ?: audioData.sampleRate
799
+ val channels = numberOption(formatOptions, "channels") ?: audioData.channels
800
+
839
801
  // Determine appropriate bitrate based on content type and channels
840
802
  // For voice: 8-24kbps for mono, 16-32kbps for stereo is typically sufficient
841
803
  val defaultBitrate = if (channels > 1) 32000 else 16000 // Lower defaults for voice
842
- val bitrate = formatOptions["bitrate"] as? Int ?: defaultBitrate
843
-
804
+ val bitrate = numberOption(formatOptions, "bitrate") ?: defaultBitrate
805
+
844
806
  // Determine if this is voice content based on sample rate and/or explicit flag
845
807
  val isVoiceContent = formatOptions["isVoice"] as? Boolean ?: (sampleRate <= 16000)
846
-
808
+
847
809
  val mediaFormat = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_OPUS, sampleRate, channels)
848
810
  mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, bitrate)
849
811
  mediaFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 65536)
850
-
812
+
851
813
  // Set complexity - lower for voice (faster encoding, still good quality)
852
814
  // Complexity range is 0-10, with 10 being highest quality but slowest
853
815
  val complexity = if (isVoiceContent) 5 else 7
@@ -876,12 +838,12 @@ class AudioTrimmer(
876
838
  val encoder = android.media.MediaCodec.createByCodecName(opusCodecName)
877
839
  encoder.configure(mediaFormat, null, null, android.media.MediaCodec.CONFIGURE_FLAG_ENCODE)
878
840
  encoder.start()
879
-
841
+
880
842
  // Set up MediaMuxer for Opus container (using OGG container)
881
843
  val muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_OGG)
882
844
  var trackIndex = -1
883
845
  var muxerStarted = false
884
-
846
+
885
847
  try {
886
848
  val bufferInfo = android.media.MediaCodec.BufferInfo()
887
849
  val timeoutUs = 10000L
@@ -890,18 +852,18 @@ class AudioTrimmer(
890
852
  val totalBytes = audioData.data.size.toLong()
891
853
  var allInputSubmitted = false
892
854
  var encoderDone = false
893
-
855
+
894
856
  // Calculate bytes per frame
895
857
  val bytesPerSample = audioData.bitDepth / 8
896
858
  val bytesPerFrame = bytesPerSample * audioData.channels
897
859
  val frameSizeInBytes = 65536 // 64KB buffer instead of smaller chunks
898
-
860
+
899
861
  // Process the PCM data in chunks
900
862
  var inputOffset = 0
901
-
863
+
902
864
  var lastUpdateTime = 0L
903
865
  val updateIntervalMs = 100L
904
-
866
+
905
867
  while (!encoderDone) {
906
868
  // Submit input data if we have any left
907
869
  if (!allInputSubmitted) {
@@ -909,21 +871,21 @@ class AudioTrimmer(
909
871
  if (inputBufferIndex >= 0) {
910
872
  val inputBuffer = encoder.getInputBuffer(inputBufferIndex)
911
873
  inputBuffer?.clear()
912
-
874
+
913
875
  // Calculate how many bytes to read
914
876
  val bytesToRead = if (inputOffset + frameSizeInBytes <= audioData.data.size) {
915
877
  frameSizeInBytes
916
878
  } else {
917
879
  audioData.data.size - inputOffset
918
880
  }
919
-
881
+
920
882
  if (bytesToRead > 0) {
921
883
  // Copy data to the input buffer
922
884
  inputBuffer?.put(audioData.data, inputOffset, bytesToRead)
923
-
885
+
924
886
  // Calculate presentation time in microseconds
925
887
  val frameDurationUs = (bytesToRead * 1000000L) / (sampleRate * bytesPerFrame)
926
-
888
+
927
889
  // Submit the input buffer
928
890
  encoder.queueInputBuffer(
929
891
  inputBufferIndex,
@@ -932,12 +894,12 @@ class AudioTrimmer(
932
894
  presentationTimeUs,
933
895
  0
934
896
  )
935
-
897
+
936
898
  // Update state
937
899
  presentationTimeUs += frameDurationUs
938
900
  inputOffset += bytesToRead
939
901
  totalBytesProcessed += bytesToRead
940
-
902
+
941
903
  // Report progress
942
904
  val progress = (totalBytesProcessed * 100f) / totalBytes
943
905
  val currentTime = System.currentTimeMillis()
@@ -958,7 +920,7 @@ class AudioTrimmer(
958
920
  }
959
921
  }
960
922
  }
961
-
923
+
962
924
  // Get encoded output
963
925
  val outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, timeoutUs)
964
926
  if (outputBufferIndex == android.media.MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
@@ -974,25 +936,25 @@ class AudioTrimmer(
974
936
  } else if (outputBufferIndex >= 0) {
975
937
  // Got encoded data
976
938
  val encodedData = encoder.getOutputBuffer(outputBufferIndex)
977
-
939
+
978
940
  if (encodedData != null) {
979
941
  if ((bufferInfo.flags and android.media.MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
980
942
  // Codec config data, not actual media data
981
943
  bufferInfo.size = 0
982
944
  }
983
-
945
+
984
946
  if (bufferInfo.size > 0 && muxerStarted) {
985
947
  // Adjust buffer info offset and size for the buffer
986
948
  encodedData.position(bufferInfo.offset)
987
949
  encodedData.limit(bufferInfo.offset + bufferInfo.size)
988
-
950
+
989
951
  // Write to muxer
990
952
  muxer.writeSampleData(trackIndex, encodedData, bufferInfo)
991
953
  }
992
-
954
+
993
955
  // Release the output buffer
994
956
  encoder.releaseOutputBuffer(outputBufferIndex, false)
995
-
957
+
996
958
  // Check if we're done
997
959
  if ((bufferInfo.flags and android.media.MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
998
960
  encoderDone = true
@@ -1000,10 +962,10 @@ class AudioTrimmer(
1000
962
  }
1001
963
  }
1002
964
  }
1003
-
965
+
1004
966
  // Make sure we report 100% progress
1005
967
  progressListener?.onProgress(100f, totalBytes, totalBytes)
1006
-
968
+
1007
969
  } finally {
1008
970
  // Clean up resources
1009
971
  try {
@@ -1012,7 +974,7 @@ class AudioTrimmer(
1012
974
  } catch (e: Exception) {
1013
975
  Log.w(TAG, "Error releasing encoder: ${e.message}")
1014
976
  }
1015
-
977
+
1016
978
  if (muxerStarted) {
1017
979
  try {
1018
980
  muxer.stop()
@@ -1020,21 +982,21 @@ class AudioTrimmer(
1020
982
  Log.w(TAG, "Error stopping muxer: ${e.message}")
1021
983
  }
1022
984
  }
1023
-
985
+
1024
986
  try {
1025
987
  muxer.release()
1026
988
  } catch (e: Exception) {
1027
989
  Log.w(TAG, "Error releasing muxer: ${e.message}")
1028
990
  }
1029
991
  }
1030
-
992
+
1031
993
  Log.d(TAG, "Opus encoding completed successfully")
1032
994
  } catch (e: Exception) {
1033
995
  Log.e(TAG, "Error encoding to Opus: ${e.message}", e)
1034
-
996
+
1035
997
  // Fall back to AAC if Opus encoding fails
1036
998
  Log.w(TAG, "Opus encoding failed, falling back to AAC")
1037
-
999
+
1038
1000
  // Create a temporary WAV file
1039
1001
  val tempWavFile = File(context.filesDir, "temp_${System.currentTimeMillis()}.wav")
1040
1002
  try {
@@ -1050,7 +1012,7 @@ class AudioTrimmer(
1050
1012
  outputStream.write(audioData.data)
1051
1013
  }
1052
1014
  audioFileHandler.updateWavHeader(tempWavFile)
1053
-
1015
+
1054
1016
  // Encode to AAC
1055
1017
  encodeWavToAac(
1056
1018
  tempWavFile,
@@ -1063,7 +1025,7 @@ class AudioTrimmer(
1063
1025
  tempWavFile.delete()
1064
1026
  }
1065
1027
  }
1066
-
1028
+
1067
1029
  throw IOException("Failed to encode to Opus: ${e.message}", e)
1068
1030
  }
1069
1031
  }
@@ -1075,16 +1037,16 @@ class AudioTrimmer(
1075
1037
  // Estimate channels from bitrate and sample rate if not directly available
1076
1038
  if (it > sampleRate * 16) 2 else 1
1077
1039
  } ?: 1
1078
-
1040
+
1079
1041
  // Bit depth is often not directly available, assume 16-bit as default
1080
1042
  val bitDepth = 16
1081
-
1043
+
1082
1044
  // Get duration in milliseconds
1083
1045
  val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L
1084
-
1046
+
1085
1047
  // Get MIME type
1086
1048
  val mimeType = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE) ?: "audio/mpeg"
1087
-
1049
+
1088
1050
  return AudioFormat(sampleRate, channels, bitDepth, durationMs, mimeType)
1089
1051
  }
1090
1052
 
@@ -1096,4 +1058,4 @@ class AudioTrimmer(
1096
1058
  val durationMs: Long = 0,
1097
1059
  val mimeType: String = "audio/mpeg"
1098
1060
  )
1099
- }
1061
+ }