@hasna/recordings 0.1.27 → 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 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.27",
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.27";
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.27",
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.27";
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.27";
1
+ export declare const VERSION = "0.1.28";
2
2
  //# sourceMappingURL=version.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/recordings",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
4
4
  "type": "module",
5
5
  "description": "Speech-to-text recording tool with MCP and CLI — records, transcribes, and optionally enhances text using AI",
6
6
  "repository": {
@@ -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
- private static func loadConfigKey(homePath: String, environment: [String: String]) -> String? {
51
- let url = URL(fileURLWithPath: homePath)
52
- .appendingPathComponent(".hasna")
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
- guard let data = try? Data(contentsOf: url),
57
- let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
58
- else { return nil }
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
- guard let value = json[key] as? String,
62
- let resolved = resolve(value: value, environment: environment)
63
- else { continue }
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
- log("realtime streaming task starting")
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(realtimeText: realtimeText, pcmByteCount: self.recordedPCM.count),
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(realtimeText: String?, pcmByteCount: Int) -> Bool {
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
  }