@clarionhq/recorder 0.0.1 → 0.1.0
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 +110 -0
- package/android/build.gradle +97 -0
- package/android/gradle.properties +4 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/cpp/cpp-adapter.cpp +7 -0
- package/android/src/main/java/com/clarionhq/recorder/AudioLevelMeter.kt +38 -0
- package/android/src/main/java/com/clarionhq/recorder/ClarionRecorderPackage.kt +27 -0
- package/android/src/main/java/com/clarionhq/recorder/EncodeFile.kt +62 -0
- package/android/src/main/java/com/clarionhq/recorder/RecorderConfig.kt +33 -0
- package/android/src/main/java/com/clarionhq/recorder/RecorderConstants.kt +16 -0
- package/android/src/main/java/com/clarionhq/recorder/RecorderSession.kt +336 -0
- package/android/src/main/java/com/clarionhq/recorder/RecorderTypes.kt +29 -0
- package/android/src/main/java/com/margelo/nitro/clarion/recorder/HybridClarionRecorder.kt +174 -0
- package/clarionhq-recorder.podspec +31 -0
- package/ios/AudioLevelMeter.swift +37 -0
- package/ios/EncodeFile.swift +69 -0
- package/ios/HybridClarionRecorder.swift +186 -0
- package/ios/RecorderConstants.swift +11 -0
- package/ios/RecorderSession.swift +278 -0
- package/ios/RecorderTypes.swift +41 -0
- package/lib/RecorderEngine.d.ts +31 -0
- package/lib/RecorderEngine.d.ts.map +1 -0
- package/lib/RecorderEngine.js +245 -0
- package/lib/RecorderEngine.js.map +1 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +2 -0
- package/lib/index.js.map +1 -0
- package/lib/native.d.ts +3 -0
- package/lib/native.d.ts.map +1 -0
- package/lib/native.js +3 -0
- package/lib/native.js.map +1 -0
- package/lib/specs/ClarionRecorder.nitro.d.ts +49 -0
- package/lib/specs/ClarionRecorder.nitro.d.ts.map +1 -0
- package/lib/specs/ClarionRecorder.nitro.js +2 -0
- package/lib/specs/ClarionRecorder.nitro.js.map +1 -0
- package/nitro.json +24 -0
- package/nitrogen/generated/android/ClarionRecorder+autolinking.cmake +81 -0
- package/nitrogen/generated/android/ClarionRecorder+autolinking.gradle +27 -0
- package/nitrogen/generated/android/ClarionRecorderOnLoad.cpp +62 -0
- package/nitrogen/generated/android/ClarionRecorderOnLoad.hpp +34 -0
- package/nitrogen/generated/android/c++/JFunc_void_NativeRecorderError.hpp +78 -0
- package/nitrogen/generated/android/c++/JFunc_void_double_double.hpp +75 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__string_double_double_double.hpp +76 -0
- package/nitrogen/generated/android/c++/JHybridClarionRecorderSpec.cpp +207 -0
- package/nitrogen/generated/android/c++/JHybridClarionRecorderSpec.hpp +75 -0
- package/nitrogen/generated/android/c++/JNativeRecorderConfig.hpp +90 -0
- package/nitrogen/generated/android/c++/JNativeRecorderError.hpp +65 -0
- package/nitrogen/generated/android/c++/JNativeRecorderResult.hpp +77 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/ClarionRecorderOnLoad.kt +35 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/Func_void_NativeRecorderError.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/Func_void_double_double.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/Func_void_std__string.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/Func_void_std__string_double_double_double.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/HybridClarionRecorderSpec.kt +125 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/NativeRecorderConfig.kt +91 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/NativeRecorderError.kt +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/NativeRecorderResult.kt +76 -0
- package/nitrogen/generated/ios/ClarionRecorder+autolinking.rb +62 -0
- package/nitrogen/generated/ios/ClarionRecorder-Swift-Cxx-Bridge.cpp +89 -0
- package/nitrogen/generated/ios/ClarionRecorder-Swift-Cxx-Bridge.hpp +297 -0
- package/nitrogen/generated/ios/ClarionRecorder-Swift-Cxx-Umbrella.hpp +56 -0
- package/nitrogen/generated/ios/ClarionRecorderAutolinking.mm +33 -0
- package/nitrogen/generated/ios/ClarionRecorderAutolinking.swift +26 -0
- package/nitrogen/generated/ios/c++/HybridClarionRecorderSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridClarionRecorderSpecSwift.hpp +188 -0
- package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_NativeRecorderError.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_NativeRecorderResult.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_double_double.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string_double_double_double.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridClarionRecorderSpec.swift +67 -0
- package/nitrogen/generated/ios/swift/HybridClarionRecorderSpec_cxx.swift +354 -0
- package/nitrogen/generated/ios/swift/NativeRecorderConfig.swift +108 -0
- package/nitrogen/generated/ios/swift/NativeRecorderError.swift +39 -0
- package/nitrogen/generated/ios/swift/NativeRecorderResult.swift +54 -0
- package/nitrogen/generated/shared/c++/HybridClarionRecorderSpec.cpp +34 -0
- package/nitrogen/generated/shared/c++/HybridClarionRecorderSpec.hpp +84 -0
- package/nitrogen/generated/shared/c++/NativeRecorderConfig.hpp +116 -0
- package/nitrogen/generated/shared/c++/NativeRecorderError.hpp +91 -0
- package/nitrogen/generated/shared/c++/NativeRecorderResult.hpp +103 -0
- package/package.json +66 -8
- package/src/RecorderEngine.ts +298 -0
- package/src/index.ts +8 -0
- package/src/native.ts +5 -0
- package/src/specs/ClarionRecorder.nitro.ts +58 -0
- package/index.js +0 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import NitroModules
|
|
3
|
+
|
|
4
|
+
public typealias StateClosure = (String) -> Void
|
|
5
|
+
public typealias AudioLevelClosure = (Double, Double) -> Void
|
|
6
|
+
public typealias ChunkClosure = (String, Double, Double, Double) -> Void
|
|
7
|
+
public typealias ErrorClosure = (NativeRecorderError) -> Void
|
|
8
|
+
|
|
9
|
+
public final class HybridClarionRecorder: HybridClarionRecorderSpec {
|
|
10
|
+
private var session: RecorderSession?
|
|
11
|
+
private var currentStateValue: String = "idle"
|
|
12
|
+
|
|
13
|
+
private var nextListenerId: Int = 1
|
|
14
|
+
private var stateListeners: [Int: StateClosure] = [:]
|
|
15
|
+
private var audioLevelListeners: [Int: AudioLevelClosure] = [:]
|
|
16
|
+
private var chunkListeners: [Int: ChunkClosure] = [:]
|
|
17
|
+
private var errorListeners: [Int: ErrorClosure] = [:]
|
|
18
|
+
|
|
19
|
+
private let listenerLock = NSLock()
|
|
20
|
+
|
|
21
|
+
public var state: String { currentStateValue }
|
|
22
|
+
|
|
23
|
+
public func prepare(config: NativeRecorderConfig) throws -> Promise<Void> {
|
|
24
|
+
return Promise.async { [weak self] in
|
|
25
|
+
guard let self else { return }
|
|
26
|
+
let parsed = config.toSwiftConfig()
|
|
27
|
+
let newSession = RecorderSession(config: parsed, callbacks: self)
|
|
28
|
+
self.emit(state: "preparing")
|
|
29
|
+
try await newSession.prepare()
|
|
30
|
+
self.session = newSession
|
|
31
|
+
self.emit(state: "ready")
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public func start() throws -> Promise<Void> {
|
|
36
|
+
return Promise.async { [weak self] in
|
|
37
|
+
guard let self else { return }
|
|
38
|
+
guard let session = self.session else {
|
|
39
|
+
throw RecorderError(code: "ENGINE_NOT_READY", message: "Call prepare() first")
|
|
40
|
+
}
|
|
41
|
+
self.emit(state: "starting")
|
|
42
|
+
try session.start()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
public func pause() throws -> Promise<Void> {
|
|
47
|
+
return Promise.async { [weak self] in
|
|
48
|
+
try self?.session?.pause()
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public func resume() throws -> Promise<Void> {
|
|
53
|
+
return Promise.async { [weak self] in
|
|
54
|
+
try self?.session?.resume()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
public func stop() throws -> Promise<NativeRecorderResult> {
|
|
59
|
+
return Promise.async { [weak self] in
|
|
60
|
+
guard let self, let session = self.session else {
|
|
61
|
+
throw RecorderError(code: "ENGINE_NOT_READY", message: "No session")
|
|
62
|
+
}
|
|
63
|
+
let output = try await session.stop()
|
|
64
|
+
return output.toNative()
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
public func discard() throws -> Promise<Void> {
|
|
69
|
+
return Promise.async { [weak self] in
|
|
70
|
+
await self?.session?.discard()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public func release() throws -> Promise<Void> {
|
|
75
|
+
return Promise.async { [weak self] in
|
|
76
|
+
guard let self else { return }
|
|
77
|
+
await self.session?.release()
|
|
78
|
+
self.session = nil
|
|
79
|
+
self.removeAllListeners()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
public func addStateListener(listener: @escaping StateClosure) throws -> Double {
|
|
84
|
+
register { id in stateListeners[id] = listener }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
public func addAudioLevelListener(listener: @escaping AudioLevelClosure) throws -> Double {
|
|
88
|
+
register { id in audioLevelListeners[id] = listener }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
public func addChunkListener(listener: @escaping ChunkClosure) throws -> Double {
|
|
92
|
+
register { id in chunkListeners[id] = listener }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
public func addErrorListener(listener: @escaping ErrorClosure) throws -> Double {
|
|
96
|
+
register { id in errorListeners[id] = listener }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
public func removeListener(id: Double) throws {
|
|
100
|
+
listenerLock.lock(); defer { listenerLock.unlock() }
|
|
101
|
+
let key = Int(id)
|
|
102
|
+
stateListeners.removeValue(forKey: key)
|
|
103
|
+
audioLevelListeners.removeValue(forKey: key)
|
|
104
|
+
chunkListeners.removeValue(forKey: key)
|
|
105
|
+
errorListeners.removeValue(forKey: key)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
public func removeAllListeners() {
|
|
109
|
+
listenerLock.lock(); defer { listenerLock.unlock() }
|
|
110
|
+
stateListeners.removeAll()
|
|
111
|
+
audioLevelListeners.removeAll()
|
|
112
|
+
chunkListeners.removeAll()
|
|
113
|
+
errorListeners.removeAll()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private func register(_ assign: (Int) -> Void) -> Double {
|
|
117
|
+
listenerLock.lock(); defer { listenerLock.unlock() }
|
|
118
|
+
let id = nextListenerId
|
|
119
|
+
nextListenerId += 1
|
|
120
|
+
assign(id)
|
|
121
|
+
return Double(id)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private func emit(state: String) {
|
|
125
|
+
currentStateValue = state
|
|
126
|
+
snapshotListeners(stateListeners).forEach { $0(state) }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private func snapshotListeners<T>(_ map: [Int: T]) -> [T] {
|
|
130
|
+
listenerLock.lock(); defer { listenerLock.unlock() }
|
|
131
|
+
return Array(map.values)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
extension HybridClarionRecorder: RecorderCallbacks {
|
|
136
|
+
func onState(_ state: String) {
|
|
137
|
+
emit(state: state)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
func onAudioLevel(rms: Double, peak: Double) {
|
|
141
|
+
snapshotListeners(audioLevelListeners).forEach { $0(rms, peak) }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func onChunk(uri: String, startMs: Double, endMs: Double, sizeBytes: Double) {
|
|
145
|
+
snapshotListeners(chunkListeners).forEach { $0(uri, startMs, endMs, sizeBytes) }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
func onError(_ error: RecorderError) {
|
|
149
|
+
currentStateValue = "error"
|
|
150
|
+
let payload = NativeRecorderError(
|
|
151
|
+
code: error.code,
|
|
152
|
+
message: error.message,
|
|
153
|
+
recoverable: error.recoverable
|
|
154
|
+
)
|
|
155
|
+
snapshotListeners(errorListeners).forEach { $0(payload) }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private extension NativeRecorderConfig {
|
|
160
|
+
func toSwiftConfig() -> RecorderConfig {
|
|
161
|
+
return RecorderConfig(
|
|
162
|
+
sampleRate: sampleRate,
|
|
163
|
+
channels: UInt32(channels),
|
|
164
|
+
bitDepth: UInt32(bitDepth),
|
|
165
|
+
outputDirectory: outputDirectory,
|
|
166
|
+
filenamePrefix: filenamePrefix,
|
|
167
|
+
rotateAfterMs: rotateAfterMs,
|
|
168
|
+
emitAudioLevel: emitAudioLevel,
|
|
169
|
+
audioLevelIntervalMs: audioLevelIntervalMs,
|
|
170
|
+
aacBitrate: Int(aacBitrate)
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private extension RecorderOutput {
|
|
176
|
+
func toNative() -> NativeRecorderResult {
|
|
177
|
+
return NativeRecorderResult(
|
|
178
|
+
uri: uri,
|
|
179
|
+
durationMs: durationMs,
|
|
180
|
+
sizeBytes: sizeBytes,
|
|
181
|
+
sampleRate: sampleRate,
|
|
182
|
+
channels: Double(channels),
|
|
183
|
+
bitDepth: Double(bitDepth)
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
internal enum RecorderConstants {
|
|
4
|
+
static let defaultCacheSubdir = "clarion-recorder"
|
|
5
|
+
static let defaultFilenamePrefix = "clarion"
|
|
6
|
+
static let outputExtension = "m4a"
|
|
7
|
+
static let uriScheme = "file://"
|
|
8
|
+
static let captureBufferFractionOfSecond: UInt32 = 5
|
|
9
|
+
static let writeQueueLabel = "dev.clarionhq.recorder.write"
|
|
10
|
+
static let captureQueueLabel = "dev.clarionhq.recorder.capture"
|
|
11
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
internal final class RecorderSession {
|
|
5
|
+
private let config: RecorderConfig
|
|
6
|
+
private weak var callbacks: RecorderCallbacks?
|
|
7
|
+
|
|
8
|
+
private let audioEngine = AVAudioEngine()
|
|
9
|
+
private var converter: AVAudioConverter?
|
|
10
|
+
private var targetFormat: AVAudioFormat?
|
|
11
|
+
|
|
12
|
+
private var encodeFile: EncodeFile?
|
|
13
|
+
private let writeQueue = DispatchQueue(label: RecorderConstants.writeQueueLabel)
|
|
14
|
+
|
|
15
|
+
private var running = false
|
|
16
|
+
private var paused = false
|
|
17
|
+
|
|
18
|
+
private var sessionStartHostTime: UInt64 = 0
|
|
19
|
+
private var sessionStartWallMs: Double = 0
|
|
20
|
+
private var fileStartedAtMs: Double = 0
|
|
21
|
+
private var lastLevelEmitMs: Double = 0
|
|
22
|
+
private var totalBytesWritten: Int64 = 0
|
|
23
|
+
|
|
24
|
+
init(config: RecorderConfig, callbacks: RecorderCallbacks) {
|
|
25
|
+
self.config = config
|
|
26
|
+
self.callbacks = callbacks
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func prepare() async throws {
|
|
30
|
+
try await ensurePermission()
|
|
31
|
+
try configureAudioSession()
|
|
32
|
+
|
|
33
|
+
let inputNode = audioEngine.inputNode
|
|
34
|
+
let inputFormat = inputNode.outputFormat(forBus: 0)
|
|
35
|
+
let target = try makeTargetFormat()
|
|
36
|
+
targetFormat = target
|
|
37
|
+
|
|
38
|
+
if inputFormat.sampleRate != target.sampleRate
|
|
39
|
+
|| inputFormat.channelCount != target.channelCount {
|
|
40
|
+
converter = AVAudioConverter(from: inputFormat, to: target)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func start() throws {
|
|
45
|
+
guard let target = targetFormat else {
|
|
46
|
+
throw RecorderError(code: "ENGINE_NOT_READY", message: "Call prepare() first")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let directory = resolveOutputDirectory()
|
|
50
|
+
encodeFile = try EncodeFile.open(
|
|
51
|
+
in: directory,
|
|
52
|
+
filenamePrefix: config.filenamePrefix ?? RecorderConstants.defaultFilenamePrefix,
|
|
53
|
+
sampleRate: target.sampleRate,
|
|
54
|
+
channels: target.channelCount,
|
|
55
|
+
aacBitrate: config.aacBitrate
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
totalBytesWritten = 0
|
|
59
|
+
sessionStartHostTime = mach_absolute_time()
|
|
60
|
+
sessionStartWallMs = nowMs()
|
|
61
|
+
fileStartedAtMs = sessionStartWallMs
|
|
62
|
+
|
|
63
|
+
installTap()
|
|
64
|
+
try audioEngine.start()
|
|
65
|
+
|
|
66
|
+
running = true
|
|
67
|
+
paused = false
|
|
68
|
+
callbacks?.onState("recording")
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func pause() throws {
|
|
72
|
+
guard running else { throw RecorderError(code: "INVALID_STATE", message: "Not recording") }
|
|
73
|
+
paused = true
|
|
74
|
+
callbacks?.onState("paused")
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func resume() throws {
|
|
78
|
+
guard running else { throw RecorderError(code: "INVALID_STATE", message: "Not recording") }
|
|
79
|
+
paused = false
|
|
80
|
+
callbacks?.onState("recording")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func stop() async throws -> RecorderOutput {
|
|
84
|
+
guard running else { throw RecorderError(code: "INVALID_STATE", message: "Not recording") }
|
|
85
|
+
callbacks?.onState("stopping")
|
|
86
|
+
|
|
87
|
+
running = false
|
|
88
|
+
audioEngine.inputNode.removeTap(onBus: 0)
|
|
89
|
+
audioEngine.stop()
|
|
90
|
+
|
|
91
|
+
// Drain any frames still queued on the write queue before closing the file.
|
|
92
|
+
writeQueue.sync { }
|
|
93
|
+
|
|
94
|
+
guard let file = encodeFile else {
|
|
95
|
+
throw RecorderError(code: "INTERNAL_ERROR", message: "No output file")
|
|
96
|
+
}
|
|
97
|
+
file.close()
|
|
98
|
+
let durationMs = nowMs() - sessionStartWallMs
|
|
99
|
+
encodeFile = nil
|
|
100
|
+
|
|
101
|
+
callbacks?.onState("idle")
|
|
102
|
+
return RecorderOutput(
|
|
103
|
+
uri: "\(RecorderConstants.uriScheme)\(file.url.path)",
|
|
104
|
+
durationMs: durationMs,
|
|
105
|
+
sizeBytes: Double(totalBytesWritten),
|
|
106
|
+
sampleRate: config.sampleRate,
|
|
107
|
+
channels: config.channels,
|
|
108
|
+
bitDepth: config.bitDepth
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
func discard() async {
|
|
113
|
+
running = false
|
|
114
|
+
audioEngine.inputNode.removeTap(onBus: 0)
|
|
115
|
+
audioEngine.stop()
|
|
116
|
+
encodeFile?.cancel()
|
|
117
|
+
encodeFile = nil
|
|
118
|
+
callbacks?.onState("idle")
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
func release() async {
|
|
122
|
+
if running { await discard() }
|
|
123
|
+
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
124
|
+
callbacks?.onState("released")
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private func installTap() {
|
|
128
|
+
let inputNode = audioEngine.inputNode
|
|
129
|
+
let inputFormat = inputNode.outputFormat(forBus: 0)
|
|
130
|
+
let bufferSize = AVAudioFrameCount(inputFormat.sampleRate / Double(RecorderConstants.captureBufferFractionOfSecond))
|
|
131
|
+
|
|
132
|
+
inputNode.installTap(onBus: 0, bufferSize: bufferSize, format: inputFormat) { [weak self] buffer, _ in
|
|
133
|
+
guard let self else { return }
|
|
134
|
+
self.handle(inputBuffer: buffer)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private func handle(inputBuffer: AVAudioPCMBuffer) {
|
|
139
|
+
guard running, !paused, let file = encodeFile else { return }
|
|
140
|
+
if config.emitAudioLevel { maybeEmitLevels(inputBuffer) }
|
|
141
|
+
|
|
142
|
+
guard let converted = convert(inputBuffer) else { return }
|
|
143
|
+
|
|
144
|
+
writeQueue.async { [weak self] in
|
|
145
|
+
guard let self else { return }
|
|
146
|
+
self.maybeRotate(currentFile: file)
|
|
147
|
+
self.append(converted, to: self.encodeFile)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private func convert(_ buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
|
|
152
|
+
guard let target = targetFormat else { return nil }
|
|
153
|
+
guard let converter else { return buffer }
|
|
154
|
+
|
|
155
|
+
let ratio = target.sampleRate / buffer.format.sampleRate
|
|
156
|
+
let outFrames = AVAudioFrameCount(Double(buffer.frameLength) * ratio + 1)
|
|
157
|
+
guard let outBuffer = AVAudioPCMBuffer(pcmFormat: target, frameCapacity: outFrames) else {
|
|
158
|
+
return nil
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
var error: NSError?
|
|
162
|
+
var consumed = false
|
|
163
|
+
converter.convert(to: outBuffer, error: &error) { _, status in
|
|
164
|
+
if consumed {
|
|
165
|
+
status.pointee = .noDataNow
|
|
166
|
+
return nil
|
|
167
|
+
}
|
|
168
|
+
consumed = true
|
|
169
|
+
status.pointee = .haveData
|
|
170
|
+
return buffer
|
|
171
|
+
}
|
|
172
|
+
if error != nil { return nil }
|
|
173
|
+
return outBuffer
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private func append(_ buffer: AVAudioPCMBuffer, to file: EncodeFile?) {
|
|
177
|
+
guard let file else { return }
|
|
178
|
+
do {
|
|
179
|
+
try file.write(buffer: buffer)
|
|
180
|
+
totalBytesWritten = file.bytesWritten
|
|
181
|
+
} catch {
|
|
182
|
+
callbacks?.onError(RecorderError(code: "IO_ERROR", message: error.localizedDescription))
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private func maybeRotate(currentFile: EncodeFile) {
|
|
187
|
+
guard let rotateAfterMs = config.rotateAfterMs else { return }
|
|
188
|
+
let nowWall = nowMs()
|
|
189
|
+
if nowWall - fileStartedAtMs < rotateAfterMs { return }
|
|
190
|
+
|
|
191
|
+
currentFile.close()
|
|
192
|
+
callbacks?.onChunk(
|
|
193
|
+
uri: "\(RecorderConstants.uriScheme)\(currentFile.url.path)",
|
|
194
|
+
startMs: fileStartedAtMs,
|
|
195
|
+
endMs: nowWall,
|
|
196
|
+
sizeBytes: Double(currentFile.bytesWritten)
|
|
197
|
+
)
|
|
198
|
+
openNextFile(startedAtMs: nowWall)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private func openNextFile(startedAtMs: Double) {
|
|
202
|
+
do {
|
|
203
|
+
let directory = resolveOutputDirectory()
|
|
204
|
+
let next = try EncodeFile.open(
|
|
205
|
+
in: directory,
|
|
206
|
+
filenamePrefix: config.filenamePrefix ?? RecorderConstants.defaultFilenamePrefix,
|
|
207
|
+
sampleRate: config.sampleRate,
|
|
208
|
+
channels: config.channels,
|
|
209
|
+
aacBitrate: config.aacBitrate
|
|
210
|
+
)
|
|
211
|
+
encodeFile = next
|
|
212
|
+
fileStartedAtMs = startedAtMs
|
|
213
|
+
} catch let err as RecorderError {
|
|
214
|
+
callbacks?.onError(err)
|
|
215
|
+
} catch {
|
|
216
|
+
callbacks?.onError(RecorderError(code: "IO_ERROR", message: error.localizedDescription))
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private func maybeEmitLevels(_ buffer: AVAudioPCMBuffer) {
|
|
221
|
+
let now = nowMs()
|
|
222
|
+
if now - lastLevelEmitMs < config.audioLevelIntervalMs { return }
|
|
223
|
+
lastLevelEmitMs = now
|
|
224
|
+
let levels = AudioLevelMeter.compute(buffer: buffer)
|
|
225
|
+
callbacks?.onAudioLevel(rms: levels.rms, peak: levels.peak)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private func makeTargetFormat() throws -> AVAudioFormat {
|
|
229
|
+
// AAC encoder requires interleaved int16 PCM input; float32 yields
|
|
230
|
+
// AudioCodecInitialize failed / err=-12651 (kAudioConverterErr_FormatNotSupported).
|
|
231
|
+
guard let fmt = AVAudioFormat(
|
|
232
|
+
commonFormat: .pcmFormatInt16,
|
|
233
|
+
sampleRate: config.sampleRate,
|
|
234
|
+
channels: AVAudioChannelCount(config.channels),
|
|
235
|
+
interleaved: true
|
|
236
|
+
) else {
|
|
237
|
+
throw RecorderError(code: "UNSUPPORTED_FORMAT", message: "Invalid target format")
|
|
238
|
+
}
|
|
239
|
+
return fmt
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private func resolveOutputDirectory() -> URL {
|
|
243
|
+
if let custom = config.outputDirectory {
|
|
244
|
+
return URL(fileURLWithPath: custom, isDirectory: true)
|
|
245
|
+
}
|
|
246
|
+
let cache = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
|
247
|
+
return cache.appendingPathComponent(RecorderConstants.defaultCacheSubdir, isDirectory: true)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private func configureAudioSession() throws {
|
|
251
|
+
let session = AVAudioSession.sharedInstance()
|
|
252
|
+
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetooth])
|
|
253
|
+
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private func ensurePermission() async throws {
|
|
257
|
+
let session = AVAudioSession.sharedInstance()
|
|
258
|
+
switch session.recordPermission {
|
|
259
|
+
case .granted:
|
|
260
|
+
return
|
|
261
|
+
case .denied:
|
|
262
|
+
throw RecorderError(code: "PERMISSION_DENIED", message: "Microphone permission denied")
|
|
263
|
+
default:
|
|
264
|
+
// Undetermined — prompt the user. iOS uses NSMicrophoneUsageDescription
|
|
265
|
+
// from Info.plist as the prompt copy.
|
|
266
|
+
let granted = await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
|
267
|
+
session.requestRecordPermission { cont.resume(returning: $0) }
|
|
268
|
+
}
|
|
269
|
+
if !granted {
|
|
270
|
+
throw RecorderError(code: "PERMISSION_DENIED", message: "Microphone permission denied")
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private func nowMs() -> Double {
|
|
276
|
+
return Date().timeIntervalSince1970 * 1_000
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
internal struct RecorderError: Error {
|
|
4
|
+
let code: String
|
|
5
|
+
let message: String
|
|
6
|
+
let recoverable: Bool
|
|
7
|
+
|
|
8
|
+
init(code: String, message: String, recoverable: Bool = false) {
|
|
9
|
+
self.code = code
|
|
10
|
+
self.message = message
|
|
11
|
+
self.recoverable = recoverable
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
internal struct RecorderConfig {
|
|
16
|
+
let sampleRate: Double
|
|
17
|
+
let channels: UInt32
|
|
18
|
+
let bitDepth: UInt32
|
|
19
|
+
let outputDirectory: String?
|
|
20
|
+
let filenamePrefix: String?
|
|
21
|
+
let rotateAfterMs: Double?
|
|
22
|
+
let emitAudioLevel: Bool
|
|
23
|
+
let audioLevelIntervalMs: Double
|
|
24
|
+
let aacBitrate: Int
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
internal struct RecorderOutput {
|
|
28
|
+
let uri: String
|
|
29
|
+
let durationMs: Double
|
|
30
|
+
let sizeBytes: Double
|
|
31
|
+
let sampleRate: Double
|
|
32
|
+
let channels: UInt32
|
|
33
|
+
let bitDepth: UInt32
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
internal protocol RecorderCallbacks: AnyObject {
|
|
37
|
+
func onState(_ state: String)
|
|
38
|
+
func onAudioLevel(rms: Double, peak: Double)
|
|
39
|
+
func onChunk(uri: String, startMs: Double, endMs: Double, sizeBytes: Double)
|
|
40
|
+
func onError(_ error: RecorderError)
|
|
41
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type ClarionEngine, type ClarionEvent, type EngineKind, type EngineState, type Listener, type RecorderEngineConfig, type Unsubscribe } from '@clarionhq/core';
|
|
2
|
+
export interface RecorderEngineOptions extends RecorderEngineConfig {
|
|
3
|
+
aacBitrate?: number;
|
|
4
|
+
}
|
|
5
|
+
export declare class RecorderEngine implements ClarionEngine {
|
|
6
|
+
readonly kind: EngineKind;
|
|
7
|
+
private readonly emitter;
|
|
8
|
+
private readonly native;
|
|
9
|
+
private readonly listenerIds;
|
|
10
|
+
private readonly options;
|
|
11
|
+
private currentState;
|
|
12
|
+
constructor(options?: RecorderEngineOptions);
|
|
13
|
+
get state(): EngineState;
|
|
14
|
+
on(listener: Listener<ClarionEvent>): Unsubscribe;
|
|
15
|
+
prepare(): Promise<void>;
|
|
16
|
+
start(): Promise<void>;
|
|
17
|
+
pause(): Promise<void>;
|
|
18
|
+
resume(): Promise<void>;
|
|
19
|
+
stop(): Promise<void>;
|
|
20
|
+
discard(): Promise<void>;
|
|
21
|
+
release(): Promise<void>;
|
|
22
|
+
private buildNativeConfig;
|
|
23
|
+
private bindNativeListeners;
|
|
24
|
+
private transitionTo;
|
|
25
|
+
private setState;
|
|
26
|
+
private toRecorderResult;
|
|
27
|
+
private toClarionError;
|
|
28
|
+
private mapErrorCode;
|
|
29
|
+
private handleNativeError;
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=RecorderEngine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RecorderEngine.d.ts","sourceRoot":"","sources":["../src/RecorderEngine.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,UAAU,EACf,KAAK,WAAW,EAChB,KAAK,QAAQ,EACb,KAAK,oBAAoB,EAEzB,KAAK,WAAW,EACjB,MAAM,iBAAiB,CAAC;AAiCzB,MAAM,WAAW,qBAAsB,SAAQ,oBAAoB;IACjE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,qBAAa,cAAe,YAAW,aAAa;IAClD,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAc;IAEvC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwB;IAChD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAkB;IACzC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAgB;IAC5C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwB;IAEhD,OAAO,CAAC,YAAY,CAAuB;gBAE/B,OAAO,GAAE,qBAA0B;IAM/C,IAAI,KAAK,IAAI,WAAW,CAEvB;IAED,EAAE,CAAC,QAAQ,EAAE,QAAQ,CAAC,YAAY,CAAC,GAAG,WAAW;IAI3C,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAYxB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAyBtB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAetB,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAevB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAcrB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IASxB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAW9B,OAAO,CAAC,iBAAiB;IAuBzB,OAAO,CAAC,mBAAmB;IAoC3B,OAAO,CAAC,YAAY;IAMpB,OAAO,CAAC,QAAQ;IAMhB,OAAO,CAAC,gBAAgB;IAcxB,OAAO,CAAC,cAAc;IAQtB,OAAO,CAAC,YAAY;IAiBpB,OAAO,CAAC,iBAAiB;CAY1B"}
|