@hasna/recordings 0.1.14 → 0.1.16
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/dist/cli/index.js +2 -2
- package/dist/mcp/index.js +2 -2
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +123 -21
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +11 -1
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +31 -2
package/dist/cli/index.js
CHANGED
|
@@ -7,7 +7,7 @@ var __require = import.meta.require;
|
|
|
7
7
|
var require_package = __commonJS((exports, module) => {
|
|
8
8
|
module.exports = {
|
|
9
9
|
name: "@hasna/recordings",
|
|
10
|
-
version: "0.1.
|
|
10
|
+
version: "0.1.16",
|
|
11
11
|
type: "module",
|
|
12
12
|
description: "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
|
|
13
13
|
repository: {
|
|
@@ -10466,7 +10466,7 @@ async function processText(rawText, config, systemPrompt) {
|
|
|
10466
10466
|
}
|
|
10467
10467
|
|
|
10468
10468
|
// src/version.ts
|
|
10469
|
-
var VERSION = "0.1.
|
|
10469
|
+
var VERSION = "0.1.16";
|
|
10470
10470
|
|
|
10471
10471
|
// src/cli/index.ts
|
|
10472
10472
|
var program = new Command;
|
package/dist/mcp/index.js
CHANGED
|
@@ -21,7 +21,7 @@ var __require = import.meta.require;
|
|
|
21
21
|
var require_package = __commonJS((exports, module) => {
|
|
22
22
|
module.exports = {
|
|
23
23
|
name: "@hasna/recordings",
|
|
24
|
-
version: "0.1.
|
|
24
|
+
version: "0.1.16",
|
|
25
25
|
type: "module",
|
|
26
26
|
description: "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
|
|
27
27
|
repository: {
|
|
@@ -14983,7 +14983,7 @@ async function processText(rawText, config, systemPrompt) {
|
|
|
14983
14983
|
}
|
|
14984
14984
|
|
|
14985
14985
|
// src/version.ts
|
|
14986
|
-
var VERSION = "0.1.
|
|
14986
|
+
var VERSION = "0.1.16";
|
|
14987
14987
|
|
|
14988
14988
|
// src/mcp/index.ts
|
|
14989
14989
|
var config = loadConfig();
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const VERSION = "0.1.
|
|
1
|
+
export declare const VERSION = "0.1.16";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/package.json
CHANGED
|
@@ -16,12 +16,24 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
16
16
|
|
|
17
17
|
private var ws: URLSessionWebSocketTask?
|
|
18
18
|
private var receiveTask: Task<Void, Never>?
|
|
19
|
+
private var outboundEventTask: Task<Void, Never>?
|
|
20
|
+
private var liveCommitTask: Task<Void, Never>?
|
|
19
21
|
private var isConfigured = false
|
|
20
22
|
private var pendingAudioChunks: [Data] = []
|
|
21
23
|
private var itemOrder: [String] = []
|
|
22
24
|
private var deltaTextByItem: [String: String] = [:]
|
|
23
25
|
private var completedTextByItem: [String: String] = [:]
|
|
26
|
+
private var committedItemIDs = Set<String>()
|
|
27
|
+
private var completedItemIDs = Set<String>()
|
|
24
28
|
private var completedEventCount = 0
|
|
29
|
+
private var queuedCommitCount = 0
|
|
30
|
+
private var uncommittedAudioBytes = 0
|
|
31
|
+
private var lastRealtimeEventAt = Date.distantPast
|
|
32
|
+
private var lastCommitQueuedAt = Date.distantPast
|
|
33
|
+
|
|
34
|
+
private nonisolated static let minimumManualCommitBytes = 5_760
|
|
35
|
+
private nonisolated static let minimumLiveCommitBytes = 38_400
|
|
36
|
+
private nonisolated static let liveCommitIntervalSeconds: TimeInterval = 0.8
|
|
25
37
|
|
|
26
38
|
private let apiKey: String
|
|
27
39
|
private let homePath: String
|
|
@@ -52,7 +64,13 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
52
64
|
itemOrder.removeAll(keepingCapacity: true)
|
|
53
65
|
deltaTextByItem.removeAll(keepingCapacity: true)
|
|
54
66
|
completedTextByItem.removeAll(keepingCapacity: true)
|
|
67
|
+
committedItemIDs.removeAll(keepingCapacity: true)
|
|
68
|
+
completedItemIDs.removeAll(keepingCapacity: true)
|
|
55
69
|
completedEventCount = 0
|
|
70
|
+
queuedCommitCount = 0
|
|
71
|
+
uncommittedAudioBytes = 0
|
|
72
|
+
lastRealtimeEventAt = Date()
|
|
73
|
+
lastCommitQueuedAt = Date()
|
|
56
74
|
error = nil
|
|
57
75
|
|
|
58
76
|
var request = URLRequest(url: Self.transcriptionURL)
|
|
@@ -98,36 +116,53 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
98
116
|
return
|
|
99
117
|
}
|
|
100
118
|
let base64 = data.base64EncodedString()
|
|
119
|
+
uncommittedAudioBytes += data.count
|
|
101
120
|
let msg: [String: Any] = [
|
|
102
121
|
"type": "input_audio_buffer.append",
|
|
103
122
|
"audio": base64,
|
|
104
123
|
]
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
124
|
+
enqueueOutboundEvent(msg)
|
|
125
|
+
scheduleLiveCommitIfNeeded()
|
|
108
126
|
}
|
|
109
127
|
|
|
110
128
|
/// Signal end of input — triggers final transcription completion.
|
|
111
|
-
|
|
112
|
-
|
|
129
|
+
@discardableResult
|
|
130
|
+
public func commitInput(reason: String = "final") async -> Bool {
|
|
131
|
+
guard isStreaming, ws != nil else { return false }
|
|
113
132
|
flushPendingAudio()
|
|
133
|
+
let bytesToCommit = uncommittedAudioBytes
|
|
134
|
+
guard Self.shouldManuallyCommit(uncommittedAudioBytes: bytesToCommit) else {
|
|
135
|
+
return false
|
|
136
|
+
}
|
|
114
137
|
let msg: [String: Any] = [
|
|
115
138
|
"type": "input_audio_buffer.commit",
|
|
116
139
|
]
|
|
117
|
-
|
|
140
|
+
uncommittedAudioBytes = 0
|
|
141
|
+
queuedCommitCount += 1
|
|
142
|
+
lastRealtimeEventAt = Date()
|
|
143
|
+
lastCommitQueuedAt = Date()
|
|
144
|
+
NativeAppLog.write("realtime commit queued reason=\(reason) bytes=\(bytesToCommit)", homePath: homePath)
|
|
145
|
+
let commitTask = enqueueOutboundEvent(msg)
|
|
146
|
+
await commitTask?.value
|
|
147
|
+
return true
|
|
118
148
|
}
|
|
119
149
|
|
|
120
150
|
/// Commit buffered input, wait briefly for a final completed event, then close.
|
|
121
|
-
public func finish(timeoutMilliseconds: UInt64 =
|
|
151
|
+
public func finish(timeoutMilliseconds: UInt64 = 2_800) async -> String {
|
|
122
152
|
guard isStreaming else { return accumulatedText }
|
|
123
|
-
let
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
await commitInput()
|
|
153
|
+
let completedCountBeforeCommit = completedEventCount
|
|
154
|
+
let didManualCommit = await commitInput()
|
|
155
|
+
let expectedCommitCount = queuedCommitCount
|
|
128
156
|
|
|
129
157
|
let deadline = Date().addingTimeInterval(TimeInterval(timeoutMilliseconds) / 1_000)
|
|
130
|
-
while Date() < deadline, isStreaming
|
|
158
|
+
while Date() < deadline, isStreaming {
|
|
159
|
+
let hasIncompleteCommittedItems = !committedItemIDs.subtracting(completedItemIDs).isEmpty
|
|
160
|
+
let hasManualCommitCompletion = !didManualCommit || completedEventCount > completedCountBeforeCommit
|
|
161
|
+
let hasQueuedCommitCompletion = completedEventCount >= expectedCommitCount
|
|
162
|
+
let quietLongEnough = Date().timeIntervalSince(lastRealtimeEventAt) >= 0.35
|
|
163
|
+
if !hasIncompleteCommittedItems, hasManualCommitCompletion, hasQueuedCommitCompletion, quietLongEnough {
|
|
164
|
+
break
|
|
165
|
+
}
|
|
131
166
|
try? await Task.sleep(for: .milliseconds(50))
|
|
132
167
|
}
|
|
133
168
|
|
|
@@ -142,8 +177,12 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
142
177
|
isConfigured = false
|
|
143
178
|
receiveTask?.cancel()
|
|
144
179
|
ws?.cancel(with: .normalClosure, reason: nil)
|
|
180
|
+
outboundEventTask?.cancel()
|
|
181
|
+
liveCommitTask?.cancel()
|
|
145
182
|
ws = nil
|
|
146
183
|
receiveTask = nil
|
|
184
|
+
outboundEventTask = nil
|
|
185
|
+
liveCommitTask = nil
|
|
147
186
|
pendingAudioChunks.removeAll(keepingCapacity: true)
|
|
148
187
|
let text = accumulatedText
|
|
149
188
|
return text
|
|
@@ -183,11 +222,14 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
183
222
|
|
|
184
223
|
switch type {
|
|
185
224
|
case "input_audio_buffer.committed":
|
|
225
|
+
lastRealtimeEventAt = Date()
|
|
186
226
|
if let itemID = json["item_id"] as? String {
|
|
187
227
|
registerItem(itemID, previousItemID: json["previous_item_id"] as? String)
|
|
228
|
+
committedItemIDs.insert(itemID)
|
|
188
229
|
}
|
|
189
230
|
|
|
190
231
|
case "conversation.item.input_audio_transcription.delta":
|
|
232
|
+
lastRealtimeEventAt = Date()
|
|
191
233
|
let itemID = json["item_id"] as? String ?? "__default__"
|
|
192
234
|
registerItem(itemID, previousItemID: nil)
|
|
193
235
|
if let delta = json["delta"] as? String {
|
|
@@ -197,21 +239,29 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
197
239
|
|
|
198
240
|
case "conversation.item.input_audio_transcription.completed":
|
|
199
241
|
// The server may send the complete transcript here
|
|
242
|
+
lastRealtimeEventAt = Date()
|
|
200
243
|
let itemID = json["item_id"] as? String ?? "__default__"
|
|
201
244
|
registerItem(itemID, previousItemID: nil)
|
|
245
|
+
completedItemIDs.insert(itemID)
|
|
246
|
+
completedEventCount += 1
|
|
202
247
|
if let text = json["transcript"] as? String, !text.isEmpty {
|
|
203
248
|
completedTextByItem[itemID] = text
|
|
204
|
-
completedEventCount += 1
|
|
205
|
-
rebuildAccumulatedText()
|
|
206
249
|
}
|
|
250
|
+
rebuildAccumulatedText()
|
|
207
251
|
|
|
208
252
|
case "conversation.item.input_audio_transcription.failed":
|
|
253
|
+
lastRealtimeEventAt = Date()
|
|
254
|
+
if let itemID = json["item_id"] as? String {
|
|
255
|
+
completedItemIDs.insert(itemID)
|
|
256
|
+
completedEventCount += 1
|
|
257
|
+
}
|
|
209
258
|
if let msg = json["error"] as? [String: Any],
|
|
210
259
|
let message = msg["message"] as? String {
|
|
211
260
|
self.error = message
|
|
212
261
|
}
|
|
213
262
|
|
|
214
263
|
case "error":
|
|
264
|
+
lastRealtimeEventAt = Date()
|
|
215
265
|
if let detail = json["error"] as? [String: Any],
|
|
216
266
|
let msg = detail["message"] as? String {
|
|
217
267
|
fputs("[RealtimeClient] Error: \(msg)\n", stderr)
|
|
@@ -223,6 +273,37 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
223
273
|
}
|
|
224
274
|
}
|
|
225
275
|
|
|
276
|
+
@discardableResult
|
|
277
|
+
private func enqueueOutboundEvent(_ obj: [String: Any]) -> Task<Void, Never>? {
|
|
278
|
+
guard ws != nil else { return nil }
|
|
279
|
+
let previousTask = outboundEventTask
|
|
280
|
+
let task = Task { [weak self] in
|
|
281
|
+
await previousTask?.value
|
|
282
|
+
try? await self?.sendEvent(obj)
|
|
283
|
+
}
|
|
284
|
+
outboundEventTask = task
|
|
285
|
+
return task
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private func scheduleLiveCommitIfNeeded(now: Date = Date()) {
|
|
289
|
+
guard liveCommitTask == nil else { return }
|
|
290
|
+
let secondsSinceLastCommit = now.timeIntervalSince(lastCommitQueuedAt)
|
|
291
|
+
guard Self.shouldAutoCommitLiveInput(
|
|
292
|
+
uncommittedAudioBytes: uncommittedAudioBytes,
|
|
293
|
+
secondsSinceLastCommit: secondsSinceLastCommit
|
|
294
|
+
) else { return }
|
|
295
|
+
|
|
296
|
+
liveCommitTask = Task { [weak self] in
|
|
297
|
+
_ = await self?.commitLiveInput()
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
@discardableResult
|
|
302
|
+
private func commitLiveInput() async -> Bool {
|
|
303
|
+
defer { liveCommitTask = nil }
|
|
304
|
+
return await commitInput(reason: "live")
|
|
305
|
+
}
|
|
306
|
+
|
|
226
307
|
private func registerItem(_ itemID: String, previousItemID: String?) {
|
|
227
308
|
guard !itemOrder.contains(itemID) else { return }
|
|
228
309
|
if let previousItemID, let previousIndex = itemOrder.firstIndex(of: previousItemID) {
|
|
@@ -298,6 +379,18 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
298
379
|
return output
|
|
299
380
|
}
|
|
300
381
|
|
|
382
|
+
private nonisolated static func shouldManuallyCommit(uncommittedAudioBytes: Int) -> Bool {
|
|
383
|
+
uncommittedAudioBytes >= minimumManualCommitBytes
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private nonisolated static func shouldAutoCommitLiveInput(
|
|
387
|
+
uncommittedAudioBytes: Int,
|
|
388
|
+
secondsSinceLastCommit: TimeInterval
|
|
389
|
+
) -> Bool {
|
|
390
|
+
uncommittedAudioBytes >= minimumLiveCommitBytes &&
|
|
391
|
+
secondsSinceLastCommit >= liveCommitIntervalSeconds
|
|
392
|
+
}
|
|
393
|
+
|
|
301
394
|
private nonisolated static func transcriptionSessionUpdateEvent(transcription: [String: Any]) -> [String: Any] {
|
|
302
395
|
[
|
|
303
396
|
"type": "session.update",
|
|
@@ -310,12 +403,7 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
310
403
|
"rate": 24_000,
|
|
311
404
|
],
|
|
312
405
|
"transcription": transcription,
|
|
313
|
-
"turn_detection":
|
|
314
|
-
"type": "server_vad",
|
|
315
|
-
"threshold": 0.5,
|
|
316
|
-
"prefix_padding_ms": 300,
|
|
317
|
-
"silence_duration_ms": 350,
|
|
318
|
-
],
|
|
406
|
+
"turn_detection": NSNull(),
|
|
319
407
|
"noise_reduction": [
|
|
320
408
|
"type": "near_field",
|
|
321
409
|
],
|
|
@@ -366,6 +454,20 @@ extension RealtimeTranscriptionClient {
|
|
|
366
454
|
joinTranscriptParts(parts)
|
|
367
455
|
}
|
|
368
456
|
|
|
457
|
+
public nonisolated static func shouldManuallyCommitTestHelper(uncommittedAudioBytes: Int) -> Bool {
|
|
458
|
+
shouldManuallyCommit(uncommittedAudioBytes: uncommittedAudioBytes)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
public nonisolated static func shouldAutoCommitLiveInputTestHelper(
|
|
462
|
+
uncommittedAudioBytes: Int,
|
|
463
|
+
secondsSinceLastCommit: TimeInterval
|
|
464
|
+
) -> Bool {
|
|
465
|
+
shouldAutoCommitLiveInput(
|
|
466
|
+
uncommittedAudioBytes: uncommittedAudioBytes,
|
|
467
|
+
secondsSinceLastCommit: secondsSinceLastCommit
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
|
|
369
471
|
public nonisolated static func sessionUpdateTestHelper(prompt: String, language: String = "") -> [String: Any] {
|
|
370
472
|
var transcription: [String: Any] = [
|
|
371
473
|
"model": modelID,
|
|
@@ -457,7 +457,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
457
457
|
|
|
458
458
|
self.liveTranscriptionText = ""
|
|
459
459
|
|
|
460
|
-
if let text {
|
|
460
|
+
if let text, !Self.shouldFallbackFromPartialRealtime(text: text, pcmByteCount: self.recordedPCM.count) {
|
|
461
461
|
self.log("finish using realtime text chars=\(text.count)")
|
|
462
462
|
self.isTranscribing = false
|
|
463
463
|
self.finishWithText(
|
|
@@ -468,6 +468,9 @@ public final class RecordingEngine: ObservableObject {
|
|
|
468
468
|
activeProjectName: activeProjectName
|
|
469
469
|
)
|
|
470
470
|
} else if let audioPath, self.writeCapturedWAV(to: audioPath) {
|
|
471
|
+
if let text {
|
|
472
|
+
self.log("realtime text looked partial chars=\(text.count); falling back to CLI")
|
|
473
|
+
}
|
|
471
474
|
self.log("falling back to CLI transcription audioPath=\(audioPath)")
|
|
472
475
|
self.fallbackTranscribe(
|
|
473
476
|
audioPath: audioPath,
|
|
@@ -486,6 +489,13 @@ public final class RecordingEngine: ObservableObject {
|
|
|
486
489
|
}
|
|
487
490
|
}
|
|
488
491
|
|
|
492
|
+
public nonisolated static func shouldFallbackFromPartialRealtime(text: String, pcmByteCount: Int) -> Bool {
|
|
493
|
+
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
494
|
+
guard pcmByteCount >= 48_000, !trimmed.isEmpty else { return false }
|
|
495
|
+
let words = trimmed.split(whereSeparator: { $0.isWhitespace || $0.isNewline })
|
|
496
|
+
return trimmed.count < 12 || words.count <= 2
|
|
497
|
+
}
|
|
498
|
+
|
|
489
499
|
private func finishWithText(_ text: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, activeProjectId: String?, activeProjectName: String?) {
|
|
490
500
|
log("finishWithText mode=\(curMode.rawValue) chars=\(text.count)")
|
|
491
501
|
if curMode == .command {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import Foundation
|
|
1
2
|
import Testing
|
|
2
3
|
@testable import RecordingsLib
|
|
3
4
|
|
|
@@ -98,8 +99,7 @@ struct RealtimeTranscriptionTests {
|
|
|
98
99
|
#expect(transcription?["prompt"] as? String == "Use Alumia as vocabulary")
|
|
99
100
|
#expect(transcription?["language"] as? String == "en")
|
|
100
101
|
|
|
101
|
-
|
|
102
|
-
#expect(turnDetection?["type"] as? String == "server_vad")
|
|
102
|
+
#expect(input?["turn_detection"] is NSNull)
|
|
103
103
|
|
|
104
104
|
let include = session?["include"] as? [String]
|
|
105
105
|
#expect(include?.contains("item.input_audio_transcription.logprobs") == true)
|
|
@@ -110,4 +110,33 @@ struct RealtimeTranscriptionTests {
|
|
|
110
110
|
let text = RealtimeTranscriptionClient.joinTranscriptPartsTestHelper(["Hello", "world.", " Next"])
|
|
111
111
|
#expect(text == "Hello world. Next")
|
|
112
112
|
}
|
|
113
|
+
|
|
114
|
+
@Test("Manual commit waits for enough buffered PCM audio")
|
|
115
|
+
func manualCommitThreshold() {
|
|
116
|
+
#expect(RealtimeTranscriptionClient.shouldManuallyCommitTestHelper(uncommittedAudioBytes: 4_799) == false)
|
|
117
|
+
#expect(RealtimeTranscriptionClient.shouldManuallyCommitTestHelper(uncommittedAudioBytes: 5_760) == true)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@Test("Live commits wait for enough audio and elapsed time")
|
|
121
|
+
func liveCommitThreshold() {
|
|
122
|
+
#expect(RealtimeTranscriptionClient.shouldAutoCommitLiveInputTestHelper(
|
|
123
|
+
uncommittedAudioBytes: 38_399,
|
|
124
|
+
secondsSinceLastCommit: 1.0
|
|
125
|
+
) == false)
|
|
126
|
+
#expect(RealtimeTranscriptionClient.shouldAutoCommitLiveInputTestHelper(
|
|
127
|
+
uncommittedAudioBytes: 38_400,
|
|
128
|
+
secondsSinceLastCommit: 0.79
|
|
129
|
+
) == false)
|
|
130
|
+
#expect(RealtimeTranscriptionClient.shouldAutoCommitLiveInputTestHelper(
|
|
131
|
+
uncommittedAudioBytes: 38_400,
|
|
132
|
+
secondsSinceLastCommit: 0.8
|
|
133
|
+
) == true)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
@Test("Partial realtime text falls back for longer recordings")
|
|
137
|
+
func partialRealtimeFallback() {
|
|
138
|
+
#expect(RecordingEngine.shouldFallbackFromPartialRealtime(text: "Hi", pcmByteCount: 96_000) == true)
|
|
139
|
+
#expect(RecordingEngine.shouldFallbackFromPartialRealtime(text: "This is a complete sentence.", pcmByteCount: 96_000) == false)
|
|
140
|
+
#expect(RecordingEngine.shouldFallbackFromPartialRealtime(text: "Hi", pcmByteCount: 12_000) == false)
|
|
141
|
+
}
|
|
113
142
|
}
|