@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 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.26",
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.26";
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.26",
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.26";
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.26";
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.26",
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": {
@@ -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
- Image(systemName: "mic.fill")
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
- 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) ?? ""
@@ -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
- /// Latest stable transcription model.
10
- public nonisolated static let modelID = "gpt-4o-transcribe"
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
- "prompt": Self.verbatimPrompt(context: systemPrompt),
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 = 2_800) async -> String {
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
- "prompt": prompt,
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 F5 or your chosen shortcut to record, then release to paste"
30
- case .dictation: return "Hold F5 or your chosen shortcut to dictate, then release to paste"
31
- case .command: return "Select text, then hold F5 or your chosen shortcut and release to rewrite"
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 actor PCMStreamState {
60
- private var recordedPCM = Data()
61
- private var pendingChunk = Data()
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
- recordedPCM.append(data)
67
- pendingChunk.append(data)
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
- var chunks: [Data] = []
70
- while pendingChunk.count >= chunkSize {
71
- chunks.append(pendingChunk.prefixData(count: chunkSize))
72
- pendingChunk.removeFirst(chunkSize)
84
+ if !pendingChunk.isEmpty {
85
+ await client?.sendAudio(pendingChunk)
86
+ }
87
+ return recordedPCM
73
88
  }
74
- return chunks
75
89
  }
76
90
 
77
- func flushPendingChunk() -> Data? {
78
- guard !pendingChunk.isEmpty else { return nil }
79
- let chunk = pendingChunk
80
- pendingChunk.removeAll(keepingCapacity: true)
81
- return chunk
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 capturedPCM() -> Data {
85
- recordedPCM
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 pcmStreamState: PCMStreamState?
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 { [weak client] data in
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
- Task {
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
- pcmStreamState = nil
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
- log("realtime streaming task starting")
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 pcmStreamState = pcmStreamState
542
+ let pcmStreamPipe = pcmStreamPipe
511
543
  let client = realtimeClient
544
+ let transcriptionLanguage = transcriptionLanguage
512
545
  resetRecordingIntent()
513
- self.pcmStreamState = nil
546
+ self.pcmStreamPipe = nil
514
547
 
515
548
  Task {
516
- if let pcmStreamState {
517
- if let finalChunk = await pcmStreamState.flushPendingChunk() {
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 = streamingResult.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : streamingResult
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 writeWAV(pcmData: Data, sampleRate: UInt32, channelCount: UInt16, bitsPerSample: UInt16, to url: URL) throws {
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
- postKey(0x08, flags: .maskCommand) // Cmd+C
739
- let homePath = home
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(250))
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 ? pb.string(forType: .string) : nil
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 candidates = runningApps.map {
813
- PasteTargetCandidate(
814
- pid: $0.processIdentifier,
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 = "No target app found"
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
- pb.clearContents()
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 {
@@ -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 ignores current app when falling back")
23
- func ignoresCurrentApp() {
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?.pid == 30)
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?.pid == 30)
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 latest gpt-4o-transcribe")
7
+ @Test("Model ID is set to low-latency realtime transcription model")
8
8
  func modelID() {
9
- #expect(RealtimeTranscriptionClient.modelID == "gpt-4o-transcribe")
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-4o-transcribe")
98
- #expect(transcription?["prompt"] as? String == "Use Alumia as vocabulary")
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?["type"] as? String == "server_vad")
104
+ #expect(turnDetection == nil)
103
105
 
104
106
  let include = session?["include"] as? [String]
105
- #expect(include?.contains("item.input_audio_transcription.logprobs") == true)
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