@hasna/recordings 0.1.4 → 0.1.6
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/LICENSE +170 -21
- package/package.json +4 -4
- package/src/native/Recordings/Package.resolved +15 -0
- package/src/native/Recordings/Package.swift +21 -0
- package/src/native/Recordings/Recordings/FnKeyMonitor.swift +122 -0
- package/src/native/Recordings/Recordings/Info.plist +22 -0
- package/src/native/Recordings/Recordings/MenuBarPopover.swift +188 -0
- package/src/native/Recordings/Recordings/RecordingEngine.swift +355 -0
- package/src/native/Recordings/Recordings/Recordings.entitlements +12 -0
- package/src/native/Recordings/Recordings/RecordingsApp.swift +30 -0
- package/src/native/Recordings/Recordings/SettingsView.swift +93 -0
- package/src/native/Recordings/Recordings/VoiceShortcuts.swift +82 -0
- package/src/native/Recordings/build.sh +40 -0
- package/src/native/Recordings/test_fn.swift +79 -0
- package/src/native/Recordings/test_fn2.swift +33 -0
- package/src/native/RecordingsHelper.swift +36 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import KeyboardShortcuts
|
|
3
|
+
|
|
4
|
+
// MARK: - Custom shortcut (not fn — fn is handled by FnKeyMonitor)
|
|
5
|
+
|
|
6
|
+
extension KeyboardShortcuts.Name {
|
|
7
|
+
static let toggleRecording = Self("toggleRecording")
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// MARK: - Recording Mode
|
|
11
|
+
|
|
12
|
+
enum RecordingMode: String, CaseIterable, Identifiable, Sendable {
|
|
13
|
+
case pushToTalk = "Push to Talk"
|
|
14
|
+
case dictation = "Dictation"
|
|
15
|
+
case command = "Command"
|
|
16
|
+
|
|
17
|
+
var id: String { rawValue }
|
|
18
|
+
var icon: String {
|
|
19
|
+
switch self {
|
|
20
|
+
case .pushToTalk: return "hand.tap.fill"
|
|
21
|
+
case .dictation: return "text.bubble.fill"
|
|
22
|
+
case .command: return "wand.and.stars"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
var hint: String {
|
|
26
|
+
switch self {
|
|
27
|
+
case .pushToTalk: return "Hold fn to record, release to stop & paste"
|
|
28
|
+
case .dictation: return "Hold fn to dictate, release to stop & paste"
|
|
29
|
+
case .command: return "Select text, hold fn, speak to rewrite"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// MARK: - Transcription Result
|
|
35
|
+
|
|
36
|
+
struct TranscriptionResult: Sendable {
|
|
37
|
+
let rawText: String
|
|
38
|
+
let processedText: String?
|
|
39
|
+
let timestamp: Date
|
|
40
|
+
var displayText: String { processedText ?? rawText }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// MARK: - Recording Engine
|
|
44
|
+
|
|
45
|
+
@MainActor
|
|
46
|
+
final class RecordingEngine: ObservableObject {
|
|
47
|
+
@Published var isRecording = false
|
|
48
|
+
@Published var mode: RecordingMode = .pushToTalk
|
|
49
|
+
@Published var useFnKey: Bool = true {
|
|
50
|
+
didSet {
|
|
51
|
+
UserDefaults.standard.set(useFnKey, forKey: "useFnKey")
|
|
52
|
+
updateFnMonitor()
|
|
53
|
+
updateStatus()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
@Published var isWhisperMode = false
|
|
57
|
+
@Published var recentTranscriptions: [TranscriptionResult] = []
|
|
58
|
+
@Published var statusMessage = "Starting..."
|
|
59
|
+
@Published var isTranscribing = false
|
|
60
|
+
@Published var recordingDuration: TimeInterval = 0
|
|
61
|
+
|
|
62
|
+
private var recordProcess: Process?
|
|
63
|
+
private var stdinPipe: Pipe?
|
|
64
|
+
private var currentAudioPath: String?
|
|
65
|
+
private var recordingTimer: Timer?
|
|
66
|
+
private let maxDuration = 300
|
|
67
|
+
|
|
68
|
+
// fn key monitor (CGEventTap-based, swallows fn to prevent emoji picker)
|
|
69
|
+
private let fnMonitor = FnKeyMonitor()
|
|
70
|
+
|
|
71
|
+
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
|
72
|
+
private var audioDir: String { "\(home)/.recordings/audio" }
|
|
73
|
+
|
|
74
|
+
init() {
|
|
75
|
+
try? FileManager.default.createDirectory(atPath: audioDir, withIntermediateDirectories: true)
|
|
76
|
+
|
|
77
|
+
// Load preferences
|
|
78
|
+
useFnKey = UserDefaults.standard.object(forKey: "useFnKey") as? Bool ?? true
|
|
79
|
+
|
|
80
|
+
// Set up fn key monitor — hold fn to record, release to stop (like WisprFlow)
|
|
81
|
+
fnMonitor.onFnKeyDown = { [weak self] in
|
|
82
|
+
Task { @MainActor [weak self] in
|
|
83
|
+
guard let self, self.useFnKey, !self.isRecording else { return }
|
|
84
|
+
self.startRecording()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
fnMonitor.onFnKeyUp = { [weak self] in
|
|
88
|
+
Task { @MainActor [weak self] in
|
|
89
|
+
guard let self, self.useFnKey, self.isRecording else { return }
|
|
90
|
+
self.stopAndTranscribe()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
updateFnMonitor()
|
|
94
|
+
|
|
95
|
+
// Set up custom shortcut (toggle mode — press to start, press to stop)
|
|
96
|
+
KeyboardShortcuts.onKeyUp(for: .toggleRecording) { [weak self] in
|
|
97
|
+
Task { @MainActor [weak self] in
|
|
98
|
+
self?.toggleRecording()
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
updateStatus()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private func updateFnMonitor() {
|
|
106
|
+
if useFnKey {
|
|
107
|
+
let ok = fnMonitor.start()
|
|
108
|
+
if !ok {
|
|
109
|
+
statusMessage = "fn needs Accessibility permission (System Settings > Privacy & Security > Accessibility)"
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
fnMonitor.stop()
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func updateStatus() {
|
|
117
|
+
if isRecording || isTranscribing { return }
|
|
118
|
+
|
|
119
|
+
var parts: [String] = []
|
|
120
|
+
if useFnKey {
|
|
121
|
+
parts.append("fn (hold)")
|
|
122
|
+
}
|
|
123
|
+
if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
|
|
124
|
+
parts.append(shortcut.description)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if parts.isEmpty {
|
|
128
|
+
statusMessage = "No shortcut set — enable fn or set a custom shortcut"
|
|
129
|
+
} else {
|
|
130
|
+
statusMessage = "Ready — press \(parts.joined(separator: " or ")) to record"
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// MARK: - Toggle
|
|
135
|
+
|
|
136
|
+
func toggleRecording() {
|
|
137
|
+
if isRecording { stopAndTranscribe() } else { startRecording() }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// MARK: - Start Recording
|
|
141
|
+
|
|
142
|
+
func startRecording() {
|
|
143
|
+
guard !isRecording else { return }
|
|
144
|
+
|
|
145
|
+
let ts = DateFormatter()
|
|
146
|
+
ts.dateFormat = "yyyyMMdd'T'HHmmss"
|
|
147
|
+
let path = "\(audioDir)/rec-\(ts.string(from: Date())).wav"
|
|
148
|
+
currentAudioPath = path
|
|
149
|
+
|
|
150
|
+
let proc = Process()
|
|
151
|
+
let stdin = Pipe()
|
|
152
|
+
proc.executableURL = URL(fileURLWithPath: "/bin/bash")
|
|
153
|
+
proc.arguments = ["-c", """
|
|
154
|
+
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
|
|
155
|
+
if command -v ffmpeg &>/dev/null; then
|
|
156
|
+
ffmpeg -f avfoundation -i ":0" -ar 16000 -ac 1 -t \(maxDuration) "\(path)" -y 2>/dev/null
|
|
157
|
+
elif command -v rec &>/dev/null; then
|
|
158
|
+
rec -r 16000 -c 1 -b 16 "\(path)" trim 0 \(maxDuration)
|
|
159
|
+
else
|
|
160
|
+
exit 1
|
|
161
|
+
fi
|
|
162
|
+
"""]
|
|
163
|
+
proc.standardInput = stdin
|
|
164
|
+
proc.standardOutput = FileHandle.nullDevice
|
|
165
|
+
proc.standardError = FileHandle.nullDevice
|
|
166
|
+
|
|
167
|
+
do {
|
|
168
|
+
try proc.run()
|
|
169
|
+
recordProcess = proc
|
|
170
|
+
stdinPipe = stdin
|
|
171
|
+
isRecording = true
|
|
172
|
+
recordingDuration = 0
|
|
173
|
+
statusMessage = mode == .command ? "Speak your instruction..." : "Recording — press fn to stop"
|
|
174
|
+
|
|
175
|
+
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
|
|
176
|
+
Task { @MainActor [weak self] in
|
|
177
|
+
self?.recordingDuration += 0.1
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
statusMessage = "Failed: \(error.localizedDescription)"
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// MARK: - Stop & Transcribe
|
|
186
|
+
|
|
187
|
+
func stopAndTranscribe() {
|
|
188
|
+
guard let proc = recordProcess else { return }
|
|
189
|
+
|
|
190
|
+
recordingTimer?.invalidate()
|
|
191
|
+
recordingTimer = nil
|
|
192
|
+
|
|
193
|
+
if let pipe = stdinPipe {
|
|
194
|
+
try? pipe.fileHandleForWriting.write(contentsOf: Data("q".utf8))
|
|
195
|
+
try? pipe.fileHandleForWriting.close()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
isRecording = false
|
|
199
|
+
isTranscribing = true
|
|
200
|
+
statusMessage = "Transcribing..."
|
|
201
|
+
|
|
202
|
+
let audioPath = currentAudioPath
|
|
203
|
+
let curMode = mode
|
|
204
|
+
currentAudioPath = nil
|
|
205
|
+
recordProcess = nil
|
|
206
|
+
stdinPipe = nil
|
|
207
|
+
let homePath = home
|
|
208
|
+
|
|
209
|
+
Task.detached {
|
|
210
|
+
try? await Task.sleep(for: .seconds(1))
|
|
211
|
+
if proc.isRunning { proc.terminate() }
|
|
212
|
+
try? await Task.sleep(for: .milliseconds(300))
|
|
213
|
+
if proc.isRunning { proc.interrupt() }
|
|
214
|
+
|
|
215
|
+
guard let audioPath else {
|
|
216
|
+
await MainActor.run { self.finish("No audio file") }
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let attrs = try? FileManager.default.attributesOfItem(atPath: audioPath)
|
|
221
|
+
let size = (attrs?[.size] as? Int) ?? 0
|
|
222
|
+
guard size >= 1000 else {
|
|
223
|
+
await MainActor.run { self.finish("Audio too short — speak longer") }
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let output = CLIRunner.run(["transcribe", audioPath, "--json"], home: homePath)
|
|
228
|
+
let text = CLIRunner.parseJSON(output)
|
|
229
|
+
|
|
230
|
+
guard let text, !text.isEmpty else {
|
|
231
|
+
await MainActor.run { self.finish("Empty transcription") }
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
await MainActor.run {
|
|
236
|
+
self.isTranscribing = false
|
|
237
|
+
if curMode == .command {
|
|
238
|
+
self.runCommandMode(instruction: text)
|
|
239
|
+
} else {
|
|
240
|
+
self.pasteIntoFrontApp(text)
|
|
241
|
+
self.recentTranscriptions.insert(
|
|
242
|
+
TranscriptionResult(rawText: text, processedText: nil, timestamp: Date()), at: 0
|
|
243
|
+
)
|
|
244
|
+
if self.recentTranscriptions.count > 20 { self.recentTranscriptions.removeLast() }
|
|
245
|
+
self.statusMessage = "Pasted: \(String(text.prefix(50)))"
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private func finish(_ msg: String) {
|
|
252
|
+
isTranscribing = false
|
|
253
|
+
statusMessage = msg
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// MARK: - Command Mode
|
|
257
|
+
|
|
258
|
+
private func runCommandMode(instruction: String) {
|
|
259
|
+
postKey(0x08, flags: .maskCommand) // Cmd+C
|
|
260
|
+
let homePath = home
|
|
261
|
+
|
|
262
|
+
Task {
|
|
263
|
+
try? await Task.sleep(for: .milliseconds(250))
|
|
264
|
+
let selected = NSPasteboard.general.string(forType: .string) ?? ""
|
|
265
|
+
guard !selected.isEmpty else {
|
|
266
|
+
statusMessage = "No text selected"
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
statusMessage = "Rewriting..."
|
|
270
|
+
isTranscribing = true
|
|
271
|
+
let prompt = "Rewrite: \"\(instruction)\"\n\nText:\n\(selected)"
|
|
272
|
+
|
|
273
|
+
Task.detached {
|
|
274
|
+
let result = CLIRunner.run(["transcribe", "--text", prompt, "--enhance"], home: homePath)
|
|
275
|
+
await MainActor.run {
|
|
276
|
+
self.isTranscribing = false
|
|
277
|
+
if !result.isEmpty {
|
|
278
|
+
self.pasteIntoFrontApp(result)
|
|
279
|
+
self.statusMessage = "Rewritten"
|
|
280
|
+
} else {
|
|
281
|
+
self.statusMessage = "Rewrite failed"
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// MARK: - Paste
|
|
289
|
+
|
|
290
|
+
func pasteIntoFrontApp(_ text: String) {
|
|
291
|
+
let pb = NSPasteboard.general
|
|
292
|
+
pb.clearContents()
|
|
293
|
+
pb.setString(text, forType: .string)
|
|
294
|
+
|
|
295
|
+
// Activate the last user app (not us)
|
|
296
|
+
let myPID = ProcessInfo.processInfo.processIdentifier
|
|
297
|
+
if let target = NSWorkspace.shared.runningApplications.first(where: {
|
|
298
|
+
$0.activationPolicy == .regular && $0.processIdentifier != myPID
|
|
299
|
+
}) {
|
|
300
|
+
target.activate()
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Wait for activation, then Cmd+V
|
|
304
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
|
305
|
+
self.postKey(0x09, flags: .maskCommand)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private func postKey(_ key: CGKeyCode, flags: CGEventFlags) {
|
|
310
|
+
let src = CGEventSource(stateID: .hidSystemState)
|
|
311
|
+
let down = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: true)
|
|
312
|
+
down?.flags = flags
|
|
313
|
+
down?.post(tap: .cghidEventTap)
|
|
314
|
+
let up = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: false)
|
|
315
|
+
up?.flags = flags
|
|
316
|
+
up?.post(tap: .cghidEventTap)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// MARK: - CLI Runner
|
|
321
|
+
|
|
322
|
+
enum CLIRunner: Sendable {
|
|
323
|
+
static func run(_ args: [String], home: String) -> String {
|
|
324
|
+
let bin = "\(home)/.bun/bin/recordings"
|
|
325
|
+
let escaped = args.map { "\"\($0)\"" }.joined(separator: " ")
|
|
326
|
+
let proc = Process()
|
|
327
|
+
let pipe = Pipe()
|
|
328
|
+
proc.executableURL = URL(fileURLWithPath: "/bin/bash")
|
|
329
|
+
proc.arguments = ["-c", """
|
|
330
|
+
export PATH="\(home)/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
|
|
331
|
+
"\(bin)" \(escaped)
|
|
332
|
+
"""]
|
|
333
|
+
proc.standardOutput = pipe
|
|
334
|
+
proc.standardError = FileHandle.nullDevice
|
|
335
|
+
do {
|
|
336
|
+
try proc.run()
|
|
337
|
+
proc.waitUntilExit()
|
|
338
|
+
return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
|
339
|
+
} catch { return "" }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
static func parseJSON(_ output: String) -> String? {
|
|
343
|
+
if let s = output.range(of: "{"), let e = output.range(of: "}", options: .backwards) {
|
|
344
|
+
let json = String(output[s.lowerBound...e.upperBound])
|
|
345
|
+
if let data = json.data(using: .utf8),
|
|
346
|
+
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
347
|
+
if let t = obj["processed_text"] as? String, !t.isEmpty { return t }
|
|
348
|
+
if let t = obj["raw_text"] as? String, !t.isEmpty { return t }
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return output.components(separatedBy: "\n")
|
|
352
|
+
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
353
|
+
.first { !$0.isEmpty && !$0.hasPrefix("{") && !$0.contains("Transcribing") && !$0.hasPrefix("Saved") }
|
|
354
|
+
}
|
|
355
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>com.apple.security.app-sandbox</key>
|
|
6
|
+
<false/>
|
|
7
|
+
<key>com.apple.security.device.audio-input</key>
|
|
8
|
+
<true/>
|
|
9
|
+
<key>com.apple.security.automation.apple-events</key>
|
|
10
|
+
<true/>
|
|
11
|
+
</dict>
|
|
12
|
+
</plist>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import KeyboardShortcuts
|
|
3
|
+
|
|
4
|
+
@main
|
|
5
|
+
struct RecordingsApp: App {
|
|
6
|
+
@StateObject private var engine = RecordingEngine()
|
|
7
|
+
@StateObject private var shortcuts = VoiceShortcuts()
|
|
8
|
+
|
|
9
|
+
var body: some Scene {
|
|
10
|
+
MenuBarExtra {
|
|
11
|
+
MenuBarPopover(engine: engine, shortcuts: shortcuts)
|
|
12
|
+
.frame(width: 320, height: 440)
|
|
13
|
+
} label: {
|
|
14
|
+
if engine.isRecording {
|
|
15
|
+
Image(systemName: "record.circle.fill")
|
|
16
|
+
.symbolRenderingMode(.multicolor)
|
|
17
|
+
} else if engine.isTranscribing {
|
|
18
|
+
Image(systemName: "ellipsis.circle")
|
|
19
|
+
} else {
|
|
20
|
+
Image(systemName: "mic.fill")
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
.menuBarExtraStyle(.window)
|
|
24
|
+
|
|
25
|
+
// Settings window — opened via SettingsLink
|
|
26
|
+
Settings {
|
|
27
|
+
SettingsView(engine: engine, shortcuts: shortcuts)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import KeyboardShortcuts
|
|
3
|
+
|
|
4
|
+
struct SettingsView: View {
|
|
5
|
+
@ObservedObject var engine: RecordingEngine
|
|
6
|
+
@ObservedObject var shortcuts: VoiceShortcuts
|
|
7
|
+
@State private var newTrigger = ""
|
|
8
|
+
@State private var newContent = ""
|
|
9
|
+
|
|
10
|
+
var body: some View {
|
|
11
|
+
TabView {
|
|
12
|
+
generalTab.tabItem { Label("General", systemImage: "gear") }
|
|
13
|
+
shortcutsTab.tabItem { Label("Voice Shortcuts", systemImage: "text.badge.star") }
|
|
14
|
+
}
|
|
15
|
+
.frame(width: 480, height: 400)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
private var generalTab: some View {
|
|
19
|
+
Form {
|
|
20
|
+
Section("Shortcut — fn Key") {
|
|
21
|
+
Toggle("Use fn (Globe) key", isOn: $engine.useFnKey)
|
|
22
|
+
|
|
23
|
+
if engine.useFnKey {
|
|
24
|
+
Text("Hold fn to record, release to stop and paste.")
|
|
25
|
+
.font(.caption).foregroundStyle(.secondary)
|
|
26
|
+
HStack(spacing: 4) {
|
|
27
|
+
Image(systemName: "exclamationmark.triangle.fill")
|
|
28
|
+
.foregroundStyle(.orange)
|
|
29
|
+
Text("Required: System Settings → Keyboard → set \"Press 🌐 key to: Do Nothing\"")
|
|
30
|
+
.font(.caption)
|
|
31
|
+
}
|
|
32
|
+
Button("Open Keyboard Settings") {
|
|
33
|
+
NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.Keyboard-Settings.extension")!)
|
|
34
|
+
}.controlSize(.small)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
Section("Shortcut — Custom Key") {
|
|
39
|
+
HStack {
|
|
40
|
+
Text("Toggle Recording")
|
|
41
|
+
Spacer()
|
|
42
|
+
KeyboardShortcuts.Recorder(for: .toggleRecording) { _ in
|
|
43
|
+
engine.updateStatus()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
Text("Click the field and press any key combo (e.g. ⌥D, ⌃R, F5).")
|
|
47
|
+
.font(.caption).foregroundStyle(.secondary)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
Section("Recording") {
|
|
51
|
+
Picker("Mode", selection: $engine.mode) {
|
|
52
|
+
ForEach(RecordingMode.allCases) { m in
|
|
53
|
+
Label(m.rawValue, systemImage: m.icon).tag(m)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
Toggle("Whisper Mode", isOn: $engine.isWhisperMode)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
.formStyle(.grouped).padding()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private var shortcutsTab: some View {
|
|
63
|
+
VStack(spacing: 0) {
|
|
64
|
+
HStack(spacing: 8) {
|
|
65
|
+
TextField("Trigger phrase", text: $newTrigger).textFieldStyle(.roundedBorder)
|
|
66
|
+
TextField("Text to insert", text: $newContent).textFieldStyle(.roundedBorder)
|
|
67
|
+
Button("Add") {
|
|
68
|
+
guard !newTrigger.isEmpty, !newContent.isEmpty else { return }
|
|
69
|
+
shortcuts.add(trigger: newTrigger, content: newContent)
|
|
70
|
+
newTrigger = ""; newContent = ""
|
|
71
|
+
}.buttonStyle(.borderedProminent).disabled(newTrigger.isEmpty || newContent.isEmpty)
|
|
72
|
+
}.padding()
|
|
73
|
+
Divider()
|
|
74
|
+
if shortcuts.shortcuts.isEmpty {
|
|
75
|
+
Spacer()
|
|
76
|
+
VStack(spacing: 8) {
|
|
77
|
+
Image(systemName: "text.badge.star").font(.largeTitle).foregroundStyle(.quaternary)
|
|
78
|
+
Text("No voice shortcuts yet").foregroundStyle(.secondary)
|
|
79
|
+
}
|
|
80
|
+
Spacer()
|
|
81
|
+
} else {
|
|
82
|
+
List {
|
|
83
|
+
ForEach(shortcuts.shortcuts) { s in
|
|
84
|
+
VStack(alignment: .leading) {
|
|
85
|
+
Text(s.trigger).font(.body.bold())
|
|
86
|
+
Text(s.content).font(.caption).foregroundStyle(.secondary).lineLimit(2)
|
|
87
|
+
}
|
|
88
|
+
}.onDelete(perform: shortcuts.remove)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
// MARK: - Voice Shortcut
|
|
4
|
+
|
|
5
|
+
struct VoiceShortcut: Identifiable, Codable {
|
|
6
|
+
let id: UUID
|
|
7
|
+
var trigger: String // e.g. "add disclaimer"
|
|
8
|
+
var content: String // text to insert when trigger is spoken
|
|
9
|
+
|
|
10
|
+
init(trigger: String, content: String) {
|
|
11
|
+
self.id = UUID()
|
|
12
|
+
self.trigger = trigger
|
|
13
|
+
self.content = content
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// MARK: - Voice Shortcuts Manager
|
|
18
|
+
|
|
19
|
+
@MainActor
|
|
20
|
+
final class VoiceShortcuts: ObservableObject {
|
|
21
|
+
@Published var shortcuts: [VoiceShortcut] = []
|
|
22
|
+
|
|
23
|
+
private let storageURL: URL = {
|
|
24
|
+
let home = FileManager.default.homeDirectoryForCurrentUser
|
|
25
|
+
return home.appendingPathComponent(".recordings/voice-shortcuts.json")
|
|
26
|
+
}()
|
|
27
|
+
|
|
28
|
+
init() {
|
|
29
|
+
load()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func add(trigger: String, content: String) {
|
|
33
|
+
let shortcut = VoiceShortcut(trigger: trigger, content: content)
|
|
34
|
+
shortcuts.append(shortcut)
|
|
35
|
+
save()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func remove(at offsets: IndexSet) {
|
|
39
|
+
shortcuts.remove(atOffsets: offsets)
|
|
40
|
+
save()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func update(_ shortcut: VoiceShortcut) {
|
|
44
|
+
if let index = shortcuts.firstIndex(where: { $0.id == shortcut.id }) {
|
|
45
|
+
shortcuts[index] = shortcut
|
|
46
|
+
save()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// Check if transcribed text matches a voice shortcut trigger.
|
|
51
|
+
/// Returns the content to paste if matched, nil otherwise.
|
|
52
|
+
func match(_ text: String) -> String? {
|
|
53
|
+
let lower = text.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
|
|
54
|
+
for shortcut in shortcuts {
|
|
55
|
+
if lower.contains(shortcut.trigger.lowercased()) {
|
|
56
|
+
return shortcut.content
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return nil
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// MARK: - Persistence
|
|
63
|
+
|
|
64
|
+
private func save() {
|
|
65
|
+
do {
|
|
66
|
+
let data = try JSONEncoder().encode(shortcuts)
|
|
67
|
+
try data.write(to: storageURL, options: .atomic)
|
|
68
|
+
} catch {
|
|
69
|
+
print("Failed to save voice shortcuts: \(error)")
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private func load() {
|
|
74
|
+
guard FileManager.default.fileExists(atPath: storageURL.path) else { return }
|
|
75
|
+
do {
|
|
76
|
+
let data = try Data(contentsOf: storageURL)
|
|
77
|
+
shortcuts = try JSONDecoder().decode([VoiceShortcut].self, from: data)
|
|
78
|
+
} catch {
|
|
79
|
+
print("Failed to load voice shortcuts: \(error)")
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Build Recordings.app for macOS 26
|
|
3
|
+
# Usage: ./build.sh [debug|release]
|
|
4
|
+
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
MODE="${1:-release}"
|
|
8
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
9
|
+
cd "$SCRIPT_DIR"
|
|
10
|
+
|
|
11
|
+
echo "Building Recordings.app ($MODE)..."
|
|
12
|
+
swift build -c "$MODE"
|
|
13
|
+
|
|
14
|
+
# Create .app bundle
|
|
15
|
+
BUILD_DIR=".build/$MODE"
|
|
16
|
+
APP_DIR="$BUILD_DIR/Recordings.app"
|
|
17
|
+
CONTENTS="$APP_DIR/Contents"
|
|
18
|
+
MACOS="$CONTENTS/MacOS"
|
|
19
|
+
|
|
20
|
+
rm -rf "$APP_DIR"
|
|
21
|
+
mkdir -p "$MACOS"
|
|
22
|
+
|
|
23
|
+
# Copy binary
|
|
24
|
+
cp "$BUILD_DIR/Recordings" "$MACOS/Recordings"
|
|
25
|
+
|
|
26
|
+
# Copy Info.plist
|
|
27
|
+
cp Recordings/Info.plist "$CONTENTS/Info.plist"
|
|
28
|
+
|
|
29
|
+
# Copy entitlements (for codesigning)
|
|
30
|
+
if [ -f Recordings/Recordings.entitlements ]; then
|
|
31
|
+
codesign --force --sign - --entitlements Recordings/Recordings.entitlements "$APP_DIR" 2>/dev/null || true
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
echo "✓ Built $APP_DIR"
|
|
35
|
+
echo ""
|
|
36
|
+
echo "To install to ~/.recordings/:"
|
|
37
|
+
echo " cp -r $APP_DIR ~/.recordings/Recordings.app"
|
|
38
|
+
echo ""
|
|
39
|
+
echo "To run:"
|
|
40
|
+
echo " open $APP_DIR"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env swift
|
|
2
|
+
// Quick test: can we see fn key events via CGEventTap?
|
|
3
|
+
import Cocoa
|
|
4
|
+
|
|
5
|
+
print("Listening for ALL key events for 15 seconds...")
|
|
6
|
+
print("Press fn, F5, Option, or any key. Ctrl+C to quit.\n")
|
|
7
|
+
|
|
8
|
+
let mask: CGEventMask =
|
|
9
|
+
(1 << CGEventType.flagsChanged.rawValue) |
|
|
10
|
+
(1 << CGEventType.keyDown.rawValue) |
|
|
11
|
+
(1 << CGEventType.keyUp.rawValue)
|
|
12
|
+
|
|
13
|
+
let callback: CGEventTapCallBack = { proxy, type, event, refcon in
|
|
14
|
+
let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
|
|
15
|
+
let flags = event.flags
|
|
16
|
+
|
|
17
|
+
if type == .flagsChanged {
|
|
18
|
+
let fn = flags.contains(.maskSecondaryFn) ? " [fn]" : ""
|
|
19
|
+
let cmd = flags.contains(.maskCommand) ? " [cmd]" : ""
|
|
20
|
+
let opt = flags.contains(.maskAlternate) ? " [opt]" : ""
|
|
21
|
+
let ctrl = flags.contains(.maskControl) ? " [ctrl]" : ""
|
|
22
|
+
let shift = flags.contains(.maskShift) ? " [shift]" : ""
|
|
23
|
+
print("flagsChanged keyCode=\(keyCode)\(fn)\(cmd)\(opt)\(ctrl)\(shift)")
|
|
24
|
+
} else if type == .keyDown {
|
|
25
|
+
print("keyDown keyCode=\(keyCode)")
|
|
26
|
+
} else if type == .keyUp {
|
|
27
|
+
print("keyUp keyCode=\(keyCode)")
|
|
28
|
+
} else if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
|
|
29
|
+
print("TAP DISABLED — re-enabling")
|
|
30
|
+
if let refcon {
|
|
31
|
+
let tap = Unmanaged<AnyObject>.fromOpaque(refcon).takeUnretainedValue() as! CFMachPort
|
|
32
|
+
CGEvent.tapEnable(tap: tap, enable: true)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return Unmanaged.passRetained(event)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Try HID-level tap first
|
|
40
|
+
var tap = CGEvent.tapCreate(
|
|
41
|
+
tap: .cghidEventTap,
|
|
42
|
+
place: .headInsertEventTap,
|
|
43
|
+
options: .defaultTap,
|
|
44
|
+
eventsOfInterest: mask,
|
|
45
|
+
callback: callback,
|
|
46
|
+
userInfo: nil
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if tap == nil {
|
|
50
|
+
print("⚠ cghidEventTap failed, trying cgSessionEventTap...")
|
|
51
|
+
tap = CGEvent.tapCreate(
|
|
52
|
+
tap: .cgSessionEventTap,
|
|
53
|
+
place: .headInsertEventTap,
|
|
54
|
+
options: .defaultTap,
|
|
55
|
+
eventsOfInterest: mask,
|
|
56
|
+
callback: callback,
|
|
57
|
+
userInfo: nil
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
guard let tap else {
|
|
62
|
+
print("❌ FAILED to create event tap. Grant Accessibility permission:")
|
|
63
|
+
print(" System Settings > Privacy & Security > Accessibility")
|
|
64
|
+
exit(1)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
print("✅ Event tap created successfully\n")
|
|
68
|
+
|
|
69
|
+
let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
|
|
70
|
+
CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .commonModes)
|
|
71
|
+
CGEvent.tapEnable(tap: tap, enable: true)
|
|
72
|
+
|
|
73
|
+
// Run for 15 seconds
|
|
74
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 15) {
|
|
75
|
+
print("\nDone.")
|
|
76
|
+
exit(0)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
CFRunLoopRun()
|