@hasna/recordings 0.1.27 → 0.1.29
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 +40 -11
- 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 +27 -0
- package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +2 -2
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +72 -12
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +82 -5
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +4 -0
- package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +26 -0
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +18 -0
- package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +14 -0
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.29",
|
|
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.29";
|
|
6085
6085
|
|
|
6086
6086
|
// src/cli/storage.ts
|
|
6087
6087
|
import chalk from "chalk";
|
|
@@ -6804,16 +6804,33 @@ appCommand.command("reset-permissions").description("Reset macOS Microphone and
|
|
|
6804
6804
|
console.error(chalk2.red("Permission reset is only available on macOS"));
|
|
6805
6805
|
process.exit(1);
|
|
6806
6806
|
}
|
|
6807
|
-
|
|
6808
|
-
|
|
6809
|
-
|
|
6810
|
-
|
|
6811
|
-
|
|
6812
|
-
|
|
6813
|
-
|
|
6814
|
-
|
|
6815
|
-
|
|
6807
|
+
resetMacOSPermissions();
|
|
6808
|
+
});
|
|
6809
|
+
appCommand.command("request-permissions").description("Open Recordings.app and trigger macOS Microphone and Accessibility permission prompts").option("--reset", "Reset existing Microphone and Accessibility decisions before requesting").action((opts) => {
|
|
6810
|
+
if (process.platform !== "darwin") {
|
|
6811
|
+
console.error(chalk2.red("Permission prompts are only available on macOS"));
|
|
6812
|
+
process.exit(1);
|
|
6813
|
+
}
|
|
6814
|
+
const status = getMacOSAppStatus();
|
|
6815
|
+
if (!status.installed) {
|
|
6816
|
+
console.error(chalk2.red("Recordings.app is not installed. Run: recordings app install"));
|
|
6817
|
+
process.exit(1);
|
|
6816
6818
|
}
|
|
6819
|
+
if (opts.reset) {
|
|
6820
|
+
resetMacOSPermissions();
|
|
6821
|
+
}
|
|
6822
|
+
const result = spawnSync("open", [
|
|
6823
|
+
"-n",
|
|
6824
|
+
status.installed_app_path,
|
|
6825
|
+
"--args",
|
|
6826
|
+
"--request-permissions",
|
|
6827
|
+
"--open-permission-settings"
|
|
6828
|
+
], { stdio: "inherit" });
|
|
6829
|
+
if (result.error) {
|
|
6830
|
+
console.error(chalk2.red(result.error.message));
|
|
6831
|
+
process.exit(1);
|
|
6832
|
+
}
|
|
6833
|
+
process.exit(result.status ?? 1);
|
|
6817
6834
|
});
|
|
6818
6835
|
appCommand.command("log").description("Show the Recordings.app diagnostic log").option("-n, --lines <lines>", "Number of lines to print", "120").action((opts) => {
|
|
6819
6836
|
const status = getMacOSAppStatus();
|
|
@@ -7319,6 +7336,18 @@ function getMacOSAppStatus() {
|
|
|
7319
7336
|
log_path: logPath
|
|
7320
7337
|
};
|
|
7321
7338
|
}
|
|
7339
|
+
function resetMacOSPermissions() {
|
|
7340
|
+
const services = ["Microphone", "Accessibility"];
|
|
7341
|
+
for (const service of services) {
|
|
7342
|
+
const result = spawnSync("tccutil", ["reset", service, "com.hasna.recordings"], {
|
|
7343
|
+
stdio: "inherit"
|
|
7344
|
+
});
|
|
7345
|
+
if (result.error) {
|
|
7346
|
+
console.error(chalk2.red(result.error.message));
|
|
7347
|
+
process.exit(1);
|
|
7348
|
+
}
|
|
7349
|
+
}
|
|
7350
|
+
}
|
|
7322
7351
|
function getCodeSigningInfo(appPath) {
|
|
7323
7352
|
if (process.platform !== "darwin" || !existsSync4(appPath)) {
|
|
7324
7353
|
return { cdHash: null, adHoc: false };
|
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.29",
|
|
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.29";
|
|
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.29";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
@preconcurrency import Cocoa
|
|
2
|
+
import AVFoundation
|
|
2
3
|
import SwiftUI
|
|
3
4
|
import KeyboardShortcuts
|
|
4
5
|
import RecordingsLib
|
|
@@ -14,6 +15,7 @@ struct RecordingsApp: App {
|
|
|
14
15
|
AXIsProcessTrustedWithOptions(
|
|
15
16
|
[kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
|
|
16
17
|
)
|
|
18
|
+
Self.handlePermissionRequestArguments()
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
var body: some Scene {
|
|
@@ -49,4 +51,29 @@ struct RecordingsApp: App {
|
|
|
49
51
|
app.terminate()
|
|
50
52
|
}
|
|
51
53
|
}
|
|
54
|
+
|
|
55
|
+
private static func handlePermissionRequestArguments() {
|
|
56
|
+
let arguments = CommandLine.arguments
|
|
57
|
+
guard arguments.contains("--request-permissions") else { return }
|
|
58
|
+
|
|
59
|
+
NativeAppLog.write("request-permissions argument received")
|
|
60
|
+
AVCaptureDevice.requestAccess(for: .audio) { granted in
|
|
61
|
+
NativeAppLog.write("request-permissions microphone granted=\(granted)")
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let trusted = AXIsProcessTrustedWithOptions(
|
|
65
|
+
[kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
|
|
66
|
+
)
|
|
67
|
+
NativeAppLog.write("request-permissions accessibility trusted=\(trusted)")
|
|
68
|
+
|
|
69
|
+
guard arguments.contains("--open-permission-settings") else { return }
|
|
70
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
|
71
|
+
if let microphone = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone") {
|
|
72
|
+
NSWorkspace.shared.open(microphone)
|
|
73
|
+
}
|
|
74
|
+
if let accessibility = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
|
|
75
|
+
NSWorkspace.shared.open(accessibility)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
52
79
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
|
|
3
|
-
enum NativeAppLog {
|
|
3
|
+
public enum NativeAppLog {
|
|
4
4
|
private static let lock = NSLock()
|
|
5
5
|
|
|
6
|
-
static func write(_ message: String, homePath: String = FileManager.default.homeDirectoryForCurrentUser.path) {
|
|
6
|
+
public static func write(_ message: String, homePath: String = FileManager.default.homeDirectoryForCurrentUser.path) {
|
|
7
7
|
lock.lock()
|
|
8
8
|
defer { lock.unlock() }
|
|
9
9
|
|
|
@@ -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) ?? ""
|
|
@@ -133,6 +133,12 @@ public final class RecordingEngine: ObservableObject {
|
|
|
133
133
|
@Published public var isTranscribing = false
|
|
134
134
|
@Published public var recordingDuration: TimeInterval = 0
|
|
135
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
|
+
}
|
|
136
142
|
|
|
137
143
|
private var nativeRecorder: NativePCMRecorder?
|
|
138
144
|
private var recordingTimer: Timer?
|
|
@@ -178,6 +184,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
178
184
|
let parsedMode = RecordingMode(rawValue: savedMode) {
|
|
179
185
|
mode = parsedMode
|
|
180
186
|
}
|
|
187
|
+
transcriptionLanguage = OpenAIAPIKeyStore.loadLanguage(homePath: home)
|
|
181
188
|
useFnKey = UserDefaults.standard.object(forKey: "useFnKey") as? Bool ?? false
|
|
182
189
|
if KeyboardShortcuts.getShortcut(for: .toggleRecording) == nil {
|
|
183
190
|
KeyboardShortcuts.setShortcut(.init(.f5), for: .toggleRecording)
|
|
@@ -471,10 +478,11 @@ public final class RecordingEngine: ObservableObject {
|
|
|
471
478
|
private func startRealtimeStreaming(apiKey: String) {
|
|
472
479
|
let client = RealtimeTranscriptionClient(apiKey: apiKey, homePath: home)
|
|
473
480
|
realtimeClient = client
|
|
474
|
-
|
|
481
|
+
let language = OpenAIAPIKeyStore.apiLanguageHint(for: transcriptionLanguage)
|
|
482
|
+
log("realtime streaming task starting language=\(language.isEmpty ? "auto" : language)")
|
|
475
483
|
|
|
476
484
|
streamingTask = Task {
|
|
477
|
-
await client.startStreaming()
|
|
485
|
+
await client.startStreaming(language: language)
|
|
478
486
|
self.log("realtime start completed streaming=\(client.isStreaming) error=\(client.error ?? "")")
|
|
479
487
|
|
|
480
488
|
var lastPeriodicCommitAt = Date.distantPast
|
|
@@ -492,7 +500,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
492
500
|
if text != streamingText {
|
|
493
501
|
await MainActor.run {
|
|
494
502
|
self.streamingText = text
|
|
495
|
-
self.liveTranscriptionText = text
|
|
503
|
+
self.liveTranscriptionText = Self.cleanRealtimeArtifactText(text)
|
|
496
504
|
}
|
|
497
505
|
}
|
|
498
506
|
}
|
|
@@ -533,6 +541,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
533
541
|
let audioPath = activeAudioPath
|
|
534
542
|
let pcmStreamPipe = pcmStreamPipe
|
|
535
543
|
let client = realtimeClient
|
|
544
|
+
let transcriptionLanguage = transcriptionLanguage
|
|
536
545
|
resetRecordingIntent()
|
|
537
546
|
self.pcmStreamPipe = nil
|
|
538
547
|
|
|
@@ -552,7 +561,11 @@ public final class RecordingEngine: ObservableObject {
|
|
|
552
561
|
|
|
553
562
|
self.liveTranscriptionText = ""
|
|
554
563
|
|
|
555
|
-
if Self.shouldUseRealtimeFastPath(
|
|
564
|
+
if Self.shouldUseRealtimeFastPath(
|
|
565
|
+
realtimeText: streamingResult,
|
|
566
|
+
pcmByteCount: self.recordedPCM.count,
|
|
567
|
+
language: transcriptionLanguage
|
|
568
|
+
),
|
|
556
569
|
let realtimeText {
|
|
557
570
|
let releaseToTextMS = Int(Date().timeIntervalSince(stopStartedAt) * 1_000)
|
|
558
571
|
self.log("using realtime fast path chars=\(realtimeText.count) releaseToTextMs=\(releaseToTextMS)")
|
|
@@ -621,11 +634,39 @@ public final class RecordingEngine: ObservableObject {
|
|
|
621
634
|
return trimmed.isEmpty ? nil : trimmed
|
|
622
635
|
}
|
|
623
636
|
|
|
624
|
-
public nonisolated static func shouldUseRealtimeFastPath(
|
|
637
|
+
public nonisolated static func shouldUseRealtimeFastPath(
|
|
638
|
+
realtimeText: String?,
|
|
639
|
+
pcmByteCount: Int,
|
|
640
|
+
language: String = "en"
|
|
641
|
+
) -> Bool {
|
|
625
642
|
guard let text = normalizedRealtimeTranscript(realtimeText) else { return false }
|
|
643
|
+
guard isSafeRealtimeFastPathText(rawText: realtimeText ?? "", cleanedText: text, language: language) else {
|
|
644
|
+
return false
|
|
645
|
+
}
|
|
626
646
|
return !shouldFallbackFromPartialRealtime(text: text, pcmByteCount: pcmByteCount)
|
|
627
647
|
}
|
|
628
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
|
+
|
|
629
670
|
public nonisolated static func cleanRealtimeArtifactText(_ text: String) -> String {
|
|
630
671
|
var cleaned = text
|
|
631
672
|
.replacingOccurrences(of: "\n", with: " ")
|
|
@@ -653,11 +694,17 @@ public final class RecordingEngine: ObservableObject {
|
|
|
653
694
|
|
|
654
695
|
private nonisolated static func removeStandaloneRealtimeArtifacts(from text: String) -> String {
|
|
655
696
|
let artifactTokens: Set<String> = ["어", "음", "um", "umm", "uh", "uhh", "erm", "hmm", "eh"]
|
|
697
|
+
let englishDominant = latinLetterCount(in: text) >= max(12, cjkLetterCount(in: text) * 3)
|
|
656
698
|
let words = text.split(separator: " ").compactMap { rawWord -> String? in
|
|
657
699
|
let normalized = rawWord
|
|
658
700
|
.trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines))
|
|
659
701
|
.lowercased()
|
|
660
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
|
|
661
708
|
}
|
|
662
709
|
return words.joined(separator: " ")
|
|
663
710
|
}
|
|
@@ -707,6 +754,36 @@ public final class RecordingEngine: ObservableObject {
|
|
|
707
754
|
word.trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines)).lowercased()
|
|
708
755
|
}
|
|
709
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
|
+
|
|
710
787
|
private func finishWithText(_ text: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, targetAppPid: pid_t?, activeProjectId: String?, activeProjectName: String?) {
|
|
711
788
|
log("finishWithText mode=\(curMode.rawValue) chars=\(text.count)")
|
|
712
789
|
if curMode == .command {
|
|
@@ -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
|
}
|
|
@@ -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()
|
|
@@ -132,6 +132,16 @@ struct RealtimeTranscriptionTests {
|
|
|
132
132
|
#expect(RecordingEngine.shouldUseRealtimeFastPath(realtimeText: "Hi", pcmByteCount: 12_000))
|
|
133
133
|
#expect(RecordingEngine.shouldUseRealtimeFastPath(realtimeText: "Hi", pcmByteCount: 96_000) == false)
|
|
134
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
|
+
))
|
|
135
145
|
}
|
|
136
146
|
|
|
137
147
|
@Test("Realtime artifact cleanup removes duplicated chunks and filler tokens")
|
|
@@ -141,4 +151,12 @@ struct RealtimeTranscriptionTests {
|
|
|
141
151
|
)
|
|
142
152
|
#expect(cleaned == "Okay I don't know if this is working")
|
|
143
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
|
+
}
|
|
144
162
|
}
|
|
@@ -63,4 +63,18 @@ struct TranscriptResolutionTests {
|
|
|
63
63
|
#expect(RecordingEngine.normalizedRealtimeTranscript(" \n ") == nil)
|
|
64
64
|
#expect(RecordingEngine.normalizedRealtimeTranscript(nil) == nil)
|
|
65
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
|
+
}
|
|
66
80
|
}
|