@siteed/expo-audio-stream 2.0.0 → 2.1.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 (57) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/README.md +202 -1
  3. package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +300 -1
  4. package/android/src/main/java/net/siteed/audiostream/AudioRecordingService.kt +16 -2
  5. package/android/src/main/java/net/siteed/audiostream/AudioTrimmer.kt +1099 -0
  6. package/android/src/main/java/net/siteed/audiostream/Constants.kt +1 -0
  7. package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +274 -44
  8. package/build/AudioAnalysis/AudioAnalysis.types.d.ts +35 -0
  9. package/build/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -1
  10. package/build/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  11. package/build/AudioAnalysis/extractAudioAnalysis.d.ts +2 -12
  12. package/build/AudioAnalysis/extractAudioAnalysis.d.ts.map +1 -1
  13. package/build/AudioAnalysis/extractAudioAnalysis.js +0 -26
  14. package/build/AudioAnalysis/extractAudioAnalysis.js.map +1 -1
  15. package/build/AudioAnalysis/extractAudioData.d.ts +3 -0
  16. package/build/AudioAnalysis/extractAudioData.d.ts.map +1 -0
  17. package/build/AudioAnalysis/extractAudioData.js +5 -0
  18. package/build/AudioAnalysis/extractAudioData.js.map +1 -0
  19. package/build/AudioAnalysis/extractMelSpectrogram.d.ts +14 -0
  20. package/build/AudioAnalysis/extractMelSpectrogram.d.ts.map +1 -0
  21. package/build/AudioAnalysis/extractMelSpectrogram.js +85 -0
  22. package/build/AudioAnalysis/extractMelSpectrogram.js.map +1 -0
  23. package/build/AudioAnalysis/extractPreview.d.ts +11 -0
  24. package/build/AudioAnalysis/extractPreview.d.ts.map +1 -0
  25. package/build/AudioAnalysis/extractPreview.js +25 -0
  26. package/build/AudioAnalysis/extractPreview.js.map +1 -0
  27. package/build/ExpoAudioStream.types.d.ts +329 -3
  28. package/build/ExpoAudioStream.types.d.ts.map +1 -1
  29. package/build/ExpoAudioStream.types.js.map +1 -1
  30. package/build/ExpoAudioStreamModule.d.ts.map +1 -1
  31. package/build/ExpoAudioStreamModule.js +455 -1
  32. package/build/ExpoAudioStreamModule.js.map +1 -1
  33. package/build/WebRecorder.web.js +2 -2
  34. package/build/WebRecorder.web.js.map +1 -1
  35. package/build/index.d.ts +6 -3
  36. package/build/index.d.ts.map +1 -1
  37. package/build/index.js +6 -2
  38. package/build/index.js.map +1 -1
  39. package/build/trimAudio.d.ts +25 -0
  40. package/build/trimAudio.d.ts.map +1 -0
  41. package/build/trimAudio.js +67 -0
  42. package/build/trimAudio.js.map +1 -0
  43. package/ios/AudioProcessor.swift +536 -81
  44. package/ios/ExpoAudioStreamModule.swift +125 -18
  45. package/package.json +1 -1
  46. package/plugin/build/index.js +6 -1
  47. package/plugin/src/index.ts +9 -1
  48. package/src/AudioAnalysis/AudioAnalysis.types.ts +38 -1
  49. package/src/AudioAnalysis/extractAudioAnalysis.ts +1 -38
  50. package/src/AudioAnalysis/extractAudioData.ts +6 -0
  51. package/src/AudioAnalysis/extractMelSpectrogram.ts +144 -0
  52. package/src/AudioAnalysis/extractPreview.ts +34 -0
  53. package/src/ExpoAudioStream.types.ts +354 -42
  54. package/src/ExpoAudioStreamModule.ts +682 -1
  55. package/src/WebRecorder.web.ts +2 -2
  56. package/src/index.ts +7 -8
  57. package/src/trimAudio.ts +90 -0
@@ -6,14 +6,61 @@ import AVFoundation
6
6
  import QuartzCore
7
7
 
8
8
  public struct TrimResult {
9
- public let uri: String
10
- public let duration: Double
11
- public let size: Int64
12
-
13
- public init(uri: String, duration: Double, size: Int64) {
9
+ let uri: String
10
+ let filename: String
11
+ let durationMs: Double
12
+ let size: Int64
13
+ let sampleRate: Int
14
+ let channels: Int
15
+ let bitDepth: Int
16
+ let mimeType: String
17
+ let requestedFormat: String
18
+ let actualFormat: String
19
+ let compression: [String: Any]?
20
+
21
+ init(
22
+ uri: String,
23
+ filename: String,
24
+ durationMs: Double,
25
+ size: Int64,
26
+ sampleRate: Int,
27
+ channels: Int,
28
+ bitDepth: Int,
29
+ mimeType: String,
30
+ requestedFormat: String,
31
+ actualFormat: String,
32
+ compression: [String: Any]?
33
+ ) {
14
34
  self.uri = uri
15
- self.duration = duration
35
+ self.filename = filename
36
+ self.durationMs = durationMs
16
37
  self.size = size
38
+ self.sampleRate = sampleRate
39
+ self.channels = channels
40
+ self.bitDepth = bitDepth
41
+ self.mimeType = mimeType
42
+ self.requestedFormat = requestedFormat
43
+ self.actualFormat = actualFormat
44
+ self.compression = compression
45
+ }
46
+
47
+ func toDictionary() -> [String: Any] {
48
+ var dict: [String: Any] = [
49
+ "uri": uri,
50
+ "filename": filename,
51
+ "durationMs": durationMs,
52
+ "size": size,
53
+ "sampleRate": sampleRate,
54
+ "channels": channels,
55
+ "bitDepth": bitDepth,
56
+ "mimeType": mimeType,
57
+ "requestedFormat": requestedFormat,
58
+ "actualFormat": actualFormat
59
+ ]
60
+ if let compression = compression {
61
+ dict["compression"] = compression
62
+ }
63
+ return dict
17
64
  }
18
65
  }
19
66
 
@@ -596,95 +643,498 @@ public class AudioProcessor {
596
643
 
597
644
  /// Trims audio file to specified range
598
645
  public func trimAudio(
599
- startTimeMs: Double,
600
- endTimeMs: Double,
601
- outputFormat: [String: Any]?
646
+ mode: String,
647
+ startTimeMs: Double?,
648
+ endTimeMs: Double?,
649
+ ranges: [[String: Double]]?,
650
+ outputFileName: String?,
651
+ outputFormat: [String: Any]?,
652
+ decodingOptions: [String: Any]?,
653
+ progressCallback: ((Float, Int64, Int64) -> Void)? = nil
602
654
  ) -> TrimResult? {
603
- guard let currentAudioFile = audioFile else {
604
- Logger.debug("No audio file loaded")
605
- return nil
655
+ // Log the input parameters
656
+ Logger.debug("Starting audio trim operation:")
657
+ Logger.debug("- Mode: \(mode)")
658
+ if let start = startTimeMs, let end = endTimeMs {
659
+ Logger.debug("- Time range: \(start)ms to \(end)ms")
660
+ }
661
+ if let ranges = ranges {
662
+ Logger.debug("- Ranges count: \(ranges.count)")
606
663
  }
607
-
608
- let sampleRate = currentAudioFile.fileFormat.sampleRate
609
- let startFrame = AVAudioFramePosition(startTimeMs * sampleRate / 1000.0)
610
- let endFrame = AVAudioFramePosition(endTimeMs * sampleRate / 1000.0)
611
664
 
612
- // Create output format
613
- let outputSettings = createOutputSettings(from: outputFormat, originalFormat: currentAudioFile.fileFormat)
665
+ // Log output format details
666
+ if let format = outputFormat {
667
+ let formatType = format["format"] as? String ?? "unknown"
668
+ let bitrate = format["bitrate"] as? Int ?? 0
669
+ Logger.debug("- Output format: \(formatType), bitrate: \(bitrate)")
670
+ }
614
671
 
615
- // Create temporary output file
672
+ guard let audioFile = audioFile else { return nil }
673
+
674
+ let inputFormat = audioFile.processingFormat
675
+ let inputSampleRate = inputFormat.sampleRate
676
+ let inputChannels = Int(inputFormat.channelCount)
677
+ let totalDurationMs = Double(audioFile.length) / inputSampleRate * 1000
678
+
679
+ // Compute ranges to keep
680
+ let keepRanges = computeKeepRanges(
681
+ mode: mode,
682
+ startTimeMs: startTimeMs,
683
+ endTimeMs: endTimeMs,
684
+ ranges: ranges,
685
+ totalDurationMs: totalDurationMs
686
+ )
687
+
688
+ guard !keepRanges.isEmpty else { return nil }
689
+
690
+ // Output format setup
691
+ let requestedFormat = outputFormat?["format"] as? String ?? "wav"
692
+ let validFormats = ["wav", "aac", "opus"]
693
+ let formatStr = validFormats.contains(requestedFormat.lowercased()) ? requestedFormat.lowercased() : "aac"
694
+
695
+ if formatStr != requestedFormat.lowercased() {
696
+ Logger.debug("Unsupported format '\(requestedFormat)', falling back to 'aac'")
697
+ }
698
+
699
+ let targetSampleRate = outputFormat?["sampleRate"] as? Double ?? inputSampleRate
700
+ let targetChannels = outputFormat?["channels"] as? Int ?? inputChannels
701
+ let targetBitDepth = outputFormat?["bitDepth"] as? Int ?? 16
702
+ let bitrate = outputFormat?["bitrate"] as? Int ?? 128000
703
+
704
+ let fileExtension = formatStr == "wav" ? "wav" : (formatStr == "aac" ? "aac" : "opus")
616
705
  let outputURL = FileManager.default.temporaryDirectory
617
- .appendingPathComponent(UUID().uuidString)
618
- .appendingPathExtension("wav")
619
-
706
+ .appendingPathComponent(outputFileName ?? UUID().uuidString)
707
+ .appendingPathExtension(fileExtension)
708
+
709
+ let decodingConfig = DecodingConfig.fromDictionary(decodingOptions ?? [:])
710
+ let needFormatChange = decodingConfig.targetSampleRate != nil || decodingConfig.targetChannels != nil || decodingConfig.targetBitDepth != nil
711
+ let isWavInput = audioFile.fileFormat.settings[AVFormatIDKey] as? UInt32 == kAudioFormatLinearPCM
712
+
620
713
  do {
621
- let outputFile = try AVAudioFile(
622
- forWriting: outputURL,
623
- settings: outputSettings,
624
- commonFormat: .pcmFormatFloat32,
625
- interleaved: false
626
- )
627
-
628
- // Read and write in chunks
629
- let bufferSize = 32768
630
- let buffer = AVAudioPCMBuffer(
631
- pcmFormat: currentAudioFile.processingFormat,
632
- frameCapacity: AVAudioFrameCount(bufferSize)
633
- )!
634
-
635
- currentAudioFile.framePosition = startFrame
636
- var currentFrame = startFrame
637
-
638
- while currentFrame < endFrame {
639
- let framesToRead = min(
640
- AVAudioFrameCount(bufferSize),
641
- AVAudioFrameCount(endFrame - currentFrame)
642
- )
714
+ if isWavInput && formatStr == "wav" && !needFormatChange {
715
+ // Fast path: WAV-to-WAV with no format changes
716
+ let outputFile = try AVAudioFile(forWriting: outputURL, settings: inputFormat.settings)
717
+ var totalFrames: Int64 = 0
718
+ for range in keepRanges {
719
+ // Break down complex expression
720
+ let startTimeInSeconds = range[0] / 1000
721
+ let endTimeInSeconds = range[1] / 1000
722
+ let startFramePosition = startTimeInSeconds * inputSampleRate
723
+ let endFramePosition = endTimeInSeconds * inputSampleRate
724
+ totalFrames += Int64(endFramePosition - startFramePosition)
725
+ }
726
+ var cumulativeFrames: Int64 = 0
727
+
728
+ for range in keepRanges {
729
+ // Break down complex expressions
730
+ let startTimeInSeconds = range[0] / 1000
731
+ let startFrame = AVAudioFramePosition(startTimeInSeconds * inputSampleRate)
732
+
733
+ let endTimeInSeconds = range[1] / 1000
734
+ let endFramePosition = endTimeInSeconds * inputSampleRate
735
+ let frameCount = AVAudioFrameCount(endFramePosition - Double(startFrame))
736
+
737
+ let buffer = AVAudioPCMBuffer(pcmFormat: inputFormat, frameCapacity: frameCount)!
738
+ audioFile.framePosition = startFrame
739
+ try audioFile.read(into: buffer, frameCount: frameCount)
740
+ try outputFile.write(from: buffer)
741
+ cumulativeFrames += Int64(frameCount)
742
+ let progress = Float(cumulativeFrames) / Float(totalFrames) * 100
743
+ progressCallback?(progress, Int64(frameCount) * Int64(inputFormat.streamDescription.pointee.mBytesPerFrame), totalFrames * Int64(inputFormat.streamDescription.pointee.mBytesPerFrame))
744
+ }
745
+
746
+ // When creating the output file
747
+ Logger.debug("Creating output file at: \(outputURL.path)")
643
748
 
644
- try currentAudioFile.read(into: buffer, frameCount: framesToRead)
645
- try outputFile.write(from: buffer)
749
+ // After processing is complete
750
+ Logger.debug("Trim operation completed")
751
+ Logger.debug("- Output file: \(outputURL.path)")
752
+ Logger.debug("- File exists: \(FileManager.default.fileExists(atPath: outputURL.path))")
753
+ Logger.debug("- File size: \(try? FileManager.default.attributesOfItem(atPath: outputURL.path)[.size] as? Int64 ?? 0) bytes")
754
+ Logger.debug("- File extension: \(outputURL.pathExtension)")
646
755
 
647
- currentFrame += Int64(framesToRead)
756
+ return createTrimResult(from: outputURL, keepRanges: keepRanges, formatStr: formatStr, sampleRate: Int(inputSampleRate), channels: inputChannels, bitDepth: 16, bitrate: bitrate)
757
+ } else {
758
+ // Non-fast path: Decode and re-encode
759
+ let targetFormat = AVAudioFormat(
760
+ commonFormat: .pcmFormatFloat32,
761
+ sampleRate: targetSampleRate,
762
+ channels: AVAudioChannelCount(targetChannels),
763
+ interleaved: false
764
+ )!
765
+
766
+ var totalFrames: Int64 = 0
767
+ for range in keepRanges {
768
+ // Break down complex expression
769
+ let startTimeInSeconds = range[0] / 1000
770
+ let endTimeInSeconds = range[1] / 1000
771
+ let startFramePosition = startTimeInSeconds * inputSampleRate
772
+ let endFramePosition = endTimeInSeconds * inputSampleRate
773
+ totalFrames += Int64(endFramePosition - startFramePosition)
774
+ }
775
+ var cumulativeFrames: Int64 = 0
776
+
777
+ if formatStr == "wav" {
778
+ let outputFile = try AVAudioFile(forWriting: outputURL, settings: [
779
+ AVFormatIDKey: kAudioFormatLinearPCM,
780
+ AVSampleRateKey: targetSampleRate,
781
+ AVNumberOfChannelsKey: targetChannels,
782
+ AVLinearPCMBitDepthKey: targetBitDepth,
783
+ AVLinearPCMIsFloatKey: false,
784
+ AVLinearPCMIsBigEndianKey: false
785
+ ])
786
+
787
+ for range in keepRanges {
788
+ // Break down complex expressions
789
+ let startTimeInSeconds = range[0] / 1000
790
+ let startFrame = AVAudioFramePosition(startTimeInSeconds * inputSampleRate)
791
+
792
+ let endTimeInSeconds = range[1] / 1000
793
+ let endFramePosition = endTimeInSeconds * inputSampleRate
794
+ let frameCount = AVAudioFrameCount(endFramePosition - Double(startFrame))
795
+
796
+ let buffer = AVAudioPCMBuffer(pcmFormat: inputFormat, frameCapacity: frameCount)!
797
+ audioFile.framePosition = startFrame
798
+ try audioFile.read(into: buffer, frameCount: frameCount)
799
+ let converter = AVAudioConverter(from: inputFormat, to: targetFormat)!
800
+ let convertedBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: frameCount)!
801
+ try converter.convert(to: convertedBuffer, from: buffer)
802
+ try outputFile.write(from: convertedBuffer)
803
+ cumulativeFrames += Int64(frameCount)
804
+ let progress = Float(cumulativeFrames) / Float(totalFrames) * 100
805
+ progressCallback?(progress, 0, totalFrames * Int64(inputFormat.streamDescription.pointee.mBytesPerFrame))
806
+ }
807
+ return createTrimResult(from: outputURL, keepRanges: keepRanges, formatStr: formatStr, sampleRate: Int(targetSampleRate), channels: targetChannels, bitDepth: targetBitDepth, bitrate: bitrate)
808
+ } else {
809
+ // AAC or Opus output
810
+ let outputSettings: [String: Any]
811
+ let fileType: AVFileType
812
+
813
+ if formatStr == "aac" {
814
+ // AAC settings
815
+ let outputExtension = "m4a"
816
+ let tempOutputURL = FileManager.default.temporaryDirectory
817
+ .appendingPathComponent(outputFileName ?? UUID().uuidString)
818
+ .appendingPathExtension(outputExtension)
819
+
820
+ // Validate and adjust sample rate for AAC
821
+ // AAC typically supports: 8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000 Hz
822
+ let supportedSampleRates = [8000.0, 11025.0, 12000.0, 16000.0, 22050.0, 24000.0, 32000.0, 44100.0, 48000.0]
823
+
824
+ // Default to 44100 if not specified
825
+ var sampleRate = outputFormat?["sampleRate"] as? Double ?? 44100.0
826
+
827
+ // Find closest supported sample rate
828
+ if !supportedSampleRates.contains(sampleRate) {
829
+ let closestRate = supportedSampleRates.min(by: { abs($0 - sampleRate) < abs($1 - sampleRate) }) ?? 44100.0
830
+ Logger.debug("Unsupported sample rate \(sampleRate)Hz for AAC, using closest supported rate: \(closestRate)Hz")
831
+ sampleRate = closestRate
832
+ }
833
+
834
+ // Validate channels (AAC typically supports 1 or 2 channels)
835
+ var channels = outputFormat?["channels"] as? Int ?? 2
836
+ if channels > 2 {
837
+ Logger.debug("AAC encoding doesn't support \(channels) channels, limiting to 2 channels")
838
+ channels = 2
839
+ } else if channels < 1 {
840
+ channels = 1
841
+ }
842
+
843
+ // Validate bitrate (AAC typically supports 8000-320000 bps)
844
+ var bitrate = outputFormat?["bitrate"] as? Int ?? 128000
845
+ if bitrate < 8000 {
846
+ Logger.debug("AAC bitrate too low, setting to minimum 8000 bps")
847
+ bitrate = 8000
848
+ } else if bitrate > 320000 {
849
+ Logger.debug("AAC bitrate too high, setting to maximum 320000 bps")
850
+ bitrate = 320000
851
+ }
852
+
853
+ // Set up proper audio settings for AAC
854
+ outputSettings = [
855
+ AVFormatIDKey: kAudioFormatMPEG4AAC,
856
+ AVSampleRateKey: sampleRate,
857
+ AVNumberOfChannelsKey: channels,
858
+ AVEncoderBitRateKey: bitrate,
859
+ AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
860
+ ]
861
+ fileType = .m4a
862
+
863
+ Logger.debug("""
864
+ Configuring AAC output:
865
+ - Container: m4a
866
+ - Format: AAC
867
+ - Sample rate: \(sampleRate)Hz
868
+ - Channels: \(channels)
869
+ - Bitrate: \(bitrate) bps
870
+ - Output path: \(tempOutputURL.path)
871
+ - File type: \(fileType)
872
+ """)
873
+ } else {
874
+ // Opus settings - use CAF container which can hold Opus
875
+ outputSettings = [
876
+ AVFormatIDKey: kAudioFormatOpus,
877
+ AVSampleRateKey: targetSampleRate,
878
+ AVNumberOfChannelsKey: targetChannels,
879
+ AVEncoderBitRateKey: bitrate
880
+ ]
881
+ fileType = .caf // Core Audio Format can contain Opus
882
+ }
883
+
884
+ // Use proper file extension for the container format
885
+ let tempFileExtension = formatStr == "aac" ? "m4a" : "caf"
886
+ let tempOutputURL = FileManager.default.temporaryDirectory
887
+ .appendingPathComponent(outputFileName ?? UUID().uuidString)
888
+ .appendingPathExtension(tempFileExtension)
889
+
890
+ // Create the asset writer with the appropriate file type
891
+ let assetWriter = try AVAssetWriter(
892
+ outputURL: tempOutputURL,
893
+ fileType: fileType
894
+ )
895
+
896
+ // Configure the writer input with better settings
897
+ let writerInput = AVAssetWriterInput(mediaType: .audio, outputSettings: outputSettings)
898
+ writerInput.expectsMediaDataInRealTime = false
899
+ assetWriter.add(writerInput)
900
+
901
+ // Start the writing session
902
+ assetWriter.startWriting()
903
+ assetWriter.startSession(atSourceTime: CMTime.zero)
904
+
905
+ // Improved buffer handling
906
+ let bufferSize = 32768 // Use a larger buffer for better performance
907
+ let pcmBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: AVAudioFrameCount(bufferSize))!
908
+
909
+ for range in keepRanges {
910
+ let startTimeInSeconds = range[0] / 1000
911
+ let startFrame = AVAudioFramePosition(startTimeInSeconds * inputSampleRate)
912
+
913
+ let endTimeInSeconds = range[1] / 1000
914
+ let endFramePosition = endTimeInSeconds * inputSampleRate
915
+ let totalFramesToProcess = AVAudioFrameCount(endFramePosition - Double(startFrame))
916
+
917
+ // Process in chunks for better memory management
918
+ var framesProcessed: AVAudioFrameCount = 0
919
+ audioFile.framePosition = startFrame
920
+
921
+ while framesProcessed < totalFramesToProcess {
922
+ let framesToRead = min(AVAudioFrameCount(bufferSize), totalFramesToProcess - framesProcessed)
923
+ let buffer = AVAudioPCMBuffer(pcmFormat: inputFormat, frameCapacity: framesToRead)!
924
+
925
+ do {
926
+ try audioFile.read(into: buffer, frameCount: framesToRead)
927
+
928
+ // Convert the buffer to the target format
929
+ let converter = AVAudioConverter(from: inputFormat, to: targetFormat)!
930
+ let convertedBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: framesToRead)!
931
+
932
+ var error: NSError?
933
+ let conversionStatus = converter.convert(to: convertedBuffer, error: &error) { inNumPackets, outStatus in
934
+ outStatus.pointee = .haveData
935
+ return buffer
936
+ }
937
+
938
+ if let error = error {
939
+ Logger.debug("Conversion error: \(error)")
940
+ continue
941
+ }
942
+
943
+ // Create a sample buffer and append to writer
944
+ if let sampleBuffer = createSampleBuffer(from: convertedBuffer) {
945
+ // Wait until the writer is ready
946
+ while !writerInput.isReadyForMoreMediaData {
947
+ Thread.sleep(forTimeInterval: 0.01)
948
+ }
949
+
950
+ if !writerInput.append(sampleBuffer) {
951
+ Logger.debug("Failed to append sample buffer: \(assetWriter.error?.localizedDescription ?? "Unknown error")")
952
+ }
953
+ }
954
+
955
+ framesProcessed += framesToRead
956
+ cumulativeFrames += Int64(framesToRead)
957
+ let progress = Float(cumulativeFrames) / Float(totalFrames) * 100
958
+ progressCallback?(progress, 0, totalFrames * Int64(inputFormat.streamDescription.pointee.mBytesPerFrame))
959
+
960
+ if framesProcessed % 10000 == 0 { // Log every 10000 frames to avoid excessive logging
961
+ Logger.debug("Processed \(framesProcessed)/\(totalFramesToProcess) frames")
962
+ }
963
+
964
+ } catch {
965
+ Logger.debug("Error reading audio: \(error)")
966
+ break
967
+ }
968
+ }
969
+ }
970
+
971
+ // Finish writing properly
972
+ writerInput.markAsFinished()
973
+ let finishSemaphore = DispatchSemaphore(value: 0)
974
+ assetWriter.finishWriting {
975
+ if let error = assetWriter.error {
976
+ Logger.debug("Error finishing writing: \(error)")
977
+ } else {
978
+ Logger.debug("Writing finished successfully")
979
+
980
+ // Verify the output file
981
+ let fileExists = FileManager.default.fileExists(atPath: tempOutputURL.path)
982
+ let fileSize = (try? FileManager.default.attributesOfItem(atPath: tempOutputURL.path)[.size] as? Int64) ?? 0
983
+
984
+ Logger.debug("""
985
+ Output file verification:
986
+ - Path: \(tempOutputURL.path)
987
+ - Exists: \(fileExists)
988
+ - Size: \(fileSize) bytes
989
+ - Extension: \(tempOutputURL.pathExtension)
990
+ """)
991
+ }
992
+ finishSemaphore.signal()
993
+ }
994
+ finishSemaphore.wait()
995
+
996
+ // Verify the file was created successfully
997
+ guard FileManager.default.fileExists(atPath: tempOutputURL.path) else {
998
+ reject("FILE_CREATION_FAILED", "Failed to create output file")
999
+ return nil
1000
+ }
1001
+
1002
+ // Create compression info
1003
+ var compressionInfo: [String: Any] = [
1004
+ "format": formatStr,
1005
+ "bitrate": bitrate,
1006
+ "size": (try? FileManager.default.attributesOfItem(atPath: tempOutputURL.path)[.size] as? Int64) ?? 0
1007
+ ]
1008
+
1009
+ // Add fallback information if applicable
1010
+ if formatStr != requestedFormat.lowercased() {
1011
+ compressionInfo["requestedFormat"] = requestedFormat
1012
+ compressionInfo["fallbackReason"] = "Unsupported format"
1013
+ }
1014
+
1015
+ // Use the correct MIME type
1016
+ let mimeType = formatStr == "aac" ? "audio/mp4" : "audio/opus"
1017
+
1018
+ return TrimResult(
1019
+ uri: tempOutputURL.absoluteString,
1020
+ filename: tempOutputURL.lastPathComponent,
1021
+ durationMs: keepRanges.map { $0[1] - $0[0] }.reduce(0, +),
1022
+ size: (try? FileManager.default.attributesOfItem(atPath: tempOutputURL.path)[.size] as? Int64) ?? 0,
1023
+ sampleRate: Int(targetSampleRate),
1024
+ channels: targetChannels,
1025
+ bitDepth: 16,
1026
+ mimeType: mimeType,
1027
+ requestedFormat: formatStr,
1028
+ actualFormat: tempFileExtension,
1029
+ compression: compressionInfo
1030
+ )
1031
+ }
648
1032
  }
649
-
650
- // Get file size
651
- let attributes = try FileManager.default.attributesOfItem(atPath: outputURL.path)
652
- let fileSize = attributes[.size] as! Int64
653
-
654
- // After successful trim, update the class property
655
- audioFile = try AVAudioFile(forReading: outputURL)
656
-
657
- // After successful trim, create the result
658
- let trimmedDuration = (endTimeMs - startTimeMs) / 1000.0 // Convert to seconds
659
- let result = TrimResult(
660
- uri: outputURL.absoluteString,
661
- duration: trimmedDuration, // Use actual trimmed duration
662
- size: fileSize
663
- )
664
-
665
- return result
666
1033
  } catch {
667
- Logger.debug("Error trimming audio: \(error)")
1034
+ reject("TRIM_ERROR", "Failed to trim audio: \(error.localizedDescription)")
668
1035
  return nil
669
1036
  }
670
1037
  }
1038
+
1039
+ private func computeKeepRanges(mode: String, startTimeMs: Double?, endTimeMs: Double?, ranges: [[String: Double]]?, totalDurationMs: Double) -> [[Double]] {
1040
+ switch mode {
1041
+ case "single":
1042
+ guard let start = startTimeMs, let end = endTimeMs else { return [] }
1043
+ return [[start, end]]
1044
+ case "keep":
1045
+ return ranges?.map { [$0["startTimeMs"] ?? 0, $0["endTimeMs"] ?? totalDurationMs] } ?? []
1046
+ case "remove":
1047
+ let removeRanges = ranges?.map { [$0["startTimeMs"] ?? 0, $0["endTimeMs"] ?? totalDurationMs] }.sorted { $0[0] < $1[0] } ?? []
1048
+ var keepRanges: [[Double]] = []
1049
+ var lastEnd = 0.0
1050
+ for range in removeRanges {
1051
+ if range[0] > lastEnd {
1052
+ keepRanges.append([lastEnd, range[0]])
1053
+ }
1054
+ lastEnd = max(lastEnd, range[1])
1055
+ }
1056
+ if lastEnd < totalDurationMs {
1057
+ keepRanges.append([lastEnd, totalDurationMs])
1058
+ }
1059
+ return keepRanges
1060
+ default:
1061
+ return []
1062
+ }
1063
+ }
671
1064
 
672
- private func createOutputSettings(
673
- from options: [String: Any]?,
674
- originalFormat: AVAudioFormat
675
- ) -> [String: Any] {
676
- var settings: [String: Any] = [:]
677
-
678
- // Use original format settings as defaults
679
- settings[AVFormatIDKey] = kAudioFormatLinearPCM
680
- settings[AVSampleRateKey] = options?["sampleRate"] as? Double ?? originalFormat.sampleRate
681
- settings[AVNumberOfChannelsKey] = options?["channels"] as? Int ?? originalFormat.channelCount
682
- settings[AVLinearPCMBitDepthKey] = options?["bitDepth"] as? Int ?? 16
683
- settings[AVLinearPCMIsFloatKey] = false
684
- settings[AVLinearPCMIsBigEndianKey] = false
685
- settings[AVLinearPCMIsNonInterleaved] = false
686
-
687
- return settings
1065
+ private func createTrimResult(from url: URL, keepRanges: [[Double]], formatStr: String, sampleRate: Int, channels: Int, bitDepth: Int, bitrate: Int, compression: [String: Any]? = nil) -> TrimResult {
1066
+ let durationMs = keepRanges.map { $0[1] - $0[0] }.reduce(0, +)
1067
+ let size = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64 ?? 0) ?? 0
1068
+ let fileExtension = formatStr == "wav" ? "wav" : (formatStr == "aac" ? "aac" : "opus")
1069
+ return TrimResult(
1070
+ uri: url.absoluteString,
1071
+ filename: url.lastPathComponent,
1072
+ durationMs: durationMs,
1073
+ size: size,
1074
+ sampleRate: sampleRate,
1075
+ channels: channels,
1076
+ bitDepth: bitDepth,
1077
+ mimeType: "audio/\(fileExtension)",
1078
+ requestedFormat: formatStr,
1079
+ actualFormat: fileExtension,
1080
+ compression: compression
1081
+ )
1082
+ }
1083
+
1084
+ private func createSampleBuffer(from buffer: AVAudioPCMBuffer) -> CMSampleBuffer? {
1085
+ var formatDesc: CMAudioFormatDescription?
1086
+ CMAudioFormatDescriptionCreate(
1087
+ allocator: kCFAllocatorDefault,
1088
+ asbd: buffer.format.streamDescription,
1089
+ layoutSize: 0,
1090
+ layout: nil,
1091
+ magicCookieSize: 0,
1092
+ magicCookie: nil,
1093
+ extensions: nil,
1094
+ formatDescriptionOut: &formatDesc
1095
+ )
1096
+ guard let format = formatDesc else { return nil }
1097
+
1098
+ var sampleBuffer: CMSampleBuffer?
1099
+ var timingInfo = CMSampleTimingInfo(
1100
+ duration: CMTime(value: 1, timescale: CMTimeScale(buffer.format.sampleRate)),
1101
+ presentationTimeStamp: .zero,
1102
+ decodeTimeStamp: .invalid
1103
+ )
1104
+
1105
+ CMSampleBufferCreate(
1106
+ allocator: kCFAllocatorDefault,
1107
+ dataBuffer: nil,
1108
+ dataReady: false,
1109
+ makeDataReadyCallback: nil,
1110
+ refcon: nil,
1111
+ formatDescription: format,
1112
+ sampleCount: CMItemCount(buffer.frameLength),
1113
+ sampleTimingEntryCount: 1,
1114
+ sampleTimingArray: &timingInfo,
1115
+ sampleSizeEntryCount: 0,
1116
+ sampleSizeArray: nil,
1117
+ sampleBufferOut: &sampleBuffer
1118
+ )
1119
+ guard let sampleBuf = sampleBuffer else { return nil }
1120
+
1121
+ var dataBuffer: CMBlockBuffer?
1122
+ CMBlockBufferCreateWithMemoryBlock(
1123
+ allocator: kCFAllocatorDefault,
1124
+ memoryBlock: UnsafeMutableRawPointer(buffer.floatChannelData![0]),
1125
+ blockLength: Int(buffer.frameLength * buffer.format.streamDescription.pointee.mBytesPerFrame),
1126
+ blockAllocator: kCFAllocatorNull,
1127
+ customBlockSource: nil,
1128
+ offsetToData: 0,
1129
+ dataLength: Int(buffer.frameLength * buffer.format.streamDescription.pointee.mBytesPerFrame),
1130
+ flags: 0,
1131
+ blockBufferOut: &dataBuffer
1132
+ )
1133
+ guard let blockBuf = dataBuffer else { return nil }
1134
+
1135
+ CMSampleBufferSetDataBuffer(sampleBuf, newValue: blockBuf)
1136
+
1137
+ return sampleBuf
688
1138
  }
689
1139
 
690
1140
  /// Extracts a preview of the audio data with consistent time range support
@@ -855,4 +1305,9 @@ public class AudioProcessor {
855
1305
  extractionTimeMs: extractionTimeMs
856
1306
  )
857
1307
  }
1308
+
1309
+ // Add this helper function to the AudioProcessor class
1310
+ private func getDocumentsDirectory() -> URL {
1311
+ return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
1312
+ }
858
1313
  }