@siteed/audio-studio 3.1.0 → 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 +30 -1
- package/README.md +97 -50
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioFinalMetadataContractInstrumentedTest.kt +190 -0
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderInstrumentedTest.kt +29 -83
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderPerformanceInstrumentedTest.kt +17 -1
- package/android/src/androidTest/java/net/siteed/audiostudio/OpusRangeDecodeRegressionInstrumentedTest.kt +186 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioProcessor.kt +473 -380
- package/android/src/main/java/net/siteed/audiostudio/AudioStreamDecoder.kt +640 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +187 -13
- package/android/src/main/java/net/siteed/audiostudio/AudioTrimmer.kt +174 -212
- package/android/src/main/java/net/siteed/audiostudio/Constants.kt +4 -0
- package/build/cjs/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
- package/build/cjs/AudioAnalysis/extractPreview.js +92 -15
- package/build/cjs/AudioAnalysis/extractPreview.js.map +1 -1
- package/build/cjs/AudioAnalysis/extractPreviewBars.js +134 -0
- package/build/cjs/AudioAnalysis/extractPreviewBars.js.map +1 -0
- package/build/cjs/errors/AudioExtractionError.js +127 -0
- package/build/cjs/errors/AudioExtractionError.js.map +1 -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 +12 -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/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
- package/build/esm/AudioAnalysis/extractPreview.js +92 -15
- package/build/esm/AudioAnalysis/extractPreview.js.map +1 -1
- package/build/esm/AudioAnalysis/extractPreviewBars.js +128 -0
- package/build/esm/AudioAnalysis/extractPreviewBars.js.map +1 -0
- package/build/esm/errors/AudioExtractionError.js +122 -0
- package/build/esm/errors/AudioExtractionError.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 +5 -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/AudioAnalysis/AudioAnalysis.types.d.ts +79 -0
- package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -1
- package/build/types/AudioAnalysis/extractPreview.d.ts +2 -2
- package/build/types/AudioAnalysis/extractPreview.d.ts.map +1 -1
- package/build/types/AudioAnalysis/extractPreviewBars.d.ts +12 -0
- package/build/types/AudioAnalysis/extractPreviewBars.d.ts.map +1 -0
- package/build/types/errors/AudioExtractionError.d.ts +24 -0
- package/build/types/errors/AudioExtractionError.d.ts.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 +8 -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/AudioProcessor.swift +99 -0
- package/ios/AudioStreamDecoder.swift +523 -0
- package/ios/AudioStudioModule.swift +210 -3
- package/ios/AudioStudioTests/AudioStreamDecoderTests.swift +128 -0
- package/package.json +7 -7
- package/src/AudioAnalysis/AudioAnalysis.types.ts +82 -0
- package/src/AudioAnalysis/extractPreview.ts +118 -17
- package/src/AudioAnalysis/extractPreviewBars.ts +193 -0
- package/src/errors/AudioExtractionError.ts +167 -0
- package/src/errors/AudioStreamError.test.ts +65 -0
- package/src/errors/AudioStreamError.ts +185 -0
- package/src/index.ts +34 -0
- package/src/streamAudioData.ts +654 -0
|
@@ -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
|
+
}
|