@capgo/capacitor-screen-recorder 8.3.11 → 8.3.12

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.
@@ -10,7 +10,7 @@ import dev.bmcreations.scrcast.config.Options;
10
10
  @CapacitorPlugin(name = "ScreenRecorder")
11
11
  public class ScreenRecorderPlugin extends Plugin {
12
12
 
13
- private final String pluginVersion = "8.3.11";
13
+ private final String pluginVersion = "8.3.12";
14
14
 
15
15
  private CapgoScrCast videoRecorder;
16
16
  private CapgoScrCast audioRecorder;
@@ -0,0 +1,862 @@
1
+ import AVFoundation
2
+ import CoreMedia
3
+ import os
4
+
5
+ private struct QueuedMic {
6
+ let buffer: CMSampleBuffer
7
+ var consumedFrames: Int
8
+ }
9
+
10
+ final class ReplayKitAudioTrackMixer {
11
+ private var micQueue: [QueuedMic] = []
12
+ private var pendingAppQueue: [CMSampleBuffer] = []
13
+ private var referenceAppBuffer: CMSampleBuffer?
14
+ private let maxMicQueueSize = 128
15
+ private let maxPendingAppQueueSize = 128
16
+
17
+ // Satisfies SwiftLint required_deinit without custom teardown logic.
18
+ deinit {
19
+ // No teardown required; this deinit exists only for SwiftLint compliance.
20
+ }
21
+
22
+ func handleMic(_ sampleBuffer: CMSampleBuffer) -> [CMSampleBuffer] {
23
+ micQueue.append(QueuedMic(buffer: sampleBuffer, consumedFrames: 0))
24
+ var outputs: [CMSampleBuffer] = []
25
+ while micQueue.count > maxMicQueueSize {
26
+ let queuedMic = micQueue.removeFirst()
27
+ if let normalized = Self.micTailForWriter(queuedMic, reference: referenceAppBuffer) {
28
+ outputs.append(normalized)
29
+ }
30
+ }
31
+ outputs.append(contentsOf: emitReadySamples())
32
+ return outputs
33
+ }
34
+
35
+ func handleApp(_ sampleBuffer: CMSampleBuffer) -> [CMSampleBuffer] {
36
+ if referenceAppBuffer == nil {
37
+ referenceAppBuffer = sampleBuffer
38
+ }
39
+ pendingAppQueue.append(sampleBuffer)
40
+ var outputs: [CMSampleBuffer] = []
41
+ while pendingAppQueue.count > maxPendingAppQueueSize {
42
+ _ = pendingAppQueue.removeFirst()
43
+ }
44
+ outputs.append(contentsOf: emitReadySamples())
45
+ return outputs
46
+ }
47
+
48
+ func drain() -> [CMSampleBuffer] {
49
+ var outputs: [CMSampleBuffer] = []
50
+ while !micQueue.isEmpty || !pendingAppQueue.isEmpty {
51
+ let remainingCount = micQueue.count + pendingAppQueue.count
52
+ outputs.append(contentsOf: emitReadySamples())
53
+ if micQueue.count + pendingAppQueue.count == remainingCount {
54
+ break
55
+ }
56
+ }
57
+
58
+ var remaining: [(CMTime, CMSampleBuffer)] = []
59
+ for queuedMic in micQueue {
60
+ if let normalized = Self.micTailForWriter(queuedMic, reference: referenceAppBuffer) {
61
+ remaining.append((Self.startTime(of: normalized), normalized))
62
+ }
63
+ }
64
+ for app in pendingAppQueue {
65
+ remaining.append((Self.startTime(of: app), app))
66
+ }
67
+ remaining.sort { CMTimeCompare($0.0, $1.0) < 0 }
68
+ outputs.append(contentsOf: remaining.map(\.1))
69
+
70
+ micQueue.removeAll()
71
+ pendingAppQueue.removeAll()
72
+ return outputs
73
+ }
74
+
75
+ private func emitReadySamples() -> [CMSampleBuffer] {
76
+ var outputs: [CMSampleBuffer] = []
77
+
78
+ while let queuedMic = micQueue.first {
79
+ let nextAppStart = pendingAppQueue.first.map { Self.startTime(of: $0) }
80
+ guard let appStart = nextAppStart else { break }
81
+ if CMTimeCompare(Self.endTime(of: queuedMic.buffer, consumedFrames: queuedMic.consumedFrames), appStart) <= 0 {
82
+ micQueue.removeFirst()
83
+ if let normalized = Self.micTailForWriter(queuedMic, reference: referenceAppBuffer) {
84
+ outputs.append(normalized)
85
+ }
86
+ } else {
87
+ break
88
+ }
89
+ }
90
+
91
+ while let app = pendingAppQueue.first,
92
+ let queuedMic = micQueue.first,
93
+ Self.rangesOverlap(queuedMic, app) {
94
+ if let leadingMic = Self.flushLeadingMicOnly(queuedMic: &micQueue[0], app: app, reference: referenceAppBuffer) {
95
+ outputs.append(leadingMic)
96
+ continue
97
+ }
98
+ if let result = ReplayKitAudioMixer.mix(
99
+ app: app,
100
+ mic: queuedMic.buffer,
101
+ micFrameOffset: queuedMic.consumedFrames
102
+ ) {
103
+ pendingAppQueue.removeFirst()
104
+ outputs.append(result.sample)
105
+ if result.micFramesConsumed >= CMSampleBufferGetNumSamples(queuedMic.buffer) {
106
+ micQueue.removeFirst()
107
+ } else {
108
+ micQueue[0].consumedFrames = result.micFramesConsumed
109
+ }
110
+ } else {
111
+ pendingAppQueue.removeFirst()
112
+ var fallbackSamples: [(CMTime, CMSampleBuffer)] = [
113
+ (Self.startTime(of: app), app)
114
+ ]
115
+ if let micOverlap = Self.micOverlapSlice(
116
+ queuedMic: micQueue[0],
117
+ app: app,
118
+ reference: referenceAppBuffer
119
+ ) {
120
+ fallbackSamples.append((Self.startTime(of: micOverlap), micOverlap))
121
+ }
122
+ fallbackSamples.sort { CMTimeCompare($0.0, $1.0) < 0 }
123
+ outputs.append(contentsOf: fallbackSamples.map(\.1))
124
+ Self.advanceMicPastOverlap(queuedMic: &micQueue[0], app: app)
125
+ if micQueue[0].consumedFrames >= CMSampleBufferGetNumSamples(micQueue[0].buffer) {
126
+ micQueue.removeFirst()
127
+ }
128
+ }
129
+ }
130
+
131
+ while let app = pendingAppQueue.first {
132
+ if let queuedMic = micQueue.first {
133
+ if CMTimeCompare(Self.startTime(of: queuedMic.buffer, consumedFrames: queuedMic.consumedFrames),
134
+ Self.endTime(of: app)) > 0 {
135
+ pendingAppQueue.removeFirst()
136
+ outputs.append(app)
137
+ } else {
138
+ break
139
+ }
140
+ } else {
141
+ pendingAppQueue.removeFirst()
142
+ outputs.append(app)
143
+ }
144
+ }
145
+
146
+ return outputs
147
+ }
148
+
149
+ private static func startTime(of buffer: CMSampleBuffer, consumedFrames: Int = 0) -> CMTime {
150
+ guard consumedFrames > 0 else {
151
+ return CMSampleBufferGetPresentationTimeStamp(buffer)
152
+ }
153
+ let sampleRate = sampleRate(of: buffer)
154
+ let consumedDuration = CMTime(value: CMTimeValue(consumedFrames), timescale: CMTimeScale(sampleRate))
155
+ return CMTimeAdd(CMSampleBufferGetPresentationTimeStamp(buffer), consumedDuration)
156
+ }
157
+
158
+ private static func endTime(of buffer: CMSampleBuffer, consumedFrames: Int = 0) -> CMTime {
159
+ CMTimeRangeGetEnd(timeRange(of: buffer, consumedFrames: consumedFrames))
160
+ }
161
+
162
+ private static func sampleRate(of buffer: CMSampleBuffer) -> Double {
163
+ guard let format = CMSampleBufferGetFormatDescription(buffer),
164
+ let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(format)?.pointee else {
165
+ return 48_000
166
+ }
167
+ return asbd.mSampleRate
168
+ }
169
+
170
+ private static func timeRange(of buffer: CMSampleBuffer, consumedFrames: Int = 0) -> CMTimeRange {
171
+ let start = startTime(of: buffer, consumedFrames: consumedFrames)
172
+ let totalFrames = CMSampleBufferGetNumSamples(buffer)
173
+ let remainingFrames = max(0, totalFrames - consumedFrames)
174
+ let timescale = CMTimeScale(sampleRate(of: buffer))
175
+ return CMTimeRange(start: start, duration: CMTime(value: CMTimeValue(remainingFrames), timescale: timescale))
176
+ }
177
+
178
+ private static func rangesOverlap(_ queuedMic: QueuedMic, _ app: CMSampleBuffer) -> Bool {
179
+ let intersection = CMTimeRangeGetIntersection(
180
+ timeRange(of: queuedMic.buffer, consumedFrames: queuedMic.consumedFrames),
181
+ otherRange: timeRange(of: app)
182
+ )
183
+ return intersection.duration.isValid && intersection.duration > .zero
184
+ }
185
+
186
+ private static func flushLeadingMicOnly(
187
+ queuedMic: inout QueuedMic,
188
+ app: CMSampleBuffer,
189
+ reference: CMSampleBuffer?
190
+ ) -> CMSampleBuffer? {
191
+ let micRange = timeRange(of: queuedMic.buffer, consumedFrames: queuedMic.consumedFrames)
192
+ let intersection = CMTimeRangeGetIntersection(micRange, otherRange: timeRange(of: app))
193
+ guard intersection.duration.isValid, intersection.duration > .zero else { return nil }
194
+ guard CMTimeCompare(micRange.start, intersection.start) < 0 else { return nil }
195
+
196
+ let sampleRate = sampleRate(of: queuedMic.buffer)
197
+ let bufferStart = CMSampleBufferGetPresentationTimeStamp(queuedMic.buffer)
198
+ let leadingEndFrame = frameOffset(
199
+ for: intersection.start,
200
+ relativeTo: bufferStart,
201
+ sampleRate: sampleRate
202
+ )
203
+ let leadingFrameCount = leadingEndFrame - queuedMic.consumedFrames
204
+ guard leadingFrameCount > 0 else { return nil }
205
+
206
+ guard let leadingSlice = ReplayKitAudioMixer.slice(
207
+ queuedMic.buffer,
208
+ fromFrame: queuedMic.consumedFrames,
209
+ frameCount: leadingFrameCount
210
+ ) else {
211
+ return nil
212
+ }
213
+
214
+ queuedMic.consumedFrames = leadingEndFrame
215
+ return normalizeForWriter(leadingSlice, reference: reference)
216
+ }
217
+
218
+ private static func frameOffset(for time: CMTime, relativeTo start: CMTime, sampleRate: Double) -> Int {
219
+ let delta = CMTimeSubtract(time, start)
220
+ return max(0, Int((CMTimeGetSeconds(delta) * sampleRate).rounded()))
221
+ }
222
+
223
+ private static func advanceMicPastOverlap(queuedMic: inout QueuedMic, app: CMSampleBuffer) {
224
+ let intersection = CMTimeRangeGetIntersection(
225
+ timeRange(of: queuedMic.buffer, consumedFrames: queuedMic.consumedFrames),
226
+ otherRange: timeRange(of: app)
227
+ )
228
+ guard intersection.duration.isValid, intersection.duration > .zero else { return }
229
+
230
+ let sampleRate = sampleRate(of: queuedMic.buffer)
231
+ let bufferStart = CMSampleBufferGetPresentationTimeStamp(queuedMic.buffer)
232
+ let endFrame = frameOffset(
233
+ for: CMTimeRangeGetEnd(intersection),
234
+ relativeTo: bufferStart,
235
+ sampleRate: sampleRate
236
+ )
237
+ queuedMic.consumedFrames = max(queuedMic.consumedFrames, endFrame)
238
+ }
239
+
240
+ private static func micOverlapSlice(
241
+ queuedMic: QueuedMic,
242
+ app: CMSampleBuffer,
243
+ reference: CMSampleBuffer?
244
+ ) -> CMSampleBuffer? {
245
+ let intersection = CMTimeRangeGetIntersection(
246
+ timeRange(of: queuedMic.buffer, consumedFrames: queuedMic.consumedFrames),
247
+ otherRange: timeRange(of: app)
248
+ )
249
+ guard intersection.duration.isValid, intersection.duration > .zero else { return nil }
250
+
251
+ let sampleRate = sampleRate(of: queuedMic.buffer)
252
+ let bufferStart = CMSampleBufferGetPresentationTimeStamp(queuedMic.buffer)
253
+ let startFrame = max(
254
+ queuedMic.consumedFrames,
255
+ frameOffset(for: intersection.start, relativeTo: bufferStart, sampleRate: sampleRate)
256
+ )
257
+ let endFrame = frameOffset(
258
+ for: CMTimeRangeGetEnd(intersection),
259
+ relativeTo: bufferStart,
260
+ sampleRate: sampleRate
261
+ )
262
+ let frameCount = endFrame - startFrame
263
+ guard frameCount > 0 else { return nil }
264
+
265
+ guard let overlap = ReplayKitAudioMixer.slice(
266
+ queuedMic.buffer,
267
+ fromFrame: startFrame,
268
+ frameCount: frameCount
269
+ ) else {
270
+ return nil
271
+ }
272
+ return normalizeForWriter(overlap, reference: reference)
273
+ }
274
+
275
+ private static func micTailForWriter(_ queuedMic: QueuedMic, reference: CMSampleBuffer?) -> CMSampleBuffer? {
276
+ guard let tail = ReplayKitAudioMixer.slice(queuedMic.buffer, fromFrame: queuedMic.consumedFrames) else {
277
+ return nil
278
+ }
279
+ return normalizeForWriter(tail, reference: reference)
280
+ }
281
+
282
+ private static func normalizeForWriter(_ buffer: CMSampleBuffer, reference: CMSampleBuffer?) -> CMSampleBuffer? {
283
+ guard let reference = reference else { return buffer }
284
+ return ReplayKitAudioMixer.matchFormat(buffer, to: reference) ?? buffer
285
+ }
286
+ }
287
+
288
+ enum ReplayKitAudioMixer {
289
+ private static let logger = Logger(subsystem: "CapgoScreenRecorder", category: "ReplayKitAudioMixer")
290
+
291
+ struct MixResult {
292
+ let sample: CMSampleBuffer
293
+ let micFramesConsumed: Int
294
+ }
295
+
296
+ static func mix(app: CMSampleBuffer, mic: CMSampleBuffer, micFrameOffset: Int = 0) -> MixResult? {
297
+ guard CMSampleBufferDataIsReady(app), CMSampleBufferDataIsReady(mic) else { return nil }
298
+
299
+ guard let appFormat = CMSampleBufferGetFormatDescription(app),
300
+ let micFormat = CMSampleBufferGetFormatDescription(mic),
301
+ let appAsbd = CMAudioFormatDescriptionGetStreamBasicDescription(appFormat)?.pointee,
302
+ let micAsbd = CMAudioFormatDescriptionGetStreamBasicDescription(micFormat)?.pointee,
303
+ appAsbd.mFormatID == kAudioFormatLinearPCM,
304
+ micAsbd.mFormatID == kAudioFormatLinearPCM,
305
+ (appAsbd.mFormatFlags & kAudioFormatFlagIsSignedInteger) != 0,
306
+ (micAsbd.mFormatFlags & kAudioFormatFlagIsSignedInteger) != 0,
307
+ (appAsbd.mFormatFlags & kAudioFormatFlagIsNonInterleaved) == 0,
308
+ (micAsbd.mFormatFlags & kAudioFormatFlagIsNonInterleaved) == 0,
309
+ appAsbd.mBitsPerChannel == 16,
310
+ micAsbd.mBitsPerChannel == 16 else {
311
+ return nil
312
+ }
313
+
314
+ var micBuffer = mic
315
+ var effectiveMicOffset = micFrameOffset
316
+ if micAsbd.mSampleRate != appAsbd.mSampleRate {
317
+ guard let resampled = resampleMicBuffer(mic, toAppSampleRate: appAsbd.mSampleRate) else {
318
+ logger.error("Failed to resample microphone audio from \(micAsbd.mSampleRate) Hz to \(appAsbd.mSampleRate) Hz")
319
+ return nil
320
+ }
321
+ micBuffer = resampled
322
+ effectiveMicOffset = Int(
323
+ (Double(micFrameOffset) * appAsbd.mSampleRate / micAsbd.mSampleRate).rounded()
324
+ )
325
+ }
326
+
327
+ guard let micFormat = CMSampleBufferGetFormatDescription(micBuffer),
328
+ let resampledMicAsbd = CMAudioFormatDescriptionGetStreamBasicDescription(micFormat)?.pointee else {
329
+ return nil
330
+ }
331
+
332
+ let appRange = timeRange(of: app)
333
+ let micRange = timeRange(of: micBuffer)
334
+ let intersection = CMTimeRangeGetIntersection(appRange, otherRange: micRange)
335
+ guard intersection.duration.isValid, intersection.duration > .zero else { return nil }
336
+
337
+ let sampleRate = appAsbd.mSampleRate
338
+ let micStartFrame = max(
339
+ effectiveMicOffset,
340
+ frameOffset(for: intersection.start, relativeTo: micRange.start, sampleRate: sampleRate)
341
+ )
342
+ let appStartFrame = frameOffset(for: intersection.start, relativeTo: appRange.start, sampleRate: sampleRate)
343
+ let mixFrames = frameCount(for: intersection.duration, sampleRate: sampleRate)
344
+ guard mixFrames > 0 else { return nil }
345
+
346
+ let appFrames = CMSampleBufferGetNumSamples(app)
347
+ let micFrames = CMSampleBufferGetNumSamples(micBuffer)
348
+ let appAvailable = appFrames - appStartFrame
349
+ let micAvailable = micFrames - micStartFrame
350
+ let frames = min(mixFrames, appAvailable, micAvailable)
351
+ guard frames > 0 else { return nil }
352
+
353
+ guard let appBlock = CMSampleBufferGetDataBuffer(app),
354
+ let micBlock = CMSampleBufferGetDataBuffer(micBuffer) else {
355
+ return nil
356
+ }
357
+
358
+ guard let output = copyPCMBuffer(app),
359
+ let outputBlock = CMSampleBufferGetDataBuffer(output) else {
360
+ return nil
361
+ }
362
+
363
+ let appChannels = Int(appAsbd.mChannelsPerFrame)
364
+ let micChannels = Int(resampledMicAsbd.mChannelsPerFrame)
365
+ let appBytesPerFrame = Int(appAsbd.mBytesPerFrame)
366
+ let micBytesPerFrame = Int(resampledMicAsbd.mBytesPerFrame)
367
+ let outputBytes = frames * appBytesPerFrame
368
+ let appByteOffset = appStartFrame * appBytesPerFrame
369
+ let micByteOffset = micStartFrame * micBytesPerFrame
370
+ let micBytes = frames * micBytesPerFrame
371
+
372
+ var appSamples = [Int16](repeating: 0, count: outputBytes / MemoryLayout<Int16>.size)
373
+ var micSamples = [Int16](repeating: 0, count: micBytes / MemoryLayout<Int16>.size)
374
+
375
+ let appCopyStatus = appSamples.withUnsafeMutableBytes { destination in
376
+ CMBlockBufferCopyDataBytes(
377
+ appBlock,
378
+ atOffset: appByteOffset,
379
+ dataLength: outputBytes,
380
+ destination: destination.baseAddress!
381
+ )
382
+ }
383
+ let micCopyStatus = micSamples.withUnsafeMutableBytes { destination in
384
+ CMBlockBufferCopyDataBytes(
385
+ micBlock,
386
+ atOffset: micByteOffset,
387
+ dataLength: micBytes,
388
+ destination: destination.baseAddress!
389
+ )
390
+ }
391
+ guard appCopyStatus == noErr, micCopyStatus == noErr else { return nil }
392
+
393
+ for frame in 0..<frames {
394
+ var micAccumulator: Int32 = 0
395
+ for micChannel in 0..<micChannels {
396
+ micAccumulator += Int32(micSamples[frame * micChannels + micChannel])
397
+ }
398
+ let micSample = micAccumulator / Int32(micChannels)
399
+ for channel in 0..<appChannels {
400
+ let index = frame * appChannels + channel
401
+ let mixed = max(Int32(Int16.min), min(Int32(Int16.max), Int32(appSamples[index]) + micSample))
402
+ appSamples[index] = Int16(mixed)
403
+ }
404
+ }
405
+
406
+ let replaceStatus = appSamples.withUnsafeMutableBytes { source in
407
+ CMBlockBufferReplaceDataBytes(
408
+ with: source.baseAddress!,
409
+ blockBuffer: outputBlock,
410
+ offsetIntoDestination: appByteOffset,
411
+ dataLength: outputBytes
412
+ )
413
+ }
414
+ guard replaceStatus == noErr else {
415
+ logger.error("Failed to write mixed PCM into output block buffer")
416
+ return nil
417
+ }
418
+
419
+ let intersectionEnd = CMTimeRangeGetEnd(intersection)
420
+ let originalMicStart = CMSampleBufferGetPresentationTimeStamp(mic)
421
+ let consumedInOriginalMic = max(
422
+ micFrameOffset,
423
+ frameOffset(for: intersectionEnd, relativeTo: originalMicStart, sampleRate: micAsbd.mSampleRate)
424
+ )
425
+ return MixResult(sample: output, micFramesConsumed: consumedInOriginalMic)
426
+ }
427
+
428
+ private static func resampleMicBuffer(_ mic: CMSampleBuffer, toAppSampleRate targetRate: Double) -> CMSampleBuffer? {
429
+ guard CMSampleBufferDataIsReady(mic),
430
+ let formatDesc = CMSampleBufferGetFormatDescription(mic),
431
+ let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(formatDesc)?.pointee,
432
+ asbd.mFormatID == kAudioFormatLinearPCM,
433
+ (asbd.mFormatFlags & kAudioFormatFlagIsNonInterleaved) == 0,
434
+ asbd.mBitsPerChannel == 16,
435
+ asbd.mSampleRate != targetRate,
436
+ let block = CMSampleBufferGetDataBuffer(mic) else {
437
+ return nil
438
+ }
439
+
440
+ let channels = AVAudioChannelCount(asbd.mChannelsPerFrame)
441
+ guard let inputFormat = AVAudioFormat(
442
+ commonFormat: .pcmFormatInt16,
443
+ sampleRate: asbd.mSampleRate,
444
+ channels: channels,
445
+ interleaved: true
446
+ ), let outputFormat = AVAudioFormat(
447
+ commonFormat: .pcmFormatInt16,
448
+ sampleRate: targetRate,
449
+ channels: channels,
450
+ interleaved: true
451
+ ), let converter = AVAudioConverter(from: inputFormat, to: outputFormat) else {
452
+ return nil
453
+ }
454
+
455
+ let inputFrames = CMSampleBufferGetNumSamples(mic)
456
+ let inputBytes = inputFrames * Int(asbd.mBytesPerFrame)
457
+ var rawBytes = [UInt8](repeating: 0, count: inputBytes)
458
+ let copyStatus = rawBytes.withUnsafeMutableBytes { destination in
459
+ CMBlockBufferCopyDataBytes(
460
+ block,
461
+ atOffset: 0,
462
+ dataLength: inputBytes,
463
+ destination: destination.baseAddress!
464
+ )
465
+ }
466
+ guard copyStatus == noErr else { return nil }
467
+
468
+ guard let inputPCM = AVAudioPCMBuffer(pcmFormat: inputFormat, frameCapacity: AVAudioFrameCount(inputFrames)),
469
+ let inputChannelData = inputPCM.int16ChannelData else {
470
+ return nil
471
+ }
472
+ inputPCM.frameLength = AVAudioFrameCount(inputFrames)
473
+ rawBytes.withUnsafeBytes { source in
474
+ let samples = source.bindMemory(to: Int16.self)
475
+ for index in 0..<inputFrames {
476
+ for channel in 0..<Int(channels) {
477
+ inputChannelData[0][index * Int(channels) + channel] = samples[index * Int(channels) + channel]
478
+ }
479
+ }
480
+ }
481
+
482
+ let outputFrameCapacity = AVAudioFrameCount(
483
+ (Double(inputFrames) * targetRate / asbd.mSampleRate).rounded(.up)
484
+ )
485
+ guard let outputPCM = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: outputFrameCapacity),
486
+ let outputChannelData = outputPCM.int16ChannelData else {
487
+ return nil
488
+ }
489
+
490
+ var conversionError: NSError?
491
+ var suppliedInput = false
492
+ let inputBlock: AVAudioConverterInputBlock = { _, outStatus in
493
+ if suppliedInput {
494
+ outStatus.pointee = .endOfStream
495
+ return nil
496
+ }
497
+ suppliedInput = true
498
+ outStatus.pointee = .haveData
499
+ return inputPCM
500
+ }
501
+ converter.convert(to: outputPCM, error: &conversionError, withInputFrom: inputBlock)
502
+ guard conversionError == nil, outputPCM.frameLength > 0 else { return nil }
503
+
504
+ let outputFrames = Int(outputPCM.frameLength)
505
+ let outputBytesPerFrame = Int(outputFormat.streamDescription.pointee.mBytesPerFrame)
506
+ let outputBytes = outputFrames * outputBytesPerFrame
507
+ var outputBytesArray = [UInt8](repeating: 0, count: outputBytes)
508
+ outputBytesArray.withUnsafeMutableBytes { destination in
509
+ let samples = destination.bindMemory(to: Int16.self)
510
+ for index in 0..<outputFrames {
511
+ for channel in 0..<Int(channels) {
512
+ samples[index * Int(channels) + channel] = outputChannelData[0][index * Int(channels) + channel]
513
+ }
514
+ }
515
+ }
516
+
517
+ var outputBlockBuffer: CMBlockBuffer?
518
+ let blockStatus = CMBlockBufferCreateWithMemoryBlock(
519
+ allocator: kCFAllocatorDefault,
520
+ memoryBlock: nil,
521
+ blockLength: outputBytes,
522
+ blockAllocator: kCFAllocatorDefault,
523
+ customBlockSource: nil,
524
+ offsetToData: 0,
525
+ dataLength: outputBytes,
526
+ flags: 0,
527
+ blockBufferOut: &outputBlockBuffer
528
+ )
529
+ guard blockStatus == noErr, let outputBlock = outputBlockBuffer else { return nil }
530
+
531
+ let replaceStatus = outputBytesArray.withUnsafeMutableBytes { source in
532
+ CMBlockBufferReplaceDataBytes(
533
+ with: source.baseAddress!,
534
+ blockBuffer: outputBlock,
535
+ offsetIntoDestination: 0,
536
+ dataLength: outputBytes
537
+ )
538
+ }
539
+ guard replaceStatus == noErr else { return nil }
540
+
541
+ var outputAsbd = asbd
542
+ outputAsbd.mSampleRate = targetRate
543
+ var outputFormatDescription: CMAudioFormatDescription?
544
+ let formatStatus = CMAudioFormatDescriptionCreate(
545
+ allocator: kCFAllocatorDefault,
546
+ asbd: &outputAsbd,
547
+ layoutSize: 0,
548
+ layout: nil,
549
+ magicCookieSize: 0,
550
+ magicCookie: nil,
551
+ extensions: nil,
552
+ formatDescriptionOut: &outputFormatDescription
553
+ )
554
+ guard formatStatus == noErr, let resampledFormat = outputFormatDescription else { return nil }
555
+
556
+ let timescale = CMTimeScale(targetRate)
557
+ var timing = CMSampleTimingInfo(
558
+ duration: CMTime(value: 1, timescale: timescale),
559
+ presentationTimeStamp: CMSampleBufferGetPresentationTimeStamp(mic),
560
+ decodeTimeStamp: CMSampleBufferGetDecodeTimeStamp(mic)
561
+ )
562
+ var outputBuffer: CMSampleBuffer?
563
+ let createStatus = CMSampleBufferCreate(
564
+ allocator: kCFAllocatorDefault,
565
+ dataBuffer: outputBlock,
566
+ dataReady: true,
567
+ makeDataReadyCallback: nil,
568
+ refcon: nil,
569
+ formatDescription: resampledFormat,
570
+ sampleCount: outputFrames,
571
+ sampleTimingEntryCount: 1,
572
+ sampleTimingArray: &timing,
573
+ sampleSizeEntryCount: 0,
574
+ sampleSizeArray: nil,
575
+ sampleBufferOut: &outputBuffer
576
+ )
577
+ guard createStatus == noErr else { return nil }
578
+ return outputBuffer
579
+ }
580
+
581
+ static func slice(_ buffer: CMSampleBuffer, fromFrame frameOffset: Int, frameCount: Int? = nil) -> CMSampleBuffer? {
582
+ guard CMSampleBufferDataIsReady(buffer) else { return nil }
583
+
584
+ let totalFrames = CMSampleBufferGetNumSamples(buffer)
585
+ guard frameOffset < totalFrames else { return nil }
586
+ if frameOffset <= 0, frameCount == nil {
587
+ return buffer
588
+ }
589
+
590
+ let framesToCopy = min(frameCount ?? (totalFrames - frameOffset), totalFrames - frameOffset)
591
+ guard framesToCopy > 0 else { return nil }
592
+
593
+ guard let format = CMSampleBufferGetFormatDescription(buffer),
594
+ let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(format)?.pointee,
595
+ asbd.mFormatID == kAudioFormatLinearPCM,
596
+ (asbd.mFormatFlags & kAudioFormatFlagIsNonInterleaved) == 0,
597
+ let sourceBlock = CMSampleBufferGetDataBuffer(buffer) else {
598
+ return nil
599
+ }
600
+
601
+ let bytesPerFrame = Int(asbd.mBytesPerFrame)
602
+ let byteOffset = frameOffset * bytesPerFrame
603
+ let remainingBytes = framesToCopy * bytesPerFrame
604
+ let timescale = CMTimeScale(asbd.mSampleRate)
605
+
606
+ var tailBytes = [UInt8](repeating: 0, count: remainingBytes)
607
+ let copyStatus = tailBytes.withUnsafeMutableBytes { destination in
608
+ CMBlockBufferCopyDataBytes(
609
+ sourceBlock,
610
+ atOffset: byteOffset,
611
+ dataLength: remainingBytes,
612
+ destination: destination.baseAddress!
613
+ )
614
+ }
615
+ guard copyStatus == noErr else { return nil }
616
+
617
+ var blockBuffer: CMBlockBuffer?
618
+ let blockStatus = CMBlockBufferCreateWithMemoryBlock(
619
+ allocator: kCFAllocatorDefault,
620
+ memoryBlock: nil,
621
+ blockLength: remainingBytes,
622
+ blockAllocator: kCFAllocatorDefault,
623
+ customBlockSource: nil,
624
+ offsetToData: 0,
625
+ dataLength: remainingBytes,
626
+ flags: 0,
627
+ blockBufferOut: &blockBuffer
628
+ )
629
+ guard blockStatus == noErr, let outputBlock = blockBuffer else { return nil }
630
+
631
+ let replaceStatus = tailBytes.withUnsafeMutableBytes { source in
632
+ CMBlockBufferReplaceDataBytes(
633
+ with: source.baseAddress!,
634
+ blockBuffer: outputBlock,
635
+ offsetIntoDestination: 0,
636
+ dataLength: remainingBytes
637
+ )
638
+ }
639
+ guard replaceStatus == noErr else { return nil }
640
+
641
+ let presentationTimeStamp = CMTimeAdd(
642
+ CMSampleBufferGetPresentationTimeStamp(buffer),
643
+ CMTime(value: CMTimeValue(frameOffset), timescale: timescale)
644
+ )
645
+ var timing = CMSampleTimingInfo(
646
+ duration: CMTime(value: 1, timescale: timescale),
647
+ presentationTimeStamp: presentationTimeStamp,
648
+ decodeTimeStamp: CMSampleBufferGetDecodeTimeStamp(buffer)
649
+ )
650
+ var outputBuffer: CMSampleBuffer?
651
+ let createStatus = CMSampleBufferCreate(
652
+ allocator: kCFAllocatorDefault,
653
+ dataBuffer: outputBlock,
654
+ dataReady: true,
655
+ makeDataReadyCallback: nil,
656
+ refcon: nil,
657
+ formatDescription: format,
658
+ sampleCount: framesToCopy,
659
+ sampleTimingEntryCount: 1,
660
+ sampleTimingArray: &timing,
661
+ sampleSizeEntryCount: 0,
662
+ sampleSizeArray: nil,
663
+ sampleBufferOut: &outputBuffer
664
+ )
665
+ guard createStatus == noErr else { return nil }
666
+ return outputBuffer
667
+ }
668
+
669
+ static func matchFormat(_ buffer: CMSampleBuffer, to reference: CMSampleBuffer) -> CMSampleBuffer? {
670
+ guard CMSampleBufferDataIsReady(buffer), CMSampleBufferDataIsReady(reference) else { return nil }
671
+ guard let bufferFormat = CMSampleBufferGetFormatDescription(buffer),
672
+ let referenceFormat = CMSampleBufferGetFormatDescription(reference),
673
+ let bufferAsbd = CMAudioFormatDescriptionGetStreamBasicDescription(bufferFormat)?.pointee,
674
+ let referenceAsbd = CMAudioFormatDescriptionGetStreamBasicDescription(referenceFormat)?.pointee,
675
+ bufferAsbd.mFormatID == kAudioFormatLinearPCM,
676
+ referenceAsbd.mFormatID == kAudioFormatLinearPCM,
677
+ (bufferAsbd.mFormatFlags & kAudioFormatFlagIsNonInterleaved) == 0,
678
+ (referenceAsbd.mFormatFlags & kAudioFormatFlagIsNonInterleaved) == 0,
679
+ bufferAsbd.mBitsPerChannel == 16,
680
+ referenceAsbd.mBitsPerChannel == 16,
681
+ bufferAsbd.mSampleRate == referenceAsbd.mSampleRate else {
682
+ return nil
683
+ }
684
+
685
+ let bufferChannels = Int(bufferAsbd.mChannelsPerFrame)
686
+ let referenceChannels = Int(referenceAsbd.mChannelsPerFrame)
687
+ if bufferChannels == referenceChannels {
688
+ return buffer
689
+ }
690
+
691
+ guard bufferChannels == 1, referenceChannels > 1,
692
+ let block = CMSampleBufferGetDataBuffer(buffer) else {
693
+ return nil
694
+ }
695
+
696
+ let frames = CMSampleBufferGetNumSamples(buffer)
697
+ let inputBytes = frames * Int(bufferAsbd.mBytesPerFrame)
698
+ let outputBytes = frames * referenceChannels * MemoryLayout<Int16>.size
699
+ var monoSamples = [Int16](repeating: 0, count: inputBytes / MemoryLayout<Int16>.size)
700
+ let copyStatus = monoSamples.withUnsafeMutableBytes { destination in
701
+ CMBlockBufferCopyDataBytes(block, atOffset: 0, dataLength: inputBytes, destination: destination.baseAddress!)
702
+ }
703
+ guard copyStatus == noErr else { return nil }
704
+
705
+ var stereoSamples = [Int16]()
706
+ stereoSamples.reserveCapacity(frames * referenceChannels)
707
+ for frame in 0..<frames {
708
+ let sample = monoSamples[frame]
709
+ for _ in 0..<referenceChannels {
710
+ stereoSamples.append(sample)
711
+ }
712
+ }
713
+
714
+ var blockBuffer: CMBlockBuffer?
715
+ let blockStatus = CMBlockBufferCreateWithMemoryBlock(
716
+ allocator: kCFAllocatorDefault,
717
+ memoryBlock: nil,
718
+ blockLength: outputBytes,
719
+ blockAllocator: kCFAllocatorDefault,
720
+ customBlockSource: nil,
721
+ offsetToData: 0,
722
+ dataLength: outputBytes,
723
+ flags: 0,
724
+ blockBufferOut: &blockBuffer
725
+ )
726
+ guard blockStatus == noErr, let outputBlock = blockBuffer else { return nil }
727
+
728
+ let replaceStatus = stereoSamples.withUnsafeMutableBytes { source in
729
+ CMBlockBufferReplaceDataBytes(
730
+ with: source.baseAddress!,
731
+ blockBuffer: outputBlock,
732
+ offsetIntoDestination: 0,
733
+ dataLength: outputBytes
734
+ )
735
+ }
736
+ guard replaceStatus == noErr else { return nil }
737
+
738
+ let referenceTimescale = CMTimeScale(referenceAsbd.mSampleRate)
739
+ var timing = CMSampleTimingInfo(
740
+ duration: CMTime(value: 1, timescale: referenceTimescale),
741
+ presentationTimeStamp: CMSampleBufferGetPresentationTimeStamp(buffer),
742
+ decodeTimeStamp: CMSampleBufferGetDecodeTimeStamp(buffer)
743
+ )
744
+ var outputBuffer: CMSampleBuffer?
745
+ let createStatus = CMSampleBufferCreate(
746
+ allocator: kCFAllocatorDefault,
747
+ dataBuffer: outputBlock,
748
+ dataReady: true,
749
+ makeDataReadyCallback: nil,
750
+ refcon: nil,
751
+ formatDescription: referenceFormat,
752
+ sampleCount: frames,
753
+ sampleTimingEntryCount: 1,
754
+ sampleTimingArray: &timing,
755
+ sampleSizeEntryCount: 0,
756
+ sampleSizeArray: nil,
757
+ sampleBufferOut: &outputBuffer
758
+ )
759
+ guard createStatus == noErr else { return nil }
760
+ return outputBuffer
761
+ }
762
+
763
+ private static func copyPCMBuffer(_ buffer: CMSampleBuffer) -> CMSampleBuffer? {
764
+ guard CMSampleBufferDataIsReady(buffer) else { return nil }
765
+ guard let format = CMSampleBufferGetFormatDescription(buffer),
766
+ let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(format)?.pointee,
767
+ asbd.mFormatID == kAudioFormatLinearPCM,
768
+ (asbd.mFormatFlags & kAudioFormatFlagIsNonInterleaved) == 0,
769
+ let sourceBlock = CMSampleBufferGetDataBuffer(buffer) else {
770
+ return nil
771
+ }
772
+
773
+ let frames = CMSampleBufferGetNumSamples(buffer)
774
+ let totalBytes = frames * Int(asbd.mBytesPerFrame)
775
+ guard totalBytes > 0 else { return nil }
776
+
777
+ var bytes = [UInt8](repeating: 0, count: totalBytes)
778
+ let copyStatus = bytes.withUnsafeMutableBytes { destination in
779
+ CMBlockBufferCopyDataBytes(
780
+ sourceBlock,
781
+ atOffset: 0,
782
+ dataLength: totalBytes,
783
+ destination: destination.baseAddress!
784
+ )
785
+ }
786
+ guard copyStatus == noErr else { return nil }
787
+
788
+ var blockBuffer: CMBlockBuffer?
789
+ let blockStatus = CMBlockBufferCreateWithMemoryBlock(
790
+ allocator: kCFAllocatorDefault,
791
+ memoryBlock: nil,
792
+ blockLength: totalBytes,
793
+ blockAllocator: kCFAllocatorDefault,
794
+ customBlockSource: nil,
795
+ offsetToData: 0,
796
+ dataLength: totalBytes,
797
+ flags: 0,
798
+ blockBufferOut: &blockBuffer
799
+ )
800
+ guard blockStatus == noErr, let outputBlock = blockBuffer else { return nil }
801
+
802
+ let replaceStatus = bytes.withUnsafeMutableBytes { source in
803
+ CMBlockBufferReplaceDataBytes(
804
+ with: source.baseAddress!,
805
+ blockBuffer: outputBlock,
806
+ offsetIntoDestination: 0,
807
+ dataLength: totalBytes
808
+ )
809
+ }
810
+ guard replaceStatus == noErr else { return nil }
811
+
812
+ let timescale = CMTimeScale(asbd.mSampleRate)
813
+ var timing = CMSampleTimingInfo(
814
+ duration: CMTime(value: 1, timescale: timescale),
815
+ presentationTimeStamp: CMSampleBufferGetPresentationTimeStamp(buffer),
816
+ decodeTimeStamp: CMSampleBufferGetDecodeTimeStamp(buffer)
817
+ )
818
+ var outputBuffer: CMSampleBuffer?
819
+ let createStatus = CMSampleBufferCreate(
820
+ allocator: kCFAllocatorDefault,
821
+ dataBuffer: outputBlock,
822
+ dataReady: true,
823
+ makeDataReadyCallback: nil,
824
+ refcon: nil,
825
+ formatDescription: format,
826
+ sampleCount: frames,
827
+ sampleTimingEntryCount: 1,
828
+ sampleTimingArray: &timing,
829
+ sampleSizeEntryCount: 0,
830
+ sampleSizeArray: nil,
831
+ sampleBufferOut: &outputBuffer
832
+ )
833
+ guard createStatus == noErr else { return nil }
834
+ return outputBuffer
835
+ }
836
+
837
+ private static func timeRange(of buffer: CMSampleBuffer) -> CMTimeRange {
838
+ let start = CMSampleBufferGetPresentationTimeStamp(buffer)
839
+ let duration = CMSampleBufferGetDuration(buffer)
840
+ if duration.isValid, duration > .zero {
841
+ return CMTimeRange(start: start, duration: duration)
842
+ }
843
+
844
+ guard let format = CMSampleBufferGetFormatDescription(buffer),
845
+ let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(format)?.pointee else {
846
+ return CMTimeRange(start: start, duration: .zero)
847
+ }
848
+
849
+ let frames = CMSampleBufferGetNumSamples(buffer)
850
+ let timescale = CMTimeScale(asbd.mSampleRate)
851
+ return CMTimeRange(start: start, duration: CMTime(value: CMTimeValue(frames), timescale: timescale))
852
+ }
853
+
854
+ private static func frameOffset(for time: CMTime, relativeTo start: CMTime, sampleRate: Double) -> Int {
855
+ let delta = CMTimeSubtract(time, start)
856
+ return max(0, Int((CMTimeGetSeconds(delta) * sampleRate).rounded()))
857
+ }
858
+
859
+ private static func frameCount(for duration: CMTime, sampleRate: Double) -> Int {
860
+ max(0, Int((CMTimeGetSeconds(duration) * sampleRate).rounded()))
861
+ }
862
+ }
@@ -7,7 +7,7 @@ import Capacitor
7
7
  */
8
8
  @objc(ScreenRecorderPlugin)
9
9
  public class ScreenRecorderPlugin: CAPPlugin, CAPBridgedPlugin {
10
- private let pluginVersion: String = "8.3.11"
10
+ private let pluginVersion: String = "8.3.12"
11
11
  public let identifier = "ScreenRecorderPlugin"
12
12
  public let jsName = "ScreenRecorder"
13
13
  public let pluginMethods: [CAPPluginMethod] = [
@@ -55,8 +55,9 @@ public final class ScreenRecorder {
55
55
  private var didCreateOutputFile = false
56
56
  private var videoWriter: AVAssetWriter?
57
57
  private var videoWriterInput: AVAssetWriterInput?
58
- private var micAudioWriterInput: AVAssetWriterInput?
59
- private var appAudioWriterInput: AVAssetWriterInput?
58
+ private var audioWriterInput: AVAssetWriterInput?
59
+ private var audioTrackMixer: ReplayKitAudioTrackMixer?
60
+ private let audioMixerQueue = DispatchQueue(label: "CapgoScreenRecorder.audioMixer")
60
61
  private var saveToCameraRoll = false
61
62
  private var recordAudio = false
62
63
  private var videoFormat: VideoContainerFormat = .mp4
@@ -93,8 +94,8 @@ public final class ScreenRecorder {
93
94
  attemptDidCreate = self.didCreateOutputFile
94
95
  addVideoWriterInput(size: size)
95
96
  if recordAudio {
96
- self.micAudioWriterInput = createAndAddAudioInput()
97
- self.appAudioWriterInput = createAndAddAudioInput()
97
+ self.audioWriterInput = createAndAddAudioInput()
98
+ self.audioTrackMixer = ReplayKitAudioTrackMixer()
98
99
  }
99
100
  startCapture(handler: handler)
100
101
  } catch let err {
@@ -111,8 +112,8 @@ public final class ScreenRecorder {
111
112
  private func resetWriterState() {
112
113
  videoWriter = nil
113
114
  videoWriterInput = nil
114
- micAudioWriterInput = nil
115
- appAudioWriterInput = nil
115
+ audioWriterInput = nil
116
+ audioTrackMixer = nil
116
117
  videoOutputURL = nil
117
118
  didCreateOutputFile = false
118
119
  }
@@ -183,8 +184,7 @@ public final class ScreenRecorder {
183
184
  recorder.startCapture(handler: { (sampleBuffer, sampleType, passedError) in
184
185
  if let passedError = passedError {
185
186
  self.videoWriterInput?.markAsFinished()
186
- self.micAudioWriterInput?.markAsFinished()
187
- self.appAudioWriterInput?.markAsFinished()
187
+ self.audioWriterInput?.markAsFinished()
188
188
  if self.videoWriter?.status == .writing {
189
189
  self.videoWriter?.cancelWriting()
190
190
  }
@@ -201,12 +201,20 @@ public final class ScreenRecorder {
201
201
  case .video:
202
202
  self.handleSampleBuffer(sampleBuffer: sampleBuffer)
203
203
  case .audioApp:
204
- if self.recordAudio {
205
- self.add(sample: sampleBuffer, to: self.appAudioWriterInput)
204
+ if self.recordAudio, let mixer = self.audioTrackMixer {
205
+ self.audioMixerQueue.sync {
206
+ for sample in mixer.handleApp(sampleBuffer) {
207
+ self.add(sample: sample, to: self.audioWriterInput)
208
+ }
209
+ }
206
210
  }
207
211
  case .audioMic:
208
- if self.recordAudio {
209
- self.add(sample: sampleBuffer, to: self.micAudioWriterInput)
212
+ if self.recordAudio, let mixer = self.audioTrackMixer {
213
+ self.audioMixerQueue.sync {
214
+ for sample in mixer.handleMic(sampleBuffer) {
215
+ self.add(sample: sample, to: self.audioWriterInput)
216
+ }
217
+ }
210
218
  }
211
219
  default:
212
220
  break
@@ -248,8 +256,7 @@ public final class ScreenRecorder {
248
256
 
249
257
  if let error = error {
250
258
  self.videoWriterInput?.markAsFinished()
251
- self.micAudioWriterInput?.markAsFinished()
252
- self.appAudioWriterInput?.markAsFinished()
259
+ self.audioWriterInput?.markAsFinished()
253
260
  if self.videoWriter?.status == .writing {
254
261
  self.videoWriter?.cancelWriting()
255
262
  }
@@ -258,9 +265,16 @@ public final class ScreenRecorder {
258
265
  return
259
266
  }
260
267
 
268
+ self.audioMixerQueue.sync {
269
+ if let mixer = self.audioTrackMixer {
270
+ for sample in mixer.drain() {
271
+ self.add(sample: sample, to: self.audioWriterInput)
272
+ }
273
+ }
274
+ }
275
+
261
276
  self.videoWriterInput?.markAsFinished()
262
- self.micAudioWriterInput?.markAsFinished()
263
- self.appAudioWriterInput?.markAsFinished()
277
+ self.audioWriterInput?.markAsFinished()
264
278
 
265
279
  guard let writer = self.videoWriter else {
266
280
  complete(nil)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-screen-recorder",
3
- "version": "8.3.11",
3
+ "version": "8.3.12",
4
4
  "description": "Record device's screen",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",