@hasna/recordings 0.1.26 → 0.1.28
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/App/RecordingsApp.swift +9 -0
- package/src/native/Recordings/Package.swift +2 -1
- package/src/native/Recordings/RecordingsLib/BrandAssets.swift +50 -0
- package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +1 -2
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +72 -12
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +6 -15
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +356 -76
- package/src/native/Recordings/RecordingsLib/Resources/HasnaLogo.jpg +0 -0
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +9 -0
- package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +26 -0
- package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +4 -4
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +42 -6
- package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +22 -0
- package/src/native/Recordings/build.sh +9 -1
package/dist/cli/index.js
CHANGED
|
@@ -5098,7 +5098,7 @@ var init_pg_migrate = __esm(() => {
|
|
|
5098
5098
|
var require_package = __commonJS((exports, module) => {
|
|
5099
5099
|
module.exports = {
|
|
5100
5100
|
name: "@hasna/recordings",
|
|
5101
|
-
version: "0.1.
|
|
5101
|
+
version: "0.1.28",
|
|
5102
5102
|
type: "module",
|
|
5103
5103
|
description: "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
|
|
5104
5104
|
repository: {
|
|
@@ -6081,7 +6081,7 @@ async function processText(rawText, config, systemPrompt) {
|
|
|
6081
6081
|
}
|
|
6082
6082
|
|
|
6083
6083
|
// src/version.ts
|
|
6084
|
-
var VERSION = "0.1.
|
|
6084
|
+
var VERSION = "0.1.28";
|
|
6085
6085
|
|
|
6086
6086
|
// src/cli/storage.ts
|
|
6087
6087
|
import chalk from "chalk";
|
package/dist/mcp/index.js
CHANGED
|
@@ -4920,7 +4920,7 @@ var require_lib2 = __commonJS((exports, module) => {
|
|
|
4920
4920
|
var require_package = __commonJS((exports, module) => {
|
|
4921
4921
|
module.exports = {
|
|
4922
4922
|
name: "@hasna/recordings",
|
|
4923
|
-
version: "0.1.
|
|
4923
|
+
version: "0.1.28",
|
|
4924
4924
|
type: "module",
|
|
4925
4925
|
description: "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
|
|
4926
4926
|
repository: {
|
|
@@ -9797,7 +9797,7 @@ async function processText(rawText, config, systemPrompt) {
|
|
|
9797
9797
|
}
|
|
9798
9798
|
|
|
9799
9799
|
// src/version.ts
|
|
9800
|
-
var VERSION = "0.1.
|
|
9800
|
+
var VERSION = "0.1.28";
|
|
9801
9801
|
|
|
9802
9802
|
// src/db/storage-config.ts
|
|
9803
9803
|
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const VERSION = "0.1.
|
|
1
|
+
export declare const VERSION = "0.1.28";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@ struct RecordingsApp: App {
|
|
|
10
10
|
@StateObject private var projectStore = ProjectStore()
|
|
11
11
|
|
|
12
12
|
init() {
|
|
13
|
+
Self.terminateDuplicateInstances()
|
|
13
14
|
AXIsProcessTrustedWithOptions(
|
|
14
15
|
[kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
|
|
15
16
|
)
|
|
@@ -40,4 +41,12 @@ struct RecordingsApp: App {
|
|
|
40
41
|
SettingsView(engine: engine, shortcuts: shortcuts, projectStore: projectStore)
|
|
41
42
|
}
|
|
42
43
|
}
|
|
44
|
+
|
|
45
|
+
private static func terminateDuplicateInstances() {
|
|
46
|
+
let currentPID = ProcessInfo.processInfo.processIdentifier
|
|
47
|
+
for app in NSRunningApplication.runningApplications(withBundleIdentifier: "com.hasna.recordings")
|
|
48
|
+
where app.processIdentifier != currentPID {
|
|
49
|
+
app.terminate()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
43
52
|
}
|
|
@@ -16,7 +16,8 @@ let package = Package(
|
|
|
16
16
|
name: "RecordingsLib",
|
|
17
17
|
dependencies: ["KeyboardShortcuts"],
|
|
18
18
|
path: "RecordingsLib",
|
|
19
|
-
exclude: ["Info.plist", "Recordings.entitlements"]
|
|
19
|
+
exclude: ["Info.plist", "Recordings.entitlements"],
|
|
20
|
+
resources: [.process("Resources")]
|
|
20
21
|
),
|
|
21
22
|
.executableTarget(
|
|
22
23
|
name: "App",
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
public struct HasnaLogoMark: View {
|
|
4
|
+
private let size: CGFloat
|
|
5
|
+
|
|
6
|
+
public init(size: CGFloat = 22) {
|
|
7
|
+
self.size = size
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
public var body: some View {
|
|
11
|
+
Image("HasnaLogo", bundle: .module)
|
|
12
|
+
.resizable()
|
|
13
|
+
.interpolation(.high)
|
|
14
|
+
.scaledToFit()
|
|
15
|
+
.frame(width: size, height: size)
|
|
16
|
+
.clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
public struct HasnaMenuBarIcon: View {
|
|
21
|
+
private let isRecording: Bool
|
|
22
|
+
private let isTranscribing: Bool
|
|
23
|
+
|
|
24
|
+
public init(isRecording: Bool, isTranscribing: Bool) {
|
|
25
|
+
self.isRecording = isRecording
|
|
26
|
+
self.isTranscribing = isTranscribing
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public var body: some View {
|
|
30
|
+
ZStack(alignment: .bottomTrailing) {
|
|
31
|
+
Image("HasnaLogo", bundle: .module)
|
|
32
|
+
.resizable()
|
|
33
|
+
.renderingMode(.template)
|
|
34
|
+
.interpolation(.high)
|
|
35
|
+
.scaledToFit()
|
|
36
|
+
.frame(width: 18, height: 18)
|
|
37
|
+
|
|
38
|
+
if isRecording {
|
|
39
|
+
Circle()
|
|
40
|
+
.fill(.red)
|
|
41
|
+
.frame(width: 6, height: 6)
|
|
42
|
+
} else if isTranscribing {
|
|
43
|
+
Circle()
|
|
44
|
+
.fill(.tint)
|
|
45
|
+
.frame(width: 6, height: 6)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
.frame(width: 22, height: 18)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -52,8 +52,7 @@ public struct MenuBarPopover: View {
|
|
|
52
52
|
private var header: some View {
|
|
53
53
|
VStack(alignment: .leading, spacing: 4) {
|
|
54
54
|
HStack {
|
|
55
|
-
|
|
56
|
-
.foregroundStyle(.tint)
|
|
55
|
+
HasnaLogoMark(size: 22)
|
|
57
56
|
Text("Hasna Recordings")
|
|
58
57
|
.font(.headline)
|
|
59
58
|
Spacer()
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
|
|
3
3
|
enum OpenAIAPIKeyStore {
|
|
4
|
+
static let defaultLanguage = "en"
|
|
5
|
+
|
|
4
6
|
static func load(
|
|
5
7
|
homePath: String,
|
|
6
8
|
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
@@ -21,6 +23,23 @@ enum OpenAIAPIKeyStore {
|
|
|
21
23
|
return ""
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
static func loadLanguage(
|
|
27
|
+
homePath: String,
|
|
28
|
+
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
29
|
+
userDefaultLanguage: String? = UserDefaults.standard.string(forKey: "recordingsLanguage")
|
|
30
|
+
) -> String {
|
|
31
|
+
if let language = firstNonEmpty(environment["RECORDINGS_LANGUAGE"]) {
|
|
32
|
+
return normalizedStoredLanguage(language)
|
|
33
|
+
}
|
|
34
|
+
if let language = firstNonEmpty(userDefaultLanguage) {
|
|
35
|
+
return normalizedStoredLanguage(language)
|
|
36
|
+
}
|
|
37
|
+
if let language = loadConfigValue(homePath: homePath, key: "language", environment: environment) {
|
|
38
|
+
return normalizedStoredLanguage(language)
|
|
39
|
+
}
|
|
40
|
+
return defaultLanguage
|
|
41
|
+
}
|
|
42
|
+
|
|
24
43
|
/// Persist the key into ~/.hasna/recordings/config.json so the CLI (which the app
|
|
25
44
|
/// shells out to for final transcription) uses the same key as the app itself.
|
|
26
45
|
static func save(key: String, homePath: String) throws {
|
|
@@ -47,25 +66,61 @@ enum OpenAIAPIKeyStore {
|
|
|
47
66
|
try data.write(to: url, options: .atomic)
|
|
48
67
|
}
|
|
49
68
|
|
|
50
|
-
|
|
51
|
-
let
|
|
52
|
-
|
|
53
|
-
.appendingPathComponent("recordings")
|
|
54
|
-
.appendingPathComponent("config.json")
|
|
69
|
+
static func saveLanguage(language: String, homePath: String) throws {
|
|
70
|
+
let normalized = normalizedStoredLanguage(language)
|
|
71
|
+
var json = loadMutableConfig(homePath: homePath)
|
|
55
72
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
else {
|
|
73
|
+
if apiLanguageHint(for: normalized).isEmpty {
|
|
74
|
+
json.removeValue(forKey: "language")
|
|
75
|
+
} else {
|
|
76
|
+
json["language"] = normalized
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try writeConfig(json, homePath: homePath)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
static func apiLanguageHint(for storedLanguage: String) -> String {
|
|
83
|
+
let normalized = normalizedStoredLanguage(storedLanguage)
|
|
84
|
+
return normalized == "auto" ? "" : normalized
|
|
85
|
+
}
|
|
59
86
|
|
|
87
|
+
private static func loadConfigKey(homePath: String, environment: [String: String]) -> String? {
|
|
60
88
|
for key in ["openai_api_key", "api_key"] {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return resolved
|
|
89
|
+
if let resolved = loadConfigValue(homePath: homePath, key: key, environment: environment) {
|
|
90
|
+
return resolved
|
|
91
|
+
}
|
|
65
92
|
}
|
|
66
93
|
return nil
|
|
67
94
|
}
|
|
68
95
|
|
|
96
|
+
private static func loadConfigValue(homePath: String, key: String, environment: [String: String]) -> String? {
|
|
97
|
+
let json = loadMutableConfig(homePath: homePath)
|
|
98
|
+
guard let value = json[key] as? String else { return nil }
|
|
99
|
+
return resolve(value: value, environment: environment)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private static func loadMutableConfig(homePath: String) -> [String: Any] {
|
|
103
|
+
let url = configURL(homePath: homePath)
|
|
104
|
+
guard let data = try? Data(contentsOf: url),
|
|
105
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
106
|
+
else { return [:] }
|
|
107
|
+
return json
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private static func writeConfig(_ json: [String: Any], homePath: String) throws {
|
|
111
|
+
let url = configURL(homePath: homePath)
|
|
112
|
+
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
113
|
+
let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys])
|
|
114
|
+
try data.write(to: url, options: .atomic)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private static func configURL(homePath: String) -> URL {
|
|
118
|
+
URL(fileURLWithPath: homePath)
|
|
119
|
+
.appendingPathComponent(".hasna")
|
|
120
|
+
.appendingPathComponent("recordings")
|
|
121
|
+
.appendingPathComponent("config.json")
|
|
122
|
+
}
|
|
123
|
+
|
|
69
124
|
private static func loadSecretKey(homePath: String) -> String? {
|
|
70
125
|
let root = URL(fileURLWithPath: homePath).appendingPathComponent(".secrets")
|
|
71
126
|
let fileManager = FileManager.default
|
|
@@ -127,6 +182,11 @@ enum OpenAIAPIKeyStore {
|
|
|
127
182
|
return value
|
|
128
183
|
}
|
|
129
184
|
|
|
185
|
+
private static func normalizedStoredLanguage(_ language: String) -> String {
|
|
186
|
+
let trimmed = language.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
187
|
+
return trimmed.isEmpty ? defaultLanguage : trimmed
|
|
188
|
+
}
|
|
189
|
+
|
|
130
190
|
private static func firstNonEmpty(_ values: String?...) -> String? {
|
|
131
191
|
for value in values {
|
|
132
192
|
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
@@ -6,8 +6,9 @@ import Foundation
|
|
|
6
6
|
/// Receives transcription deltas in real time.
|
|
7
7
|
@MainActor
|
|
8
8
|
public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sendable {
|
|
9
|
-
///
|
|
10
|
-
public nonisolated static let modelID = "gpt-
|
|
9
|
+
/// Low-latency model for realtime transcript deltas.
|
|
10
|
+
public nonisolated static let modelID = "gpt-realtime-whisper"
|
|
11
|
+
public nonisolated static let transcriptionDelay = "low"
|
|
11
12
|
private nonisolated static let transcriptionURL = URL(string: "wss://api.openai.com/v1/realtime?intent=transcription")!
|
|
12
13
|
|
|
13
14
|
@Published public var accumulatedText = ""
|
|
@@ -80,7 +81,7 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
80
81
|
|
|
81
82
|
var transcription: [String: Any] = [
|
|
82
83
|
"model": Self.modelID,
|
|
83
|
-
"
|
|
84
|
+
"delay": Self.transcriptionDelay,
|
|
84
85
|
]
|
|
85
86
|
let trimmedLanguage = language.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
86
87
|
if !trimmedLanguage.isEmpty {
|
|
@@ -141,7 +142,7 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
/// Commit buffered input, wait briefly for a final completed event, then close.
|
|
144
|
-
public func finish(timeoutMilliseconds: UInt64 =
|
|
145
|
+
public func finish(timeoutMilliseconds: UInt64 = 700) async -> String {
|
|
145
146
|
guard isStreaming else { return accumulatedText }
|
|
146
147
|
let completedCountBeforeCommit = completedEventCount
|
|
147
148
|
let didManualCommit = await commitInput()
|
|
@@ -379,18 +380,8 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
379
380
|
"rate": 24_000,
|
|
380
381
|
],
|
|
381
382
|
"transcription": transcription,
|
|
382
|
-
"turn_detection": [
|
|
383
|
-
"type": "server_vad",
|
|
384
|
-
"threshold": 0.5,
|
|
385
|
-
"prefix_padding_ms": 300,
|
|
386
|
-
"silence_duration_ms": 350,
|
|
387
|
-
],
|
|
388
|
-
"noise_reduction": [
|
|
389
|
-
"type": "near_field",
|
|
390
|
-
],
|
|
391
383
|
],
|
|
392
384
|
],
|
|
393
|
-
"include": ["item.input_audio_transcription.logprobs"],
|
|
394
385
|
],
|
|
395
386
|
]
|
|
396
387
|
}
|
|
@@ -442,7 +433,7 @@ extension RealtimeTranscriptionClient {
|
|
|
442
433
|
public nonisolated static func sessionUpdateTestHelper(prompt: String, language: String = "") -> [String: Any] {
|
|
443
434
|
var transcription: [String: Any] = [
|
|
444
435
|
"model": modelID,
|
|
445
|
-
"
|
|
436
|
+
"delay": transcriptionDelay,
|
|
446
437
|
]
|
|
447
438
|
if !language.isEmpty {
|
|
448
439
|
transcription["language"] = language
|
|
@@ -26,9 +26,9 @@ public enum RecordingMode: String, CaseIterable, Identifiable, Sendable {
|
|
|
26
26
|
}
|
|
27
27
|
public var hint: String {
|
|
28
28
|
switch self {
|
|
29
|
-
case .pushToTalk: return "Hold
|
|
30
|
-
case .dictation: return "Hold
|
|
31
|
-
case .command: return "Select text,
|
|
29
|
+
case .pushToTalk: return "Hold your recording shortcut, then release to paste"
|
|
30
|
+
case .dictation: return "Hold your recording shortcut to dictate, then release to paste"
|
|
31
|
+
case .command: return "Select text, hold your recording shortcut, then release to rewrite"
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
}
|
|
@@ -56,33 +56,50 @@ struct PasteTargetCandidate: Equatable, Sendable {
|
|
|
56
56
|
let isRegularApp: Bool
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
private
|
|
60
|
-
private
|
|
61
|
-
private
|
|
62
|
-
|
|
63
|
-
func append(_ data: Data, chunkSize: Int) -> [Data] {
|
|
64
|
-
guard !data.isEmpty else { return [] }
|
|
59
|
+
private final class PCMStreamPipe: @unchecked Sendable {
|
|
60
|
+
private let continuation: AsyncStream<Data>.Continuation
|
|
61
|
+
private let processor: Task<Data, Never>
|
|
65
62
|
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
init(chunkSize: Int, client: RealtimeTranscriptionClient?) {
|
|
64
|
+
var streamContinuation: AsyncStream<Data>.Continuation!
|
|
65
|
+
let stream = AsyncStream<Data>(bufferingPolicy: .unbounded) { continuation in
|
|
66
|
+
streamContinuation = continuation
|
|
67
|
+
}
|
|
68
|
+
continuation = streamContinuation
|
|
69
|
+
processor = Task {
|
|
70
|
+
var recordedPCM = Data()
|
|
71
|
+
var pendingChunk = Data()
|
|
72
|
+
|
|
73
|
+
for await data in stream {
|
|
74
|
+
guard !data.isEmpty else { continue }
|
|
75
|
+
recordedPCM.append(data)
|
|
76
|
+
pendingChunk.append(data)
|
|
77
|
+
|
|
78
|
+
while pendingChunk.count >= chunkSize {
|
|
79
|
+
await client?.sendAudio(pendingChunk.prefixData(count: chunkSize))
|
|
80
|
+
pendingChunk.removeFirst(chunkSize)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
68
83
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
84
|
+
if !pendingChunk.isEmpty {
|
|
85
|
+
await client?.sendAudio(pendingChunk)
|
|
86
|
+
}
|
|
87
|
+
return recordedPCM
|
|
73
88
|
}
|
|
74
|
-
return chunks
|
|
75
89
|
}
|
|
76
90
|
|
|
77
|
-
func
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
91
|
+
func append(_ data: Data) {
|
|
92
|
+
continuation.yield(data)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func finish() async -> Data {
|
|
96
|
+
continuation.finish()
|
|
97
|
+
return await processor.value
|
|
82
98
|
}
|
|
83
99
|
|
|
84
|
-
func
|
|
85
|
-
|
|
100
|
+
func cancel() {
|
|
101
|
+
continuation.finish()
|
|
102
|
+
processor.cancel()
|
|
86
103
|
}
|
|
87
104
|
}
|
|
88
105
|
|
|
@@ -116,6 +133,12 @@ public final class RecordingEngine: ObservableObject {
|
|
|
116
133
|
@Published public var isTranscribing = false
|
|
117
134
|
@Published public var recordingDuration: TimeInterval = 0
|
|
118
135
|
@Published public var liveTranscriptionText = ""
|
|
136
|
+
@Published public var transcriptionLanguage = OpenAIAPIKeyStore.defaultLanguage {
|
|
137
|
+
didSet {
|
|
138
|
+
UserDefaults.standard.set(transcriptionLanguage, forKey: "recordingsLanguage")
|
|
139
|
+
try? OpenAIAPIKeyStore.saveLanguage(language: transcriptionLanguage, homePath: home)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
119
142
|
|
|
120
143
|
private var nativeRecorder: NativePCMRecorder?
|
|
121
144
|
private var recordingTimer: Timer?
|
|
@@ -130,12 +153,15 @@ public final class RecordingEngine: ObservableObject {
|
|
|
130
153
|
// Real-time streaming
|
|
131
154
|
private var realtimeClient: RealtimeTranscriptionClient?
|
|
132
155
|
private var streamingTask: Task<Void, Never>?
|
|
133
|
-
private var
|
|
156
|
+
private var pcmStreamPipe: PCMStreamPipe?
|
|
134
157
|
private var streamingText = ""
|
|
135
158
|
private var recordedPCM = Data()
|
|
136
159
|
private var activeAudioPath: String?
|
|
137
160
|
private var lastAccessibilityPromptAt: Date?
|
|
138
161
|
|
|
162
|
+
private nonisolated static let realtimePeriodicCommitInterval: TimeInterval = 0.9
|
|
163
|
+
private nonisolated static let realtimeFinishTimeoutMilliseconds: UInt64 = 700
|
|
164
|
+
|
|
139
165
|
// fn key monitor (CGEventTap-based, swallows fn to prevent emoji picker)
|
|
140
166
|
private let fnMonitor = FnKeyMonitor()
|
|
141
167
|
private var permissionRetryTimer: Timer?
|
|
@@ -158,6 +184,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
158
184
|
let parsedMode = RecordingMode(rawValue: savedMode) {
|
|
159
185
|
mode = parsedMode
|
|
160
186
|
}
|
|
187
|
+
transcriptionLanguage = OpenAIAPIKeyStore.loadLanguage(homePath: home)
|
|
161
188
|
useFnKey = UserDefaults.standard.object(forKey: "useFnKey") as? Bool ?? false
|
|
162
189
|
if KeyboardShortcuts.getShortcut(for: .toggleRecording) == nil {
|
|
163
190
|
KeyboardShortcuts.setShortcut(.init(.f5), for: .toggleRecording)
|
|
@@ -393,9 +420,6 @@ public final class RecordingEngine: ObservableObject {
|
|
|
393
420
|
}
|
|
394
421
|
|
|
395
422
|
private func startNativeRecording() {
|
|
396
|
-
let streamState = PCMStreamState()
|
|
397
|
-
pcmStreamState = streamState
|
|
398
|
-
|
|
399
423
|
let apiKey = openAIAPIKey
|
|
400
424
|
log("startNativeRecording apiKeyConfigured=\(!apiKey.isEmpty)")
|
|
401
425
|
if !apiKey.isEmpty {
|
|
@@ -403,18 +427,15 @@ public final class RecordingEngine: ObservableObject {
|
|
|
403
427
|
}
|
|
404
428
|
|
|
405
429
|
let client = realtimeClient
|
|
430
|
+
let streamPipe = PCMStreamPipe(chunkSize: 4_800, client: client)
|
|
431
|
+
pcmStreamPipe = streamPipe
|
|
406
432
|
let homePath = home
|
|
407
433
|
let firstChunkLogged = LockedFlag()
|
|
408
|
-
let recorder = NativePCMRecorder {
|
|
434
|
+
let recorder = NativePCMRecorder { data in
|
|
409
435
|
if firstChunkLogged.take() {
|
|
410
436
|
NativeAppLog.write("native recorder received first PCM chunk bytes=\(data.count)", homePath: homePath)
|
|
411
437
|
}
|
|
412
|
-
|
|
413
|
-
let chunks = await streamState.append(data, chunkSize: 4_800)
|
|
414
|
-
for chunk in chunks {
|
|
415
|
-
await client?.sendAudio(chunk)
|
|
416
|
-
}
|
|
417
|
-
}
|
|
438
|
+
streamPipe.append(data)
|
|
418
439
|
}
|
|
419
440
|
|
|
420
441
|
do {
|
|
@@ -445,7 +466,8 @@ public final class RecordingEngine: ObservableObject {
|
|
|
445
466
|
realtimeClient = nil
|
|
446
467
|
streamingTask?.cancel()
|
|
447
468
|
streamingTask = nil
|
|
448
|
-
|
|
469
|
+
pcmStreamPipe?.cancel()
|
|
470
|
+
pcmStreamPipe = nil
|
|
449
471
|
resetRecordingIntent()
|
|
450
472
|
statusMessage = "Failed: \(error.localizedDescription)"
|
|
451
473
|
}
|
|
@@ -456,20 +478,29 @@ public final class RecordingEngine: ObservableObject {
|
|
|
456
478
|
private func startRealtimeStreaming(apiKey: String) {
|
|
457
479
|
let client = RealtimeTranscriptionClient(apiKey: apiKey, homePath: home)
|
|
458
480
|
realtimeClient = client
|
|
459
|
-
|
|
481
|
+
let language = OpenAIAPIKeyStore.apiLanguageHint(for: transcriptionLanguage)
|
|
482
|
+
log("realtime streaming task starting language=\(language.isEmpty ? "auto" : language)")
|
|
460
483
|
|
|
461
484
|
streamingTask = Task {
|
|
462
|
-
await client.startStreaming()
|
|
485
|
+
await client.startStreaming(language: language)
|
|
463
486
|
self.log("realtime start completed streaming=\(client.isStreaming) error=\(client.error ?? "")")
|
|
464
487
|
|
|
488
|
+
var lastPeriodicCommitAt = Date.distantPast
|
|
489
|
+
|
|
465
490
|
// Receive deltas
|
|
466
491
|
while client.isStreaming {
|
|
467
492
|
try? await Task.sleep(for: .milliseconds(100))
|
|
493
|
+
if self.isRecording,
|
|
494
|
+
Date().timeIntervalSince(lastPeriodicCommitAt) >= Self.realtimePeriodicCommitInterval {
|
|
495
|
+
if await client.commitInput(reason: "periodic") {
|
|
496
|
+
lastPeriodicCommitAt = Date()
|
|
497
|
+
}
|
|
498
|
+
}
|
|
468
499
|
let text = client.accumulatedText
|
|
469
500
|
if text != streamingText {
|
|
470
501
|
await MainActor.run {
|
|
471
502
|
self.streamingText = text
|
|
472
|
-
self.liveTranscriptionText = text
|
|
503
|
+
self.liveTranscriptionText = Self.cleanRealtimeArtifactText(text)
|
|
473
504
|
}
|
|
474
505
|
}
|
|
475
506
|
}
|
|
@@ -489,6 +520,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
489
520
|
|
|
490
521
|
public func stopAndTranscribe() {
|
|
491
522
|
guard isRecording else { return }
|
|
523
|
+
let stopStartedAt = Date()
|
|
492
524
|
log("stopAndTranscribe")
|
|
493
525
|
|
|
494
526
|
recordingTimer?.invalidate()
|
|
@@ -507,30 +539,53 @@ public final class RecordingEngine: ObservableObject {
|
|
|
507
539
|
let activeProjectId = projectStore?.settings.activeProjectId
|
|
508
540
|
let activeProjectName = projectStore?.activeProject?.name
|
|
509
541
|
let audioPath = activeAudioPath
|
|
510
|
-
let
|
|
542
|
+
let pcmStreamPipe = pcmStreamPipe
|
|
511
543
|
let client = realtimeClient
|
|
544
|
+
let transcriptionLanguage = transcriptionLanguage
|
|
512
545
|
resetRecordingIntent()
|
|
513
|
-
self.
|
|
546
|
+
self.pcmStreamPipe = nil
|
|
514
547
|
|
|
515
548
|
Task {
|
|
516
|
-
if let
|
|
517
|
-
|
|
518
|
-
client?.sendAudio(finalChunk)
|
|
519
|
-
}
|
|
520
|
-
self.recordedPCM = await pcmStreamState.capturedPCM()
|
|
549
|
+
if let pcmStreamPipe {
|
|
550
|
+
self.recordedPCM = await pcmStreamPipe.finish()
|
|
521
551
|
}
|
|
522
552
|
self.log("captured pcm bytes=\(self.recordedPCM.count)")
|
|
523
553
|
|
|
524
|
-
let streamingResult = await client?.finish() ?? ""
|
|
554
|
+
let streamingResult = await client?.finish(timeoutMilliseconds: Self.realtimeFinishTimeoutMilliseconds) ?? ""
|
|
525
555
|
|
|
526
556
|
self.realtimeClient = nil
|
|
527
557
|
self.streamingTask?.cancel()
|
|
528
558
|
self.streamingTask = nil
|
|
529
559
|
|
|
530
|
-
let realtimeText =
|
|
560
|
+
let realtimeText = Self.normalizedRealtimeTranscript(streamingResult)
|
|
531
561
|
|
|
532
562
|
self.liveTranscriptionText = ""
|
|
533
563
|
|
|
564
|
+
if Self.shouldUseRealtimeFastPath(
|
|
565
|
+
realtimeText: streamingResult,
|
|
566
|
+
pcmByteCount: self.recordedPCM.count,
|
|
567
|
+
language: transcriptionLanguage
|
|
568
|
+
),
|
|
569
|
+
let realtimeText {
|
|
570
|
+
let releaseToTextMS = Int(Date().timeIntervalSince(stopStartedAt) * 1_000)
|
|
571
|
+
self.log("using realtime fast path chars=\(realtimeText.count) releaseToTextMs=\(releaseToTextMS)")
|
|
572
|
+
self.isTranscribing = false
|
|
573
|
+
self.finishWithText(
|
|
574
|
+
realtimeText,
|
|
575
|
+
curMode: curMode,
|
|
576
|
+
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
577
|
+
targetAppPid: targetAppPid,
|
|
578
|
+
activeProjectId: activeProjectId,
|
|
579
|
+
activeProjectName: activeProjectName
|
|
580
|
+
)
|
|
581
|
+
if let audioPath, !self.recordedPCM.isEmpty {
|
|
582
|
+
Self.saveCapturedWAVInBackground(pcmData: self.recordedPCM, audioPath: audioPath, homePath: self.home)
|
|
583
|
+
}
|
|
584
|
+
self.activeAudioPath = nil
|
|
585
|
+
self.recordedPCM.removeAll(keepingCapacity: true)
|
|
586
|
+
return
|
|
587
|
+
}
|
|
588
|
+
|
|
534
589
|
if let audioPath, self.writeCapturedWAV(to: audioPath) {
|
|
535
590
|
self.log("transcribing captured full audio audioPath=\(audioPath) realtimePreviewChars=\(realtimeText?.count ?? 0)")
|
|
536
591
|
self.fallbackTranscribe(
|
|
@@ -573,10 +628,166 @@ public final class RecordingEngine: ObservableObject {
|
|
|
573
628
|
return trimmed.count < 12 || words.count <= 2
|
|
574
629
|
}
|
|
575
630
|
|
|
631
|
+
public nonisolated static func normalizedRealtimeTranscript(_ text: String?) -> String? {
|
|
632
|
+
guard let text else { return nil }
|
|
633
|
+
let trimmed = cleanRealtimeArtifactText(text).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
634
|
+
return trimmed.isEmpty ? nil : trimmed
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
public nonisolated static func shouldUseRealtimeFastPath(
|
|
638
|
+
realtimeText: String?,
|
|
639
|
+
pcmByteCount: Int,
|
|
640
|
+
language: String = "en"
|
|
641
|
+
) -> Bool {
|
|
642
|
+
guard let text = normalizedRealtimeTranscript(realtimeText) else { return false }
|
|
643
|
+
guard isSafeRealtimeFastPathText(rawText: realtimeText ?? "", cleanedText: text, language: language) else {
|
|
644
|
+
return false
|
|
645
|
+
}
|
|
646
|
+
return !shouldFallbackFromPartialRealtime(text: text, pcmByteCount: pcmByteCount)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
public nonisolated static func isSafeRealtimeFastPathText(rawText: String, cleanedText: String, language: String) -> Bool {
|
|
650
|
+
guard !cleanedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false }
|
|
651
|
+
let languageHint = OpenAIAPIKeyStore.apiLanguageHint(for: language)
|
|
652
|
+
guard languageHint == "en" else { return true }
|
|
653
|
+
|
|
654
|
+
let rawTrimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
655
|
+
guard !containsCJKArtifact(in: rawTrimmed) else { return false }
|
|
656
|
+
|
|
657
|
+
let rawNormalized = rawTrimmed.replacingOccurrences(
|
|
658
|
+
of: #"\s+"#,
|
|
659
|
+
with: " ",
|
|
660
|
+
options: .regularExpression
|
|
661
|
+
)
|
|
662
|
+
let cleanedNormalized = cleanedText.replacingOccurrences(
|
|
663
|
+
of: #"\s+"#,
|
|
664
|
+
with: " ",
|
|
665
|
+
options: .regularExpression
|
|
666
|
+
)
|
|
667
|
+
return rawNormalized == cleanedNormalized
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
public nonisolated static func cleanRealtimeArtifactText(_ text: String) -> String {
|
|
671
|
+
var cleaned = text
|
|
672
|
+
.replacingOccurrences(of: "\n", with: " ")
|
|
673
|
+
.replacingOccurrences(of: "\t", with: " ")
|
|
674
|
+
cleaned = cleaned.replacingOccurrences(
|
|
675
|
+
of: #"(?i)(?<=[A-Za-z])어\b"#,
|
|
676
|
+
with: "",
|
|
677
|
+
options: .regularExpression
|
|
678
|
+
)
|
|
679
|
+
cleaned = cleaned.replacingOccurrences(
|
|
680
|
+
of: #"\s+"#,
|
|
681
|
+
with: " ",
|
|
682
|
+
options: .regularExpression
|
|
683
|
+
)
|
|
684
|
+
cleaned = removeStandaloneRealtimeArtifacts(from: cleaned)
|
|
685
|
+
cleaned = collapseAdjacentDuplicateWords(in: cleaned)
|
|
686
|
+
cleaned = collapseAdjacentDuplicatePhrases(in: cleaned)
|
|
687
|
+
cleaned = cleaned.replacingOccurrences(
|
|
688
|
+
of: #"\s+([,.;:!?])"#,
|
|
689
|
+
with: "$1",
|
|
690
|
+
options: .regularExpression
|
|
691
|
+
)
|
|
692
|
+
return cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
private nonisolated static func removeStandaloneRealtimeArtifacts(from text: String) -> String {
|
|
696
|
+
let artifactTokens: Set<String> = ["어", "음", "um", "umm", "uh", "uhh", "erm", "hmm", "eh"]
|
|
697
|
+
let englishDominant = latinLetterCount(in: text) >= max(12, cjkLetterCount(in: text) * 3)
|
|
698
|
+
let words = text.split(separator: " ").compactMap { rawWord -> String? in
|
|
699
|
+
let normalized = rawWord
|
|
700
|
+
.trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines))
|
|
701
|
+
.lowercased()
|
|
702
|
+
return artifactTokens.contains(normalized) ? nil : String(rawWord)
|
|
703
|
+
}.compactMap { word -> String? in
|
|
704
|
+
guard englishDominant else { return word }
|
|
705
|
+
let normalized = word.trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines))
|
|
706
|
+
guard !normalized.isEmpty else { return word }
|
|
707
|
+
return isMostlyCJKArtifact(normalized) ? nil : word
|
|
708
|
+
}
|
|
709
|
+
return words.joined(separator: " ")
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
private nonisolated static func collapseAdjacentDuplicateWords(in text: String) -> String {
|
|
713
|
+
let words = text.split(separator: " ").map(String.init)
|
|
714
|
+
guard words.count > 1 else { return text }
|
|
715
|
+
|
|
716
|
+
var output: [String] = []
|
|
717
|
+
for word in words {
|
|
718
|
+
if let last = output.last,
|
|
719
|
+
normalizedTranscriptWord(last) == normalizedTranscriptWord(word) {
|
|
720
|
+
continue
|
|
721
|
+
}
|
|
722
|
+
output.append(word)
|
|
723
|
+
}
|
|
724
|
+
return output.joined(separator: " ")
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
private nonisolated static func collapseAdjacentDuplicatePhrases(in text: String) -> String {
|
|
728
|
+
var words = text.split(separator: " ").map(String.init)
|
|
729
|
+
guard words.count >= 6 else { return text }
|
|
730
|
+
|
|
731
|
+
var i = 0
|
|
732
|
+
while i < words.count {
|
|
733
|
+
let maxLength = min(24, (words.count - i) / 2)
|
|
734
|
+
var removedDuplicate = false
|
|
735
|
+
if maxLength >= 3 {
|
|
736
|
+
for length in stride(from: maxLength, through: 3, by: -1) {
|
|
737
|
+
let first = words[i..<(i + length)].map(normalizedTranscriptWord)
|
|
738
|
+
let second = words[(i + length)..<(i + (2 * length))].map(normalizedTranscriptWord)
|
|
739
|
+
if first == second {
|
|
740
|
+
words.removeSubrange((i + length)..<(i + (2 * length)))
|
|
741
|
+
removedDuplicate = true
|
|
742
|
+
break
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if !removedDuplicate {
|
|
747
|
+
i += 1
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
return words.joined(separator: " ")
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
private nonisolated static func normalizedTranscriptWord(_ word: String) -> String {
|
|
754
|
+
word.trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines)).lowercased()
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
private nonisolated static func isMostlyCJKArtifact(_ word: String) -> Bool {
|
|
758
|
+
let cjkCount = word.unicodeScalars.filter(isCJKScalar).count
|
|
759
|
+
guard cjkCount > 0 else { return false }
|
|
760
|
+
let letterCount = word.unicodeScalars.filter { CharacterSet.letters.contains($0) }.count
|
|
761
|
+
return cjkCount >= max(1, letterCount - cjkCount)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
private nonisolated static func latinLetterCount(in text: String) -> Int {
|
|
765
|
+
text.unicodeScalars.filter { scalar in
|
|
766
|
+
(65...90).contains(Int(scalar.value)) || (97...122).contains(Int(scalar.value))
|
|
767
|
+
}.count
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
private nonisolated static func cjkLetterCount(in text: String) -> Int {
|
|
771
|
+
text.unicodeScalars.filter(isCJKScalar).count
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
private nonisolated static func containsCJKArtifact(in text: String) -> Bool {
|
|
775
|
+
cjkLetterCount(in: text) > 0
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
private nonisolated static func isCJKScalar(_ scalar: UnicodeScalar) -> Bool {
|
|
779
|
+
switch scalar.value {
|
|
780
|
+
case 0x3040...0x30FF, 0x3400...0x4DBF, 0x4E00...0x9FFF, 0xAC00...0xD7AF:
|
|
781
|
+
return true
|
|
782
|
+
default:
|
|
783
|
+
return false
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
576
787
|
private func finishWithText(_ text: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, targetAppPid: pid_t?, activeProjectId: String?, activeProjectName: String?) {
|
|
577
788
|
log("finishWithText mode=\(curMode.rawValue) chars=\(text.count)")
|
|
578
789
|
if curMode == .command {
|
|
579
|
-
runCommandMode(instruction: text)
|
|
790
|
+
runCommandMode(instruction: text, targetAppBundleIdentifier: targetAppBundleIdentifier, targetAppPid: targetAppPid)
|
|
580
791
|
return
|
|
581
792
|
}
|
|
582
793
|
|
|
@@ -615,7 +826,24 @@ public final class RecordingEngine: ObservableObject {
|
|
|
615
826
|
}
|
|
616
827
|
}
|
|
617
828
|
|
|
618
|
-
private static func
|
|
829
|
+
private nonisolated static func saveCapturedWAVInBackground(pcmData: Data, audioPath: String, homePath: String) {
|
|
830
|
+
Task.detached(priority: .utility) {
|
|
831
|
+
do {
|
|
832
|
+
try Self.writeWAV(
|
|
833
|
+
pcmData: pcmData,
|
|
834
|
+
sampleRate: 24_000,
|
|
835
|
+
channelCount: 1,
|
|
836
|
+
bitsPerSample: 16,
|
|
837
|
+
to: URL(fileURLWithPath: audioPath)
|
|
838
|
+
)
|
|
839
|
+
NativeAppLog.write("wrote wav path=\(audioPath) pcmBytes=\(pcmData.count)", homePath: homePath)
|
|
840
|
+
} catch {
|
|
841
|
+
NativeAppLog.write("failed to save wav after realtime fast path error=\(error.localizedDescription)", homePath: homePath)
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
private nonisolated static func writeWAV(pcmData: Data, sampleRate: UInt32, channelCount: UInt16, bitsPerSample: UInt16, to url: URL) throws {
|
|
619
847
|
let byteRate = sampleRate * UInt32(channelCount) * UInt32(bitsPerSample / 8)
|
|
620
848
|
let blockAlign = channelCount * (bitsPerSample / 8)
|
|
621
849
|
let dataSize = UInt32(pcmData.count)
|
|
@@ -729,17 +957,35 @@ public final class RecordingEngine: ObservableObject {
|
|
|
729
957
|
|
|
730
958
|
// MARK: - Command Mode
|
|
731
959
|
|
|
732
|
-
private func runCommandMode(instruction: String) {
|
|
960
|
+
private func runCommandMode(instruction: String, targetAppBundleIdentifier: String?, targetAppPid: pid_t?) {
|
|
733
961
|
guard ensureAccessibilityPermission(prompt: shouldPromptAccessibility()) else {
|
|
734
962
|
log("command mode blocked by accessibility permission")
|
|
735
963
|
statusMessage = "Enable Accessibility permission for Recordings to rewrite selected text"
|
|
736
964
|
return
|
|
737
965
|
}
|
|
738
|
-
|
|
739
|
-
|
|
966
|
+
let targetApp = selectedRunningPasteTarget(
|
|
967
|
+
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
968
|
+
targetAppPid: targetAppPid,
|
|
969
|
+
frontmostPid: NSWorkspace.shared.frontmostApplication?.processIdentifier
|
|
970
|
+
)
|
|
971
|
+
guard let targetApp else {
|
|
972
|
+
log("command mode target app not found")
|
|
973
|
+
statusMessage = "No target app found"
|
|
974
|
+
return
|
|
975
|
+
}
|
|
976
|
+
let alreadyFrontmost = targetApp.processIdentifier == NSWorkspace.shared.frontmostApplication?.processIdentifier
|
|
977
|
+
if !alreadyFrontmost {
|
|
978
|
+
targetApp.activate(options: [.activateIgnoringOtherApps])
|
|
979
|
+
}
|
|
740
980
|
|
|
981
|
+
let copyDelay: TimeInterval = alreadyFrontmost ? 0.15 : 0.5
|
|
982
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + copyDelay) {
|
|
983
|
+
self.postKey(0x08, flags: .maskCommand) // Cmd+C
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
let homePath = home
|
|
741
987
|
Task {
|
|
742
|
-
try? await Task.sleep(for: .milliseconds(
|
|
988
|
+
try? await Task.sleep(for: .milliseconds(Int((copyDelay + 0.25) * 1_000)))
|
|
743
989
|
let selected = NSPasteboard.general.string(forType: .string) ?? ""
|
|
744
990
|
guard !selected.isEmpty else {
|
|
745
991
|
statusMessage = "No text selected"
|
|
@@ -793,7 +1039,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
793
1039
|
func pasteIntoFrontApp(_ text: String, targetAppBundleIdentifier: String? = nil, targetAppPid: pid_t? = nil, restoreClipboard: Bool = false) {
|
|
794
1040
|
log("paste requested chars=\(text.count) target=\(targetAppBundleIdentifier ?? "nil") pid=\(targetAppPid.map(String.init) ?? "nil") accessibility=\(AXIsProcessTrusted())")
|
|
795
1041
|
let pb = NSPasteboard.general
|
|
796
|
-
let previousClipboard = restoreClipboard ?
|
|
1042
|
+
let previousClipboard = restoreClipboard ? ClipboardSnapshot(pasteboard: pb) : nil
|
|
797
1043
|
pb.clearContents()
|
|
798
1044
|
pb.setString(text, forType: .string)
|
|
799
1045
|
|
|
@@ -806,30 +1052,16 @@ public final class RecordingEngine: ObservableObject {
|
|
|
806
1052
|
return
|
|
807
1053
|
}
|
|
808
1054
|
|
|
809
|
-
let myPID = ProcessInfo.processInfo.processIdentifier
|
|
810
|
-
let runningApps = NSWorkspace.shared.runningApplications
|
|
811
1055
|
let frontmostPid = NSWorkspace.shared.frontmostApplication?.processIdentifier
|
|
812
|
-
let
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
bundleIdentifier: $0.bundleIdentifier,
|
|
816
|
-
isRegularApp: $0.activationPolicy == .regular
|
|
817
|
-
)
|
|
818
|
-
}
|
|
819
|
-
let selectedTarget = Self.selectPasteTarget(
|
|
820
|
-
candidates: candidates,
|
|
821
|
-
currentPid: myPID,
|
|
822
|
-
targetBundleIdentifier: targetAppBundleIdentifier,
|
|
823
|
-
targetPid: targetAppPid,
|
|
1056
|
+
let targetApp = selectedRunningPasteTarget(
|
|
1057
|
+
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
1058
|
+
targetAppPid: targetAppPid,
|
|
824
1059
|
frontmostPid: frontmostPid
|
|
825
1060
|
)
|
|
826
|
-
let targetApp = selectedTarget.flatMap { selected in
|
|
827
|
-
runningApps.first { $0.processIdentifier == selected.pid }
|
|
828
|
-
}
|
|
829
1061
|
|
|
830
1062
|
guard let app = targetApp else {
|
|
831
1063
|
log("paste target app not found")
|
|
832
|
-
self.statusMessage = "
|
|
1064
|
+
self.statusMessage = "Copied — no target app found"
|
|
833
1065
|
return
|
|
834
1066
|
}
|
|
835
1067
|
|
|
@@ -857,14 +1089,35 @@ public final class RecordingEngine: ObservableObject {
|
|
|
857
1089
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
|
858
1090
|
let pb = NSPasteboard.general
|
|
859
1091
|
if pb.string(forType: .string) == text {
|
|
860
|
-
|
|
861
|
-
pb.setString(previousClipboard, forType: .string)
|
|
1092
|
+
previousClipboard.restore(to: pb)
|
|
862
1093
|
}
|
|
863
1094
|
}
|
|
864
1095
|
}
|
|
865
1096
|
}
|
|
866
1097
|
}
|
|
867
1098
|
|
|
1099
|
+
private func selectedRunningPasteTarget(targetAppBundleIdentifier: String?, targetAppPid: pid_t?, frontmostPid: pid_t?) -> NSRunningApplication? {
|
|
1100
|
+
let myPID = ProcessInfo.processInfo.processIdentifier
|
|
1101
|
+
let runningApps = NSWorkspace.shared.runningApplications
|
|
1102
|
+
let candidates = runningApps.map {
|
|
1103
|
+
PasteTargetCandidate(
|
|
1104
|
+
pid: $0.processIdentifier,
|
|
1105
|
+
bundleIdentifier: $0.bundleIdentifier,
|
|
1106
|
+
isRegularApp: $0.activationPolicy == .regular
|
|
1107
|
+
)
|
|
1108
|
+
}
|
|
1109
|
+
let selectedTarget = Self.selectPasteTarget(
|
|
1110
|
+
candidates: candidates,
|
|
1111
|
+
currentPid: myPID,
|
|
1112
|
+
targetBundleIdentifier: targetAppBundleIdentifier,
|
|
1113
|
+
targetPid: targetAppPid,
|
|
1114
|
+
frontmostPid: frontmostPid
|
|
1115
|
+
)
|
|
1116
|
+
return selectedTarget.flatMap { selected in
|
|
1117
|
+
runningApps.first { $0.processIdentifier == selected.pid }
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
868
1121
|
nonisolated static func selectPasteTarget(
|
|
869
1122
|
candidates: [PasteTargetCandidate],
|
|
870
1123
|
currentPid: pid_t,
|
|
@@ -882,8 +1135,6 @@ public final class RecordingEngine: ObservableObject {
|
|
|
882
1135
|
} ?? candidates.first {
|
|
883
1136
|
guard let frontmostPid else { return false }
|
|
884
1137
|
return $0.pid == frontmostPid && $0.pid != currentPid && $0.isRegularApp
|
|
885
|
-
} ?? candidates.first {
|
|
886
|
-
$0.isRegularApp && $0.pid != currentPid
|
|
887
1138
|
}
|
|
888
1139
|
}
|
|
889
1140
|
|
|
@@ -941,6 +1192,35 @@ private final class LockedFlag: @unchecked Sendable {
|
|
|
941
1192
|
}
|
|
942
1193
|
}
|
|
943
1194
|
|
|
1195
|
+
private struct ClipboardSnapshot {
|
|
1196
|
+
private let items: [[NSPasteboard.PasteboardType: Data]]
|
|
1197
|
+
|
|
1198
|
+
init?(pasteboard: NSPasteboard) {
|
|
1199
|
+
let capturedItems = pasteboard.pasteboardItems?.compactMap { item -> [NSPasteboard.PasteboardType: Data]? in
|
|
1200
|
+
let dataByType = item.types.reduce(into: [NSPasteboard.PasteboardType: Data]()) { result, type in
|
|
1201
|
+
if let data = item.data(forType: type) {
|
|
1202
|
+
result[type] = data
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
return dataByType.isEmpty ? nil : dataByType
|
|
1206
|
+
} ?? []
|
|
1207
|
+
guard !capturedItems.isEmpty else { return nil }
|
|
1208
|
+
items = capturedItems
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
func restore(to pasteboard: NSPasteboard) {
|
|
1212
|
+
pasteboard.clearContents()
|
|
1213
|
+
let pasteboardItems = items.map { itemData in
|
|
1214
|
+
let item = NSPasteboardItem()
|
|
1215
|
+
for (type, data) in itemData {
|
|
1216
|
+
item.setData(data, forType: type)
|
|
1217
|
+
}
|
|
1218
|
+
return item
|
|
1219
|
+
}
|
|
1220
|
+
pasteboard.writeObjects(pasteboardItems)
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
944
1224
|
// MARK: - CLI Runner
|
|
945
1225
|
|
|
946
1226
|
enum CLIRunner: Sendable {
|
|
Binary file
|
|
@@ -33,6 +33,10 @@ public struct SettingsView: View {
|
|
|
33
33
|
// Keep the CLI config in sync — final transcription shells out to it.
|
|
34
34
|
try? OpenAIAPIKeyStore.save(key: openAIAPIKey, homePath: engine.home)
|
|
35
35
|
}
|
|
36
|
+
Picker("Language", selection: $engine.transcriptionLanguage) {
|
|
37
|
+
Text("English").tag("en")
|
|
38
|
+
Text("Auto Detect").tag("auto")
|
|
39
|
+
}
|
|
36
40
|
Text("Used for live transcription and the final paste. Stored in ~/.hasna/recordings/config.json.")
|
|
37
41
|
.foregroundStyle(.secondary)
|
|
38
42
|
}
|
|
@@ -44,7 +48,12 @@ public struct SettingsView: View {
|
|
|
44
48
|
KeyboardShortcuts.Recorder(for: .toggleRecording) { _ in
|
|
45
49
|
engine.updateStatus()
|
|
46
50
|
}
|
|
51
|
+
Button("Reset to F5") {
|
|
52
|
+
KeyboardShortcuts.setShortcut(.init(.f5), for: .toggleRecording)
|
|
53
|
+
engine.updateStatus()
|
|
54
|
+
}
|
|
47
55
|
}
|
|
56
|
+
Toggle("Use fn/Globe as recording key", isOn: $engine.useFnKey)
|
|
48
57
|
Text("Hold to record, release to transcribe and paste.")
|
|
49
58
|
.foregroundStyle(.secondary)
|
|
50
59
|
}
|
|
@@ -52,6 +52,32 @@ struct OpenAIAPIKeyStoreTests {
|
|
|
52
52
|
#expect(key == "referenced-key")
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
@Test("Language defaults to English and can be loaded from config")
|
|
56
|
+
func languageConfig() throws {
|
|
57
|
+
let home = try makeHome()
|
|
58
|
+
#expect(OpenAIAPIKeyStore.loadLanguage(homePath: home.path, environment: [:], userDefaultLanguage: nil) == "en")
|
|
59
|
+
|
|
60
|
+
try writeConfig(home: home, ["language": "FR"])
|
|
61
|
+
#expect(OpenAIAPIKeyStore.loadLanguage(homePath: home.path, environment: [:], userDefaultLanguage: nil) == "fr")
|
|
62
|
+
#expect(OpenAIAPIKeyStore.apiLanguageHint(for: "auto") == "")
|
|
63
|
+
#expect(OpenAIAPIKeyStore.apiLanguageHint(for: "en") == "en")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@Test("Saving language writes the CLI language config")
|
|
67
|
+
func saveLanguageWritesConfig() throws {
|
|
68
|
+
let home = try makeHome()
|
|
69
|
+
|
|
70
|
+
try OpenAIAPIKeyStore.saveLanguage(language: "en", homePath: home.path)
|
|
71
|
+
|
|
72
|
+
let configURL = home
|
|
73
|
+
.appendingPathComponent(".hasna")
|
|
74
|
+
.appendingPathComponent("recordings")
|
|
75
|
+
.appendingPathComponent("config.json")
|
|
76
|
+
let data = try Data(contentsOf: configURL)
|
|
77
|
+
let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
|
78
|
+
#expect(json["language"] as? String == "en")
|
|
79
|
+
}
|
|
80
|
+
|
|
55
81
|
@Test("Secrets env files are searched recursively")
|
|
56
82
|
func recursiveSecrets() throws {
|
|
57
83
|
let home = try makeHome()
|
|
@@ -19,8 +19,8 @@ struct PasteTargetTests {
|
|
|
19
19
|
#expect(selected?.pid == 20)
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
@Test("paste target
|
|
23
|
-
func
|
|
22
|
+
@Test("paste target does not choose arbitrary apps without a captured or frontmost target")
|
|
23
|
+
func noArbitraryFallback() {
|
|
24
24
|
let candidates = [
|
|
25
25
|
PasteTargetCandidate(pid: 99, bundleIdentifier: "com.hasna.recordings", isRegularApp: true),
|
|
26
26
|
PasteTargetCandidate(pid: 30, bundleIdentifier: "com.notes", isRegularApp: true),
|
|
@@ -33,7 +33,7 @@ struct PasteTargetTests {
|
|
|
33
33
|
targetPid: nil
|
|
34
34
|
)
|
|
35
35
|
|
|
36
|
-
#expect(selected
|
|
36
|
+
#expect(selected == nil)
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
@Test("paste target prefers the frontmost app over an arbitrary regular app")
|
|
@@ -69,7 +69,7 @@ struct PasteTargetTests {
|
|
|
69
69
|
frontmostPid: 99
|
|
70
70
|
)
|
|
71
71
|
|
|
72
|
-
#expect(selected
|
|
72
|
+
#expect(selected == nil)
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
@Test("captured pid wins over frontmost fallback")
|
|
@@ -4,9 +4,10 @@ import Testing
|
|
|
4
4
|
// MARK: - RealtimeTranscriptionClient Event Parsing Tests
|
|
5
5
|
|
|
6
6
|
struct RealtimeTranscriptionTests {
|
|
7
|
-
@Test("Model ID is set to
|
|
7
|
+
@Test("Model ID is set to low-latency realtime transcription model")
|
|
8
8
|
func modelID() {
|
|
9
|
-
#expect(RealtimeTranscriptionClient.modelID == "gpt-
|
|
9
|
+
#expect(RealtimeTranscriptionClient.modelID == "gpt-realtime-whisper")
|
|
10
|
+
#expect(RealtimeTranscriptionClient.transcriptionDelay == "low")
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
@Test("Parses transcription delta events")
|
|
@@ -94,15 +95,16 @@ struct RealtimeTranscriptionTests {
|
|
|
94
95
|
#expect(format?["rate"] as? Int == 24_000)
|
|
95
96
|
|
|
96
97
|
let transcription = input?["transcription"] as? [String: Any]
|
|
97
|
-
#expect(transcription?["model"] as? String == "gpt-
|
|
98
|
-
#expect(transcription?["
|
|
98
|
+
#expect(transcription?["model"] as? String == "gpt-realtime-whisper")
|
|
99
|
+
#expect(transcription?["delay"] as? String == "low")
|
|
100
|
+
#expect(transcription?["prompt"] as? String == nil)
|
|
99
101
|
#expect(transcription?["language"] as? String == "en")
|
|
100
102
|
|
|
101
103
|
let turnDetection = input?["turn_detection"] as? [String: Any]
|
|
102
|
-
#expect(turnDetection
|
|
104
|
+
#expect(turnDetection == nil)
|
|
103
105
|
|
|
104
106
|
let include = session?["include"] as? [String]
|
|
105
|
-
#expect(include
|
|
107
|
+
#expect(include == nil)
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
@Test("Joins transcript parts without dropping spoken text")
|
|
@@ -123,4 +125,38 @@ struct RealtimeTranscriptionTests {
|
|
|
123
125
|
#expect(RecordingEngine.shouldFallbackFromPartialRealtime(text: "This is a complete sentence.", pcmByteCount: 96_000) == false)
|
|
124
126
|
#expect(RecordingEngine.shouldFallbackFromPartialRealtime(text: "Hi", pcmByteCount: 12_000) == false)
|
|
125
127
|
}
|
|
128
|
+
|
|
129
|
+
@Test("Realtime fast path accepts useful text and rejects empty or suspicious partial output")
|
|
130
|
+
func realtimeFastPathDecision() {
|
|
131
|
+
#expect(RecordingEngine.shouldUseRealtimeFastPath(realtimeText: " this is a useful transcript ", pcmByteCount: 96_000))
|
|
132
|
+
#expect(RecordingEngine.shouldUseRealtimeFastPath(realtimeText: "Hi", pcmByteCount: 12_000))
|
|
133
|
+
#expect(RecordingEngine.shouldUseRealtimeFastPath(realtimeText: "Hi", pcmByteCount: 96_000) == false)
|
|
134
|
+
#expect(RecordingEngine.shouldUseRealtimeFastPath(realtimeText: " ", pcmByteCount: 96_000) == false)
|
|
135
|
+
#expect(RecordingEngine.shouldUseRealtimeFastPath(
|
|
136
|
+
realtimeText: "Actually 리수 Zoom your goal",
|
|
137
|
+
pcmByteCount: 96_000,
|
|
138
|
+
language: "en"
|
|
139
|
+
) == false)
|
|
140
|
+
#expect(RecordingEngine.shouldUseRealtimeFastPath(
|
|
141
|
+
realtimeText: "Actually Zoom your goal",
|
|
142
|
+
pcmByteCount: 96_000,
|
|
143
|
+
language: "en"
|
|
144
|
+
))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
@Test("Realtime artifact cleanup removes duplicated chunks and filler tokens")
|
|
148
|
+
func realtimeArtifactCleanup() {
|
|
149
|
+
let cleaned = RecordingEngine.cleanRealtimeArtifactText(
|
|
150
|
+
"어 Okay I don't know if this This is working어 Okay I don't know if this This is working"
|
|
151
|
+
)
|
|
152
|
+
#expect(cleaned == "Okay I don't know if this is working")
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@Test("Realtime artifact cleanup removes CJK tokens from English-dominant text")
|
|
156
|
+
func realtimeCJKArtifactCleanup() {
|
|
157
|
+
let cleaned = RecordingEngine.cleanRealtimeArtifactText(
|
|
158
|
+
"Actually 리수 Zoom your goal and do this work with sabi 度扫 agents actually"
|
|
159
|
+
)
|
|
160
|
+
#expect(cleaned == "Actually Zoom your goal and do this work with sabi agents actually")
|
|
161
|
+
}
|
|
126
162
|
}
|
|
@@ -55,4 +55,26 @@ struct TranscriptResolutionTests {
|
|
|
55
55
|
#expect(resolved.text == nil)
|
|
56
56
|
#expect(resolved.failureStatus == "Empty transcription")
|
|
57
57
|
}
|
|
58
|
+
|
|
59
|
+
@Test("Realtime transcripts are normalized before fast-path use")
|
|
60
|
+
func realtimeNormalization() {
|
|
61
|
+
#expect(RecordingEngine.normalizedRealtimeTranscript(" hello world\n") == "hello world")
|
|
62
|
+
#expect(RecordingEngine.normalizedRealtimeTranscript("working어") == "working")
|
|
63
|
+
#expect(RecordingEngine.normalizedRealtimeTranscript(" \n ") == nil)
|
|
64
|
+
#expect(RecordingEngine.normalizedRealtimeTranscript(nil) == nil)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@Test("Unsafe English realtime text is not eligible for fast-path paste")
|
|
68
|
+
func unsafeRealtimeTextRejectedForFastPath() {
|
|
69
|
+
#expect(RecordingEngine.isSafeRealtimeFastPathText(
|
|
70
|
+
rawText: "Actually 리수 Zoom your goal",
|
|
71
|
+
cleanedText: "Actually Zoom your goal",
|
|
72
|
+
language: "en"
|
|
73
|
+
) == false)
|
|
74
|
+
#expect(RecordingEngine.isSafeRealtimeFastPathText(
|
|
75
|
+
rawText: "Actually Zoom your goal",
|
|
76
|
+
cleanedText: "Actually Zoom your goal",
|
|
77
|
+
language: "en"
|
|
78
|
+
))
|
|
79
|
+
}
|
|
58
80
|
}
|
|
@@ -16,9 +16,10 @@ BUILD_DIR=".build/$MODE"
|
|
|
16
16
|
APP_DIR="$BUILD_DIR/Recordings.app"
|
|
17
17
|
CONTENTS="$APP_DIR/Contents"
|
|
18
18
|
MACOS="$CONTENTS/MacOS"
|
|
19
|
+
RESOURCES="$CONTENTS/Resources"
|
|
19
20
|
|
|
20
21
|
rm -rf "$APP_DIR"
|
|
21
|
-
mkdir -p "$MACOS"
|
|
22
|
+
mkdir -p "$MACOS" "$RESOURCES"
|
|
22
23
|
|
|
23
24
|
# Copy binary
|
|
24
25
|
cp "$BUILD_DIR/App" "$MACOS/Recordings"
|
|
@@ -26,6 +27,13 @@ cp "$BUILD_DIR/App" "$MACOS/Recordings"
|
|
|
26
27
|
# Copy Info.plist
|
|
27
28
|
cp RecordingsLib/Info.plist "$CONTENTS/Info.plist"
|
|
28
29
|
|
|
30
|
+
# Copy SwiftPM resource bundles used by Bundle.module.
|
|
31
|
+
for bundle in "$BUILD_DIR"/*.resources "$BUILD_DIR"/*.bundle .build/*/"$MODE"/*.resources .build/*/"$MODE"/*.bundle; do
|
|
32
|
+
[ -e "$bundle" ] || continue
|
|
33
|
+
rm -rf "$RESOURCES/$(basename "$bundle")"
|
|
34
|
+
ditto "$bundle" "$RESOURCES/$(basename "$bundle")"
|
|
35
|
+
done
|
|
36
|
+
|
|
29
37
|
# Copy entitlements (for codesigning)
|
|
30
38
|
if [ -f RecordingsLib/Recordings.entitlements ]; then
|
|
31
39
|
codesign --force --sign - --entitlements RecordingsLib/Recordings.entitlements "$APP_DIR" 2>/dev/null || true
|