sezo-audio-engine 0.0.3 → 0.0.5
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/README.md
CHANGED
|
@@ -22,6 +22,28 @@ Android recording:
|
|
|
22
22
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
## Android Engine Dependency
|
|
26
|
+
|
|
27
|
+
The Expo module links to the Android engine from JitPack by default. Add the JitPack repository to your app:
|
|
28
|
+
|
|
29
|
+
```gradle
|
|
30
|
+
// android/build.gradle
|
|
31
|
+
allprojects {
|
|
32
|
+
repositories {
|
|
33
|
+
maven { url "https://www.jitpack.io" }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Optionally pin the engine version in `android/gradle.properties`:
|
|
39
|
+
|
|
40
|
+
```properties
|
|
41
|
+
sezoAudioEngineVersion=android-engine-v0.1.3
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
If you want to build from source instead, include the local engine module in
|
|
45
|
+
`android/settings.gradle` and it will be picked up automatically.
|
|
46
|
+
|
|
25
47
|
iOS recording or background playback:
|
|
26
48
|
|
|
27
49
|
```json
|
package/android/build.gradle
CHANGED
|
@@ -44,7 +44,7 @@ dependencies {
|
|
|
44
44
|
if (androidEngineProject != null) {
|
|
45
45
|
implementation androidEngineProject
|
|
46
46
|
} else {
|
|
47
|
-
def sezoAudioEngineVersion = project.findProperty("sezoAudioEngineVersion") ?: "v0.1.3"
|
|
47
|
+
def sezoAudioEngineVersion = project.findProperty("sezoAudioEngineVersion") ?: "android-engine-v0.1.3"
|
|
48
48
|
implementation "com.github.Sepzie:SezoAudioEngine:${sezoAudioEngineVersion}"
|
|
49
49
|
}
|
|
50
50
|
}
|
|
@@ -2,8 +2,11 @@ import AVFoundation
|
|
|
2
2
|
|
|
3
3
|
final class AudioSessionManager {
|
|
4
4
|
private let session = AVAudioSession.sharedInstance()
|
|
5
|
+
private var lastError: String?
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
@discardableResult
|
|
8
|
+
func configure(with config: AudioEngineConfig) -> Bool {
|
|
9
|
+
lastError = nil
|
|
7
10
|
do {
|
|
8
11
|
try session.setCategory(
|
|
9
12
|
.playAndRecord,
|
|
@@ -11,25 +14,42 @@ final class AudioSessionManager {
|
|
|
11
14
|
options: [.defaultToSpeaker, .allowBluetooth]
|
|
12
15
|
)
|
|
13
16
|
} catch {
|
|
14
|
-
|
|
17
|
+
lastError = "setCategory failed: \(error.localizedDescription)"
|
|
18
|
+
return false
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
if let sampleRate = config.sampleRate {
|
|
18
|
-
|
|
22
|
+
do {
|
|
23
|
+
try session.setPreferredSampleRate(sampleRate)
|
|
24
|
+
} catch {
|
|
25
|
+
lastError = "setPreferredSampleRate failed: \(error.localizedDescription)"
|
|
26
|
+
}
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
if let bufferSize = config.bufferSize {
|
|
22
30
|
let sampleRate = config.sampleRate ?? session.sampleRate
|
|
23
31
|
if sampleRate > 0 {
|
|
24
32
|
let duration = bufferSize / sampleRate
|
|
25
|
-
|
|
33
|
+
do {
|
|
34
|
+
try session.setPreferredIOBufferDuration(duration)
|
|
35
|
+
} catch {
|
|
36
|
+
lastError = "setPreferredIOBufferDuration failed: \(error.localizedDescription)"
|
|
37
|
+
}
|
|
26
38
|
}
|
|
27
39
|
}
|
|
28
40
|
|
|
29
|
-
|
|
41
|
+
do {
|
|
42
|
+
try session.setActive(true)
|
|
43
|
+
} catch {
|
|
44
|
+
lastError = "setActive failed: \(error.localizedDescription)"
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
return true
|
|
30
48
|
}
|
|
31
49
|
|
|
32
|
-
|
|
50
|
+
@discardableResult
|
|
51
|
+
func enableBackgroundPlayback(with config: AudioEngineConfig) -> Bool {
|
|
52
|
+
lastError = nil
|
|
33
53
|
do {
|
|
34
54
|
try session.setCategory(
|
|
35
55
|
.playback,
|
|
@@ -37,22 +57,41 @@ final class AudioSessionManager {
|
|
|
37
57
|
options: [.allowBluetooth, .allowAirPlay]
|
|
38
58
|
)
|
|
39
59
|
} catch {
|
|
40
|
-
|
|
60
|
+
lastError = "setCategory failed: \(error.localizedDescription)"
|
|
61
|
+
return false
|
|
41
62
|
}
|
|
42
63
|
|
|
43
64
|
if let sampleRate = config.sampleRate {
|
|
44
|
-
|
|
65
|
+
do {
|
|
66
|
+
try session.setPreferredSampleRate(sampleRate)
|
|
67
|
+
} catch {
|
|
68
|
+
lastError = "setPreferredSampleRate failed: \(error.localizedDescription)"
|
|
69
|
+
}
|
|
45
70
|
}
|
|
46
71
|
|
|
47
72
|
if let bufferSize = config.bufferSize {
|
|
48
73
|
let sampleRate = config.sampleRate ?? session.sampleRate
|
|
49
74
|
if sampleRate > 0 {
|
|
50
75
|
let duration = bufferSize / sampleRate
|
|
51
|
-
|
|
76
|
+
do {
|
|
77
|
+
try session.setPreferredIOBufferDuration(duration)
|
|
78
|
+
} catch {
|
|
79
|
+
lastError = "setPreferredIOBufferDuration failed: \(error.localizedDescription)"
|
|
80
|
+
}
|
|
52
81
|
}
|
|
53
82
|
}
|
|
54
83
|
|
|
55
|
-
|
|
84
|
+
do {
|
|
85
|
+
try session.setActive(true)
|
|
86
|
+
} catch {
|
|
87
|
+
lastError = "setActive failed: \(error.localizedDescription)"
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func lastErrorDescription() -> String? {
|
|
94
|
+
return lastError
|
|
56
95
|
}
|
|
57
96
|
|
|
58
97
|
func deactivate() {
|
|
@@ -30,6 +30,11 @@ final class NativeAudioEngine {
|
|
|
30
30
|
private var activePlaybackCount = 0
|
|
31
31
|
private var playbackToken = UUID()
|
|
32
32
|
private var recordingState: RecordingState?
|
|
33
|
+
private var lastRecordingError: String?
|
|
34
|
+
private var lastEngineError: String?
|
|
35
|
+
private let recordingMixer = AVAudioMixerNode()
|
|
36
|
+
private var recordingMixerConnected = false
|
|
37
|
+
private var recordingTapInstalled = false
|
|
33
38
|
private var inputLevel: Double = 0.0
|
|
34
39
|
private var backgroundPlaybackEnabled = false
|
|
35
40
|
private var nowPlayingMetadata: [String: Any] = [:]
|
|
@@ -53,7 +58,11 @@ final class NativeAudioEngine {
|
|
|
53
58
|
let parsedConfig = AudioEngineConfig(dictionary: config)
|
|
54
59
|
queue.sync {
|
|
55
60
|
lastConfig = parsedConfig
|
|
56
|
-
sessionManager.configure(with: parsedConfig)
|
|
61
|
+
_ = sessionManager.configure(with: parsedConfig)
|
|
62
|
+
_ = ensureRecordingMixerConnected(
|
|
63
|
+
sampleRate: AVAudioSession.sharedInstance().sampleRate,
|
|
64
|
+
channels: AVAudioSession.sharedInstance().inputNumberOfChannels
|
|
65
|
+
)
|
|
57
66
|
engine.mainMixerNode.outputVolume = Float(masterVolume)
|
|
58
67
|
engine.prepare()
|
|
59
68
|
isInitialized = true
|
|
@@ -68,6 +77,7 @@ final class NativeAudioEngine {
|
|
|
68
77
|
stopRecordingInternal()
|
|
69
78
|
detachAllTracks()
|
|
70
79
|
tracks.removeAll()
|
|
80
|
+
detachRecordingMixer()
|
|
71
81
|
isPlayingFlag = false
|
|
72
82
|
isRecordingFlag = false
|
|
73
83
|
currentPositionMs = 0.0
|
|
@@ -290,16 +300,51 @@ final class NativeAudioEngine {
|
|
|
290
300
|
}
|
|
291
301
|
|
|
292
302
|
/// Starts recording from the input node into AAC or WAV.
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
303
|
+
@discardableResult
|
|
304
|
+
func startRecording(config: [String: Any]?) -> Bool {
|
|
305
|
+
return queue.sync {
|
|
306
|
+
guard !isRecordingFlag else { return true }
|
|
307
|
+
lastRecordingError = nil
|
|
308
|
+
let session = AVAudioSession.sharedInstance()
|
|
309
|
+
let permission = session.recordPermission
|
|
310
|
+
if permission != .granted {
|
|
311
|
+
if permission == .undetermined {
|
|
312
|
+
requestRecordPermission()
|
|
313
|
+
lastRecordingError = "microphone permission not determined"
|
|
314
|
+
} else {
|
|
315
|
+
lastRecordingError = "microphone permission denied"
|
|
316
|
+
}
|
|
317
|
+
return false
|
|
318
|
+
}
|
|
319
|
+
if !sessionManager.configure(with: lastConfig) {
|
|
320
|
+
lastRecordingError = sessionManager.lastErrorDescription() ?? "audio session configuration failed"
|
|
321
|
+
return false
|
|
322
|
+
}
|
|
323
|
+
if !session.isInputAvailable {
|
|
324
|
+
let routeInfo = describeRoute(session)
|
|
325
|
+
lastRecordingError = "audio input not available (category=\(session.category.rawValue), route=\(routeInfo))"
|
|
326
|
+
return false
|
|
327
|
+
}
|
|
296
328
|
ensureInitializedIfNeeded()
|
|
297
329
|
|
|
298
|
-
|
|
299
|
-
|
|
330
|
+
guard startEngineIfNeeded() else {
|
|
331
|
+
lastRecordingError = lastEngineError ?? "audio engine failed to start"
|
|
332
|
+
return false
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let sampleRate = session.sampleRate
|
|
336
|
+
let channels = max(1, session.inputNumberOfChannels)
|
|
337
|
+
if !ensureRecordingMixerConnected(sampleRate: sampleRate, channels: channels) {
|
|
338
|
+
return false
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let tapFormat = recordingMixer.outputFormat(forBus: 0)
|
|
342
|
+
guard tapFormat.sampleRate > 0, tapFormat.channelCount > 0 else {
|
|
343
|
+
let routeInfo = describeRoute(session)
|
|
344
|
+
lastRecordingError = "input format invalid (sampleRate=\(tapFormat.sampleRate), channels=\(tapFormat.channelCount), sessionRate=\(session.sampleRate), sessionChannels=\(session.inputNumberOfChannels), route=\(routeInfo))"
|
|
345
|
+
return false
|
|
346
|
+
}
|
|
300
347
|
let requestedFormat = config?["format"] as? String ?? "aac"
|
|
301
|
-
let channels = config?["channels"] as? Int ?? Int(inputFormat.channelCount)
|
|
302
|
-
let sampleRate = config?["sampleRate"] as? Double ?? inputFormat.sampleRate
|
|
303
348
|
let bitrate = resolveBitrate(config: config)
|
|
304
349
|
let formatInfo = resolveRecordingFormat(requestedFormat: requestedFormat)
|
|
305
350
|
let outputURL = resolveOutputURL(
|
|
@@ -339,33 +384,28 @@ final class NativeAudioEngine {
|
|
|
339
384
|
startTimeMs: startTimeMs
|
|
340
385
|
)
|
|
341
386
|
|
|
342
|
-
guard startEngineIfNeeded() else {
|
|
343
|
-
recordingState = nil
|
|
344
|
-
return
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
inputNode.removeTap(onBus: 0)
|
|
348
|
-
inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { [weak self] buffer, _ in
|
|
349
|
-
guard let self = self else { return }
|
|
350
|
-
self.queue.async {
|
|
351
|
-
guard let state = self.recordingState else { return }
|
|
352
|
-
self.applyRecordingGain(buffer: buffer)
|
|
353
|
-
self.updateInputLevel(buffer: buffer)
|
|
354
|
-
do {
|
|
355
|
-
try state.file.write(from: buffer)
|
|
356
|
-
} catch {
|
|
357
|
-
return
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
387
|
isRecordingFlag = true
|
|
388
|
+
return true
|
|
363
389
|
} catch {
|
|
390
|
+
lastRecordingError = "recording setup failed: \(error.localizedDescription)"
|
|
364
391
|
recordingState = nil
|
|
392
|
+
return false
|
|
365
393
|
}
|
|
366
394
|
}
|
|
367
395
|
}
|
|
368
396
|
|
|
397
|
+
private func requestRecordPermission() {
|
|
398
|
+
let session = AVAudioSession.sharedInstance()
|
|
399
|
+
let requestBlock = {
|
|
400
|
+
session.requestRecordPermission { _ in }
|
|
401
|
+
}
|
|
402
|
+
if Thread.isMainThread {
|
|
403
|
+
requestBlock()
|
|
404
|
+
} else {
|
|
405
|
+
DispatchQueue.main.async(execute: requestBlock)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
369
409
|
/// Stops recording and returns metadata about the output file.
|
|
370
410
|
func stopRecording() -> [String: Any] {
|
|
371
411
|
return queue.sync {
|
|
@@ -411,6 +451,10 @@ final class NativeAudioEngine {
|
|
|
411
451
|
}
|
|
412
452
|
}
|
|
413
453
|
|
|
454
|
+
func getLastRecordingError() -> String? {
|
|
455
|
+
return queue.sync { lastRecordingError }
|
|
456
|
+
}
|
|
457
|
+
|
|
414
458
|
/// Offline export for a single track.
|
|
415
459
|
func extractTrack(trackId: String, config: [String: Any]?) -> [String: Any] {
|
|
416
460
|
return queue.sync {
|
|
@@ -479,7 +523,7 @@ final class NativeAudioEngine {
|
|
|
479
523
|
queue.sync {
|
|
480
524
|
backgroundPlaybackEnabled = true
|
|
481
525
|
nowPlayingMetadata.merge(metadata) { _, new in new }
|
|
482
|
-
sessionManager.enableBackgroundPlayback(with: lastConfig)
|
|
526
|
+
_ = sessionManager.enableBackgroundPlayback(with: lastConfig)
|
|
483
527
|
configureRemoteCommandsIfNeeded()
|
|
484
528
|
updateNowPlayingInfoInternal()
|
|
485
529
|
}
|
|
@@ -500,7 +544,7 @@ final class NativeAudioEngine {
|
|
|
500
544
|
nowPlayingMetadata.removeAll()
|
|
501
545
|
removeRemoteCommands()
|
|
502
546
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
|
503
|
-
sessionManager.configure(with: lastConfig)
|
|
547
|
+
_ = sessionManager.configure(with: lastConfig)
|
|
504
548
|
}
|
|
505
549
|
}
|
|
506
550
|
|
|
@@ -519,10 +563,14 @@ final class NativeAudioEngine {
|
|
|
519
563
|
}
|
|
520
564
|
let config = lastConfig
|
|
521
565
|
if backgroundPlaybackEnabled {
|
|
522
|
-
sessionManager.enableBackgroundPlayback(with: config)
|
|
566
|
+
_ = sessionManager.enableBackgroundPlayback(with: config)
|
|
523
567
|
} else {
|
|
524
|
-
sessionManager.configure(with: config)
|
|
568
|
+
_ = sessionManager.configure(with: config)
|
|
525
569
|
}
|
|
570
|
+
_ = ensureRecordingMixerConnected(
|
|
571
|
+
sampleRate: AVAudioSession.sharedInstance().sampleRate,
|
|
572
|
+
channels: AVAudioSession.sharedInstance().inputNumberOfChannels
|
|
573
|
+
)
|
|
526
574
|
engine.mainMixerNode.outputVolume = Float(masterVolume)
|
|
527
575
|
engine.prepare()
|
|
528
576
|
isInitialized = true
|
|
@@ -668,11 +716,67 @@ final class NativeAudioEngine {
|
|
|
668
716
|
|
|
669
717
|
/// Stops the input tap and clears recording state.
|
|
670
718
|
private func stopRecordingInternal() {
|
|
671
|
-
engine.inputNode.removeTap(onBus: 0)
|
|
672
719
|
recordingState = nil
|
|
673
720
|
isRecordingFlag = false
|
|
674
721
|
}
|
|
675
722
|
|
|
723
|
+
private func ensureRecordingMixerConnected(sampleRate: Double, channels: Int) -> Bool {
|
|
724
|
+
if recordingMixerConnected {
|
|
725
|
+
return true
|
|
726
|
+
}
|
|
727
|
+
if engine.isRunning {
|
|
728
|
+
lastRecordingError = "recording mixer unavailable while engine is running"
|
|
729
|
+
return false
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
let resolvedRate = sampleRate > 0 ? sampleRate : (lastConfig.sampleRate ?? 44100)
|
|
733
|
+
let resolvedChannels = channels > 0 ? channels : 1
|
|
734
|
+
guard let format = AVAudioFormat(
|
|
735
|
+
standardFormatWithSampleRate: resolvedRate,
|
|
736
|
+
channels: AVAudioChannelCount(resolvedChannels)
|
|
737
|
+
) else {
|
|
738
|
+
lastRecordingError = "recording format invalid (sampleRate=\(resolvedRate), channels=\(resolvedChannels))"
|
|
739
|
+
return false
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
engine.attach(recordingMixer)
|
|
743
|
+
engine.connect(engine.inputNode, to: recordingMixer, format: format)
|
|
744
|
+
engine.connect(recordingMixer, to: engine.mainMixerNode, format: format)
|
|
745
|
+
recordingMixer.volume = 0.0
|
|
746
|
+
if !recordingTapInstalled {
|
|
747
|
+
recordingMixer.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in
|
|
748
|
+
guard let self = self else { return }
|
|
749
|
+
self.queue.async {
|
|
750
|
+
guard let state = self.recordingState else { return }
|
|
751
|
+
self.applyRecordingGain(buffer: buffer)
|
|
752
|
+
self.updateInputLevel(buffer: buffer)
|
|
753
|
+
do {
|
|
754
|
+
try state.file.write(from: buffer)
|
|
755
|
+
} catch {
|
|
756
|
+
return
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
recordingTapInstalled = true
|
|
761
|
+
}
|
|
762
|
+
recordingMixerConnected = true
|
|
763
|
+
return true
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
private func detachRecordingMixer() {
|
|
767
|
+
if !recordingMixerConnected {
|
|
768
|
+
return
|
|
769
|
+
}
|
|
770
|
+
if recordingTapInstalled {
|
|
771
|
+
recordingMixer.removeTap(onBus: 0)
|
|
772
|
+
recordingTapInstalled = false
|
|
773
|
+
}
|
|
774
|
+
engine.disconnectNodeInput(recordingMixer)
|
|
775
|
+
engine.disconnectNodeOutput(recordingMixer)
|
|
776
|
+
engine.detach(recordingMixer)
|
|
777
|
+
recordingMixerConnected = false
|
|
778
|
+
}
|
|
779
|
+
|
|
676
780
|
/// Maps requested output formats to supported formats.
|
|
677
781
|
private func resolveRecordingFormat(requestedFormat: String) -> (format: String, fileExtension: String) {
|
|
678
782
|
if requestedFormat == "wav" {
|
|
@@ -717,6 +821,19 @@ final class NativeAudioEngine {
|
|
|
717
821
|
return baseURL.appendingPathComponent(filename)
|
|
718
822
|
}
|
|
719
823
|
|
|
824
|
+
private func selectPreferredInput(_ session: AVAudioSession) {
|
|
825
|
+
guard let inputs = session.availableInputs, !inputs.isEmpty else { return }
|
|
826
|
+
if let builtIn = inputs.first(where: { $0.portType == .builtInMic }) {
|
|
827
|
+
try? session.setPreferredInput(builtIn)
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
private func describeRoute(_ session: AVAudioSession) -> String {
|
|
832
|
+
let inputs = session.currentRoute.inputs.map { "\($0.portType.rawValue):\($0.portName)" }
|
|
833
|
+
let outputs = session.currentRoute.outputs.map { "\($0.portType.rawValue):\($0.portName)" }
|
|
834
|
+
return "inputs[\(inputs.joined(separator: ","))], outputs[\(outputs.joined(separator: ","))]"
|
|
835
|
+
}
|
|
836
|
+
|
|
720
837
|
/// Returns file size for output metadata.
|
|
721
838
|
private func resolveFileSize(url: URL) -> Int {
|
|
722
839
|
let path = url.path
|
|
@@ -981,12 +1098,15 @@ final class NativeAudioEngine {
|
|
|
981
1098
|
@discardableResult
|
|
982
1099
|
private func startEngineIfNeeded() -> Bool {
|
|
983
1100
|
if !isInitialized {
|
|
1101
|
+
lastEngineError = "audio engine not initialized"
|
|
984
1102
|
return false
|
|
985
1103
|
}
|
|
986
1104
|
if !engine.isRunning {
|
|
987
1105
|
do {
|
|
988
1106
|
try engine.start()
|
|
1107
|
+
lastEngineError = nil
|
|
989
1108
|
} catch {
|
|
1109
|
+
lastEngineError = error.localizedDescription
|
|
990
1110
|
return false
|
|
991
1111
|
}
|
|
992
1112
|
}
|
|
@@ -95,8 +95,18 @@ public class ExpoAudioEngineModule: Module {
|
|
|
95
95
|
engine.setTempoAndPitch(tempo: tempo, pitch: pitch)
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
AsyncFunction("startRecording") { (config: [String: Any]?) in
|
|
99
|
-
engine.startRecording(config: config)
|
|
98
|
+
AsyncFunction("startRecording") { (config: [String: Any]?) throws in
|
|
99
|
+
let didStart = engine.startRecording(config: config)
|
|
100
|
+
if !didStart {
|
|
101
|
+
let detail = engine.getLastRecordingError() ?? "audio input format is invalid or the audio session is not ready"
|
|
102
|
+
throw NSError(
|
|
103
|
+
domain: "ExpoAudioEngine",
|
|
104
|
+
code: 1,
|
|
105
|
+
userInfo: [
|
|
106
|
+
NSLocalizedDescriptionKey: "Failed to start recording. \(detail)."
|
|
107
|
+
]
|
|
108
|
+
)
|
|
109
|
+
}
|
|
100
110
|
}
|
|
101
111
|
AsyncFunction("stopRecording") {
|
|
102
112
|
return engine.stopRecording()
|