@siteed/expo-audio-stream 2.0.1 → 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.
- package/CHANGELOG.md +12 -1
- package/README.md +202 -1
- package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +300 -1
- package/android/src/main/java/net/siteed/audiostream/AudioRecordingService.kt +16 -2
- package/android/src/main/java/net/siteed/audiostream/AudioTrimmer.kt +1099 -0
- package/android/src/main/java/net/siteed/audiostream/Constants.kt +1 -0
- package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +274 -44
- package/build/AudioAnalysis/AudioAnalysis.types.d.ts +35 -0
- package/build/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -1
- package/build/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
- package/build/AudioAnalysis/extractAudioAnalysis.d.ts +2 -12
- package/build/AudioAnalysis/extractAudioAnalysis.d.ts.map +1 -1
- package/build/AudioAnalysis/extractAudioAnalysis.js +0 -26
- package/build/AudioAnalysis/extractAudioAnalysis.js.map +1 -1
- package/build/AudioAnalysis/extractAudioData.d.ts +3 -0
- package/build/AudioAnalysis/extractAudioData.d.ts.map +1 -0
- package/build/AudioAnalysis/extractAudioData.js +5 -0
- package/build/AudioAnalysis/extractAudioData.js.map +1 -0
- package/build/AudioAnalysis/extractMelSpectrogram.d.ts +14 -0
- package/build/AudioAnalysis/extractMelSpectrogram.d.ts.map +1 -0
- package/build/AudioAnalysis/extractMelSpectrogram.js +85 -0
- package/build/AudioAnalysis/extractMelSpectrogram.js.map +1 -0
- package/build/AudioAnalysis/extractPreview.d.ts +11 -0
- package/build/AudioAnalysis/extractPreview.d.ts.map +1 -0
- package/build/AudioAnalysis/extractPreview.js +25 -0
- package/build/AudioAnalysis/extractPreview.js.map +1 -0
- package/build/ExpoAudioStream.types.d.ts +329 -3
- package/build/ExpoAudioStream.types.d.ts.map +1 -1
- package/build/ExpoAudioStream.types.js.map +1 -1
- package/build/ExpoAudioStreamModule.d.ts.map +1 -1
- package/build/ExpoAudioStreamModule.js +455 -1
- package/build/ExpoAudioStreamModule.js.map +1 -1
- package/build/WebRecorder.web.js +2 -2
- package/build/WebRecorder.web.js.map +1 -1
- package/build/index.d.ts +6 -3
- package/build/index.d.ts.map +1 -1
- package/build/index.js +6 -2
- package/build/index.js.map +1 -1
- package/build/trimAudio.d.ts +25 -0
- package/build/trimAudio.d.ts.map +1 -0
- package/build/trimAudio.js +67 -0
- package/build/trimAudio.js.map +1 -0
- package/ios/AudioProcessor.swift +536 -81
- package/ios/ExpoAudioStreamModule.swift +125 -18
- package/package.json +1 -1
- package/src/AudioAnalysis/AudioAnalysis.types.ts +38 -1
- package/src/AudioAnalysis/extractAudioAnalysis.ts +1 -38
- package/src/AudioAnalysis/extractAudioData.ts +6 -0
- package/src/AudioAnalysis/extractMelSpectrogram.ts +144 -0
- package/src/AudioAnalysis/extractPreview.ts +34 -0
- package/src/ExpoAudioStream.types.ts +354 -42
- package/src/ExpoAudioStreamModule.ts +682 -1
- package/src/WebRecorder.web.ts +2 -2
- package/src/index.ts +7 -8
- package/src/trimAudio.ts +90 -0
package/ios/AudioProcessor.swift
CHANGED
|
@@ -6,14 +6,61 @@ import AVFoundation
|
|
|
6
6
|
import QuartzCore
|
|
7
7
|
|
|
8
8
|
public struct TrimResult {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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.
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
//
|
|
613
|
-
let
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
settings:
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
645
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
}
|