@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 +13 -8
- package/dist/cli/options.d.ts +7 -0
- package/dist/cli/options.d.ts.map +1 -0
- package/dist/mcp/index.js +2 -2
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
- package/src/native/Recordings/App/RecordingsApp.swift +9 -0
- package/src/native/Recordings/Package.swift +2 -1
- package/src/native/Recordings/RecordingsLib/BrandAssets.swift +50 -0
- package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +1 -2
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +6 -15
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +338 -79
- package/src/native/Recordings/RecordingsLib/Resources/HasnaLogo.jpg +0 -0
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +5 -0
- package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +4 -4
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +24 -6
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +48 -0
- package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +8 -0
- package/src/native/Recordings/build.sh +9 -1
package/dist/cli/index.js
CHANGED
|
@@ -5098,7 +5098,7 @@ var init_pg_migrate = __esm(() => {
|
|
|
5098
5098
|
var require_package = __commonJS((exports, module) => {
|
|
5099
5099
|
module.exports = {
|
|
5100
5100
|
name: "@hasna/recordings",
|
|
5101
|
-
version: "0.1.
|
|
5101
|
+
version: "0.1.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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
1
|
+
export declare const VERSION = "0.1.27";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
///
|
|
10
|
-
public nonisolated static let modelID = "gpt-
|
|
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
|
-
"
|
|
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 =
|
|
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
|
-
"
|
|
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
|
|
30
|
-
case .dictation: return "Hold
|
|
31
|
-
case .command: return "Select text,
|
|
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
|
|
60
|
-
private
|
|
61
|
-
private
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
84
|
+
if !pendingChunk.isEmpty {
|
|
85
|
+
await client?.sendAudio(pendingChunk)
|
|
86
|
+
}
|
|
87
|
+
return recordedPCM
|
|
73
88
|
}
|
|
74
|
-
return chunks
|
|
75
89
|
}
|
|
76
90
|
|
|
77
|
-
func
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
85
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
534
|
+
let pcmStreamPipe = pcmStreamPipe
|
|
456
535
|
let client = realtimeClient
|
|
457
536
|
resetRecordingIntent()
|
|
458
|
-
self.
|
|
537
|
+
self.pcmStreamPipe = nil
|
|
459
538
|
|
|
460
539
|
Task {
|
|
461
|
-
if let
|
|
462
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
683
|
-
|
|
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(
|
|
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 ?
|
|
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
|
|
757
|
-
|
|
758
|
-
|
|
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 = "
|
|
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
|
-
|
|
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 {
|
|
Binary file
|
|
@@ -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
|
|
23
|
-
func
|
|
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
|
|
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
|
|
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
|
|
7
|
+
@Test("Model ID is set to low-latency realtime transcription model")
|
|
8
8
|
func modelID() {
|
|
9
|
-
#expect(RealtimeTranscriptionClient.modelID == "gpt-
|
|
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-
|
|
98
|
-
#expect(transcription?["
|
|
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
|
|
104
|
+
#expect(turnDetection == nil)
|
|
103
105
|
|
|
104
106
|
let include = session?["include"] as? [String]
|
|
105
|
-
#expect(include
|
|
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
|