@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.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -0
  3. package/android/build.gradle +97 -0
  4. package/android/gradle.properties +4 -0
  5. package/android/src/main/AndroidManifest.xml +4 -0
  6. package/android/src/main/cpp/cpp-adapter.cpp +7 -0
  7. package/android/src/main/java/com/clarionhq/recorder/AudioLevelMeter.kt +38 -0
  8. package/android/src/main/java/com/clarionhq/recorder/ClarionRecorderPackage.kt +27 -0
  9. package/android/src/main/java/com/clarionhq/recorder/EncodeFile.kt +62 -0
  10. package/android/src/main/java/com/clarionhq/recorder/RecorderConfig.kt +33 -0
  11. package/android/src/main/java/com/clarionhq/recorder/RecorderConstants.kt +16 -0
  12. package/android/src/main/java/com/clarionhq/recorder/RecorderSession.kt +336 -0
  13. package/android/src/main/java/com/clarionhq/recorder/RecorderTypes.kt +29 -0
  14. package/android/src/main/java/com/margelo/nitro/clarion/recorder/HybridClarionRecorder.kt +174 -0
  15. package/clarionhq-recorder.podspec +31 -0
  16. package/ios/AudioLevelMeter.swift +37 -0
  17. package/ios/EncodeFile.swift +69 -0
  18. package/ios/HybridClarionRecorder.swift +186 -0
  19. package/ios/RecorderConstants.swift +11 -0
  20. package/ios/RecorderSession.swift +278 -0
  21. package/ios/RecorderTypes.swift +41 -0
  22. package/lib/RecorderEngine.d.ts +31 -0
  23. package/lib/RecorderEngine.d.ts.map +1 -0
  24. package/lib/RecorderEngine.js +245 -0
  25. package/lib/RecorderEngine.js.map +1 -0
  26. package/lib/index.d.ts +4 -0
  27. package/lib/index.d.ts.map +1 -0
  28. package/lib/index.js +2 -0
  29. package/lib/index.js.map +1 -0
  30. package/lib/native.d.ts +3 -0
  31. package/lib/native.d.ts.map +1 -0
  32. package/lib/native.js +3 -0
  33. package/lib/native.js.map +1 -0
  34. package/lib/specs/ClarionRecorder.nitro.d.ts +49 -0
  35. package/lib/specs/ClarionRecorder.nitro.d.ts.map +1 -0
  36. package/lib/specs/ClarionRecorder.nitro.js +2 -0
  37. package/lib/specs/ClarionRecorder.nitro.js.map +1 -0
  38. package/nitro.json +24 -0
  39. package/nitrogen/generated/android/ClarionRecorder+autolinking.cmake +81 -0
  40. package/nitrogen/generated/android/ClarionRecorder+autolinking.gradle +27 -0
  41. package/nitrogen/generated/android/ClarionRecorderOnLoad.cpp +62 -0
  42. package/nitrogen/generated/android/ClarionRecorderOnLoad.hpp +34 -0
  43. package/nitrogen/generated/android/c++/JFunc_void_NativeRecorderError.hpp +78 -0
  44. package/nitrogen/generated/android/c++/JFunc_void_double_double.hpp +75 -0
  45. package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
  46. package/nitrogen/generated/android/c++/JFunc_void_std__string_double_double_double.hpp +76 -0
  47. package/nitrogen/generated/android/c++/JHybridClarionRecorderSpec.cpp +207 -0
  48. package/nitrogen/generated/android/c++/JHybridClarionRecorderSpec.hpp +75 -0
  49. package/nitrogen/generated/android/c++/JNativeRecorderConfig.hpp +90 -0
  50. package/nitrogen/generated/android/c++/JNativeRecorderError.hpp +65 -0
  51. package/nitrogen/generated/android/c++/JNativeRecorderResult.hpp +77 -0
  52. package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/ClarionRecorderOnLoad.kt +35 -0
  53. package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/Func_void_NativeRecorderError.kt +80 -0
  54. package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/Func_void_double_double.kt +80 -0
  55. package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/Func_void_std__string.kt +80 -0
  56. package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/Func_void_std__string_double_double_double.kt +80 -0
  57. package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/HybridClarionRecorderSpec.kt +125 -0
  58. package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/NativeRecorderConfig.kt +91 -0
  59. package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/NativeRecorderError.kt +61 -0
  60. package/nitrogen/generated/android/kotlin/com/margelo/nitro/clarion/recorder/NativeRecorderResult.kt +76 -0
  61. package/nitrogen/generated/ios/ClarionRecorder+autolinking.rb +62 -0
  62. package/nitrogen/generated/ios/ClarionRecorder-Swift-Cxx-Bridge.cpp +89 -0
  63. package/nitrogen/generated/ios/ClarionRecorder-Swift-Cxx-Bridge.hpp +297 -0
  64. package/nitrogen/generated/ios/ClarionRecorder-Swift-Cxx-Umbrella.hpp +56 -0
  65. package/nitrogen/generated/ios/ClarionRecorderAutolinking.mm +33 -0
  66. package/nitrogen/generated/ios/ClarionRecorderAutolinking.swift +26 -0
  67. package/nitrogen/generated/ios/c++/HybridClarionRecorderSpecSwift.cpp +11 -0
  68. package/nitrogen/generated/ios/c++/HybridClarionRecorderSpecSwift.hpp +188 -0
  69. package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
  70. package/nitrogen/generated/ios/swift/Func_void_NativeRecorderError.swift +46 -0
  71. package/nitrogen/generated/ios/swift/Func_void_NativeRecorderResult.swift +46 -0
  72. package/nitrogen/generated/ios/swift/Func_void_double_double.swift +46 -0
  73. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
  74. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
  75. package/nitrogen/generated/ios/swift/Func_void_std__string_double_double_double.swift +46 -0
  76. package/nitrogen/generated/ios/swift/HybridClarionRecorderSpec.swift +67 -0
  77. package/nitrogen/generated/ios/swift/HybridClarionRecorderSpec_cxx.swift +354 -0
  78. package/nitrogen/generated/ios/swift/NativeRecorderConfig.swift +108 -0
  79. package/nitrogen/generated/ios/swift/NativeRecorderError.swift +39 -0
  80. package/nitrogen/generated/ios/swift/NativeRecorderResult.swift +54 -0
  81. package/nitrogen/generated/shared/c++/HybridClarionRecorderSpec.cpp +34 -0
  82. package/nitrogen/generated/shared/c++/HybridClarionRecorderSpec.hpp +84 -0
  83. package/nitrogen/generated/shared/c++/NativeRecorderConfig.hpp +116 -0
  84. package/nitrogen/generated/shared/c++/NativeRecorderError.hpp +91 -0
  85. package/nitrogen/generated/shared/c++/NativeRecorderResult.hpp +103 -0
  86. package/package.json +66 -8
  87. package/src/RecorderEngine.ts +298 -0
  88. package/src/index.ts +8 -0
  89. package/src/native.ts +5 -0
  90. package/src/specs/ClarionRecorder.nitro.ts +58 -0
  91. 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"}