@hasna/recordings 0.2.10 → 0.2.13
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/README.md +350 -35
- package/bun.lock +3 -0
- package/dist/__tests__/helpers/native-fs-guard.d.ts +2 -0
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -0
- package/dist/cli/index.js +1728 -320
- package/dist/cli/macos-permissions.d.ts +13 -0
- package/dist/cli/macos-permissions.d.ts.map +1 -0
- package/dist/cli/options.d.ts +12 -0
- package/dist/cli/options.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/recordings.d.ts +2 -1
- package/dist/db/recordings.d.ts.map +1 -1
- package/dist/db/remote-storage.d.ts +5 -1
- package/dist/db/remote-storage.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +628 -147
- package/dist/lib/bun-runtime.d.ts +11 -0
- package/dist/lib/bun-runtime.d.ts.map +1 -0
- package/dist/lib/config.d.ts +8 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/enhancer.d.ts +10 -2
- package/dist/lib/enhancer.d.ts.map +1 -1
- package/dist/lib/install-maintenance.d.ts +24 -0
- package/dist/lib/install-maintenance.d.ts.map +1 -0
- package/dist/lib/machine.d.ts +2 -0
- package/dist/lib/machine.d.ts.map +1 -0
- package/dist/lib/recorder.d.ts +2 -1
- package/dist/lib/recorder.d.ts.map +1 -1
- package/dist/lib/recording-create-identity.d.ts +13 -0
- package/dist/lib/recording-create-identity.d.ts.map +1 -0
- package/dist/lib/release-install-policy.d.ts +31 -0
- package/dist/lib/release-install-policy.d.ts.map +1 -0
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +977 -194
- package/dist/sdk/index.js +5 -1
- package/dist/sdk/v1.generated.d.ts +4 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/cloud-config.d.ts +9 -0
- package/dist/server/cloud-config.d.ts.map +1 -0
- package/dist/server/cloud-readiness.d.ts +10 -0
- package/dist/server/cloud-readiness.d.ts.map +1 -0
- package/dist/server/cloud.d.ts +9 -18
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +1654 -304
- package/dist/server/migrate-command.d.ts +11 -0
- package/dist/server/migrate-command.d.ts.map +1 -0
- package/dist/server/openapi.d.ts +22 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/repo.d.ts +8 -1
- package/dist/server/repo.d.ts.map +1 -1
- package/dist/server/serve.d.ts +6 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/storage.js +515 -80
- package/dist/store.d.ts +3 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/types/index.d.ts +10 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/package.json +7 -4
- package/packaging/macos/Empty.entitlements +5 -0
- package/packaging/macos/Library/LaunchDaemons/com.hasna.recordings.updater.plist +34 -0
- package/packaging/macos/Verifier.entitlements +10 -0
- package/packaging/macos/artifact-verifier.sb +33 -0
- package/packaging/macos/build_release_pkg.sh +872 -0
- package/packaging/macos/managed_bootstrap.sh +623 -0
- package/packaging/macos/pkgutil_fingerprint.awk +37 -0
- package/packaging/macos/release_lifecycle.ts +463 -0
- package/packaging/macos/scripts/postinstall +565 -0
- package/packaging/macos/scripts/preinstall +297 -0
- package/scripts/build_companion_cli.sh +337 -0
- package/scripts/build_native_fs_guard.sh +59 -0
- package/scripts/generate-sdk.ts +19 -1
- package/scripts/install_macos_app.sh +1922 -77
- package/scripts/macos_artifact.ts +5316 -0
- package/scripts/migrate.ts +38 -10
- package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
- package/scripts/native/recordings_fs_guard.c +1167 -0
- package/scripts/native_fs_guard.ts +158 -0
- package/scripts/release-guard.ts +20 -1
- package/scripts/resolve_tailscale_cli.sh +389 -0
- package/scripts/smoke_macos_app.sh +573 -0
- package/src/native/Recordings/App/ContentView.swift +83 -0
- package/src/native/Recordings/App/MenuBarStatusView.swift +102 -0
- package/src/native/Recordings/App/RecordWorkspaceView.swift +468 -0
- package/src/native/Recordings/App/RecordingDetailView.swift +138 -0
- package/src/native/Recordings/App/RecordingsApp.swift +367 -44
- package/src/native/Recordings/App/RecordingsListView.swift +130 -0
- package/src/native/Recordings/App/RecordingsStore.swift +206 -0
- package/src/native/Recordings/App/RuntimeSmoke.swift +158 -0
- package/src/native/Recordings/App/SidebarView.swift +212 -0
- package/src/native/Recordings/App/Theme.swift +87 -0
- package/src/native/Recordings/Package.resolved +5 -5
- package/src/native/Recordings/Package.swift +62 -2
- package/src/native/Recordings/RecordingsLib/AccessibilityPromptGate.swift +104 -0
- package/src/native/Recordings/RecordingsLib/BrandAssets.swift +0 -32
- package/src/native/Recordings/RecordingsLib/ChromeSurface.swift +17 -0
- package/src/native/Recordings/RecordingsLib/Info.plist +7 -3
- package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +37 -0
- package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +32 -2
- package/src/native/Recordings/RecordingsLib/NativeMachineIdentity.swift +24 -0
- package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +262 -23
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +56 -0
- package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +56 -0
- package/src/native/Recordings/RecordingsLib/ProjectStore.swift +321 -25
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +605 -90
- package/src/native/Recordings/RecordingsLib/Recording.swift +141 -0
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +3704 -376
- package/src/native/Recordings/RecordingsLib/RecordingStartControlPresentation.swift +29 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.entitlements +10 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +222 -0
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -11
- package/src/native/Recordings/RecordingsLib/SpeechIntent.swift +331 -0
- package/src/native/Recordings/RecordingsLib/SpeechIntentClassifier.swift +232 -0
- package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +17 -7
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +1343 -0
- package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +98 -0
- package/src/native/Recordings/RecordingsTests/NativeAppDiagnosticsTests.swift +20 -0
- package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +370 -1
- package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +987 -1
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +385 -0
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +835 -8
- package/src/native/Recordings/RecordingsTests/RecordingBridgeTests.swift +180 -0
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +760 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartControlPresentationTests.swift +42 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +264 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +129 -0
- package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +600 -0
- package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +62 -1
- package/src/native/Recordings/RecordingsTests/TranscriptionResultIdentityTests.swift +21 -0
- package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +739 -0
- package/src/native/Recordings/Updater/Broker/ActivationRecoveryPolicy.swift +254 -0
- package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +463 -0
- package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +55 -0
- package/src/native/Recordings/Updater/Broker/ArtifactIngest.swift +341 -0
- package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +477 -0
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +624 -0
- package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +63 -0
- package/src/native/Recordings/Updater/Broker/CodeValidation.swift +561 -0
- package/src/native/Recordings/Updater/Broker/DarwinACLValidator.swift +66 -0
- package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +50 -0
- package/src/native/Recordings/Updater/Broker/InstallJournal.swift +362 -0
- package/src/native/Recordings/Updater/Broker/InstallRecovery.swift +662 -0
- package/src/native/Recordings/Updater/Broker/MonotonicState.swift +456 -0
- package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +274 -0
- package/src/native/Recordings/Updater/Broker/VerifierRunner.swift +81 -0
- package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +259 -0
- package/src/native/Recordings/Updater/BrokerTests/CanonicalReleaseOrderTests.swift +21 -0
- package/src/native/Recordings/Updater/Client/ClientMain.swift +119 -0
- package/src/native/Recordings/Updater/Protocol/BoundedProcess.swift +159 -0
- package/src/native/Recordings/Updater/Protocol/CandidateMetadataPolicy.swift +214 -0
- package/src/native/Recordings/Updater/Protocol/HostOSVersionPolicy.swift +55 -0
- package/src/native/Recordings/Updater/Protocol/MonotonicReleasePolicy.swift +152 -0
- package/src/native/Recordings/Updater/Protocol/ReleaseEnvelope.swift +229 -0
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +119 -0
- package/src/native/Recordings/Updater/ProtocolTests/BoundedProcessRunnerTests.swift +60 -0
- package/src/native/Recordings/Updater/ProtocolTests/CandidateMetadataPolicyTests.swift +168 -0
- package/src/native/Recordings/Updater/ProtocolTests/HostOSVersionPolicyTests.swift +62 -0
- package/src/native/Recordings/Updater/ProtocolTests/MonotonicReleasePolicyTests.swift +172 -0
- package/src/native/Recordings/Updater/ProtocolTests/ReleaseEnvelopeValidationTests.swift +65 -0
- package/src/native/Recordings/Updater/Signer/SignerMain.swift +210 -0
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +725 -0
- package/src/native/Recordings/Updater/VerifierLauncher/include/RecordingsVerifierLauncher.h +33 -0
- package/src/native/Recordings/build.sh +1990 -29
- package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +0 -380
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
|
|
3
|
+
public enum NativeErrorSanitizer {
|
|
4
|
+
private static let replacements: [(pattern: String, template: String)] = [
|
|
5
|
+
(#"(?i)\b(?:sk|sess)-[A-Za-z0-9_-]{8,}\b"#, "[REDACTED]"),
|
|
6
|
+
(#"(?i)(\bBearer\s+)[^\s,;]+"#, "$1[REDACTED]"),
|
|
7
|
+
(#"(?i)(\bAuthorization\s*:\s*(?:Basic|Digest)\s+)[^;\n]+"#, "$1[REDACTED]"),
|
|
8
|
+
(#"(?i)(\b[A-Z0-9_]*(?:(?:API|SECRET|PRIVATE)[_-]?KEY|SECRET[_-]?ACCESS[_-]?KEY|ACCESS[_-]?TOKEN|AUTH[_-]?TOKEN|CLIENT[_-]?(?:SECRET|PASSWORD)|PASSWORD|PASSCODE|SECRET|PRIVATE)\s*=\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^,;\n}]+)"#, "$1[REDACTED]"),
|
|
9
|
+
(#"(?i)((?:[{,]\s*)"?(?:(?:api|secret|private)[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?(?:secret|password)|password|passcode|token|secret|private)"?\s*:\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;}]+)"#, "$1[REDACTED]"),
|
|
10
|
+
(#"(?i)((?:[?&]|\b)(?:(?:api|secret|private)[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?(?:secret|password)|password|passcode|token|secret|private)=)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^&\s,;]+)"#, "$1[REDACTED]"),
|
|
11
|
+
(#"(?i)((?:api\s+key(?:\s+provided)?|client\s+(?:secret|password)|password|passcode|private\s+key|secret(?:\s+key)?)\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^,;\n}]+)"#, "$1[REDACTED]"),
|
|
12
|
+
// Bare "private:" labels a value only at the start of a clause; mid-sentence uses
|
|
13
|
+
// like "resource is private: access denied" are ordinary error prose and stay.
|
|
14
|
+
(#"(?i)((?:^|[;,{\n])\s*)(private\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^,;\n}]+)"#, "$1$2[REDACTED]"),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
public static func sanitize(_ message: String) -> String {
|
|
18
|
+
replacements.reduce(message) { result, replacement in
|
|
19
|
+
guard let expression = try? NSRegularExpression(pattern: replacement.pattern) else {
|
|
20
|
+
return result
|
|
21
|
+
}
|
|
22
|
+
let range = NSRange(result.startIndex..<result.endIndex, in: result)
|
|
23
|
+
return expression.stringByReplacingMatches(
|
|
24
|
+
in: result,
|
|
25
|
+
range: range,
|
|
26
|
+
withTemplate: replacement.template
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
3
32
|
public enum NativeAppLog {
|
|
4
33
|
private static let lock = NSLock()
|
|
5
34
|
|
|
@@ -9,7 +38,7 @@ public enum NativeAppLog {
|
|
|
9
38
|
|
|
10
39
|
let dir = "\(homePath)/.hasna/recordings"
|
|
11
40
|
let path = "\(dir)/Recordings.log"
|
|
12
|
-
let line = "[\(Self.timestamp())] \(message)\n"
|
|
41
|
+
let line = "[\(Self.timestamp())] \(NativeErrorSanitizer.sanitize(message))\n"
|
|
13
42
|
|
|
14
43
|
do {
|
|
15
44
|
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
|
@@ -24,7 +53,8 @@ public enum NativeAppLog {
|
|
|
24
53
|
try line.write(toFile: path, atomically: true, encoding: .utf8)
|
|
25
54
|
}
|
|
26
55
|
} catch {
|
|
27
|
-
|
|
56
|
+
let safeError = NativeErrorSanitizer.sanitize(error.localizedDescription)
|
|
57
|
+
fputs("[Recordings] log write failed: \(safeError)\n", stderr)
|
|
28
58
|
}
|
|
29
59
|
}
|
|
30
60
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import Darwin
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
public enum NativeMachineIdentity {
|
|
5
|
+
public static func current(
|
|
6
|
+
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
7
|
+
hostName: String? = nil
|
|
8
|
+
) -> String {
|
|
9
|
+
let configured = environment["HASNA_MACHINE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
10
|
+
if !configured.isEmpty { return configured }
|
|
11
|
+
return (hostName ?? posixHostName()).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
static func posixHostName() -> String {
|
|
15
|
+
var buffer = [CChar](repeating: 0, count: 256)
|
|
16
|
+
let result = buffer.withUnsafeMutableBufferPointer { pointer -> String? in
|
|
17
|
+
guard let baseAddress = pointer.baseAddress,
|
|
18
|
+
gethostname(baseAddress, pointer.count) == 0 else { return nil }
|
|
19
|
+
pointer[pointer.count - 1] = 0
|
|
20
|
+
return String(cString: baseAddress)
|
|
21
|
+
}
|
|
22
|
+
return result ?? ProcessInfo.processInfo.hostName
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import Foundation
|
|
3
3
|
|
|
4
4
|
enum NativePCMRecorderError: LocalizedError {
|
|
5
|
+
case alreadyActive
|
|
5
6
|
case noInputDevice
|
|
6
7
|
case unsupportedInputFormat
|
|
7
8
|
case failedToCreateConverter
|
|
@@ -9,6 +10,8 @@ enum NativePCMRecorderError: LocalizedError {
|
|
|
9
10
|
|
|
10
11
|
var errorDescription: String? {
|
|
11
12
|
switch self {
|
|
13
|
+
case .alreadyActive:
|
|
14
|
+
return "Microphone capture is already starting, running, or stopping"
|
|
12
15
|
case .noInputDevice:
|
|
13
16
|
return "No microphone input device is available"
|
|
14
17
|
case .unsupportedInputFormat:
|
|
@@ -22,86 +25,300 @@ enum NativePCMRecorderError: LocalizedError {
|
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
final class NativePCMRecorder: @unchecked Sendable {
|
|
28
|
+
private enum LifecycleState: Equatable {
|
|
29
|
+
case idle
|
|
30
|
+
case starting
|
|
31
|
+
case running
|
|
32
|
+
case stopping
|
|
33
|
+
}
|
|
34
|
+
|
|
25
35
|
private let engine = AVAudioEngine()
|
|
26
36
|
private let onPCM: @Sendable (Data) -> Void
|
|
37
|
+
private let lifecycle = NSCondition()
|
|
38
|
+
private let conversionLock = NSLock()
|
|
39
|
+
private let deliveryQueue = DispatchQueue(label: "com.hasna.recordings.native-pcm-delivery")
|
|
40
|
+
private let deliveryQueueKey = DispatchSpecificKey<UInt8>()
|
|
41
|
+
private let stopWorkQueue = DispatchQueue(label: "com.hasna.recordings.native-pcm-stop")
|
|
42
|
+
private let stopCaptureForTesting: (@Sendable () -> Void)?
|
|
43
|
+
private let startCaptureForTesting: (@Sendable () throws -> Void)?
|
|
44
|
+
private let onCallbackAdmittedForTesting: (@Sendable () -> Void)?
|
|
45
|
+
private let finalizeConverter: @Sendable (AVAudioConverter, AVAudioFormat) -> [Data]
|
|
27
46
|
private var converter: AVAudioConverter?
|
|
28
47
|
private var inputFormat: AVAudioFormat?
|
|
29
48
|
private var outputFormat: AVAudioFormat?
|
|
30
|
-
private var
|
|
49
|
+
private var state = LifecycleState.idle
|
|
50
|
+
private var acceptingCallbacks = false
|
|
51
|
+
private var inFlightCallbacks = 0
|
|
31
52
|
|
|
32
53
|
init(onPCM: @escaping @Sendable (Data) -> Void) {
|
|
33
54
|
self.onPCM = onPCM
|
|
55
|
+
self.stopCaptureForTesting = nil
|
|
56
|
+
self.startCaptureForTesting = nil
|
|
57
|
+
self.onCallbackAdmittedForTesting = nil
|
|
58
|
+
self.finalizeConverter = Self.finalizeConverterTail
|
|
59
|
+
deliveryQueue.setSpecific(key: deliveryQueueKey, value: 1)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
init(
|
|
63
|
+
testingInputFormat: AVAudioFormat,
|
|
64
|
+
outputFormat: AVAudioFormat,
|
|
65
|
+
converter: AVAudioConverter,
|
|
66
|
+
stopCapture: @escaping @Sendable () -> Void,
|
|
67
|
+
startCapture: (@Sendable () throws -> Void)? = nil,
|
|
68
|
+
onCallbackAdmitted: @escaping @Sendable () -> Void,
|
|
69
|
+
finalizeConverter: @escaping @Sendable (AVAudioConverter, AVAudioFormat) -> [Data],
|
|
70
|
+
startsRunning: Bool = true,
|
|
71
|
+
onPCM: @escaping @Sendable (Data) -> Void
|
|
72
|
+
) {
|
|
73
|
+
self.onPCM = onPCM
|
|
74
|
+
self.stopCaptureForTesting = stopCapture
|
|
75
|
+
self.startCaptureForTesting = startCapture
|
|
76
|
+
self.onCallbackAdmittedForTesting = onCallbackAdmitted
|
|
77
|
+
self.finalizeConverter = finalizeConverter
|
|
78
|
+
self.converter = converter
|
|
79
|
+
self.inputFormat = testingInputFormat
|
|
80
|
+
self.outputFormat = outputFormat
|
|
81
|
+
self.state = startsRunning ? .running : .idle
|
|
82
|
+
self.acceptingCallbacks = startsRunning
|
|
83
|
+
deliveryQueue.setSpecific(key: deliveryQueueKey, value: 1)
|
|
34
84
|
}
|
|
35
85
|
|
|
36
86
|
func start() throws {
|
|
37
|
-
|
|
87
|
+
try reserveStart()
|
|
88
|
+
|
|
89
|
+
if let startCaptureForTesting {
|
|
90
|
+
do {
|
|
91
|
+
try startCaptureForTesting()
|
|
92
|
+
finishStart()
|
|
93
|
+
} catch {
|
|
94
|
+
resetAfterStartFailure()
|
|
95
|
+
throw NativePCMRecorderError.failedToStart(error.localizedDescription)
|
|
96
|
+
}
|
|
97
|
+
return
|
|
98
|
+
}
|
|
38
99
|
|
|
39
100
|
let inputNode = engine.inputNode
|
|
40
101
|
let inputFormat = inputNode.inputFormat(forBus: 0)
|
|
41
102
|
guard inputFormat.channelCount > 0 else {
|
|
103
|
+
abandonStart()
|
|
42
104
|
throw NativePCMRecorderError.noInputDevice
|
|
43
105
|
}
|
|
44
106
|
guard inputFormat.sampleRate > 0 else {
|
|
107
|
+
abandonStart()
|
|
45
108
|
throw NativePCMRecorderError.unsupportedInputFormat
|
|
46
109
|
}
|
|
47
110
|
guard let outputFormat = Self.realtimeOutputFormat() else {
|
|
111
|
+
abandonStart()
|
|
48
112
|
throw NativePCMRecorderError.unsupportedInputFormat
|
|
49
113
|
}
|
|
50
114
|
guard let converter = AVAudioConverter(from: inputFormat, to: outputFormat) else {
|
|
115
|
+
abandonStart()
|
|
51
116
|
throw NativePCMRecorderError.failedToCreateConverter
|
|
52
117
|
}
|
|
53
118
|
|
|
119
|
+
lifecycle.lock()
|
|
54
120
|
self.converter = converter
|
|
55
121
|
self.inputFormat = inputFormat
|
|
56
122
|
self.outputFormat = outputFormat
|
|
123
|
+
lifecycle.unlock()
|
|
57
124
|
|
|
58
125
|
inputNode.installTap(onBus: 0, bufferSize: 1_024, format: inputFormat) { [weak self] buffer, _ in
|
|
59
|
-
self?.
|
|
126
|
+
self?.processInputBuffer(buffer)
|
|
60
127
|
}
|
|
61
128
|
|
|
62
129
|
do {
|
|
63
130
|
engine.prepare()
|
|
64
131
|
try engine.start()
|
|
65
|
-
|
|
132
|
+
finishStart()
|
|
66
133
|
} catch {
|
|
67
134
|
inputNode.removeTap(onBus: 0)
|
|
68
|
-
|
|
69
|
-
self.inputFormat = nil
|
|
70
|
-
self.outputFormat = nil
|
|
135
|
+
resetAfterStartFailure()
|
|
71
136
|
throw NativePCMRecorderError.failedToStart(error.localizedDescription)
|
|
72
137
|
}
|
|
73
138
|
}
|
|
74
139
|
|
|
75
|
-
func
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return
|
|
140
|
+
private func reserveStart() throws {
|
|
141
|
+
lifecycle.lock()
|
|
142
|
+
guard state == .idle else {
|
|
143
|
+
lifecycle.unlock()
|
|
144
|
+
throw NativePCMRecorderError.alreadyActive
|
|
81
145
|
}
|
|
146
|
+
state = .starting
|
|
147
|
+
lifecycle.unlock()
|
|
148
|
+
}
|
|
82
149
|
|
|
83
|
-
|
|
84
|
-
|
|
150
|
+
private func finishStart() {
|
|
151
|
+
lifecycle.lock()
|
|
152
|
+
acceptingCallbacks = true
|
|
153
|
+
state = .running
|
|
154
|
+
lifecycle.broadcast()
|
|
155
|
+
lifecycle.unlock()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private func resetAfterStartFailure() {
|
|
159
|
+
lifecycle.lock()
|
|
85
160
|
converter = nil
|
|
86
161
|
inputFormat = nil
|
|
87
162
|
outputFormat = nil
|
|
88
|
-
|
|
163
|
+
state = .idle
|
|
164
|
+
acceptingCallbacks = false
|
|
165
|
+
lifecycle.broadcast()
|
|
166
|
+
lifecycle.unlock()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
func stop() {
|
|
170
|
+
let isReentrantDelivery = DispatchQueue.getSpecific(key: deliveryQueueKey) != nil
|
|
171
|
+
lifecycle.lock()
|
|
172
|
+
while state == .starting || (state == .stopping && !isReentrantDelivery) {
|
|
173
|
+
lifecycle.wait()
|
|
174
|
+
}
|
|
175
|
+
guard state == .running else {
|
|
176
|
+
lifecycle.unlock()
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
acceptingCallbacks = false
|
|
180
|
+
state = .stopping
|
|
181
|
+
lifecycle.unlock()
|
|
182
|
+
|
|
183
|
+
if isReentrantDelivery {
|
|
184
|
+
stopWorkQueue.async { [self] in
|
|
185
|
+
stopCaptureAndFinish()
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
stopCaptureAndFinish()
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private func stopCaptureAndFinish() {
|
|
193
|
+
if let stopCaptureForTesting {
|
|
194
|
+
stopCaptureForTesting()
|
|
195
|
+
} else {
|
|
196
|
+
engine.inputNode.removeTap(onBus: 0)
|
|
197
|
+
engine.stop()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
lifecycle.lock()
|
|
201
|
+
while inFlightCallbacks > 0 {
|
|
202
|
+
lifecycle.wait()
|
|
203
|
+
}
|
|
204
|
+
let converter = converter
|
|
205
|
+
let outputFormat = outputFormat
|
|
206
|
+
lifecycle.unlock()
|
|
207
|
+
|
|
208
|
+
if let converter, let outputFormat {
|
|
209
|
+
conversionLock.lock()
|
|
210
|
+
let tailChunks = finalizeConverter(converter, outputFormat)
|
|
211
|
+
conversionLock.unlock()
|
|
212
|
+
for data in tailChunks where !data.isEmpty {
|
|
213
|
+
deliverPCM(data)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
lifecycle.lock()
|
|
218
|
+
self.converter = nil
|
|
219
|
+
inputFormat = nil
|
|
220
|
+
self.outputFormat = nil
|
|
221
|
+
state = .idle
|
|
222
|
+
lifecycle.broadcast()
|
|
223
|
+
lifecycle.unlock()
|
|
89
224
|
}
|
|
90
225
|
|
|
91
226
|
deinit {
|
|
92
227
|
stop()
|
|
93
228
|
}
|
|
94
229
|
|
|
95
|
-
private func
|
|
96
|
-
|
|
230
|
+
private func abandonStart() {
|
|
231
|
+
lifecycle.lock()
|
|
232
|
+
state = .idle
|
|
233
|
+
acceptingCallbacks = false
|
|
234
|
+
lifecycle.broadcast()
|
|
235
|
+
lifecycle.unlock()
|
|
236
|
+
}
|
|
97
237
|
|
|
238
|
+
func processInputBufferForTesting(_ inputBuffer: AVAudioPCMBuffer) {
|
|
239
|
+
processInputBuffer(inputBuffer)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
func deliverPCMForTesting(_ data: Data) {
|
|
243
|
+
lifecycle.lock()
|
|
244
|
+
guard acceptingCallbacks else {
|
|
245
|
+
lifecycle.unlock()
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
inFlightCallbacks += 1
|
|
249
|
+
lifecycle.unlock()
|
|
250
|
+
|
|
251
|
+
defer { finishCallback() }
|
|
252
|
+
deliverPCM(data)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
var isIdleForTesting: Bool {
|
|
256
|
+
lifecycle.lock()
|
|
257
|
+
defer { lifecycle.unlock() }
|
|
258
|
+
return state == .idle
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private func processInputBuffer(_ inputBuffer: AVAudioPCMBuffer) {
|
|
262
|
+
lifecycle.lock()
|
|
263
|
+
guard acceptingCallbacks,
|
|
264
|
+
let converter,
|
|
265
|
+
let inputFormat,
|
|
266
|
+
let outputFormat else {
|
|
267
|
+
lifecycle.unlock()
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
inFlightCallbacks += 1
|
|
271
|
+
lifecycle.unlock()
|
|
272
|
+
|
|
273
|
+
onCallbackAdmittedForTesting?()
|
|
274
|
+
|
|
275
|
+
defer { finishCallback() }
|
|
276
|
+
|
|
277
|
+
conversionLock.lock()
|
|
278
|
+
let data = convert(
|
|
279
|
+
inputBuffer,
|
|
280
|
+
converter: converter,
|
|
281
|
+
inputFormat: inputFormat,
|
|
282
|
+
outputFormat: outputFormat
|
|
283
|
+
)
|
|
284
|
+
conversionLock.unlock()
|
|
285
|
+
if !data.isEmpty {
|
|
286
|
+
deliverPCM(data)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private func deliverPCM(_ data: Data) {
|
|
291
|
+
if DispatchQueue.getSpecific(key: deliveryQueueKey) != nil {
|
|
292
|
+
onPCM(data)
|
|
293
|
+
} else {
|
|
294
|
+
deliveryQueue.sync {
|
|
295
|
+
onPCM(data)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private func finishCallback() {
|
|
301
|
+
lifecycle.lock()
|
|
302
|
+
inFlightCallbacks -= 1
|
|
303
|
+
if inFlightCallbacks == 0 {
|
|
304
|
+
lifecycle.broadcast()
|
|
305
|
+
}
|
|
306
|
+
lifecycle.unlock()
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private func convert(
|
|
310
|
+
_ inputBuffer: AVAudioPCMBuffer,
|
|
311
|
+
converter: AVAudioConverter,
|
|
312
|
+
inputFormat: AVAudioFormat,
|
|
313
|
+
outputFormat: AVAudioFormat
|
|
314
|
+
) -> Data {
|
|
98
315
|
let sampleRateRatio = outputFormat.sampleRate / inputFormat.sampleRate
|
|
99
316
|
let estimatedFrames = AVAudioFrameCount(Double(inputBuffer.frameLength) * sampleRateRatio) + 512
|
|
100
317
|
guard let outputBuffer = AVAudioPCMBuffer(
|
|
101
318
|
pcmFormat: outputFormat,
|
|
102
319
|
frameCapacity: max(estimatedFrames, 1)
|
|
103
320
|
) else {
|
|
104
|
-
return
|
|
321
|
+
return Data()
|
|
105
322
|
}
|
|
106
323
|
|
|
107
324
|
let inputSource = AudioConverterInputSource(buffer: inputBuffer)
|
|
@@ -110,11 +327,33 @@ final class NativePCMRecorder: @unchecked Sendable {
|
|
|
110
327
|
inputSource.next(status: status)
|
|
111
328
|
}
|
|
112
329
|
|
|
113
|
-
guard conversionError == nil else { return }
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
330
|
+
guard conversionError == nil else { return Data() }
|
|
331
|
+
return Self.extractPCM16Data(from: outputBuffer)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
static func finalizeConverterTail(_ converter: AVAudioConverter, outputFormat: AVAudioFormat) -> [Data] {
|
|
335
|
+
var chunks: [Data] = []
|
|
336
|
+
while let outputBuffer = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: 4_096) {
|
|
337
|
+
var conversionError: NSError?
|
|
338
|
+
let status = converter.convert(to: outputBuffer, error: &conversionError) { _, inputStatus in
|
|
339
|
+
inputStatus.pointee = .endOfStream
|
|
340
|
+
return nil
|
|
341
|
+
}
|
|
342
|
+
guard conversionError == nil else { break }
|
|
343
|
+
let data = extractPCM16Data(from: outputBuffer)
|
|
344
|
+
if !data.isEmpty {
|
|
345
|
+
chunks.append(data)
|
|
346
|
+
}
|
|
347
|
+
switch status {
|
|
348
|
+
case .haveData:
|
|
349
|
+
guard !data.isEmpty else { return chunks }
|
|
350
|
+
case .inputRanDry, .endOfStream, .error:
|
|
351
|
+
return chunks
|
|
352
|
+
@unknown default:
|
|
353
|
+
return chunks
|
|
354
|
+
}
|
|
117
355
|
}
|
|
356
|
+
return chunks
|
|
118
357
|
}
|
|
119
358
|
|
|
120
359
|
static func realtimeOutputFormat() -> AVAudioFormat? {
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
|
|
3
|
+
struct ProcessingModelSelection: Equatable, Sendable {
|
|
4
|
+
let transcriptionPrompt: String
|
|
5
|
+
let transcriptionModel: String
|
|
6
|
+
let transcriberModel: String
|
|
7
|
+
let enhancementModel: String
|
|
8
|
+
let intentModel: String
|
|
9
|
+
let enhanceTriggersJSON: String
|
|
10
|
+
let keywordTransformsJSON: String
|
|
11
|
+
}
|
|
12
|
+
|
|
3
13
|
enum OpenAIAPIKeyStore {
|
|
4
14
|
static let defaultLanguage = "en"
|
|
5
15
|
|
|
@@ -40,6 +50,52 @@ enum OpenAIAPIKeyStore {
|
|
|
40
50
|
return defaultLanguage
|
|
41
51
|
}
|
|
42
52
|
|
|
53
|
+
static func loadProcessingModelSelection(
|
|
54
|
+
homePath: String,
|
|
55
|
+
environment: [String: String] = ProcessInfo.processInfo.environment
|
|
56
|
+
) -> ProcessingModelSelection {
|
|
57
|
+
let json = loadMutableConfig(homePath: homePath)
|
|
58
|
+
let enhancementModel = firstNonEmpty(
|
|
59
|
+
environment["RECORDINGS_ENHANCEMENT_MODEL"],
|
|
60
|
+
json["enhancement_model"] as? String
|
|
61
|
+
) ?? "gpt-4o"
|
|
62
|
+
let transcriberModel = firstNonEmpty(
|
|
63
|
+
environment["RECORDINGS_TRANSCRIBER_MODEL"],
|
|
64
|
+
json["transcriber_model"] as? String,
|
|
65
|
+
enhancementModel
|
|
66
|
+
) ?? enhancementModel
|
|
67
|
+
let transcriptionModel = firstNonEmpty(
|
|
68
|
+
environment["RECORDINGS_MODEL"],
|
|
69
|
+
json["transcription_model"] as? String
|
|
70
|
+
) ?? "gpt-4o-transcribe"
|
|
71
|
+
let intentModel = firstNonEmpty(
|
|
72
|
+
environment["RECORDINGS_INTENT_MODEL"],
|
|
73
|
+
json["intent_model"] as? String
|
|
74
|
+
) ?? "gpt-4o-mini"
|
|
75
|
+
let triggers = (json["enhance_triggers"] as? [String]) ?? [
|
|
76
|
+
"say it better", "rewrite this", "make it sound", "clean this up",
|
|
77
|
+
"fix this", "rephrase", "write it properly", "make it professional",
|
|
78
|
+
"improve this", "polish this",
|
|
79
|
+
]
|
|
80
|
+
let triggerData = try? JSONSerialization.data(withJSONObject: triggers)
|
|
81
|
+
let triggerJSON = triggerData.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
|
|
82
|
+
let keywordTransforms = (json["keyword_transforms"] as? [String: String]) ?? [:]
|
|
83
|
+
let transformData = try? JSONSerialization.data(withJSONObject: keywordTransforms)
|
|
84
|
+
let transformJSON = transformData.flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
|
|
85
|
+
return ProcessingModelSelection(
|
|
86
|
+
transcriptionPrompt: firstNonEmpty(
|
|
87
|
+
environment["RECORDINGS_TRANSCRIPTION_PROMPT"],
|
|
88
|
+
json["transcription_prompt"] as? String
|
|
89
|
+
) ?? "",
|
|
90
|
+
transcriptionModel: transcriptionModel,
|
|
91
|
+
transcriberModel: transcriberModel,
|
|
92
|
+
enhancementModel: enhancementModel,
|
|
93
|
+
intentModel: intentModel,
|
|
94
|
+
enhanceTriggersJSON: triggerJSON,
|
|
95
|
+
keywordTransformsJSON: transformJSON
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
43
99
|
/// Persist the key into ~/.hasna/recordings/config.json so the CLI (which the app
|
|
44
100
|
/// shells out to for final transcription) uses the same key as the app itself.
|
|
45
101
|
static func save(key: String, homePath: String) throws {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
public struct PermissionRequestLaunchPlan: Sendable, Equatable {
|
|
4
|
+
public let isHelper: Bool
|
|
5
|
+
public let opensPermissionSettings: Bool
|
|
6
|
+
public let runtimeSmokeMode: String?
|
|
7
|
+
public let runtimeSmokeOutputPath: String?
|
|
8
|
+
public let runtimeSmokeAcknowledgementPath: String?
|
|
9
|
+
public let runtimeSmokeCompletionPath: String?
|
|
10
|
+
|
|
11
|
+
public var isRuntimeSmoke: Bool { runtimeSmokeMode != nil }
|
|
12
|
+
public var installsGlobalHandlers: Bool { !isHelper && !isRuntimeSmoke }
|
|
13
|
+
public var declaresMainWindow: Bool { !isHelper && !isRuntimeSmoke }
|
|
14
|
+
public var declaresMenuBar: Bool {
|
|
15
|
+
if isRuntimeSmoke { return runtimeSmokeMode == "normal" }
|
|
16
|
+
return !isHelper
|
|
17
|
+
}
|
|
18
|
+
public var terminatesAfterHandling: Bool { isHelper }
|
|
19
|
+
public var requestsAccessibilityPrompt: Bool { isHelper && !isRuntimeSmoke }
|
|
20
|
+
|
|
21
|
+
public init(arguments: [String]) {
|
|
22
|
+
isHelper = arguments.contains("--request-permissions")
|
|
23
|
+
opensPermissionSettings = isHelper && arguments.contains("--open-permission-settings")
|
|
24
|
+
runtimeSmokeMode = Self.optionValue("--runtime-smoke", arguments: arguments)
|
|
25
|
+
runtimeSmokeOutputPath = Self.optionValue("--runtime-smoke-output", arguments: arguments)
|
|
26
|
+
runtimeSmokeAcknowledgementPath = Self.optionValue(
|
|
27
|
+
"--runtime-smoke-ack",
|
|
28
|
+
arguments: arguments
|
|
29
|
+
)
|
|
30
|
+
runtimeSmokeCompletionPath = Self.optionValue(
|
|
31
|
+
"--runtime-smoke-completion",
|
|
32
|
+
arguments: arguments
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private static func optionValue(_ name: String, arguments: [String]) -> String? {
|
|
37
|
+
guard let index = arguments.firstIndex(of: name), arguments.indices.contains(index + 1) else {
|
|
38
|
+
return nil
|
|
39
|
+
}
|
|
40
|
+
return arguments[index + 1]
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public struct PermissionRequestOutcome: Sendable, Equatable {
|
|
45
|
+
public let microphoneGranted: Bool
|
|
46
|
+
public let accessibilityTrusted: Bool
|
|
47
|
+
|
|
48
|
+
public init(microphoneGranted: Bool, accessibilityTrusted: Bool) {
|
|
49
|
+
self.microphoneGranted = microphoneGranted
|
|
50
|
+
self.accessibilityTrusted = accessibilityTrusted
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public var succeeded: Bool {
|
|
54
|
+
microphoneGranted && accessibilityTrusted
|
|
55
|
+
}
|
|
56
|
+
}
|