@hasna/recordings 0.1.25 → 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.25",
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.25";
6084
+ var VERSION = "0.1.27";
6085
6085
 
6086
6086
  // src/cli/storage.ts
6087
6087
  import chalk from "chalk";
@@ -6442,6 +6442,14 @@ function registerStorageCommands(program) {
6442
6442
  });
6443
6443
  }
6444
6444
 
6445
+ // src/cli/options.ts
6446
+ function applyEnhancementOptions(config, opts) {
6447
+ if (opts.enhance === false || opts.noEnhance === false) {
6448
+ config.auto_enhance = false;
6449
+ }
6450
+ return config;
6451
+ }
6452
+
6445
6453
  // src/cli/index.ts
6446
6454
  var program = new Command;
6447
6455
  program.name("recordings").description("Speech-to-text recording tool \u2014 record, transcribe, and enhance with AI").version(VERSION).option("--json", "Output as JSON").option("--agent <name>", "Agent name or ID").option("--project <name>", "Project name or ID").option("--session <id>", "Session ID");
@@ -6451,8 +6459,7 @@ program.command("record").description("Record from microphone, transcribe, and o
6451
6459
  ensureDataDir(config);
6452
6460
  if (opts.language)
6453
6461
  config.language = opts.language;
6454
- if (opts.noEnhance === false)
6455
- config.auto_enhance = false;
6462
+ applyEnhancementOptions(config, opts);
6456
6463
  const deps = await checkRecordingDeps();
6457
6464
  if (!deps.available) {
6458
6465
  console.error(chalk2.red(`Error: ${deps.message}`));
@@ -6518,8 +6525,7 @@ Saved as ${recording.id.slice(0, 8)}`));
6518
6525
  program.command("transcribe <file>").description("Transcribe an existing audio file").option("--no-enhance", "Skip AI enhancement").option("--stream", "Stream transcription deltas while the file is processed").option("-t, --tags <tags>", "Comma-separated tags").option("--prompt <prompt>", "Vocabulary/context prompt for transcription").option("--system-prompt <prompt>", "System prompt for enhancement context").action(async (file, opts) => {
6519
6526
  const config = loadConfig();
6520
6527
  ensureDataDir(config);
6521
- if (opts.noEnhance === false)
6522
- config.auto_enhance = false;
6528
+ applyEnhancementOptions(config, opts);
6523
6529
  const parentOpts = program.opts();
6524
6530
  if (!parentOpts.json) {
6525
6531
  console.log(chalk2.blue("Transcribing..."));
@@ -6883,8 +6889,7 @@ program.command("listen").description("Push-to-talk mode \u2014 press Space to s
6883
6889
  ensureDataDir(config);
6884
6890
  if (opts.language)
6885
6891
  config.language = opts.language;
6886
- if (opts.noEnhance === false)
6887
- config.auto_enhance = false;
6892
+ applyEnhancementOptions(config, opts);
6888
6893
  const deps = await checkRecordingDeps();
6889
6894
  if (!deps.available) {
6890
6895
  console.error(chalk2.red(`Error: ${deps.message}`));
@@ -0,0 +1,7 @@
1
+ import type { RecordingsConfig } from "../types/index.js";
2
+ export type EnhancementOptionBag = {
3
+ enhance?: boolean;
4
+ noEnhance?: boolean;
5
+ };
6
+ export declare function applyEnhancementOptions(config: RecordingsConfig, opts: EnhancementOptionBag): RecordingsConfig;
7
+ //# sourceMappingURL=options.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"options.d.ts","sourceRoot":"","sources":["../../src/cli/options.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAE1D,MAAM,MAAM,oBAAoB,GAAG;IACjC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,CAAC;AAEF,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,gBAAgB,EACxB,IAAI,EAAE,oBAAoB,GACzB,gBAAgB,CAKlB"}
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.25",
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.25";
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.25";
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.25",
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
  }
@@ -44,7 +44,7 @@ public struct TranscriptionResult: Sendable {
44
44
  var displayText: String { processedText ?? rawText }
45
45
  }
46
46
 
47
- public enum RecordingTrigger {
47
+ public enum RecordingTrigger: Equatable, Sendable {
48
48
  case manual
49
49
  case fnKey
50
50
  case keyboardShortcut
@@ -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
 
@@ -121,6 +138,7 @@ public final class RecordingEngine: ObservableObject {
121
138
  private var recordingTimer: Timer?
122
139
  private var activeTrigger: RecordingTrigger?
123
140
  private var keyboardShortcutIsDown = false
141
+ private var fnKeyIsDown = false
124
142
  private var targetAppBundleIdentifier: String?
125
143
  private var targetAppPid: pid_t?
126
144
  public var projectStore: ProjectStore?
@@ -129,12 +147,15 @@ public final class RecordingEngine: ObservableObject {
129
147
  // Real-time streaming
130
148
  private var realtimeClient: RealtimeTranscriptionClient?
131
149
  private var streamingTask: Task<Void, Never>?
132
- private var pcmStreamState: PCMStreamState?
150
+ private var pcmStreamPipe: PCMStreamPipe?
133
151
  private var streamingText = ""
134
152
  private var recordedPCM = Data()
135
153
  private var activeAudioPath: String?
136
154
  private var lastAccessibilityPromptAt: Date?
137
155
 
156
+ private nonisolated static let realtimePeriodicCommitInterval: TimeInterval = 0.9
157
+ private nonisolated static let realtimeFinishTimeoutMilliseconds: UInt64 = 700
158
+
138
159
  // fn key monitor (CGEventTap-based, swallows fn to prevent emoji picker)
139
160
  private let fnMonitor = FnKeyMonitor()
140
161
  private var permissionRetryTimer: Timer?
@@ -165,13 +186,23 @@ public final class RecordingEngine: ObservableObject {
165
186
  // Set up fn key monitor — hold fn to record, release to stop (like WisprFlow)
166
187
  fnMonitor.onFnKeyDown = { [weak self] in
167
188
  Task { @MainActor [weak self] in
168
- guard let self, self.useFnKey, !self.isRecording else { return }
189
+ guard let self else { return }
190
+ self.fnKeyIsDown = true
191
+ guard self.useFnKey, Self.canBeginRecording(isRecording: self.isRecording, isTranscribing: self.isTranscribing) else { return }
169
192
  self.startRecording(trigger: .fnKey)
170
193
  }
171
194
  }
172
195
  fnMonitor.onFnKeyUp = { [weak self] in
173
196
  Task { @MainActor [weak self] in
174
- guard let self, self.useFnKey, self.isRecording, self.activeTrigger == .fnKey else { return }
197
+ guard let self else { return }
198
+ self.fnKeyIsDown = false
199
+ guard self.useFnKey, self.activeTrigger == .fnKey else { return }
200
+ guard self.isRecording else {
201
+ self.log("fn released before recording started; cancelling pending start")
202
+ self.resetRecordingIntent()
203
+ self.updateStatus()
204
+ return
205
+ }
175
206
  self.stopAndTranscribe()
176
207
  }
177
208
  }
@@ -181,7 +212,7 @@ public final class RecordingEngine: ObservableObject {
181
212
  Task { @MainActor [weak self] in
182
213
  guard let self, !self.keyboardShortcutIsDown else { return }
183
214
  self.keyboardShortcutIsDown = true
184
- guard !self.isRecording else { return }
215
+ guard Self.canBeginRecording(isRecording: self.isRecording, isTranscribing: self.isTranscribing) else { return }
185
216
  self.startRecording(trigger: .keyboardShortcut)
186
217
  }
187
218
  }
@@ -189,7 +220,13 @@ public final class RecordingEngine: ObservableObject {
189
220
  Task { @MainActor [weak self] in
190
221
  guard let self, self.keyboardShortcutIsDown else { return }
191
222
  self.keyboardShortcutIsDown = false
192
- guard self.isRecording, self.activeTrigger == .keyboardShortcut else { return }
223
+ guard self.activeTrigger == .keyboardShortcut else { return }
224
+ guard self.isRecording else {
225
+ self.log("shortcut released before recording started; cancelling pending start")
226
+ self.resetRecordingIntent()
227
+ self.updateStatus()
228
+ return
229
+ }
193
230
  self.stopAndTranscribe()
194
231
  }
195
232
  }
@@ -289,7 +326,12 @@ public final class RecordingEngine: ObservableObject {
289
326
  // MARK: - Start Recording (Streaming)
290
327
 
291
328
  public func startRecording(trigger: RecordingTrigger = .manual) {
292
- guard !isRecording else { return }
329
+ guard Self.canBeginRecording(isRecording: isRecording, isTranscribing: isTranscribing) else {
330
+ if isTranscribing {
331
+ statusMessage = "Finish transcribing before recording again"
332
+ }
333
+ return
334
+ }
293
335
  log("startRecording trigger=\(trigger) microphoneStatus=\(AVCaptureDevice.authorizationStatus(for: .audio).rawValue) accessibility=\(AXIsProcessTrusted())")
294
336
  activeTrigger = trigger
295
337
  keyboardShortcutIsDown = trigger == .keyboardShortcut
@@ -320,6 +362,12 @@ public final class RecordingEngine: ObservableObject {
320
362
  guard let self else { return }
321
363
  self.log("microphone access response granted=\(granted)")
322
364
  if granted {
365
+ guard self.shouldContinueStarting(trigger: trigger) else {
366
+ self.log("recording start cancelled before microphone permission completed trigger=\(trigger)")
367
+ self.resetRecordingIntent()
368
+ self.updateStatus()
369
+ return
370
+ }
323
371
  self.startNativeRecording()
324
372
  } else {
325
373
  self.resetRecordingIntent()
@@ -337,10 +385,34 @@ public final class RecordingEngine: ObservableObject {
337
385
  }
338
386
  }
339
387
 
340
- private func startNativeRecording() {
341
- let streamState = PCMStreamState()
342
- pcmStreamState = streamState
388
+ nonisolated static func canBeginRecording(isRecording: Bool, isTranscribing: Bool) -> Bool {
389
+ !isRecording && !isTranscribing
390
+ }
343
391
 
392
+ nonisolated static func shouldContinueStartingAfterPermission(
393
+ trigger: RecordingTrigger,
394
+ keyboardShortcutIsDown: Bool,
395
+ fnKeyIsDown: Bool
396
+ ) -> Bool {
397
+ switch trigger {
398
+ case .manual:
399
+ return true
400
+ case .keyboardShortcut:
401
+ return keyboardShortcutIsDown
402
+ case .fnKey:
403
+ return fnKeyIsDown
404
+ }
405
+ }
406
+
407
+ private func shouldContinueStarting(trigger: RecordingTrigger) -> Bool {
408
+ activeTrigger == trigger && Self.shouldContinueStartingAfterPermission(
409
+ trigger: trigger,
410
+ keyboardShortcutIsDown: keyboardShortcutIsDown,
411
+ fnKeyIsDown: fnKeyIsDown
412
+ )
413
+ }
414
+
415
+ private func startNativeRecording() {
344
416
  let apiKey = openAIAPIKey
345
417
  log("startNativeRecording apiKeyConfigured=\(!apiKey.isEmpty)")
346
418
  if !apiKey.isEmpty {
@@ -348,18 +420,15 @@ public final class RecordingEngine: ObservableObject {
348
420
  }
349
421
 
350
422
  let client = realtimeClient
423
+ let streamPipe = PCMStreamPipe(chunkSize: 4_800, client: client)
424
+ pcmStreamPipe = streamPipe
351
425
  let homePath = home
352
426
  let firstChunkLogged = LockedFlag()
353
- let recorder = NativePCMRecorder { [weak client] data in
427
+ let recorder = NativePCMRecorder { data in
354
428
  if firstChunkLogged.take() {
355
429
  NativeAppLog.write("native recorder received first PCM chunk bytes=\(data.count)", homePath: homePath)
356
430
  }
357
- Task {
358
- let chunks = await streamState.append(data, chunkSize: 4_800)
359
- for chunk in chunks {
360
- await client?.sendAudio(chunk)
361
- }
362
- }
431
+ streamPipe.append(data)
363
432
  }
364
433
 
365
434
  do {
@@ -390,7 +459,8 @@ public final class RecordingEngine: ObservableObject {
390
459
  realtimeClient = nil
391
460
  streamingTask?.cancel()
392
461
  streamingTask = nil
393
- pcmStreamState = nil
462
+ pcmStreamPipe?.cancel()
463
+ pcmStreamPipe = nil
394
464
  resetRecordingIntent()
395
465
  statusMessage = "Failed: \(error.localizedDescription)"
396
466
  }
@@ -407,9 +477,17 @@ public final class RecordingEngine: ObservableObject {
407
477
  await client.startStreaming()
408
478
  self.log("realtime start completed streaming=\(client.isStreaming) error=\(client.error ?? "")")
409
479
 
480
+ var lastPeriodicCommitAt = Date.distantPast
481
+
410
482
  // Receive deltas
411
483
  while client.isStreaming {
412
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
+ }
413
491
  let text = client.accumulatedText
414
492
  if text != streamingText {
415
493
  await MainActor.run {
@@ -434,6 +512,7 @@ public final class RecordingEngine: ObservableObject {
434
512
 
435
513
  public func stopAndTranscribe() {
436
514
  guard isRecording else { return }
515
+ let stopStartedAt = Date()
437
516
  log("stopAndTranscribe")
438
517
 
439
518
  recordingTimer?.invalidate()
@@ -452,30 +531,48 @@ public final class RecordingEngine: ObservableObject {
452
531
  let activeProjectId = projectStore?.settings.activeProjectId
453
532
  let activeProjectName = projectStore?.activeProject?.name
454
533
  let audioPath = activeAudioPath
455
- let pcmStreamState = pcmStreamState
534
+ let pcmStreamPipe = pcmStreamPipe
456
535
  let client = realtimeClient
457
536
  resetRecordingIntent()
458
- self.pcmStreamState = nil
537
+ self.pcmStreamPipe = nil
459
538
 
460
539
  Task {
461
- if let pcmStreamState {
462
- if let finalChunk = await pcmStreamState.flushPendingChunk() {
463
- client?.sendAudio(finalChunk)
464
- }
465
- self.recordedPCM = await pcmStreamState.capturedPCM()
540
+ if let pcmStreamPipe {
541
+ self.recordedPCM = await pcmStreamPipe.finish()
466
542
  }
467
543
  self.log("captured pcm bytes=\(self.recordedPCM.count)")
468
544
 
469
- let streamingResult = await client?.finish() ?? ""
545
+ let streamingResult = await client?.finish(timeoutMilliseconds: Self.realtimeFinishTimeoutMilliseconds) ?? ""
470
546
 
471
547
  self.realtimeClient = nil
472
548
  self.streamingTask?.cancel()
473
549
  self.streamingTask = nil
474
550
 
475
- let realtimeText = streamingResult.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : streamingResult
551
+ let realtimeText = Self.normalizedRealtimeTranscript(streamingResult)
476
552
 
477
553
  self.liveTranscriptionText = ""
478
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
+
479
576
  if let audioPath, self.writeCapturedWAV(to: audioPath) {
480
577
  self.log("transcribing captured full audio audioPath=\(audioPath) realtimePreviewChars=\(realtimeText?.count ?? 0)")
481
578
  self.fallbackTranscribe(
@@ -518,10 +615,102 @@ public final class RecordingEngine: ObservableObject {
518
615
  return trimmed.count < 12 || words.count <= 2
519
616
  }
520
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
+
521
710
  private func finishWithText(_ text: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, targetAppPid: pid_t?, activeProjectId: String?, activeProjectName: String?) {
522
711
  log("finishWithText mode=\(curMode.rawValue) chars=\(text.count)")
523
712
  if curMode == .command {
524
- runCommandMode(instruction: text)
713
+ runCommandMode(instruction: text, targetAppBundleIdentifier: targetAppBundleIdentifier, targetAppPid: targetAppPid)
525
714
  return
526
715
  }
527
716
 
@@ -560,7 +749,24 @@ public final class RecordingEngine: ObservableObject {
560
749
  }
561
750
  }
562
751
 
563
- 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 {
564
770
  let byteRate = sampleRate * UInt32(channelCount) * UInt32(bitsPerSample / 8)
565
771
  let blockAlign = channelCount * (bitsPerSample / 8)
566
772
  let dataSize = UInt32(pcmData.count)
@@ -667,23 +873,42 @@ public final class RecordingEngine: ObservableObject {
667
873
  private func resetRecordingIntent() {
668
874
  activeTrigger = nil
669
875
  keyboardShortcutIsDown = false
876
+ fnKeyIsDown = false
670
877
  targetAppBundleIdentifier = nil
671
878
  targetAppPid = nil
672
879
  }
673
880
 
674
881
  // MARK: - Command Mode
675
882
 
676
- private func runCommandMode(instruction: String) {
883
+ private func runCommandMode(instruction: String, targetAppBundleIdentifier: String?, targetAppPid: pid_t?) {
677
884
  guard ensureAccessibilityPermission(prompt: shouldPromptAccessibility()) else {
678
885
  log("command mode blocked by accessibility permission")
679
886
  statusMessage = "Enable Accessibility permission for Recordings to rewrite selected text"
680
887
  return
681
888
  }
682
- postKey(0x08, flags: .maskCommand) // Cmd+C
683
- 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
+ }
684
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
685
910
  Task {
686
- try? await Task.sleep(for: .milliseconds(250))
911
+ try? await Task.sleep(for: .milliseconds(Int((copyDelay + 0.25) * 1_000)))
687
912
  let selected = NSPasteboard.general.string(forType: .string) ?? ""
688
913
  guard !selected.isEmpty else {
689
914
  statusMessage = "No text selected"
@@ -737,7 +962,7 @@ public final class RecordingEngine: ObservableObject {
737
962
  func pasteIntoFrontApp(_ text: String, targetAppBundleIdentifier: String? = nil, targetAppPid: pid_t? = nil, restoreClipboard: Bool = false) {
738
963
  log("paste requested chars=\(text.count) target=\(targetAppBundleIdentifier ?? "nil") pid=\(targetAppPid.map(String.init) ?? "nil") accessibility=\(AXIsProcessTrusted())")
739
964
  let pb = NSPasteboard.general
740
- let previousClipboard = restoreClipboard ? pb.string(forType: .string) : nil
965
+ let previousClipboard = restoreClipboard ? ClipboardSnapshot(pasteboard: pb) : nil
741
966
  pb.clearContents()
742
967
  pb.setString(text, forType: .string)
743
968
 
@@ -750,30 +975,16 @@ public final class RecordingEngine: ObservableObject {
750
975
  return
751
976
  }
752
977
 
753
- let myPID = ProcessInfo.processInfo.processIdentifier
754
- let runningApps = NSWorkspace.shared.runningApplications
755
978
  let frontmostPid = NSWorkspace.shared.frontmostApplication?.processIdentifier
756
- let candidates = runningApps.map {
757
- PasteTargetCandidate(
758
- pid: $0.processIdentifier,
759
- bundleIdentifier: $0.bundleIdentifier,
760
- isRegularApp: $0.activationPolicy == .regular
761
- )
762
- }
763
- let selectedTarget = Self.selectPasteTarget(
764
- candidates: candidates,
765
- currentPid: myPID,
766
- targetBundleIdentifier: targetAppBundleIdentifier,
767
- targetPid: targetAppPid,
979
+ let targetApp = selectedRunningPasteTarget(
980
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
981
+ targetAppPid: targetAppPid,
768
982
  frontmostPid: frontmostPid
769
983
  )
770
- let targetApp = selectedTarget.flatMap { selected in
771
- runningApps.first { $0.processIdentifier == selected.pid }
772
- }
773
984
 
774
985
  guard let app = targetApp else {
775
986
  log("paste target app not found")
776
- self.statusMessage = "No target app found"
987
+ self.statusMessage = "Copied — no target app found"
777
988
  return
778
989
  }
779
990
 
@@ -801,14 +1012,35 @@ public final class RecordingEngine: ObservableObject {
801
1012
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
802
1013
  let pb = NSPasteboard.general
803
1014
  if pb.string(forType: .string) == text {
804
- pb.clearContents()
805
- pb.setString(previousClipboard, forType: .string)
1015
+ previousClipboard.restore(to: pb)
806
1016
  }
807
1017
  }
808
1018
  }
809
1019
  }
810
1020
  }
811
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
+
812
1044
  nonisolated static func selectPasteTarget(
813
1045
  candidates: [PasteTargetCandidate],
814
1046
  currentPid: pid_t,
@@ -826,8 +1058,6 @@ public final class RecordingEngine: ObservableObject {
826
1058
  } ?? candidates.first {
827
1059
  guard let frontmostPid else { return false }
828
1060
  return $0.pid == frontmostPid && $0.pid != currentPid && $0.isRegularApp
829
- } ?? candidates.first {
830
- $0.isRegularApp && $0.pid != currentPid
831
1061
  }
832
1062
  }
833
1063
 
@@ -885,6 +1115,35 @@ private final class LockedFlag: @unchecked Sendable {
885
1115
  }
886
1116
  }
887
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
+
888
1147
  // MARK: - CLI Runner
889
1148
 
890
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
  }
@@ -0,0 +1,48 @@
1
+ import Testing
2
+ @testable import RecordingsLib
3
+
4
+ struct RecordingStartGateTests {
5
+ @Test("recording cannot begin while already recording or transcribing")
6
+ func cannotBeginWhenBusy() {
7
+ #expect(RecordingEngine.canBeginRecording(isRecording: false, isTranscribing: false) == true)
8
+ #expect(RecordingEngine.canBeginRecording(isRecording: true, isTranscribing: false) == false)
9
+ #expect(RecordingEngine.canBeginRecording(isRecording: false, isTranscribing: true) == false)
10
+ }
11
+
12
+ @Test("manual recording can continue after permission grant")
13
+ func manualPermissionContinuation() {
14
+ #expect(RecordingEngine.shouldContinueStartingAfterPermission(
15
+ trigger: .manual,
16
+ keyboardShortcutIsDown: false,
17
+ fnKeyIsDown: false
18
+ ))
19
+ }
20
+
21
+ @Test("keyboard shortcut recording is cancelled when released before permission grant")
22
+ func keyboardPermissionContinuationRequiresHeldKey() {
23
+ #expect(RecordingEngine.shouldContinueStartingAfterPermission(
24
+ trigger: .keyboardShortcut,
25
+ keyboardShortcutIsDown: true,
26
+ fnKeyIsDown: false
27
+ ))
28
+ #expect(RecordingEngine.shouldContinueStartingAfterPermission(
29
+ trigger: .keyboardShortcut,
30
+ keyboardShortcutIsDown: false,
31
+ fnKeyIsDown: false
32
+ ) == false)
33
+ }
34
+
35
+ @Test("fn recording is cancelled when released before permission grant")
36
+ func fnPermissionContinuationRequiresHeldKey() {
37
+ #expect(RecordingEngine.shouldContinueStartingAfterPermission(
38
+ trigger: .fnKey,
39
+ keyboardShortcutIsDown: false,
40
+ fnKeyIsDown: true
41
+ ))
42
+ #expect(RecordingEngine.shouldContinueStartingAfterPermission(
43
+ trigger: .fnKey,
44
+ keyboardShortcutIsDown: false,
45
+ fnKeyIsDown: false
46
+ ) == false)
47
+ }
48
+ }
@@ -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