@siteed/audio-studio 3.1.1 → 3.2.0-beta.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.
- package/CHANGELOG.md +20 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioStreamDecoder.kt +640 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +134 -3
- package/android/src/main/java/net/siteed/audiostudio/Constants.kt +4 -0
- package/build/cjs/errors/AudioStreamError.js +152 -0
- package/build/cjs/errors/AudioStreamError.js.map +1 -0
- package/build/cjs/errors/AudioStreamError.test.js +61 -0
- package/build/cjs/errors/AudioStreamError.test.js.map +1 -0
- package/build/cjs/index.js +7 -1
- package/build/cjs/index.js.map +1 -1
- package/build/cjs/streamAudioData.js +467 -0
- package/build/cjs/streamAudioData.js.map +1 -0
- package/build/esm/errors/AudioStreamError.js +147 -0
- package/build/esm/errors/AudioStreamError.js.map +1 -0
- package/build/esm/errors/AudioStreamError.test.js +59 -0
- package/build/esm/errors/AudioStreamError.test.js.map +1 -0
- package/build/esm/index.js +3 -1
- package/build/esm/index.js.map +1 -1
- package/build/esm/streamAudioData.js +460 -0
- package/build/esm/streamAudioData.js.map +1 -0
- package/build/types/errors/AudioStreamError.d.ts +25 -0
- package/build/types/errors/AudioStreamError.d.ts.map +1 -0
- package/build/types/errors/AudioStreamError.test.d.ts +2 -0
- package/build/types/errors/AudioStreamError.test.d.ts.map +1 -0
- package/build/types/index.d.ts +5 -1
- package/build/types/index.d.ts.map +1 -1
- package/build/types/streamAudioData.d.ts +114 -0
- package/build/types/streamAudioData.d.ts.map +1 -0
- package/ios/AudioProcessingHelpers.swift +10 -5
- package/ios/AudioStreamDecoder.swift +523 -0
- package/ios/AudioStudioModule.swift +147 -3
- package/ios/AudioStudioTests/AudioStreamDecoderTests.swift +128 -0
- package/package.json +1 -1
- package/src/errors/AudioStreamError.test.ts +65 -0
- package/src/errors/AudioStreamError.ts +185 -0
- package/src/index.ts +24 -0
- package/src/streamAudioData.ts +654 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-level API: stream decoded audio from a stored file as bounded Float32
|
|
3
|
+
* chunks without materializing the full PCM range in memory.
|
|
4
|
+
*
|
|
5
|
+
* See `docs/STREAM_AUDIO_DATA.md` for the full contract and rollout notes.
|
|
6
|
+
*/
|
|
7
|
+
export interface StreamAudioDataOptions {
|
|
8
|
+
/** URI of the audio file to decode. */
|
|
9
|
+
fileUri: string;
|
|
10
|
+
/** Start time in milliseconds (default: 0). */
|
|
11
|
+
startTimeMs?: number;
|
|
12
|
+
/** End time in milliseconds (default: end-of-file). */
|
|
13
|
+
endTimeMs?: number;
|
|
14
|
+
/**
|
|
15
|
+
* Source sample rate hint. Ignored if `targetSampleRate` is set; native
|
|
16
|
+
* decoders read the actual rate from the file.
|
|
17
|
+
*/
|
|
18
|
+
sampleRate?: number;
|
|
19
|
+
/** Output sample rate. Native resamples when this differs from the file. */
|
|
20
|
+
targetSampleRate?: number;
|
|
21
|
+
/** Output channel count (1 = mono downmix, 2 = stereo passthrough). */
|
|
22
|
+
channels?: number;
|
|
23
|
+
/** Clamp samples to [-1, 1] and replace non-finite values with 0. */
|
|
24
|
+
normalizeAudio?: boolean;
|
|
25
|
+
/** Target chunk duration in ms (default: 1000, min: 10, max: 60000). */
|
|
26
|
+
chunkDurationMs?: number;
|
|
27
|
+
/** Soft cap on chunk size in bytes (Float32 = 4 bytes/sample). */
|
|
28
|
+
maxChunkBytes?: number;
|
|
29
|
+
/** Max chunks queued in native before JS ack pauses decode (default: 4). */
|
|
30
|
+
maxBufferedChunks?: number;
|
|
31
|
+
/** Output PCM format; only `'float32'` supported today. */
|
|
32
|
+
streamFormat?: 'float32';
|
|
33
|
+
/** Abort the in-flight request. Resolves promise with `cancelled: true`. */
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
}
|
|
36
|
+
export interface StreamAudioDataChunk {
|
|
37
|
+
/** Native request id; constant across all chunks of one call. */
|
|
38
|
+
requestId: string;
|
|
39
|
+
/** Zero-based monotonic chunk index. */
|
|
40
|
+
chunkIndex: number;
|
|
41
|
+
/** Start time in output-rate ms (rounded to nearest sample). */
|
|
42
|
+
startTimeMs: number;
|
|
43
|
+
/** End time in output-rate ms. */
|
|
44
|
+
endTimeMs: number;
|
|
45
|
+
/** Duration in ms (`endTimeMs - startTimeMs`). */
|
|
46
|
+
durationMs: number;
|
|
47
|
+
/** First sample index in the output timeline. */
|
|
48
|
+
startSample: number;
|
|
49
|
+
/** Sample count in `samples` (interleaved if channels > 1). */
|
|
50
|
+
sampleCount: number;
|
|
51
|
+
/** Output sample rate. */
|
|
52
|
+
sampleRate: number;
|
|
53
|
+
/** Output channel count. */
|
|
54
|
+
channels: number;
|
|
55
|
+
/** Interleaved Float32 samples in [-1, 1]. */
|
|
56
|
+
samples: Float32Array;
|
|
57
|
+
/** True for the last chunk of a non-cancelled run. */
|
|
58
|
+
isFinal: boolean;
|
|
59
|
+
}
|
|
60
|
+
export interface StreamAudioDataProgress {
|
|
61
|
+
requestId: string;
|
|
62
|
+
processedMs: number;
|
|
63
|
+
durationMs: number;
|
|
64
|
+
progress: number;
|
|
65
|
+
emittedChunks: number;
|
|
66
|
+
bufferedChunks?: number;
|
|
67
|
+
}
|
|
68
|
+
export interface StreamAudioDataResult {
|
|
69
|
+
requestId: string;
|
|
70
|
+
durationMs: number;
|
|
71
|
+
sampleRate: number;
|
|
72
|
+
channels: number;
|
|
73
|
+
chunks: number;
|
|
74
|
+
samples: number;
|
|
75
|
+
cancelled: boolean;
|
|
76
|
+
}
|
|
77
|
+
export interface StreamAudioDataCallbacks {
|
|
78
|
+
/**
|
|
79
|
+
* Called with each decoded chunk. If this returns a Promise, native decode
|
|
80
|
+
* pauses until it resolves (backpressure). Throwing aborts the stream with
|
|
81
|
+
* `ERR_AUDIO_STREAM_DECODE_FAILED`.
|
|
82
|
+
*/
|
|
83
|
+
onChunk: (chunk: StreamAudioDataChunk) => void | Promise<void>;
|
|
84
|
+
/** Called whenever native reports progress. */
|
|
85
|
+
onProgress?: (progress: StreamAudioDataProgress) => void;
|
|
86
|
+
}
|
|
87
|
+
export interface AudioDecodeCapabilities {
|
|
88
|
+
platform: 'ios' | 'android' | 'web';
|
|
89
|
+
supportedInputFormats: string[];
|
|
90
|
+
supportedOutputFormats: Array<'float32'>;
|
|
91
|
+
supportsCancellation: boolean;
|
|
92
|
+
supportsBackpressure: boolean;
|
|
93
|
+
supportsTimeRange: boolean;
|
|
94
|
+
supportsTargetSampleRate: boolean;
|
|
95
|
+
supportsChannelMixing: boolean;
|
|
96
|
+
knownLimitations?: string[];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Stream decoded audio from a stored file as bounded Float32 PCM chunks.
|
|
100
|
+
*
|
|
101
|
+
* Memory bound:
|
|
102
|
+
* `chunkDurationMs * sampleRate * channels * 4 * maxBufferedChunks` +
|
|
103
|
+
* native decoder buffers.
|
|
104
|
+
*
|
|
105
|
+
* Cancellation: pass `options.signal` and call `abort()`. The returned promise
|
|
106
|
+
* resolves with `cancelled: true` (it does not reject) when cancellation wins.
|
|
107
|
+
*
|
|
108
|
+
* Backpressure: if `onChunk` returns a Promise, native decode is paused until
|
|
109
|
+
* it resolves; if it throws, the stream is aborted with a `decode_failed` error.
|
|
110
|
+
*/
|
|
111
|
+
export declare function streamAudioData(options: StreamAudioDataOptions, callbacks: StreamAudioDataCallbacks): Promise<StreamAudioDataResult>;
|
|
112
|
+
/** Discover what the running platform supports. */
|
|
113
|
+
export declare function getAudioDecodeCapabilities(): Promise<AudioDecodeCapabilities>;
|
|
114
|
+
//# sourceMappingURL=streamAudioData.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"streamAudioData.d.ts","sourceRoot":"","sources":["../../src/streamAudioData.ts"],"names":[],"mappings":"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"}
|
|
@@ -681,15 +681,20 @@ func extractRawAudioData(
|
|
|
681
681
|
for channel in 0..<channels {
|
|
682
682
|
let sample = floatData[channel][frame]
|
|
683
683
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
684
|
+
// Sanitize: replace NaN/Inf with 0 then clamp. Skip clamp when
|
|
685
|
+
// normalizeAudio=false so callers see the raw decoded magnitude,
|
|
686
|
+
// but always finite-check to avoid Swift's `Int16(_:)` /
|
|
687
|
+
// `Int32(_:)` trap on non-finite values.
|
|
688
|
+
let safeSample: Float = sample.isFinite ? sample : 0
|
|
689
|
+
let normalizedSample = decodingConfig.normalizeAudio ?
|
|
690
|
+
max(-1.0, min(1.0, safeSample)) : safeSample
|
|
691
|
+
|
|
687
692
|
switch targetBitDepth {
|
|
688
693
|
case 16:
|
|
689
|
-
let intValue =
|
|
694
|
+
let intValue = safeFloatToInt16(normalizedSample)
|
|
690
695
|
pcmData.append(contentsOf: withUnsafeBytes(of: intValue) { Array($0) })
|
|
691
696
|
case 32:
|
|
692
|
-
let intValue =
|
|
697
|
+
let intValue = safeFloatToInt32(normalizedSample)
|
|
693
698
|
pcmData.append(contentsOf: withUnsafeBytes(of: intValue) { Array($0) })
|
|
694
699
|
default:
|
|
695
700
|
throw NSError(domain: "AudioProcessing", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported bit depth \(targetBitDepth)"])
|
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
// packages/audio-studio/ios/AudioStreamDecoder.swift
|
|
2
|
+
//
|
|
3
|
+
// Progressive file decoder for `streamAudioData`. Decodes a stored audio
|
|
4
|
+
// file into bounded Float32 chunks via AVAssetReader without materializing
|
|
5
|
+
// the full PCM range. Sanitizes samples (NaN/Inf -> 0, clamp to [-1, 1])
|
|
6
|
+
// and honors per-request cancellation + backpressure ack.
|
|
7
|
+
import AVFoundation
|
|
8
|
+
import Foundation
|
|
9
|
+
|
|
10
|
+
public protocol AudioStreamDecoderDelegate: AnyObject {
|
|
11
|
+
func streamDecoder(
|
|
12
|
+
_ decoder: AudioStreamDecoder,
|
|
13
|
+
didEmitChunk payload: [String: Any]
|
|
14
|
+
)
|
|
15
|
+
func streamDecoder(
|
|
16
|
+
_ decoder: AudioStreamDecoder,
|
|
17
|
+
didReportProgress payload: [String: Any]
|
|
18
|
+
)
|
|
19
|
+
func streamDecoder(
|
|
20
|
+
_ decoder: AudioStreamDecoder,
|
|
21
|
+
didCompleteWith payload: [String: Any]
|
|
22
|
+
)
|
|
23
|
+
func streamDecoder(
|
|
24
|
+
_ decoder: AudioStreamDecoder,
|
|
25
|
+
didFailWith payload: [String: Any]
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public final class AudioStreamDecoder {
|
|
30
|
+
public struct Options {
|
|
31
|
+
public let requestId: String
|
|
32
|
+
public let fileUri: String
|
|
33
|
+
public let startTimeMs: Double?
|
|
34
|
+
public let endTimeMs: Double?
|
|
35
|
+
public let targetSampleRate: Double?
|
|
36
|
+
public let channels: Int?
|
|
37
|
+
public let normalizeAudio: Bool
|
|
38
|
+
public let chunkDurationMs: Int
|
|
39
|
+
public let maxChunkBytes: Int?
|
|
40
|
+
public let maxBufferedChunks: Int
|
|
41
|
+
|
|
42
|
+
public init(
|
|
43
|
+
requestId: String,
|
|
44
|
+
fileUri: String,
|
|
45
|
+
startTimeMs: Double?,
|
|
46
|
+
endTimeMs: Double?,
|
|
47
|
+
targetSampleRate: Double?,
|
|
48
|
+
channels: Int?,
|
|
49
|
+
normalizeAudio: Bool,
|
|
50
|
+
chunkDurationMs: Int,
|
|
51
|
+
maxChunkBytes: Int?,
|
|
52
|
+
maxBufferedChunks: Int
|
|
53
|
+
) {
|
|
54
|
+
self.requestId = requestId
|
|
55
|
+
self.fileUri = fileUri
|
|
56
|
+
self.startTimeMs = startTimeMs
|
|
57
|
+
self.endTimeMs = endTimeMs
|
|
58
|
+
self.targetSampleRate = targetSampleRate
|
|
59
|
+
self.channels = channels
|
|
60
|
+
self.normalizeAudio = normalizeAudio
|
|
61
|
+
self.chunkDurationMs = max(10, min(60000, chunkDurationMs))
|
|
62
|
+
self.maxChunkBytes = maxChunkBytes
|
|
63
|
+
self.maxBufferedChunks = max(1, maxBufferedChunks)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
public weak var delegate: AudioStreamDecoderDelegate?
|
|
68
|
+
|
|
69
|
+
private let options: Options
|
|
70
|
+
private let queue: DispatchQueue
|
|
71
|
+
private let cancelLock = NSLock()
|
|
72
|
+
private var cancelled = false
|
|
73
|
+
private let ackLock = NSLock()
|
|
74
|
+
private let ackCondition = NSCondition()
|
|
75
|
+
private var lastAckedIndex = -1
|
|
76
|
+
private var lastEmittedIndex = -1
|
|
77
|
+
|
|
78
|
+
public init(options: Options) {
|
|
79
|
+
self.options = options
|
|
80
|
+
self.queue = DispatchQueue(
|
|
81
|
+
label: "net.siteed.audiostudio.streamdecoder.\(options.requestId)",
|
|
82
|
+
qos: .userInitiated
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
public func start() {
|
|
87
|
+
queue.async { [weak self] in
|
|
88
|
+
self?.run()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
public func cancel() {
|
|
93
|
+
cancelLock.lock()
|
|
94
|
+
cancelled = true
|
|
95
|
+
cancelLock.unlock()
|
|
96
|
+
ackCondition.lock()
|
|
97
|
+
ackCondition.broadcast()
|
|
98
|
+
ackCondition.unlock()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
public func acknowledgeChunk(_ index: Int) {
|
|
102
|
+
ackCondition.lock()
|
|
103
|
+
if index > lastAckedIndex {
|
|
104
|
+
lastAckedIndex = index
|
|
105
|
+
}
|
|
106
|
+
ackCondition.broadcast()
|
|
107
|
+
ackCondition.unlock()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private func isCancelled() -> Bool {
|
|
111
|
+
cancelLock.lock()
|
|
112
|
+
defer { cancelLock.unlock() }
|
|
113
|
+
return cancelled
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private func run() {
|
|
117
|
+
let url: URL
|
|
118
|
+
if let parsed = URL(string: options.fileUri),
|
|
119
|
+
parsed.scheme != nil {
|
|
120
|
+
url = parsed
|
|
121
|
+
} else {
|
|
122
|
+
url = URL(fileURLWithPath: options.fileUri)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if !FileManager.default.fileExists(atPath: url.path) {
|
|
126
|
+
emitError(
|
|
127
|
+
code: "ERR_AUDIO_STREAM_FILE_NOT_FOUND",
|
|
128
|
+
message: "File not found: \(url.lastPathComponent)"
|
|
129
|
+
)
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let asset = AVURLAsset(url: url)
|
|
134
|
+
guard let audioTrack = asset.tracks(withMediaType: .audio).first else {
|
|
135
|
+
emitError(
|
|
136
|
+
code: "ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT",
|
|
137
|
+
message: "No audio track found"
|
|
138
|
+
)
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let trackDuration = CMTimeGetSeconds(asset.duration) * 1000.0
|
|
143
|
+
let totalDurationMs = trackDuration.isFinite && trackDuration > 0
|
|
144
|
+
? trackDuration
|
|
145
|
+
: 0.0
|
|
146
|
+
|
|
147
|
+
// Read source sample rate from track.
|
|
148
|
+
let sourceSampleRate: Double = {
|
|
149
|
+
guard let desc = audioTrack.formatDescriptions.first else { return 0 }
|
|
150
|
+
// swiftlint:disable:next force_cast
|
|
151
|
+
let formatDescription = desc as! CMAudioFormatDescription
|
|
152
|
+
guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(
|
|
153
|
+
formatDescription
|
|
154
|
+
)?.pointee else {
|
|
155
|
+
return 0
|
|
156
|
+
}
|
|
157
|
+
return asbd.mSampleRate
|
|
158
|
+
}()
|
|
159
|
+
let sourceChannelCount: Int = {
|
|
160
|
+
guard let desc = audioTrack.formatDescriptions.first else { return 1 }
|
|
161
|
+
// swiftlint:disable:next force_cast
|
|
162
|
+
let formatDescription = desc as! CMAudioFormatDescription
|
|
163
|
+
guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(
|
|
164
|
+
formatDescription
|
|
165
|
+
)?.pointee else {
|
|
166
|
+
return 1
|
|
167
|
+
}
|
|
168
|
+
return Int(asbd.mChannelsPerFrame)
|
|
169
|
+
}()
|
|
170
|
+
|
|
171
|
+
let outputSampleRate = options.targetSampleRate
|
|
172
|
+
?? (sourceSampleRate > 0 ? sourceSampleRate : 16000)
|
|
173
|
+
let outputChannels = options.channels ?? min(2, max(1, sourceChannelCount))
|
|
174
|
+
|
|
175
|
+
let outputSettings: [String: Any] = [
|
|
176
|
+
AVFormatIDKey: kAudioFormatLinearPCM,
|
|
177
|
+
AVLinearPCMBitDepthKey: 32,
|
|
178
|
+
AVLinearPCMIsFloatKey: true,
|
|
179
|
+
AVLinearPCMIsBigEndianKey: false,
|
|
180
|
+
AVLinearPCMIsNonInterleaved: false,
|
|
181
|
+
AVSampleRateKey: outputSampleRate,
|
|
182
|
+
AVNumberOfChannelsKey: outputChannels,
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
let reader: AVAssetReader
|
|
186
|
+
do {
|
|
187
|
+
reader = try AVAssetReader(asset: asset)
|
|
188
|
+
} catch {
|
|
189
|
+
emitError(
|
|
190
|
+
code: "ERR_AUDIO_STREAM_DECODE_FAILED",
|
|
191
|
+
message: "Reader init failed: \(error.localizedDescription)"
|
|
192
|
+
)
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if let startMs = options.startTimeMs, let endMs = options.endTimeMs {
|
|
197
|
+
if startMs < 0 || endMs <= startMs {
|
|
198
|
+
emitError(
|
|
199
|
+
code: "ERR_AUDIO_STREAM_INVALID_RANGE",
|
|
200
|
+
message: "Invalid time range"
|
|
201
|
+
)
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
let start = CMTime(seconds: startMs / 1000.0, preferredTimescale: 600)
|
|
205
|
+
let duration = CMTime(
|
|
206
|
+
seconds: (endMs - startMs) / 1000.0,
|
|
207
|
+
preferredTimescale: 600
|
|
208
|
+
)
|
|
209
|
+
reader.timeRange = CMTimeRange(start: start, duration: duration)
|
|
210
|
+
} else if let startMs = options.startTimeMs {
|
|
211
|
+
let start = CMTime(seconds: startMs / 1000.0, preferredTimescale: 600)
|
|
212
|
+
reader.timeRange = CMTimeRange(
|
|
213
|
+
start: start,
|
|
214
|
+
duration: CMTime.positiveInfinity
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let trackOutput = AVAssetReaderTrackOutput(
|
|
219
|
+
track: audioTrack,
|
|
220
|
+
outputSettings: outputSettings
|
|
221
|
+
)
|
|
222
|
+
trackOutput.alwaysCopiesSampleData = false
|
|
223
|
+
|
|
224
|
+
guard reader.canAdd(trackOutput) else {
|
|
225
|
+
emitError(
|
|
226
|
+
code: "ERR_AUDIO_STREAM_DECODE_FAILED",
|
|
227
|
+
message: "Cannot attach decoder output"
|
|
228
|
+
)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
reader.add(trackOutput)
|
|
232
|
+
|
|
233
|
+
guard reader.startReading() else {
|
|
234
|
+
let err = reader.error?.localizedDescription ?? "Unknown reader error"
|
|
235
|
+
emitError(
|
|
236
|
+
code: "ERR_AUDIO_STREAM_DECODE_FAILED",
|
|
237
|
+
message: "startReading failed: \(err)"
|
|
238
|
+
)
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let samplesPerChunk = max(
|
|
243
|
+
1,
|
|
244
|
+
Int((Double(options.chunkDurationMs) / 1000.0) * outputSampleRate)
|
|
245
|
+
) * outputChannels
|
|
246
|
+
let cappedSamplesPerChunk: Int = {
|
|
247
|
+
guard let maxBytes = options.maxChunkBytes else { return samplesPerChunk }
|
|
248
|
+
let maxFloats = max(1, maxBytes / 4)
|
|
249
|
+
return min(samplesPerChunk, maxFloats)
|
|
250
|
+
}()
|
|
251
|
+
|
|
252
|
+
var pending = [Float]()
|
|
253
|
+
pending.reserveCapacity(cappedSamplesPerChunk * 2)
|
|
254
|
+
var chunkIndex = 0
|
|
255
|
+
var totalSamples = 0
|
|
256
|
+
var processedMs: Double = 0
|
|
257
|
+
var cancelledByUser = false
|
|
258
|
+
let rangeStartMs = options.startTimeMs ?? 0
|
|
259
|
+
|
|
260
|
+
while reader.status == .reading {
|
|
261
|
+
if isCancelled() {
|
|
262
|
+
cancelledByUser = true
|
|
263
|
+
break
|
|
264
|
+
}
|
|
265
|
+
guard let sampleBuffer = trackOutput.copyNextSampleBuffer() else {
|
|
266
|
+
break
|
|
267
|
+
}
|
|
268
|
+
guard let block = CMSampleBufferGetDataBuffer(sampleBuffer) else {
|
|
269
|
+
continue
|
|
270
|
+
}
|
|
271
|
+
let frameCount = CMSampleBufferGetNumSamples(sampleBuffer)
|
|
272
|
+
if frameCount <= 0 { continue }
|
|
273
|
+
|
|
274
|
+
var lengthAtOffset = 0
|
|
275
|
+
var totalLength = 0
|
|
276
|
+
var dataPointer: UnsafeMutablePointer<Int8>?
|
|
277
|
+
let status = CMBlockBufferGetDataPointer(
|
|
278
|
+
block,
|
|
279
|
+
atOffset: 0,
|
|
280
|
+
lengthAtOffsetOut: &lengthAtOffset,
|
|
281
|
+
totalLengthOut: &totalLength,
|
|
282
|
+
dataPointerOut: &dataPointer
|
|
283
|
+
)
|
|
284
|
+
guard status == kCMBlockBufferNoErr, let raw = dataPointer else {
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let sampleCount = totalLength / MemoryLayout<Float>.size
|
|
289
|
+
let floatPtr = UnsafeRawPointer(raw)
|
|
290
|
+
.assumingMemoryBound(to: Float.self)
|
|
291
|
+
var idx = pending.count
|
|
292
|
+
pending.append(contentsOf: repeatElement(0, count: sampleCount))
|
|
293
|
+
for i in 0..<sampleCount {
|
|
294
|
+
let v = floatPtr[i]
|
|
295
|
+
if v.isNaN || v.isInfinite {
|
|
296
|
+
pending[idx] = 0
|
|
297
|
+
} else if options.normalizeAudio {
|
|
298
|
+
pending[idx] = max(-1.0, min(1.0, v))
|
|
299
|
+
} else {
|
|
300
|
+
pending[idx] = v
|
|
301
|
+
}
|
|
302
|
+
idx += 1
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
while pending.count >= cappedSamplesPerChunk {
|
|
306
|
+
let payload = Array(pending.prefix(cappedSamplesPerChunk))
|
|
307
|
+
pending.removeFirst(cappedSamplesPerChunk)
|
|
308
|
+
let durationOfChunk = Double(cappedSamplesPerChunk)
|
|
309
|
+
/ (outputSampleRate * Double(outputChannels))
|
|
310
|
+
let startMs = rangeStartMs
|
|
311
|
+
+ (Double(totalSamples) / (outputSampleRate * Double(outputChannels))) * 1000.0
|
|
312
|
+
emitChunk(
|
|
313
|
+
index: chunkIndex,
|
|
314
|
+
startTimeMs: startMs,
|
|
315
|
+
endTimeMs: startMs + durationOfChunk * 1000.0,
|
|
316
|
+
startSample: totalSamples / outputChannels,
|
|
317
|
+
sampleRate: outputSampleRate,
|
|
318
|
+
channels: outputChannels,
|
|
319
|
+
samples: payload,
|
|
320
|
+
isFinal: false
|
|
321
|
+
)
|
|
322
|
+
lastEmittedIndex = chunkIndex
|
|
323
|
+
chunkIndex += 1
|
|
324
|
+
totalSamples += cappedSamplesPerChunk
|
|
325
|
+
processedMs = startMs + durationOfChunk * 1000.0
|
|
326
|
+
emitProgress(
|
|
327
|
+
processedMs: processedMs,
|
|
328
|
+
durationMs: totalDurationMs,
|
|
329
|
+
emittedChunks: chunkIndex
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
if waitForAckOrCancel(upTo: chunkIndex - 1) {
|
|
333
|
+
cancelledByUser = true
|
|
334
|
+
break
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if cancelledByUser { break }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if cancelledByUser {
|
|
341
|
+
reader.cancelReading()
|
|
342
|
+
emitError(
|
|
343
|
+
code: "ERR_AUDIO_STREAM_CANCELLED",
|
|
344
|
+
message: "Stream cancelled"
|
|
345
|
+
)
|
|
346
|
+
emitComplete(
|
|
347
|
+
durationMs: processedMs,
|
|
348
|
+
sampleRate: outputSampleRate,
|
|
349
|
+
channels: outputChannels,
|
|
350
|
+
chunks: chunkIndex,
|
|
351
|
+
samples: totalSamples,
|
|
352
|
+
cancelled: true
|
|
353
|
+
)
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if reader.status == .failed {
|
|
358
|
+
let err = reader.error?.localizedDescription ?? "Unknown decode error"
|
|
359
|
+
emitError(
|
|
360
|
+
code: "ERR_AUDIO_STREAM_DECODE_FAILED",
|
|
361
|
+
message: err
|
|
362
|
+
)
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Flush remaining samples as final chunk.
|
|
367
|
+
if !pending.isEmpty {
|
|
368
|
+
let durationOfChunk = Double(pending.count)
|
|
369
|
+
/ (outputSampleRate * Double(outputChannels))
|
|
370
|
+
let startMs = rangeStartMs
|
|
371
|
+
+ (Double(totalSamples) / (outputSampleRate * Double(outputChannels))) * 1000.0
|
|
372
|
+
emitChunk(
|
|
373
|
+
index: chunkIndex,
|
|
374
|
+
startTimeMs: startMs,
|
|
375
|
+
endTimeMs: startMs + durationOfChunk * 1000.0,
|
|
376
|
+
startSample: totalSamples / outputChannels,
|
|
377
|
+
sampleRate: outputSampleRate,
|
|
378
|
+
channels: outputChannels,
|
|
379
|
+
samples: pending,
|
|
380
|
+
isFinal: true
|
|
381
|
+
)
|
|
382
|
+
totalSamples += pending.count
|
|
383
|
+
chunkIndex += 1
|
|
384
|
+
processedMs = startMs + durationOfChunk * 1000.0
|
|
385
|
+
pending.removeAll(keepingCapacity: false)
|
|
386
|
+
} else if chunkIndex > 0 {
|
|
387
|
+
// Last emitted chunk wasn't marked final. Emit a zero-sample final
|
|
388
|
+
// tail so consumers that key off isFinal see termination.
|
|
389
|
+
let startMs = rangeStartMs
|
|
390
|
+
+ (Double(totalSamples) / (outputSampleRate * Double(outputChannels))) * 1000.0
|
|
391
|
+
emitChunk(
|
|
392
|
+
index: chunkIndex,
|
|
393
|
+
startTimeMs: startMs,
|
|
394
|
+
endTimeMs: startMs,
|
|
395
|
+
startSample: totalSamples / outputChannels,
|
|
396
|
+
sampleRate: outputSampleRate,
|
|
397
|
+
channels: outputChannels,
|
|
398
|
+
samples: [Float](),
|
|
399
|
+
isFinal: true
|
|
400
|
+
)
|
|
401
|
+
chunkIndex += 1
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
emitComplete(
|
|
405
|
+
durationMs: totalDurationMs > 0 ? totalDurationMs : processedMs,
|
|
406
|
+
sampleRate: outputSampleRate,
|
|
407
|
+
channels: outputChannels,
|
|
408
|
+
chunks: chunkIndex,
|
|
409
|
+
samples: totalSamples,
|
|
410
|
+
cancelled: false
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/// Blocks the decoder thread while too many unacked chunks are in flight.
|
|
415
|
+
/// Returns true if cancellation arrived while waiting.
|
|
416
|
+
private func waitForAckOrCancel(upTo index: Int) -> Bool {
|
|
417
|
+
ackCondition.lock()
|
|
418
|
+
defer { ackCondition.unlock() }
|
|
419
|
+
while true {
|
|
420
|
+
if isCancelled() {
|
|
421
|
+
return true
|
|
422
|
+
}
|
|
423
|
+
let inFlight = index - lastAckedIndex
|
|
424
|
+
if inFlight < options.maxBufferedChunks {
|
|
425
|
+
return false
|
|
426
|
+
}
|
|
427
|
+
ackCondition.wait(until: Date().addingTimeInterval(0.05))
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private func emitChunk(
|
|
432
|
+
index: Int,
|
|
433
|
+
startTimeMs: Double,
|
|
434
|
+
endTimeMs: Double,
|
|
435
|
+
startSample: Int,
|
|
436
|
+
sampleRate: Double,
|
|
437
|
+
channels: Int,
|
|
438
|
+
samples: [Float],
|
|
439
|
+
isFinal: Bool
|
|
440
|
+
) {
|
|
441
|
+
let payload: [String: Any] = [
|
|
442
|
+
"requestId": options.requestId,
|
|
443
|
+
"chunkIndex": index,
|
|
444
|
+
"startTimeMs": startTimeMs,
|
|
445
|
+
"endTimeMs": endTimeMs,
|
|
446
|
+
"startSample": startSample,
|
|
447
|
+
"sampleCount": samples.count,
|
|
448
|
+
"sampleRate": sampleRate,
|
|
449
|
+
"channels": channels,
|
|
450
|
+
"samples": samples,
|
|
451
|
+
"isFinal": isFinal,
|
|
452
|
+
]
|
|
453
|
+
delegate?.streamDecoder(self, didEmitChunk: payload)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private func emitProgress(
|
|
457
|
+
processedMs: Double,
|
|
458
|
+
durationMs: Double,
|
|
459
|
+
emittedChunks: Int
|
|
460
|
+
) {
|
|
461
|
+
let progress: Double = durationMs > 0
|
|
462
|
+
? min(1.0, max(0.0, processedMs / durationMs))
|
|
463
|
+
: 0
|
|
464
|
+
let payload: [String: Any] = [
|
|
465
|
+
"requestId": options.requestId,
|
|
466
|
+
"processedMs": processedMs,
|
|
467
|
+
"durationMs": durationMs,
|
|
468
|
+
"progress": progress,
|
|
469
|
+
"emittedChunks": emittedChunks,
|
|
470
|
+
]
|
|
471
|
+
delegate?.streamDecoder(self, didReportProgress: payload)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private func emitComplete(
|
|
475
|
+
durationMs: Double,
|
|
476
|
+
sampleRate: Double,
|
|
477
|
+
channels: Int,
|
|
478
|
+
chunks: Int,
|
|
479
|
+
samples: Int,
|
|
480
|
+
cancelled: Bool
|
|
481
|
+
) {
|
|
482
|
+
let payload: [String: Any] = [
|
|
483
|
+
"requestId": options.requestId,
|
|
484
|
+
"durationMs": durationMs,
|
|
485
|
+
"sampleRate": sampleRate,
|
|
486
|
+
"channels": channels,
|
|
487
|
+
"chunks": chunks,
|
|
488
|
+
"samples": samples,
|
|
489
|
+
"cancelled": cancelled,
|
|
490
|
+
]
|
|
491
|
+
delegate?.streamDecoder(self, didCompleteWith: payload)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private func emitError(code: String, message: String) {
|
|
495
|
+
let payload: [String: Any] = [
|
|
496
|
+
"requestId": options.requestId,
|
|
497
|
+
"code": code,
|
|
498
|
+
"message": message,
|
|
499
|
+
]
|
|
500
|
+
delegate?.streamDecoder(self, didFailWith: payload)
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/// Safe Float -> integer conversion. Replaces NaN/Inf with 0 and clamps the
|
|
505
|
+
/// scaled value into the integer range before constructing the bounded type,
|
|
506
|
+
/// avoiding the Swift trap that previously crashed the host app inside
|
|
507
|
+
/// `extractRawAudioData` on malformed samples.
|
|
508
|
+
@inline(__always)
|
|
509
|
+
public func safeFloatToInt16(_ sample: Float) -> Int16 {
|
|
510
|
+
let safe = sample.isFinite ? max(-1.0, min(1.0, sample)) : 0
|
|
511
|
+
let scaled = (safe * Float(Int16.max)).rounded()
|
|
512
|
+
let clamped = max(Float(Int16.min), min(Float(Int16.max), scaled))
|
|
513
|
+
return Int16(clamped)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
@inline(__always)
|
|
517
|
+
public func safeFloatToInt32(_ sample: Float) -> Int32 {
|
|
518
|
+
let safe = sample.isFinite ? max(-1.0, min(1.0, sample)) : 0
|
|
519
|
+
let scaled = Double(safe) * Double(Int32.max)
|
|
520
|
+
let rounded = scaled.rounded()
|
|
521
|
+
let clamped = max(Double(Int32.min), min(Double(Int32.max), rounded))
|
|
522
|
+
return Int32(clamped)
|
|
523
|
+
}
|