@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/README.md +97 -50
  3. package/android/src/androidTest/java/net/siteed/audiostudio/AudioFinalMetadataContractInstrumentedTest.kt +190 -0
  4. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderInstrumentedTest.kt +29 -83
  5. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderPerformanceInstrumentedTest.kt +17 -1
  6. package/android/src/androidTest/java/net/siteed/audiostudio/OpusRangeDecodeRegressionInstrumentedTest.kt +186 -0
  7. package/android/src/main/java/net/siteed/audiostudio/AudioProcessor.kt +473 -380
  8. package/android/src/main/java/net/siteed/audiostudio/AudioStreamDecoder.kt +640 -0
  9. package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +187 -13
  10. package/android/src/main/java/net/siteed/audiostudio/AudioTrimmer.kt +174 -212
  11. package/android/src/main/java/net/siteed/audiostudio/Constants.kt +4 -0
  12. package/build/cjs/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  13. package/build/cjs/AudioAnalysis/extractPreview.js +92 -15
  14. package/build/cjs/AudioAnalysis/extractPreview.js.map +1 -1
  15. package/build/cjs/AudioAnalysis/extractPreviewBars.js +134 -0
  16. package/build/cjs/AudioAnalysis/extractPreviewBars.js.map +1 -0
  17. package/build/cjs/errors/AudioExtractionError.js +127 -0
  18. package/build/cjs/errors/AudioExtractionError.js.map +1 -0
  19. package/build/cjs/errors/AudioStreamError.js +152 -0
  20. package/build/cjs/errors/AudioStreamError.js.map +1 -0
  21. package/build/cjs/errors/AudioStreamError.test.js +61 -0
  22. package/build/cjs/errors/AudioStreamError.test.js.map +1 -0
  23. package/build/cjs/index.js +12 -1
  24. package/build/cjs/index.js.map +1 -1
  25. package/build/cjs/streamAudioData.js +467 -0
  26. package/build/cjs/streamAudioData.js.map +1 -0
  27. package/build/esm/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  28. package/build/esm/AudioAnalysis/extractPreview.js +92 -15
  29. package/build/esm/AudioAnalysis/extractPreview.js.map +1 -1
  30. package/build/esm/AudioAnalysis/extractPreviewBars.js +128 -0
  31. package/build/esm/AudioAnalysis/extractPreviewBars.js.map +1 -0
  32. package/build/esm/errors/AudioExtractionError.js +122 -0
  33. package/build/esm/errors/AudioExtractionError.js.map +1 -0
  34. package/build/esm/errors/AudioStreamError.js +147 -0
  35. package/build/esm/errors/AudioStreamError.js.map +1 -0
  36. package/build/esm/errors/AudioStreamError.test.js +59 -0
  37. package/build/esm/errors/AudioStreamError.test.js.map +1 -0
  38. package/build/esm/index.js +5 -1
  39. package/build/esm/index.js.map +1 -1
  40. package/build/esm/streamAudioData.js +460 -0
  41. package/build/esm/streamAudioData.js.map +1 -0
  42. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts +79 -0
  43. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -1
  44. package/build/types/AudioAnalysis/extractPreview.d.ts +2 -2
  45. package/build/types/AudioAnalysis/extractPreview.d.ts.map +1 -1
  46. package/build/types/AudioAnalysis/extractPreviewBars.d.ts +12 -0
  47. package/build/types/AudioAnalysis/extractPreviewBars.d.ts.map +1 -0
  48. package/build/types/errors/AudioExtractionError.d.ts +24 -0
  49. package/build/types/errors/AudioExtractionError.d.ts.map +1 -0
  50. package/build/types/errors/AudioStreamError.d.ts +25 -0
  51. package/build/types/errors/AudioStreamError.d.ts.map +1 -0
  52. package/build/types/errors/AudioStreamError.test.d.ts +2 -0
  53. package/build/types/errors/AudioStreamError.test.d.ts.map +1 -0
  54. package/build/types/index.d.ts +8 -1
  55. package/build/types/index.d.ts.map +1 -1
  56. package/build/types/streamAudioData.d.ts +114 -0
  57. package/build/types/streamAudioData.d.ts.map +1 -0
  58. package/ios/AudioProcessingHelpers.swift +10 -5
  59. package/ios/AudioProcessor.swift +99 -0
  60. package/ios/AudioStreamDecoder.swift +523 -0
  61. package/ios/AudioStudioModule.swift +210 -3
  62. package/ios/AudioStudioTests/AudioStreamDecoderTests.swift +128 -0
  63. package/package.json +7 -7
  64. package/src/AudioAnalysis/AudioAnalysis.types.ts +82 -0
  65. package/src/AudioAnalysis/extractPreview.ts +118 -17
  66. package/src/AudioAnalysis/extractPreviewBars.ts +193 -0
  67. package/src/errors/AudioExtractionError.ts +167 -0
  68. package/src/errors/AudioStreamError.test.ts +65 -0
  69. package/src/errors/AudioStreamError.ts +185 -0
  70. package/src/index.ts +34 -0
  71. 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
+ }