@independo/capacitor-voice-recorder 8.1.0-dev.1 → 8.1.0-dev.3
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/LICENSE +21 -0
- package/README.md +40 -30
- package/android/build.gradle +44 -1
- package/android/src/main/java/app/independo/capacitorvoicerecorder/VoiceRecorder.java +146 -0
- package/android/src/main/java/app/independo/capacitorvoicerecorder/adapters/PermissionChecker.java +8 -0
- package/android/src/main/java/app/independo/capacitorvoicerecorder/adapters/RecordDataMapper.java +32 -0
- package/android/src/main/java/app/independo/capacitorvoicerecorder/adapters/RecorderAdapter.java +39 -0
- package/android/src/main/java/app/independo/capacitorvoicerecorder/adapters/RecorderPlatform.java +25 -0
- package/android/src/main/java/app/independo/capacitorvoicerecorder/core/CurrentRecordingStatus.java +9 -0
- package/android/src/main/java/app/independo/capacitorvoicerecorder/core/ErrorCodes.java +19 -0
- package/android/src/main/java/{com/tchvu3/capacitorvoicerecorder → app/independo/capacitorvoicerecorder/core}/Messages.java +2 -1
- package/android/src/main/java/{com/tchvu3/capacitorvoicerecorder → app/independo/capacitorvoicerecorder/core}/RecordData.java +15 -1
- package/android/src/main/java/app/independo/capacitorvoicerecorder/core/RecordOptions.java +4 -0
- package/android/src/main/java/app/independo/capacitorvoicerecorder/core/ResponseFormat.java +18 -0
- package/android/src/main/java/{com/tchvu3/capacitorvoicerecorder → app/independo/capacitorvoicerecorder/core}/ResponseGenerator.java +7 -1
- package/android/src/main/java/{com/tchvu3/capacitorvoicerecorder → app/independo/capacitorvoicerecorder/platform}/CustomMediaRecorder.java +33 -2
- package/android/src/main/java/app/independo/capacitorvoicerecorder/platform/DefaultRecorderPlatform.java +86 -0
- package/android/src/main/java/app/independo/capacitorvoicerecorder/platform/NotSupportedOsVersion.java +4 -0
- package/android/src/main/java/app/independo/capacitorvoicerecorder/service/VoiceRecorderService.java +144 -0
- package/android/src/main/java/app/independo/capacitorvoicerecorder/service/VoiceRecorderServiceException.java +23 -0
- package/dist/esm/adapters/VoiceRecorderWebAdapter.d.ts +23 -0
- package/dist/esm/adapters/VoiceRecorderWebAdapter.js +41 -0
- package/dist/esm/adapters/VoiceRecorderWebAdapter.js.map +1 -0
- package/dist/esm/core/error-codes.d.ts +4 -0
- package/dist/esm/core/error-codes.js +21 -0
- package/dist/esm/core/error-codes.js.map +1 -0
- package/dist/esm/core/recording-contract.d.ts +3 -0
- package/dist/esm/core/recording-contract.js +15 -0
- package/dist/esm/core/recording-contract.js.map +1 -0
- package/dist/esm/core/response-format.d.ts +8 -0
- package/dist/esm/core/response-format.js +17 -0
- package/dist/esm/core/response-format.js.map +1 -0
- package/dist/esm/platform/web/VoiceRecorderImpl.d.ts +45 -0
- package/dist/esm/{VoiceRecorderImpl.js → platform/web/VoiceRecorderImpl.js} +20 -2
- package/dist/esm/platform/web/VoiceRecorderImpl.js.map +1 -0
- package/dist/esm/platform/web/get-blob-duration.js.map +1 -0
- package/dist/esm/{predefined-web-responses.d.ts → platform/web/predefined-web-responses.d.ts} +12 -1
- package/dist/esm/{predefined-web-responses.js → platform/web/predefined-web-responses.js} +11 -0
- package/dist/esm/platform/web/predefined-web-responses.js.map +1 -0
- package/dist/esm/service/VoiceRecorderService.d.ts +47 -0
- package/dist/esm/service/VoiceRecorderService.js +60 -0
- package/dist/esm/service/VoiceRecorderService.js.map +1 -0
- package/dist/esm/web.d.ts +12 -1
- package/dist/esm/web.js +26 -12
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +200 -9
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +200 -9
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/VoiceRecorder/Adapters/DefaultRecorderPlatform.swift +33 -0
- package/ios/Sources/VoiceRecorder/Adapters/RecordDataMapper.swift +38 -0
- package/ios/Sources/VoiceRecorder/Adapters/RecorderAdapter.swift +24 -0
- package/ios/Sources/VoiceRecorder/Adapters/RecorderPlatform.swift +11 -0
- package/ios/Sources/VoiceRecorder/Bridge/VoiceRecorder.swift +172 -0
- package/ios/Sources/VoiceRecorder/{CurrentRecordingStatus.swift → Core/CurrentRecordingStatus.swift} +1 -0
- package/ios/Sources/VoiceRecorder/Core/ErrorCodes.swift +16 -0
- package/ios/Sources/VoiceRecorder/{Messages.swift → Core/Messages.swift} +1 -0
- package/ios/Sources/VoiceRecorder/{RecordData.swift → Core/RecordData.swift} +6 -0
- package/ios/Sources/VoiceRecorder/Core/RecordOptions.swift +11 -0
- package/ios/Sources/VoiceRecorder/Core/ResponseFormat.swift +22 -0
- package/ios/Sources/VoiceRecorder/{ResponseGenerator.swift → Core/ResponseGenerator.swift} +6 -0
- package/ios/Sources/VoiceRecorder/{CustomMediaRecorder.swift → Platform/CustomMediaRecorder.swift} +67 -12
- package/ios/Sources/VoiceRecorder/Service/VoiceRecorderService.swift +128 -0
- package/ios/Sources/VoiceRecorder/Service/VoiceRecorderServiceError.swift +14 -0
- package/package.json +10 -4
- package/android/src/main/java/com/tchvu3/capacitorvoicerecorder/CurrentRecordingStatus.java +0 -8
- package/android/src/main/java/com/tchvu3/capacitorvoicerecorder/NotSupportedOsVersion.java +0 -3
- package/android/src/main/java/com/tchvu3/capacitorvoicerecorder/RecordOptions.java +0 -3
- package/android/src/main/java/com/tchvu3/capacitorvoicerecorder/VoiceRecorder.java +0 -205
- package/dist/esm/VoiceRecorderImpl.d.ts +0 -27
- package/dist/esm/VoiceRecorderImpl.js.map +0 -1
- package/dist/esm/helper/get-blob-duration.js.map +0 -1
- package/dist/esm/predefined-web-responses.js.map +0 -1
- package/ios/Sources/VoiceRecorder/RecordOptions.swift +0 -8
- package/ios/Sources/VoiceRecorder/VoiceRecorder.swift +0 -170
- /package/dist/esm/{helper → platform/web}/get-blob-duration.d.ts +0 -0
- /package/dist/esm/{helper → platform/web}/get-blob-duration.js +0 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import AVFoundation
|
|
3
|
+
import Capacitor
|
|
4
|
+
|
|
5
|
+
/// Capacitor bridge for the VoiceRecorder plugin.
|
|
6
|
+
@objc(VoiceRecorder)
|
|
7
|
+
public class VoiceRecorder: CAPPlugin, CAPBridgedPlugin {
|
|
8
|
+
/// Plugin identifier used by Capacitor.
|
|
9
|
+
public let identifier = "VoiceRecorder"
|
|
10
|
+
/// JavaScript name used for the plugin proxy.
|
|
11
|
+
public let jsName = "VoiceRecorder"
|
|
12
|
+
/// Supported plugin methods exposed to the JS layer.
|
|
13
|
+
public let pluginMethods: [CAPPluginMethod] = [
|
|
14
|
+
CAPPluginMethod(name: "canDeviceVoiceRecord", returnType: CAPPluginReturnPromise),
|
|
15
|
+
CAPPluginMethod(name: "requestAudioRecordingPermission", returnType: CAPPluginReturnPromise),
|
|
16
|
+
CAPPluginMethod(name: "hasAudioRecordingPermission", returnType: CAPPluginReturnPromise),
|
|
17
|
+
CAPPluginMethod(name: "startRecording", returnType: CAPPluginReturnPromise),
|
|
18
|
+
CAPPluginMethod(name: "stopRecording", returnType: CAPPluginReturnPromise),
|
|
19
|
+
CAPPluginMethod(name: "pauseRecording", returnType: CAPPluginReturnPromise),
|
|
20
|
+
CAPPluginMethod(name: "resumeRecording", returnType: CAPPluginReturnPromise),
|
|
21
|
+
CAPPluginMethod(name: "getCurrentStatus", returnType: CAPPluginReturnPromise),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
/// Service layer that performs recording operations.
|
|
25
|
+
private var service: VoiceRecorderService?
|
|
26
|
+
/// Response format derived from plugin configuration.
|
|
27
|
+
private var responseFormat: ResponseFormat = .legacy
|
|
28
|
+
|
|
29
|
+
/// Initializes dependencies after the plugin loads.
|
|
30
|
+
public override func load() {
|
|
31
|
+
super.load()
|
|
32
|
+
responseFormat = ResponseFormat(config: getConfig())
|
|
33
|
+
service = VoiceRecorderService(
|
|
34
|
+
platform: DefaultRecorderPlatform(),
|
|
35
|
+
permissionChecker: { [weak self] in
|
|
36
|
+
self?.doesUserGaveAudioRecordingPermission() ?? false
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Returns whether the device can record audio.
|
|
42
|
+
@objc func canDeviceVoiceRecord(_ call: CAPPluginCall) {
|
|
43
|
+
let canRecord = service?.canDeviceVoiceRecord() ?? false
|
|
44
|
+
call.resolve(ResponseGenerator.fromBoolean(canRecord))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Requests microphone permission from the user.
|
|
48
|
+
@objc func requestAudioRecordingPermission(_ call: CAPPluginCall) {
|
|
49
|
+
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
|
50
|
+
if granted {
|
|
51
|
+
call.resolve(ResponseGenerator.successResponse())
|
|
52
|
+
} else {
|
|
53
|
+
call.resolve(ResponseGenerator.failResponse())
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Returns whether the app has microphone permission.
|
|
59
|
+
@objc func hasAudioRecordingPermission(_ call: CAPPluginCall) {
|
|
60
|
+
let hasPermission = service?.hasAudioRecordingPermission() ?? false
|
|
61
|
+
call.resolve(ResponseGenerator.fromBoolean(hasPermission))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// Starts a recording session with optional file output.
|
|
65
|
+
@objc func startRecording(_ call: CAPPluginCall) {
|
|
66
|
+
guard let service = service else {
|
|
67
|
+
call.reject(Messages.FAILED_TO_RECORD, ErrorCodes.failedToRecord)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let directory: String? = call.getString("directory")
|
|
72
|
+
let subDirectory: String? = call.getString("subDirectory")
|
|
73
|
+
let recordOptions = RecordOptions(directory: directory, subDirectory: subDirectory)
|
|
74
|
+
do {
|
|
75
|
+
try service.startRecording(
|
|
76
|
+
options: recordOptions,
|
|
77
|
+
onInterruptionBegan: { [weak self] in
|
|
78
|
+
self?.notifyListeners("voiceRecordingInterrupted", data: [:])
|
|
79
|
+
},
|
|
80
|
+
onInterruptionEnded: { [weak self] in
|
|
81
|
+
self?.notifyListeners("voiceRecordingInterruptionEnded", data: [:])
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
call.resolve(ResponseGenerator.successResponse())
|
|
85
|
+
} catch let error as VoiceRecorderServiceError {
|
|
86
|
+
call.reject(toLegacyMessage(error.code), error.code, error.underlyingError ?? error)
|
|
87
|
+
} catch {
|
|
88
|
+
call.reject(Messages.FAILED_TO_RECORD, ErrorCodes.failedToRecord, error)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// Stops recording and returns the audio payload.
|
|
93
|
+
@objc func stopRecording(_ call: CAPPluginCall) {
|
|
94
|
+
guard let service = service else {
|
|
95
|
+
call.reject(Messages.FAILED_TO_FETCH_RECORDING, ErrorCodes.failedToFetchRecording)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
service.stopRecording { [weak self] result in
|
|
100
|
+
DispatchQueue.main.async {
|
|
101
|
+
guard let self = self else {
|
|
102
|
+
call.reject(Messages.FAILED_TO_FETCH_RECORDING, ErrorCodes.failedToFetchRecording)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
switch result {
|
|
107
|
+
case .success(let recordData):
|
|
108
|
+
let payload: Dictionary<String, Any>
|
|
109
|
+
if self.responseFormat == .normalized {
|
|
110
|
+
payload = RecordDataMapper.toNormalizedDictionary(recordData)
|
|
111
|
+
} else {
|
|
112
|
+
payload = RecordDataMapper.toLegacyDictionary(recordData)
|
|
113
|
+
}
|
|
114
|
+
call.resolve(ResponseGenerator.dataResponse(payload))
|
|
115
|
+
case .failure(let error):
|
|
116
|
+
call.reject(self.toLegacyMessage(error.code), error.code, error.underlyingError ?? error)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// Pauses a recording session if supported.
|
|
123
|
+
@objc func pauseRecording(_ call: CAPPluginCall) {
|
|
124
|
+
guard let service = service else {
|
|
125
|
+
call.reject(Messages.RECORDING_HAS_NOT_STARTED, ErrorCodes.recordingHasNotStarted)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
do {
|
|
130
|
+
call.resolve(ResponseGenerator.fromBoolean(try service.pauseRecording()))
|
|
131
|
+
} catch let error as VoiceRecorderServiceError {
|
|
132
|
+
call.reject(toLegacyMessage(error.code), error.code, error.underlyingError ?? error)
|
|
133
|
+
} catch {
|
|
134
|
+
call.reject(Messages.FAILED_TO_RECORD, ErrorCodes.failedToRecord, error)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// Resumes a paused recording session if supported.
|
|
139
|
+
@objc func resumeRecording(_ call: CAPPluginCall) {
|
|
140
|
+
guard let service = service else {
|
|
141
|
+
call.reject(Messages.RECORDING_HAS_NOT_STARTED, ErrorCodes.recordingHasNotStarted)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
do {
|
|
146
|
+
call.resolve(ResponseGenerator.fromBoolean(try service.resumeRecording()))
|
|
147
|
+
} catch let error as VoiceRecorderServiceError {
|
|
148
|
+
call.reject(toLegacyMessage(error.code), error.code, error.underlyingError ?? error)
|
|
149
|
+
} catch {
|
|
150
|
+
call.reject(Messages.FAILED_TO_RECORD, ErrorCodes.failedToRecord, error)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/// Returns the current recording status.
|
|
155
|
+
@objc func getCurrentStatus(_ call: CAPPluginCall) {
|
|
156
|
+
let status = service?.getCurrentStatus() ?? .NONE
|
|
157
|
+
call.resolve(ResponseGenerator.statusResponse(status))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/// Returns whether AVAudioSession reports granted permission.
|
|
161
|
+
func doesUserGaveAudioRecordingPermission() -> Bool {
|
|
162
|
+
return AVAudioSession.sharedInstance().recordPermission == AVAudioSession.RecordPermission.granted
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/// Maps canonical error codes back to legacy messages.
|
|
166
|
+
private func toLegacyMessage(_ canonicalCode: String) -> String {
|
|
167
|
+
if canonicalCode == ErrorCodes.deviceCannotVoiceRecord {
|
|
168
|
+
return Messages.CANNOT_RECORD_ON_THIS_PHONE
|
|
169
|
+
}
|
|
170
|
+
return canonicalCode
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Canonical error codes returned by the plugin.
|
|
4
|
+
struct ErrorCodes {
|
|
5
|
+
static let missingPermission = "MISSING_PERMISSION"
|
|
6
|
+
static let alreadyRecording = "ALREADY_RECORDING"
|
|
7
|
+
static let microphoneBeingUsed = "MICROPHONE_BEING_USED"
|
|
8
|
+
static let deviceCannotVoiceRecord = "DEVICE_CANNOT_VOICE_RECORD"
|
|
9
|
+
static let failedToRecord = "FAILED_TO_RECORD"
|
|
10
|
+
static let emptyRecording = "EMPTY_RECORDING"
|
|
11
|
+
static let recordingHasNotStarted = "RECORDING_HAS_NOT_STARTED"
|
|
12
|
+
static let failedToFetchRecording = "FAILED_TO_FETCH_RECORDING"
|
|
13
|
+
static let failedToMergeRecording = "FAILED_TO_MERGE_RECORDING"
|
|
14
|
+
static let notSupportedOsVersion = "NOT_SUPPORTED_OS_VERSION"
|
|
15
|
+
static let couldNotQueryPermissionStatus = "COULD_NOT_QUERY_PERMISSION_STATUS"
|
|
16
|
+
}
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
|
|
3
|
+
/// Recording payload returned to the bridge layer.
|
|
3
4
|
struct RecordData {
|
|
4
5
|
|
|
6
|
+
/// Base64-encoded recording data (legacy payloads).
|
|
5
7
|
public let recordDataBase64: String?
|
|
8
|
+
/// MIME type of the recorded audio.
|
|
6
9
|
public let mimeType: String
|
|
10
|
+
/// Recording duration in milliseconds.
|
|
7
11
|
public let msDuration: Int
|
|
12
|
+
/// File path or URI to the recorded audio.
|
|
8
13
|
public let uri: String?
|
|
9
14
|
|
|
15
|
+
/// Serializes record data into the legacy payload shape.
|
|
10
16
|
public func toDictionary() -> Dictionary<String, Any> {
|
|
11
17
|
return [
|
|
12
18
|
"recordDataBase64": recordDataBase64 ?? "",
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Optional output configuration for recordings.
|
|
4
|
+
struct RecordOptions {
|
|
5
|
+
|
|
6
|
+
/// Directory name provided by the caller.
|
|
7
|
+
public let directory: String?
|
|
8
|
+
/// Subdirectory name provided by the caller.
|
|
9
|
+
public let subDirectory: String?
|
|
10
|
+
|
|
11
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Capacitor
|
|
3
|
+
|
|
4
|
+
/// Supported response payload shapes.
|
|
5
|
+
enum ResponseFormat: String {
|
|
6
|
+
case legacy
|
|
7
|
+
case normalized
|
|
8
|
+
|
|
9
|
+
/// Converts a raw config value into a response format.
|
|
10
|
+
static func from(value: String?) -> ResponseFormat {
|
|
11
|
+
guard let value = value?.lowercased(), value == "normalized" else {
|
|
12
|
+
return .legacy
|
|
13
|
+
}
|
|
14
|
+
return .normalized
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/// Reads the response format from plugin configuration.
|
|
18
|
+
init(config: PluginConfig) {
|
|
19
|
+
let value = config.getString("responseFormat", "legacy") ?? "legacy"
|
|
20
|
+
self = ResponseFormat.from(value: value)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -1,26 +1,32 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
|
|
3
|
+
/// Helper for building JS payloads in the legacy response shape.
|
|
3
4
|
struct ResponseGenerator {
|
|
4
5
|
|
|
5
6
|
private static let VALUE_RESPONSE_KEY = "value"
|
|
6
7
|
private static let STATUS_RESPONSE_KEY = "status"
|
|
7
8
|
|
|
9
|
+
/// Wraps a boolean value into the response shape.
|
|
8
10
|
public static func fromBoolean(_ value: Bool) -> Dictionary<String, Bool> {
|
|
9
11
|
return value ? successResponse() : failResponse()
|
|
10
12
|
}
|
|
11
13
|
|
|
14
|
+
/// Returns a success response with value=true.
|
|
12
15
|
public static func successResponse() -> Dictionary<String, Bool> {
|
|
13
16
|
return [VALUE_RESPONSE_KEY: true]
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
/// Returns a failure response with value=false.
|
|
16
20
|
public static func failResponse() -> Dictionary<String, Bool> {
|
|
17
21
|
return [VALUE_RESPONSE_KEY: false]
|
|
18
22
|
}
|
|
19
23
|
|
|
24
|
+
/// Wraps arbitrary data into the response shape.
|
|
20
25
|
public static func dataResponse(_ data: Any) -> Dictionary<String, Any> {
|
|
21
26
|
return [VALUE_RESPONSE_KEY: data]
|
|
22
27
|
}
|
|
23
28
|
|
|
29
|
+
/// Wraps the recording status into the response shape.
|
|
24
30
|
public static func statusResponse(_ data: CurrentRecordingStatus) -> Dictionary<String, String> {
|
|
25
31
|
return [STATUS_RESPONSE_KEY: data.rawValue]
|
|
26
32
|
}
|
package/ios/Sources/VoiceRecorder/{CustomMediaRecorder.swift → Platform/CustomMediaRecorder.swift}
RENAMED
|
@@ -1,26 +1,70 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
import AVFoundation
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
protocol AudioSessionProtocol: AnyObject {
|
|
5
|
+
var category: AVAudioSession.Category { get }
|
|
6
|
+
func setCategory(_ category: AVAudioSession.Category) throws
|
|
7
|
+
func setActive(_ active: Bool, options: AVAudioSession.SetActiveOptions) throws
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
protocol AudioRecorderProtocol: AnyObject {
|
|
11
|
+
@discardableResult
|
|
12
|
+
func record() -> Bool
|
|
13
|
+
func stop()
|
|
14
|
+
func pause()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
typealias AudioRecorderFactory = (_ url: URL, _ settings: [String: Any]) throws -> AudioRecorderProtocol
|
|
18
|
+
|
|
19
|
+
extension AVAudioSession: AudioSessionProtocol {}
|
|
20
|
+
extension AVAudioRecorder: AudioRecorderProtocol {}
|
|
5
21
|
|
|
22
|
+
/// AVAudioRecorder wrapper that supports interruptions and segment merging.
|
|
23
|
+
class CustomMediaRecorder: RecorderAdapter {
|
|
24
|
+
|
|
25
|
+
private let audioSessionProvider: () -> AudioSessionProtocol
|
|
26
|
+
private let audioRecorderFactory: AudioRecorderFactory
|
|
27
|
+
|
|
28
|
+
/// Options provided by the service layer.
|
|
6
29
|
public var options: RecordOptions?
|
|
7
|
-
|
|
8
|
-
private var
|
|
30
|
+
/// Active audio session for recording.
|
|
31
|
+
private var recordingSession: AudioSessionProtocol!
|
|
32
|
+
/// Active recorder instance for the current segment.
|
|
33
|
+
private var audioRecorder: AudioRecorderProtocol!
|
|
34
|
+
/// Base file path for the merged recording.
|
|
9
35
|
private var baseAudioFilePath: URL!
|
|
36
|
+
/// List of segment files created during interruptions.
|
|
10
37
|
private var audioFileSegments: [URL] = []
|
|
38
|
+
/// Audio session category before recording starts.
|
|
11
39
|
private var originalRecordingSessionCategory: AVAudioSession.Category!
|
|
40
|
+
/// Current recording status.
|
|
12
41
|
private var status = CurrentRecordingStatus.NONE
|
|
42
|
+
/// Notification observer for audio interruptions.
|
|
13
43
|
private var interruptionObserver: NSObjectProtocol?
|
|
44
|
+
/// Callback invoked when interruptions begin.
|
|
14
45
|
var onInterruptionBegan: (() -> Void)?
|
|
46
|
+
/// Callback invoked when interruptions end.
|
|
15
47
|
var onInterruptionEnded: (() -> Void)?
|
|
16
48
|
|
|
17
|
-
|
|
49
|
+
/// Recorder settings used for all segments.
|
|
50
|
+
private let settings: [String: Any] = [
|
|
18
51
|
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
|
|
19
52
|
AVSampleRateKey: 44100,
|
|
20
53
|
AVNumberOfChannelsKey: 1,
|
|
21
54
|
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
|
|
22
55
|
]
|
|
23
56
|
|
|
57
|
+
init(
|
|
58
|
+
audioSessionProvider: @escaping () -> AudioSessionProtocol = { AVAudioSession.sharedInstance() },
|
|
59
|
+
audioRecorderFactory: @escaping AudioRecorderFactory = { url, settings in
|
|
60
|
+
return try AVAudioRecorder(url: url, settings: settings)
|
|
61
|
+
}
|
|
62
|
+
) {
|
|
63
|
+
self.audioSessionProvider = audioSessionProvider
|
|
64
|
+
self.audioRecorderFactory = audioRecorderFactory
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// Resolves the directory where audio files should be saved.
|
|
24
68
|
private func getDirectoryToSaveAudioFile() -> URL {
|
|
25
69
|
if options?.directory != nil,
|
|
26
70
|
let directory = getDirectory(directory: options?.directory),
|
|
@@ -43,16 +87,17 @@ class CustomMediaRecorder {
|
|
|
43
87
|
return URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
|
44
88
|
}
|
|
45
89
|
|
|
90
|
+
/// Starts recording audio and prepares the session.
|
|
46
91
|
public func startRecording(recordOptions: RecordOptions?) -> Bool {
|
|
47
92
|
do {
|
|
48
93
|
options = recordOptions
|
|
49
|
-
recordingSession =
|
|
94
|
+
recordingSession = audioSessionProvider()
|
|
50
95
|
originalRecordingSessionCategory = recordingSession.category
|
|
51
96
|
try recordingSession.setCategory(AVAudioSession.Category.playAndRecord)
|
|
52
|
-
try recordingSession.setActive(true)
|
|
97
|
+
try recordingSession.setActive(true, options: [])
|
|
53
98
|
baseAudioFilePath = getDirectoryToSaveAudioFile().appendingPathComponent("recording-\(Int(Date().timeIntervalSince1970 * 1000)).aac")
|
|
54
99
|
audioFileSegments = [baseAudioFilePath]
|
|
55
|
-
audioRecorder = try
|
|
100
|
+
audioRecorder = try audioRecorderFactory(baseAudioFilePath, settings)
|
|
56
101
|
setupInterruptionHandling()
|
|
57
102
|
audioRecorder.record()
|
|
58
103
|
status = CurrentRecordingStatus.RECORDING
|
|
@@ -62,6 +107,7 @@ class CustomMediaRecorder {
|
|
|
62
107
|
}
|
|
63
108
|
}
|
|
64
109
|
|
|
110
|
+
/// Stops recording and merges segments if needed.
|
|
65
111
|
public func stopRecording(completion: @escaping (Bool) -> Void) {
|
|
66
112
|
removeInterruptionHandling()
|
|
67
113
|
audioRecorder.stop()
|
|
@@ -73,7 +119,7 @@ class CustomMediaRecorder {
|
|
|
73
119
|
}
|
|
74
120
|
|
|
75
121
|
do {
|
|
76
|
-
try self.recordingSession.setActive(false)
|
|
122
|
+
try self.recordingSession.setActive(false, options: [])
|
|
77
123
|
try self.recordingSession.setCategory(self.originalRecordingSessionCategory)
|
|
78
124
|
} catch {
|
|
79
125
|
}
|
|
@@ -100,10 +146,12 @@ class CustomMediaRecorder {
|
|
|
100
146
|
}
|
|
101
147
|
}
|
|
102
148
|
|
|
149
|
+
/// Returns the output file for the recording.
|
|
103
150
|
public func getOutputFile() -> URL {
|
|
104
151
|
return baseAudioFilePath
|
|
105
152
|
}
|
|
106
153
|
|
|
154
|
+
/// Maps directory strings to FileManager search paths.
|
|
107
155
|
public func getDirectory(directory: String?) -> FileManager.SearchPathDirectory? {
|
|
108
156
|
if let directory = directory {
|
|
109
157
|
switch directory {
|
|
@@ -118,6 +166,7 @@ class CustomMediaRecorder {
|
|
|
118
166
|
return nil
|
|
119
167
|
}
|
|
120
168
|
|
|
169
|
+
/// Pauses recording when currently active.
|
|
121
170
|
public func pauseRecording() -> Bool {
|
|
122
171
|
if(status == CurrentRecordingStatus.RECORDING) {
|
|
123
172
|
audioRecorder.pause()
|
|
@@ -128,17 +177,18 @@ class CustomMediaRecorder {
|
|
|
128
177
|
}
|
|
129
178
|
}
|
|
130
179
|
|
|
180
|
+
/// Resumes recording after pause or interruption.
|
|
131
181
|
public func resumeRecording() -> Bool {
|
|
132
182
|
if(status == CurrentRecordingStatus.PAUSED || status == CurrentRecordingStatus.INTERRUPTED) {
|
|
133
183
|
let wasInterrupted = status == CurrentRecordingStatus.INTERRUPTED
|
|
134
184
|
do {
|
|
135
|
-
try recordingSession.setActive(true)
|
|
185
|
+
try recordingSession.setActive(true, options: [])
|
|
136
186
|
if status == CurrentRecordingStatus.INTERRUPTED {
|
|
137
187
|
let directory = getDirectoryToSaveAudioFile()
|
|
138
188
|
let timestamp = Int(Date().timeIntervalSince1970 * 1000)
|
|
139
189
|
let segmentNumber = audioFileSegments.count
|
|
140
190
|
let segmentPath = directory.appendingPathComponent("recording-\(timestamp)-segment-\(segmentNumber).aac")
|
|
141
|
-
audioRecorder = try
|
|
191
|
+
audioRecorder = try audioRecorderFactory(segmentPath, settings)
|
|
142
192
|
audioFileSegments.append(segmentPath)
|
|
143
193
|
}
|
|
144
194
|
audioRecorder.record()
|
|
@@ -146,7 +196,7 @@ class CustomMediaRecorder {
|
|
|
146
196
|
return true
|
|
147
197
|
} catch {
|
|
148
198
|
if wasInterrupted {
|
|
149
|
-
try? recordingSession.setActive(false)
|
|
199
|
+
try? recordingSession.setActive(false, options: [])
|
|
150
200
|
}
|
|
151
201
|
return false
|
|
152
202
|
}
|
|
@@ -155,20 +205,23 @@ class CustomMediaRecorder {
|
|
|
155
205
|
return false
|
|
156
206
|
}
|
|
157
207
|
|
|
208
|
+
/// Returns the current recording status.
|
|
158
209
|
public func getCurrentStatus() -> CurrentRecordingStatus {
|
|
159
210
|
return status
|
|
160
211
|
}
|
|
161
212
|
|
|
213
|
+
/// Registers for interruption notifications.
|
|
162
214
|
private func setupInterruptionHandling() {
|
|
163
215
|
interruptionObserver = NotificationCenter.default.addObserver(
|
|
164
216
|
forName: AVAudioSession.interruptionNotification,
|
|
165
|
-
object:
|
|
217
|
+
object: recordingSession,
|
|
166
218
|
queue: .main
|
|
167
219
|
) { [weak self] notification in
|
|
168
220
|
self?.handleInterruption(notification: notification)
|
|
169
221
|
}
|
|
170
222
|
}
|
|
171
223
|
|
|
224
|
+
/// Removes interruption observers.
|
|
172
225
|
private func removeInterruptionHandling() {
|
|
173
226
|
if let observer = interruptionObserver {
|
|
174
227
|
NotificationCenter.default.removeObserver(observer)
|
|
@@ -176,6 +229,7 @@ class CustomMediaRecorder {
|
|
|
176
229
|
}
|
|
177
230
|
}
|
|
178
231
|
|
|
232
|
+
/// Handles audio session interruptions.
|
|
179
233
|
private func handleInterruption(notification: Notification) {
|
|
180
234
|
guard let userInfo = notification.userInfo,
|
|
181
235
|
let interruptionTypeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
|
|
@@ -201,6 +255,7 @@ class CustomMediaRecorder {
|
|
|
201
255
|
}
|
|
202
256
|
}
|
|
203
257
|
|
|
258
|
+
/// Merges recorded segments into a single file when interruptions occur.
|
|
204
259
|
private func mergeAudioSegments(completion: @escaping (Bool) -> Void) {
|
|
205
260
|
if audioFileSegments.count <= 1 {
|
|
206
261
|
completion(true)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Service layer that orchestrates recording operations.
|
|
4
|
+
final class VoiceRecorderService {
|
|
5
|
+
/// Platform adapter for device and file operations.
|
|
6
|
+
private let platform: RecorderPlatform
|
|
7
|
+
/// Closure used to check microphone permission.
|
|
8
|
+
private let permissionChecker: () -> Bool
|
|
9
|
+
/// Factory for creating recorder adapters.
|
|
10
|
+
private let recorderFactory: () -> RecorderAdapter
|
|
11
|
+
/// Active recorder instance for the current session.
|
|
12
|
+
private var recorder: RecorderAdapter?
|
|
13
|
+
|
|
14
|
+
init(
|
|
15
|
+
platform: RecorderPlatform,
|
|
16
|
+
permissionChecker: @escaping () -> Bool,
|
|
17
|
+
recorderFactory: @escaping () -> RecorderAdapter = { CustomMediaRecorder() }
|
|
18
|
+
) {
|
|
19
|
+
self.platform = platform
|
|
20
|
+
self.permissionChecker = permissionChecker
|
|
21
|
+
self.recorderFactory = recorderFactory
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/// Returns whether the device can record audio.
|
|
25
|
+
func canDeviceVoiceRecord() -> Bool {
|
|
26
|
+
return platform.canDeviceVoiceRecord()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// Returns whether the app has microphone permission.
|
|
30
|
+
func hasAudioRecordingPermission() -> Bool {
|
|
31
|
+
return permissionChecker()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// Starts a recording session or throws a service error.
|
|
35
|
+
func startRecording(
|
|
36
|
+
options: RecordOptions?,
|
|
37
|
+
onInterruptionBegan: @escaping () -> Void,
|
|
38
|
+
onInterruptionEnded: @escaping () -> Void
|
|
39
|
+
) throws {
|
|
40
|
+
if !platform.canDeviceVoiceRecord() {
|
|
41
|
+
throw VoiceRecorderServiceError(code: ErrorCodes.deviceCannotVoiceRecord)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if !permissionChecker() {
|
|
45
|
+
throw VoiceRecorderServiceError(code: ErrorCodes.missingPermission)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if recorder != nil {
|
|
49
|
+
throw VoiceRecorderServiceError(code: ErrorCodes.alreadyRecording)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let nextRecorder = recorderFactory()
|
|
53
|
+
nextRecorder.onInterruptionBegan = onInterruptionBegan
|
|
54
|
+
nextRecorder.onInterruptionEnded = onInterruptionEnded
|
|
55
|
+
let started = nextRecorder.startRecording(recordOptions: options)
|
|
56
|
+
if !started {
|
|
57
|
+
recorder = nil
|
|
58
|
+
throw VoiceRecorderServiceError(code: ErrorCodes.deviceCannotVoiceRecord)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
recorder = nextRecorder
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// Stops recording and returns the payload asynchronously.
|
|
65
|
+
func stopRecording(completion: @escaping (Result<RecordData, VoiceRecorderServiceError>) -> Void) {
|
|
66
|
+
guard let recorder = recorder else {
|
|
67
|
+
completion(.failure(VoiceRecorderServiceError(code: ErrorCodes.recordingHasNotStarted)))
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
recorder.stopRecording { [weak self] stopSuccess in
|
|
72
|
+
guard let self = self else {
|
|
73
|
+
completion(.failure(VoiceRecorderServiceError(code: ErrorCodes.failedToFetchRecording)))
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if !stopSuccess {
|
|
78
|
+
self.recorder = nil
|
|
79
|
+
completion(.failure(VoiceRecorderServiceError(code: ErrorCodes.failedToMergeRecording)))
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let audioFileUrl = recorder.getOutputFile()
|
|
84
|
+
let fileExtension = audioFileUrl.pathExtension.lowercased()
|
|
85
|
+
let mimeType = fileExtension == "m4a" ? "audio/mp4" : "audio/aac"
|
|
86
|
+
let sendDataAsBase64 = recorder.options?.directory == nil
|
|
87
|
+
let recordDataBase64 = sendDataAsBase64 ? self.platform.readFileAsBase64(audioFileUrl) : nil
|
|
88
|
+
let uri = sendDataAsBase64 ? nil : audioFileUrl.path
|
|
89
|
+
let recordData = RecordData(
|
|
90
|
+
recordDataBase64: recordDataBase64,
|
|
91
|
+
mimeType: mimeType,
|
|
92
|
+
msDuration: self.platform.getDurationMs(audioFileUrl),
|
|
93
|
+
uri: uri
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
self.recorder = nil
|
|
97
|
+
if (sendDataAsBase64 && recordData.recordDataBase64 == nil) || recordData.msDuration < 0 {
|
|
98
|
+
completion(.failure(VoiceRecorderServiceError(code: ErrorCodes.emptyRecording)))
|
|
99
|
+
} else {
|
|
100
|
+
completion(.success(recordData))
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// Pauses the active recording session.
|
|
106
|
+
func pauseRecording() throws -> Bool {
|
|
107
|
+
guard let recorder = recorder else {
|
|
108
|
+
throw VoiceRecorderServiceError(code: ErrorCodes.recordingHasNotStarted)
|
|
109
|
+
}
|
|
110
|
+
return recorder.pauseRecording()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/// Resumes a paused recording session.
|
|
114
|
+
func resumeRecording() throws -> Bool {
|
|
115
|
+
guard let recorder = recorder else {
|
|
116
|
+
throw VoiceRecorderServiceError(code: ErrorCodes.recordingHasNotStarted)
|
|
117
|
+
}
|
|
118
|
+
return recorder.resumeRecording()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// Returns the current recording status.
|
|
122
|
+
func getCurrentStatus() -> CurrentRecordingStatus {
|
|
123
|
+
guard let recorder = recorder else {
|
|
124
|
+
return .NONE
|
|
125
|
+
}
|
|
126
|
+
return recorder.getCurrentStatus()
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Error wrapper that carries a canonical error code.
|
|
4
|
+
struct VoiceRecorderServiceError: Error {
|
|
5
|
+
/// Canonical error code for bridge mapping.
|
|
6
|
+
let code: String
|
|
7
|
+
/// Underlying error, when available.
|
|
8
|
+
let underlyingError: Error?
|
|
9
|
+
|
|
10
|
+
init(code: String, underlyingError: Error? = nil) {
|
|
11
|
+
self.code = code
|
|
12
|
+
self.underlyingError = underlyingError
|
|
13
|
+
}
|
|
14
|
+
}
|
package/package.json
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"url": "https://github.com/independo-gmbh/capacitor-voice-recorder.git"
|
|
14
14
|
},
|
|
15
15
|
"description": "Capacitor plugin for voice recording",
|
|
16
|
-
"version": "8.1.0-dev.
|
|
16
|
+
"version": "8.1.0-dev.3",
|
|
17
17
|
"devDependencies": {
|
|
18
18
|
"@capacitor/android": "^8.0.0",
|
|
19
19
|
"conventional-changelog-conventionalcommits": "^9.1.0",
|
|
@@ -60,12 +60,18 @@
|
|
|
60
60
|
"prepublishOnly": "npm run build",
|
|
61
61
|
"verify": "npm run verify:ios && npm run verify:android && npm run verify:web",
|
|
62
62
|
"verify:android": "node scripts/verify-android.js",
|
|
63
|
+
"verify:ios": "xcodebuild -scheme IndependoCapacitorVoiceRecorder -destination generic/platform=iOS",
|
|
64
|
+
"verify:web": "npm run build",
|
|
63
65
|
"watch": "tsc --watch",
|
|
64
|
-
"test": "
|
|
66
|
+
"test": "npm run test:web && npm run test:android && npm run test:ios",
|
|
67
|
+
"test:web": "jest",
|
|
68
|
+
"test:web:coverage": "jest --coverage --coverageReporters=lcov --coverageReporters=text-summary",
|
|
69
|
+
"test:android": "node scripts/verify-android.js testDebugUnitTest",
|
|
70
|
+
"test:android:coverage": "node scripts/verify-android.js testDebugUnitTest jacocoTestReport",
|
|
71
|
+
"test:ios": "node scripts/test-ios.js",
|
|
72
|
+
"test:ios:coverage": "node scripts/test-ios.js --coverage --result-bundle-path coverage/ios.xcresult && node scripts/xccov-to-cobertura.js --xcresult coverage/ios.xcresult --output coverage/ios-cobertura.xml",
|
|
65
73
|
"eslint": "eslint . --ext ts",
|
|
66
|
-
"verify:ios": "xcodebuild -scheme IndependoCapacitorVoiceRecorder -destination generic/platform=iOS",
|
|
67
74
|
"clean": "rm -rf dist",
|
|
68
|
-
"verify:web": "npm run build",
|
|
69
75
|
"swiftlint": "node-swiftlint",
|
|
70
76
|
"docgen": "docgen --api VoiceRecorderPlugin --output-readme README.md --output-json dist/docs.json",
|
|
71
77
|
"fmt": "npm run eslint -- --fix"
|