@hasna/recordings 0.1.26 → 0.1.27

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.27",
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.27";
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.27",
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.27";
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.27";
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.27",
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()
@@ -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
 
@@ -130,12 +147,15 @@ public final class RecordingEngine: ObservableObject {
130
147
  // Real-time streaming
131
148
  private var realtimeClient: RealtimeTranscriptionClient?
132
149
  private var streamingTask: Task<Void, Never>?
133
- private var pcmStreamState: PCMStreamState?
150
+ private var pcmStreamPipe: PCMStreamPipe?
134
151
  private var streamingText = ""
135
152
  private var recordedPCM = Data()
136
153
  private var activeAudioPath: String?
137
154
  private var lastAccessibilityPromptAt: Date?
138
155
 
156
+ private nonisolated static let realtimePeriodicCommitInterval: TimeInterval = 0.9
157
+ private nonisolated static let realtimeFinishTimeoutMilliseconds: UInt64 = 700
158
+
139
159
  // fn key monitor (CGEventTap-based, swallows fn to prevent emoji picker)
140
160
  private let fnMonitor = FnKeyMonitor()
141
161
  private var permissionRetryTimer: Timer?
@@ -393,9 +413,6 @@ public final class RecordingEngine: ObservableObject {
393
413
  }
394
414
 
395
415
  private func startNativeRecording() {
396
- let streamState = PCMStreamState()
397
- pcmStreamState = streamState
398
-
399
416
  let apiKey = openAIAPIKey
400
417
  log("startNativeRecording apiKeyConfigured=\(!apiKey.isEmpty)")
401
418
  if !apiKey.isEmpty {
@@ -403,18 +420,15 @@ public final class RecordingEngine: ObservableObject {
403
420
  }
404
421
 
405
422
  let client = realtimeClient
423
+ let streamPipe = PCMStreamPipe(chunkSize: 4_800, client: client)
424
+ pcmStreamPipe = streamPipe
406
425
  let homePath = home
407
426
  let firstChunkLogged = LockedFlag()
408
- let recorder = NativePCMRecorder { [weak client] data in
427
+ let recorder = NativePCMRecorder { data in
409
428
  if firstChunkLogged.take() {
410
429
  NativeAppLog.write("native recorder received first PCM chunk bytes=\(data.count)", homePath: homePath)
411
430
  }
412
- Task {
413
- let chunks = await streamState.append(data, chunkSize: 4_800)
414
- for chunk in chunks {
415
- await client?.sendAudio(chunk)
416
- }
417
- }
431
+ streamPipe.append(data)
418
432
  }
419
433
 
420
434
  do {
@@ -445,7 +459,8 @@ public final class RecordingEngine: ObservableObject {
445
459
  realtimeClient = nil
446
460
  streamingTask?.cancel()
447
461
  streamingTask = nil
448
- pcmStreamState = nil
462
+ pcmStreamPipe?.cancel()
463
+ pcmStreamPipe = nil
449
464
  resetRecordingIntent()
450
465
  statusMessage = "Failed: \(error.localizedDescription)"
451
466
  }
@@ -462,9 +477,17 @@ public final class RecordingEngine: ObservableObject {
462
477
  await client.startStreaming()
463
478
  self.log("realtime start completed streaming=\(client.isStreaming) error=\(client.error ?? "")")
464
479
 
480
+ var lastPeriodicCommitAt = Date.distantPast
481
+
465
482
  // Receive deltas
466
483
  while client.isStreaming {
467
484
  try? await Task.sleep(for: .milliseconds(100))
485
+ if self.isRecording,
486
+ Date().timeIntervalSince(lastPeriodicCommitAt) >= Self.realtimePeriodicCommitInterval {
487
+ if await client.commitInput(reason: "periodic") {
488
+ lastPeriodicCommitAt = Date()
489
+ }
490
+ }
468
491
  let text = client.accumulatedText
469
492
  if text != streamingText {
470
493
  await MainActor.run {
@@ -489,6 +512,7 @@ public final class RecordingEngine: ObservableObject {
489
512
 
490
513
  public func stopAndTranscribe() {
491
514
  guard isRecording else { return }
515
+ let stopStartedAt = Date()
492
516
  log("stopAndTranscribe")
493
517
 
494
518
  recordingTimer?.invalidate()
@@ -507,30 +531,48 @@ public final class RecordingEngine: ObservableObject {
507
531
  let activeProjectId = projectStore?.settings.activeProjectId
508
532
  let activeProjectName = projectStore?.activeProject?.name
509
533
  let audioPath = activeAudioPath
510
- let pcmStreamState = pcmStreamState
534
+ let pcmStreamPipe = pcmStreamPipe
511
535
  let client = realtimeClient
512
536
  resetRecordingIntent()
513
- self.pcmStreamState = nil
537
+ self.pcmStreamPipe = nil
514
538
 
515
539
  Task {
516
- if let pcmStreamState {
517
- if let finalChunk = await pcmStreamState.flushPendingChunk() {
518
- client?.sendAudio(finalChunk)
519
- }
520
- self.recordedPCM = await pcmStreamState.capturedPCM()
540
+ if let pcmStreamPipe {
541
+ self.recordedPCM = await pcmStreamPipe.finish()
521
542
  }
522
543
  self.log("captured pcm bytes=\(self.recordedPCM.count)")
523
544
 
524
- let streamingResult = await client?.finish() ?? ""
545
+ let streamingResult = await client?.finish(timeoutMilliseconds: Self.realtimeFinishTimeoutMilliseconds) ?? ""
525
546
 
526
547
  self.realtimeClient = nil
527
548
  self.streamingTask?.cancel()
528
549
  self.streamingTask = nil
529
550
 
530
- let realtimeText = streamingResult.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : streamingResult
551
+ let realtimeText = Self.normalizedRealtimeTranscript(streamingResult)
531
552
 
532
553
  self.liveTranscriptionText = ""
533
554
 
555
+ if Self.shouldUseRealtimeFastPath(realtimeText: realtimeText, pcmByteCount: self.recordedPCM.count),
556
+ let realtimeText {
557
+ let releaseToTextMS = Int(Date().timeIntervalSince(stopStartedAt) * 1_000)
558
+ self.log("using realtime fast path chars=\(realtimeText.count) releaseToTextMs=\(releaseToTextMS)")
559
+ self.isTranscribing = false
560
+ self.finishWithText(
561
+ realtimeText,
562
+ curMode: curMode,
563
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
564
+ targetAppPid: targetAppPid,
565
+ activeProjectId: activeProjectId,
566
+ activeProjectName: activeProjectName
567
+ )
568
+ if let audioPath, !self.recordedPCM.isEmpty {
569
+ Self.saveCapturedWAVInBackground(pcmData: self.recordedPCM, audioPath: audioPath, homePath: self.home)
570
+ }
571
+ self.activeAudioPath = nil
572
+ self.recordedPCM.removeAll(keepingCapacity: true)
573
+ return
574
+ }
575
+
534
576
  if let audioPath, self.writeCapturedWAV(to: audioPath) {
535
577
  self.log("transcribing captured full audio audioPath=\(audioPath) realtimePreviewChars=\(realtimeText?.count ?? 0)")
536
578
  self.fallbackTranscribe(
@@ -573,10 +615,102 @@ public final class RecordingEngine: ObservableObject {
573
615
  return trimmed.count < 12 || words.count <= 2
574
616
  }
575
617
 
618
+ public nonisolated static func normalizedRealtimeTranscript(_ text: String?) -> String? {
619
+ guard let text else { return nil }
620
+ let trimmed = cleanRealtimeArtifactText(text).trimmingCharacters(in: .whitespacesAndNewlines)
621
+ return trimmed.isEmpty ? nil : trimmed
622
+ }
623
+
624
+ public nonisolated static func shouldUseRealtimeFastPath(realtimeText: String?, pcmByteCount: Int) -> Bool {
625
+ guard let text = normalizedRealtimeTranscript(realtimeText) else { return false }
626
+ return !shouldFallbackFromPartialRealtime(text: text, pcmByteCount: pcmByteCount)
627
+ }
628
+
629
+ public nonisolated static func cleanRealtimeArtifactText(_ text: String) -> String {
630
+ var cleaned = text
631
+ .replacingOccurrences(of: "\n", with: " ")
632
+ .replacingOccurrences(of: "\t", with: " ")
633
+ cleaned = cleaned.replacingOccurrences(
634
+ of: #"(?i)(?<=[A-Za-z])어\b"#,
635
+ with: "",
636
+ options: .regularExpression
637
+ )
638
+ cleaned = cleaned.replacingOccurrences(
639
+ of: #"\s+"#,
640
+ with: " ",
641
+ options: .regularExpression
642
+ )
643
+ cleaned = removeStandaloneRealtimeArtifacts(from: cleaned)
644
+ cleaned = collapseAdjacentDuplicateWords(in: cleaned)
645
+ cleaned = collapseAdjacentDuplicatePhrases(in: cleaned)
646
+ cleaned = cleaned.replacingOccurrences(
647
+ of: #"\s+([,.;:!?])"#,
648
+ with: "$1",
649
+ options: .regularExpression
650
+ )
651
+ return cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
652
+ }
653
+
654
+ private nonisolated static func removeStandaloneRealtimeArtifacts(from text: String) -> String {
655
+ let artifactTokens: Set<String> = ["어", "음", "um", "umm", "uh", "uhh", "erm", "hmm", "eh"]
656
+ let words = text.split(separator: " ").compactMap { rawWord -> String? in
657
+ let normalized = rawWord
658
+ .trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines))
659
+ .lowercased()
660
+ return artifactTokens.contains(normalized) ? nil : String(rawWord)
661
+ }
662
+ return words.joined(separator: " ")
663
+ }
664
+
665
+ private nonisolated static func collapseAdjacentDuplicateWords(in text: String) -> String {
666
+ let words = text.split(separator: " ").map(String.init)
667
+ guard words.count > 1 else { return text }
668
+
669
+ var output: [String] = []
670
+ for word in words {
671
+ if let last = output.last,
672
+ normalizedTranscriptWord(last) == normalizedTranscriptWord(word) {
673
+ continue
674
+ }
675
+ output.append(word)
676
+ }
677
+ return output.joined(separator: " ")
678
+ }
679
+
680
+ private nonisolated static func collapseAdjacentDuplicatePhrases(in text: String) -> String {
681
+ var words = text.split(separator: " ").map(String.init)
682
+ guard words.count >= 6 else { return text }
683
+
684
+ var i = 0
685
+ while i < words.count {
686
+ let maxLength = min(24, (words.count - i) / 2)
687
+ var removedDuplicate = false
688
+ if maxLength >= 3 {
689
+ for length in stride(from: maxLength, through: 3, by: -1) {
690
+ let first = words[i..<(i + length)].map(normalizedTranscriptWord)
691
+ let second = words[(i + length)..<(i + (2 * length))].map(normalizedTranscriptWord)
692
+ if first == second {
693
+ words.removeSubrange((i + length)..<(i + (2 * length)))
694
+ removedDuplicate = true
695
+ break
696
+ }
697
+ }
698
+ }
699
+ if !removedDuplicate {
700
+ i += 1
701
+ }
702
+ }
703
+ return words.joined(separator: " ")
704
+ }
705
+
706
+ private nonisolated static func normalizedTranscriptWord(_ word: String) -> String {
707
+ word.trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines)).lowercased()
708
+ }
709
+
576
710
  private func finishWithText(_ text: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, targetAppPid: pid_t?, activeProjectId: String?, activeProjectName: String?) {
577
711
  log("finishWithText mode=\(curMode.rawValue) chars=\(text.count)")
578
712
  if curMode == .command {
579
- runCommandMode(instruction: text)
713
+ runCommandMode(instruction: text, targetAppBundleIdentifier: targetAppBundleIdentifier, targetAppPid: targetAppPid)
580
714
  return
581
715
  }
582
716
 
@@ -615,7 +749,24 @@ public final class RecordingEngine: ObservableObject {
615
749
  }
616
750
  }
617
751
 
618
- private static func writeWAV(pcmData: Data, sampleRate: UInt32, channelCount: UInt16, bitsPerSample: UInt16, to url: URL) throws {
752
+ private nonisolated static func saveCapturedWAVInBackground(pcmData: Data, audioPath: String, homePath: String) {
753
+ Task.detached(priority: .utility) {
754
+ do {
755
+ try Self.writeWAV(
756
+ pcmData: pcmData,
757
+ sampleRate: 24_000,
758
+ channelCount: 1,
759
+ bitsPerSample: 16,
760
+ to: URL(fileURLWithPath: audioPath)
761
+ )
762
+ NativeAppLog.write("wrote wav path=\(audioPath) pcmBytes=\(pcmData.count)", homePath: homePath)
763
+ } catch {
764
+ NativeAppLog.write("failed to save wav after realtime fast path error=\(error.localizedDescription)", homePath: homePath)
765
+ }
766
+ }
767
+ }
768
+
769
+ private nonisolated static func writeWAV(pcmData: Data, sampleRate: UInt32, channelCount: UInt16, bitsPerSample: UInt16, to url: URL) throws {
619
770
  let byteRate = sampleRate * UInt32(channelCount) * UInt32(bitsPerSample / 8)
620
771
  let blockAlign = channelCount * (bitsPerSample / 8)
621
772
  let dataSize = UInt32(pcmData.count)
@@ -729,17 +880,35 @@ public final class RecordingEngine: ObservableObject {
729
880
 
730
881
  // MARK: - Command Mode
731
882
 
732
- private func runCommandMode(instruction: String) {
883
+ private func runCommandMode(instruction: String, targetAppBundleIdentifier: String?, targetAppPid: pid_t?) {
733
884
  guard ensureAccessibilityPermission(prompt: shouldPromptAccessibility()) else {
734
885
  log("command mode blocked by accessibility permission")
735
886
  statusMessage = "Enable Accessibility permission for Recordings to rewrite selected text"
736
887
  return
737
888
  }
738
- postKey(0x08, flags: .maskCommand) // Cmd+C
739
- let homePath = home
889
+ let targetApp = selectedRunningPasteTarget(
890
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
891
+ targetAppPid: targetAppPid,
892
+ frontmostPid: NSWorkspace.shared.frontmostApplication?.processIdentifier
893
+ )
894
+ guard let targetApp else {
895
+ log("command mode target app not found")
896
+ statusMessage = "No target app found"
897
+ return
898
+ }
899
+ let alreadyFrontmost = targetApp.processIdentifier == NSWorkspace.shared.frontmostApplication?.processIdentifier
900
+ if !alreadyFrontmost {
901
+ targetApp.activate(options: [.activateIgnoringOtherApps])
902
+ }
740
903
 
904
+ let copyDelay: TimeInterval = alreadyFrontmost ? 0.15 : 0.5
905
+ DispatchQueue.main.asyncAfter(deadline: .now() + copyDelay) {
906
+ self.postKey(0x08, flags: .maskCommand) // Cmd+C
907
+ }
908
+
909
+ let homePath = home
741
910
  Task {
742
- try? await Task.sleep(for: .milliseconds(250))
911
+ try? await Task.sleep(for: .milliseconds(Int((copyDelay + 0.25) * 1_000)))
743
912
  let selected = NSPasteboard.general.string(forType: .string) ?? ""
744
913
  guard !selected.isEmpty else {
745
914
  statusMessage = "No text selected"
@@ -793,7 +962,7 @@ public final class RecordingEngine: ObservableObject {
793
962
  func pasteIntoFrontApp(_ text: String, targetAppBundleIdentifier: String? = nil, targetAppPid: pid_t? = nil, restoreClipboard: Bool = false) {
794
963
  log("paste requested chars=\(text.count) target=\(targetAppBundleIdentifier ?? "nil") pid=\(targetAppPid.map(String.init) ?? "nil") accessibility=\(AXIsProcessTrusted())")
795
964
  let pb = NSPasteboard.general
796
- let previousClipboard = restoreClipboard ? pb.string(forType: .string) : nil
965
+ let previousClipboard = restoreClipboard ? ClipboardSnapshot(pasteboard: pb) : nil
797
966
  pb.clearContents()
798
967
  pb.setString(text, forType: .string)
799
968
 
@@ -806,30 +975,16 @@ public final class RecordingEngine: ObservableObject {
806
975
  return
807
976
  }
808
977
 
809
- let myPID = ProcessInfo.processInfo.processIdentifier
810
- let runningApps = NSWorkspace.shared.runningApplications
811
978
  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,
979
+ let targetApp = selectedRunningPasteTarget(
980
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
981
+ targetAppPid: targetAppPid,
824
982
  frontmostPid: frontmostPid
825
983
  )
826
- let targetApp = selectedTarget.flatMap { selected in
827
- runningApps.first { $0.processIdentifier == selected.pid }
828
- }
829
984
 
830
985
  guard let app = targetApp else {
831
986
  log("paste target app not found")
832
- self.statusMessage = "No target app found"
987
+ self.statusMessage = "Copied — no target app found"
833
988
  return
834
989
  }
835
990
 
@@ -857,14 +1012,35 @@ public final class RecordingEngine: ObservableObject {
857
1012
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
858
1013
  let pb = NSPasteboard.general
859
1014
  if pb.string(forType: .string) == text {
860
- pb.clearContents()
861
- pb.setString(previousClipboard, forType: .string)
1015
+ previousClipboard.restore(to: pb)
862
1016
  }
863
1017
  }
864
1018
  }
865
1019
  }
866
1020
  }
867
1021
 
1022
+ private func selectedRunningPasteTarget(targetAppBundleIdentifier: String?, targetAppPid: pid_t?, frontmostPid: pid_t?) -> NSRunningApplication? {
1023
+ let myPID = ProcessInfo.processInfo.processIdentifier
1024
+ let runningApps = NSWorkspace.shared.runningApplications
1025
+ let candidates = runningApps.map {
1026
+ PasteTargetCandidate(
1027
+ pid: $0.processIdentifier,
1028
+ bundleIdentifier: $0.bundleIdentifier,
1029
+ isRegularApp: $0.activationPolicy == .regular
1030
+ )
1031
+ }
1032
+ let selectedTarget = Self.selectPasteTarget(
1033
+ candidates: candidates,
1034
+ currentPid: myPID,
1035
+ targetBundleIdentifier: targetAppBundleIdentifier,
1036
+ targetPid: targetAppPid,
1037
+ frontmostPid: frontmostPid
1038
+ )
1039
+ return selectedTarget.flatMap { selected in
1040
+ runningApps.first { $0.processIdentifier == selected.pid }
1041
+ }
1042
+ }
1043
+
868
1044
  nonisolated static func selectPasteTarget(
869
1045
  candidates: [PasteTargetCandidate],
870
1046
  currentPid: pid_t,
@@ -882,8 +1058,6 @@ public final class RecordingEngine: ObservableObject {
882
1058
  } ?? candidates.first {
883
1059
  guard let frontmostPid else { return false }
884
1060
  return $0.pid == frontmostPid && $0.pid != currentPid && $0.isRegularApp
885
- } ?? candidates.first {
886
- $0.isRegularApp && $0.pid != currentPid
887
1061
  }
888
1062
  }
889
1063
 
@@ -941,6 +1115,35 @@ private final class LockedFlag: @unchecked Sendable {
941
1115
  }
942
1116
  }
943
1117
 
1118
+ private struct ClipboardSnapshot {
1119
+ private let items: [[NSPasteboard.PasteboardType: Data]]
1120
+
1121
+ init?(pasteboard: NSPasteboard) {
1122
+ let capturedItems = pasteboard.pasteboardItems?.compactMap { item -> [NSPasteboard.PasteboardType: Data]? in
1123
+ let dataByType = item.types.reduce(into: [NSPasteboard.PasteboardType: Data]()) { result, type in
1124
+ if let data = item.data(forType: type) {
1125
+ result[type] = data
1126
+ }
1127
+ }
1128
+ return dataByType.isEmpty ? nil : dataByType
1129
+ } ?? []
1130
+ guard !capturedItems.isEmpty else { return nil }
1131
+ items = capturedItems
1132
+ }
1133
+
1134
+ func restore(to pasteboard: NSPasteboard) {
1135
+ pasteboard.clearContents()
1136
+ let pasteboardItems = items.map { itemData in
1137
+ let item = NSPasteboardItem()
1138
+ for (type, data) in itemData {
1139
+ item.setData(data, forType: type)
1140
+ }
1141
+ return item
1142
+ }
1143
+ pasteboard.writeObjects(pasteboardItems)
1144
+ }
1145
+ }
1146
+
944
1147
  // MARK: - CLI Runner
945
1148
 
946
1149
  enum CLIRunner: Sendable {
@@ -44,7 +44,12 @@ public struct SettingsView: View {
44
44
  KeyboardShortcuts.Recorder(for: .toggleRecording) { _ in
45
45
  engine.updateStatus()
46
46
  }
47
+ Button("Reset to F5") {
48
+ KeyboardShortcuts.setShortcut(.init(.f5), for: .toggleRecording)
49
+ engine.updateStatus()
50
+ }
47
51
  }
52
+ Toggle("Use fn/Globe as recording key", isOn: $engine.useFnKey)
48
53
  Text("Hold to record, release to transcribe and paste.")
49
54
  .foregroundStyle(.secondary)
50
55
  }
@@ -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,20 @@ 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
+ }
136
+
137
+ @Test("Realtime artifact cleanup removes duplicated chunks and filler tokens")
138
+ func realtimeArtifactCleanup() {
139
+ let cleaned = RecordingEngine.cleanRealtimeArtifactText(
140
+ "어 Okay I don't know if this This is working어 Okay I don't know if this This is working"
141
+ )
142
+ #expect(cleaned == "Okay I don't know if this is working")
143
+ }
126
144
  }
@@ -55,4 +55,12 @@ 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
+ }
58
66
  }
@@ -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