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