@hasna/recordings 0.1.14 → 0.1.15
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 +52 -10
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +11 -1
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +13 -0
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.15",
|
|
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.15";
|
|
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.15",
|
|
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.15";
|
|
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.15";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/package.json
CHANGED
|
@@ -16,12 +16,19 @@ 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 audioSendTask: Task<Void, Never>?
|
|
19
20
|
private var isConfigured = false
|
|
20
21
|
private var pendingAudioChunks: [Data] = []
|
|
21
22
|
private var itemOrder: [String] = []
|
|
22
23
|
private var deltaTextByItem: [String: String] = [:]
|
|
23
24
|
private var completedTextByItem: [String: String] = [:]
|
|
25
|
+
private var committedItemIDs = Set<String>()
|
|
26
|
+
private var completedItemIDs = Set<String>()
|
|
24
27
|
private var completedEventCount = 0
|
|
28
|
+
private var uncommittedAudioBytes = 0
|
|
29
|
+
private var lastRealtimeEventAt = Date.distantPast
|
|
30
|
+
|
|
31
|
+
private nonisolated static let minimumManualCommitBytes = 5_760
|
|
25
32
|
|
|
26
33
|
private let apiKey: String
|
|
27
34
|
private let homePath: String
|
|
@@ -52,7 +59,11 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
52
59
|
itemOrder.removeAll(keepingCapacity: true)
|
|
53
60
|
deltaTextByItem.removeAll(keepingCapacity: true)
|
|
54
61
|
completedTextByItem.removeAll(keepingCapacity: true)
|
|
62
|
+
committedItemIDs.removeAll(keepingCapacity: true)
|
|
63
|
+
completedItemIDs.removeAll(keepingCapacity: true)
|
|
55
64
|
completedEventCount = 0
|
|
65
|
+
uncommittedAudioBytes = 0
|
|
66
|
+
lastRealtimeEventAt = Date()
|
|
56
67
|
error = nil
|
|
57
68
|
|
|
58
69
|
var request = URLRequest(url: Self.transcriptionURL)
|
|
@@ -98,36 +109,49 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
98
109
|
return
|
|
99
110
|
}
|
|
100
111
|
let base64 = data.base64EncodedString()
|
|
112
|
+
uncommittedAudioBytes += data.count
|
|
101
113
|
let msg: [String: Any] = [
|
|
102
114
|
"type": "input_audio_buffer.append",
|
|
103
115
|
"audio": base64,
|
|
104
116
|
]
|
|
105
|
-
|
|
117
|
+
let previousSendTask = audioSendTask
|
|
118
|
+
audioSendTask = Task { [weak self] in
|
|
119
|
+
await previousSendTask?.value
|
|
106
120
|
try? await self?.sendEvent(msg)
|
|
107
121
|
}
|
|
108
122
|
}
|
|
109
123
|
|
|
110
124
|
/// Signal end of input — triggers final transcription completion.
|
|
111
|
-
|
|
112
|
-
|
|
125
|
+
@discardableResult
|
|
126
|
+
public func commitInput() async -> Bool {
|
|
127
|
+
guard isStreaming, ws != nil else { return false }
|
|
113
128
|
flushPendingAudio()
|
|
129
|
+
await audioSendTask?.value
|
|
130
|
+
guard Self.shouldManuallyCommit(uncommittedAudioBytes: uncommittedAudioBytes) else {
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
114
133
|
let msg: [String: Any] = [
|
|
115
134
|
"type": "input_audio_buffer.commit",
|
|
116
135
|
]
|
|
136
|
+
lastRealtimeEventAt = Date()
|
|
117
137
|
try? await sendEvent(msg)
|
|
138
|
+
return true
|
|
118
139
|
}
|
|
119
140
|
|
|
120
141
|
/// Commit buffered input, wait briefly for a final completed event, then close.
|
|
121
|
-
public func finish(timeoutMilliseconds: UInt64 =
|
|
142
|
+
public func finish(timeoutMilliseconds: UInt64 = 2_800) async -> String {
|
|
122
143
|
guard isStreaming else { return accumulatedText }
|
|
123
|
-
let
|
|
124
|
-
|
|
125
|
-
return stop()
|
|
126
|
-
}
|
|
127
|
-
await commitInput()
|
|
144
|
+
let completedCountBeforeCommit = completedEventCount
|
|
145
|
+
let didManualCommit = await commitInput()
|
|
128
146
|
|
|
129
147
|
let deadline = Date().addingTimeInterval(TimeInterval(timeoutMilliseconds) / 1_000)
|
|
130
|
-
while Date() < deadline, isStreaming
|
|
148
|
+
while Date() < deadline, isStreaming {
|
|
149
|
+
let hasIncompleteCommittedItems = !committedItemIDs.subtracting(completedItemIDs).isEmpty
|
|
150
|
+
let hasManualCommitCompletion = !didManualCommit || completedEventCount > completedCountBeforeCommit
|
|
151
|
+
let quietLongEnough = Date().timeIntervalSince(lastRealtimeEventAt) >= 0.35
|
|
152
|
+
if !hasIncompleteCommittedItems, hasManualCommitCompletion, quietLongEnough {
|
|
153
|
+
break
|
|
154
|
+
}
|
|
131
155
|
try? await Task.sleep(for: .milliseconds(50))
|
|
132
156
|
}
|
|
133
157
|
|
|
@@ -142,8 +166,10 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
142
166
|
isConfigured = false
|
|
143
167
|
receiveTask?.cancel()
|
|
144
168
|
ws?.cancel(with: .normalClosure, reason: nil)
|
|
169
|
+
audioSendTask?.cancel()
|
|
145
170
|
ws = nil
|
|
146
171
|
receiveTask = nil
|
|
172
|
+
audioSendTask = nil
|
|
147
173
|
pendingAudioChunks.removeAll(keepingCapacity: true)
|
|
148
174
|
let text = accumulatedText
|
|
149
175
|
return text
|
|
@@ -183,11 +209,15 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
183
209
|
|
|
184
210
|
switch type {
|
|
185
211
|
case "input_audio_buffer.committed":
|
|
212
|
+
lastRealtimeEventAt = Date()
|
|
186
213
|
if let itemID = json["item_id"] as? String {
|
|
187
214
|
registerItem(itemID, previousItemID: json["previous_item_id"] as? String)
|
|
215
|
+
committedItemIDs.insert(itemID)
|
|
188
216
|
}
|
|
217
|
+
uncommittedAudioBytes = 0
|
|
189
218
|
|
|
190
219
|
case "conversation.item.input_audio_transcription.delta":
|
|
220
|
+
lastRealtimeEventAt = Date()
|
|
191
221
|
let itemID = json["item_id"] as? String ?? "__default__"
|
|
192
222
|
registerItem(itemID, previousItemID: nil)
|
|
193
223
|
if let delta = json["delta"] as? String {
|
|
@@ -197,21 +227,25 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
197
227
|
|
|
198
228
|
case "conversation.item.input_audio_transcription.completed":
|
|
199
229
|
// The server may send the complete transcript here
|
|
230
|
+
lastRealtimeEventAt = Date()
|
|
200
231
|
let itemID = json["item_id"] as? String ?? "__default__"
|
|
201
232
|
registerItem(itemID, previousItemID: nil)
|
|
202
233
|
if let text = json["transcript"] as? String, !text.isEmpty {
|
|
203
234
|
completedTextByItem[itemID] = text
|
|
235
|
+
completedItemIDs.insert(itemID)
|
|
204
236
|
completedEventCount += 1
|
|
205
237
|
rebuildAccumulatedText()
|
|
206
238
|
}
|
|
207
239
|
|
|
208
240
|
case "conversation.item.input_audio_transcription.failed":
|
|
241
|
+
lastRealtimeEventAt = Date()
|
|
209
242
|
if let msg = json["error"] as? [String: Any],
|
|
210
243
|
let message = msg["message"] as? String {
|
|
211
244
|
self.error = message
|
|
212
245
|
}
|
|
213
246
|
|
|
214
247
|
case "error":
|
|
248
|
+
lastRealtimeEventAt = Date()
|
|
215
249
|
if let detail = json["error"] as? [String: Any],
|
|
216
250
|
let msg = detail["message"] as? String {
|
|
217
251
|
fputs("[RealtimeClient] Error: \(msg)\n", stderr)
|
|
@@ -298,6 +332,10 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
298
332
|
return output
|
|
299
333
|
}
|
|
300
334
|
|
|
335
|
+
private nonisolated static func shouldManuallyCommit(uncommittedAudioBytes: Int) -> Bool {
|
|
336
|
+
uncommittedAudioBytes >= minimumManualCommitBytes
|
|
337
|
+
}
|
|
338
|
+
|
|
301
339
|
private nonisolated static func transcriptionSessionUpdateEvent(transcription: [String: Any]) -> [String: Any] {
|
|
302
340
|
[
|
|
303
341
|
"type": "session.update",
|
|
@@ -366,6 +404,10 @@ extension RealtimeTranscriptionClient {
|
|
|
366
404
|
joinTranscriptParts(parts)
|
|
367
405
|
}
|
|
368
406
|
|
|
407
|
+
public nonisolated static func shouldManuallyCommitTestHelper(uncommittedAudioBytes: Int) -> Bool {
|
|
408
|
+
shouldManuallyCommit(uncommittedAudioBytes: uncommittedAudioBytes)
|
|
409
|
+
}
|
|
410
|
+
|
|
369
411
|
public nonisolated static func sessionUpdateTestHelper(prompt: String, language: String = "") -> [String: Any] {
|
|
370
412
|
var transcription: [String: Any] = [
|
|
371
413
|
"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 {
|
|
@@ -110,4 +110,17 @@ 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("Partial realtime text falls back for longer recordings")
|
|
121
|
+
func partialRealtimeFallback() {
|
|
122
|
+
#expect(RecordingEngine.shouldFallbackFromPartialRealtime(text: "Hi", pcmByteCount: 96_000) == true)
|
|
123
|
+
#expect(RecordingEngine.shouldFallbackFromPartialRealtime(text: "This is a complete sentence.", pcmByteCount: 96_000) == false)
|
|
124
|
+
#expect(RecordingEngine.shouldFallbackFromPartialRealtime(text: "Hi", pcmByteCount: 12_000) == false)
|
|
125
|
+
}
|
|
113
126
|
}
|