@hasna/recordings 0.1.11 → 0.1.12
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 +2 -0
- package/dist/cli/index.js +274 -36
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/recordings.d.ts.map +1 -1
- package/dist/index.js +52 -10
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/enhancer.d.ts.map +1 -1
- package/dist/lib/recorder.d.ts.map +1 -1
- package/dist/lib/transcriber.d.ts +8 -2
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.js +121 -29
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/package.json +20 -3
- package/scripts/install_macos_app.sh +76 -0
- package/src/native/Recordings/{Recordings → App}/RecordingsApp.swift +5 -1
- package/src/native/Recordings/Package.resolved +19 -1
- package/src/native/Recordings/Package.swift +16 -5
- package/src/native/Recordings/{Recordings → RecordingsLib}/Info.plist +6 -0
- package/src/native/Recordings/{Recordings → RecordingsLib}/MenuBarPopover.swift +55 -11
- package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +164 -0
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +113 -0
- package/src/native/Recordings/{Recordings → RecordingsLib}/ProjectStore.swift +11 -11
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +383 -0
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +724 -0
- package/src/native/Recordings/{Recordings → RecordingsLib}/SettingsView.swift +19 -5
- package/src/native/Recordings/{Recordings → RecordingsLib}/VoiceShortcuts.swift +12 -8
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +63 -0
- package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +33 -0
- package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +92 -0
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +58 -0
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +113 -0
- package/src/native/Recordings/build.sh +6 -6
- package/.takumi/settings.local.json +0 -7
- package/bun.lock +0 -250
- package/bunfig.toml +0 -2
- package/src/__tests__/agents.test.ts +0 -136
- package/src/__tests__/config.test.ts +0 -252
- package/src/__tests__/database.test.ts +0 -167
- package/src/__tests__/enhancer.test.ts +0 -639
- package/src/__tests__/preload.ts +0 -4
- package/src/__tests__/projects.test.ts +0 -109
- package/src/__tests__/recorder.test.ts +0 -278
- package/src/__tests__/recordings.test.ts +0 -353
- package/src/__tests__/transcriber.test.ts +0 -322
- package/src/__tests__/types.test.ts +0 -75
- package/src/cli/index.ts +0 -988
- package/src/db/agents.ts +0 -104
- package/src/db/database.ts +0 -163
- package/src/db/pg-migrations.ts +0 -82
- package/src/db/projects.ts +0 -71
- package/src/db/recordings.ts +0 -225
- package/src/index.ts +0 -81
- package/src/lib/config.ts +0 -223
- package/src/lib/enhancer.ts +0 -173
- package/src/lib/recorder.ts +0 -198
- package/src/lib/transcriber.ts +0 -105
- package/src/mcp/index.ts +0 -464
- package/src/native/Recordings/Recordings/RecordingEngine.swift +0 -455
- package/src/native/Recordings/test_fn.swift +0 -79
- package/src/native/Recordings/test_fn2.swift +0 -33
- package/src/types/index.ts +0 -144
- package/tsconfig.json +0 -21
- /package/src/native/Recordings/{Recordings → RecordingsLib}/FnKeyMonitor.swift +0 -0
- /package/src/native/Recordings/{Recordings → RecordingsLib}/Recordings.entitlements +0 -0
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/recordings",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Speech-to-text recording tool with MCP and CLI — records, transcribes, and optionally enhances text using AI",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/hasna/recordings.git"
|
|
9
|
+
},
|
|
6
10
|
"main": "dist/index.js",
|
|
7
11
|
"types": "dist/index.d.ts",
|
|
8
12
|
"bin": {
|
|
@@ -25,8 +29,21 @@
|
|
|
25
29
|
"test:coverage": "bun test --coverage",
|
|
26
30
|
"dev:cli": "bun run src/cli/index.ts",
|
|
27
31
|
"dev:mcp": "bun run src/mcp/index.ts",
|
|
28
|
-
"
|
|
32
|
+
"prepack": "bun run build",
|
|
33
|
+
"postinstall": "bash scripts/install_macos_app.sh --postinstall"
|
|
29
34
|
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist/",
|
|
37
|
+
"scripts/",
|
|
38
|
+
"src/native/Recordings/App/",
|
|
39
|
+
"src/native/Recordings/RecordingsLib/",
|
|
40
|
+
"src/native/Recordings/RecordingsTests/",
|
|
41
|
+
"src/native/Recordings/Package.swift",
|
|
42
|
+
"src/native/Recordings/Package.resolved",
|
|
43
|
+
"src/native/Recordings/build.sh",
|
|
44
|
+
"README.md",
|
|
45
|
+
"LICENSE"
|
|
46
|
+
],
|
|
30
47
|
"dependencies": {
|
|
31
48
|
"@hasna/cloud": "^0.1.24",
|
|
32
49
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
@@ -45,4 +62,4 @@
|
|
|
45
62
|
},
|
|
46
63
|
"license": "Apache-2.0",
|
|
47
64
|
"author": "Hasna <andrei@hasna.com>"
|
|
48
|
-
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -u
|
|
3
|
+
|
|
4
|
+
MODE="release"
|
|
5
|
+
POSTINSTALL=0
|
|
6
|
+
|
|
7
|
+
while [ "$#" -gt 0 ]; do
|
|
8
|
+
case "$1" in
|
|
9
|
+
--mode)
|
|
10
|
+
MODE="${2:-release}"
|
|
11
|
+
shift 2
|
|
12
|
+
;;
|
|
13
|
+
--postinstall)
|
|
14
|
+
POSTINSTALL=1
|
|
15
|
+
shift
|
|
16
|
+
;;
|
|
17
|
+
*)
|
|
18
|
+
echo "Unknown argument: $1" >&2
|
|
19
|
+
exit 2
|
|
20
|
+
;;
|
|
21
|
+
esac
|
|
22
|
+
done
|
|
23
|
+
|
|
24
|
+
case "$MODE" in
|
|
25
|
+
debug|release) ;;
|
|
26
|
+
*)
|
|
27
|
+
echo "Mode must be debug or release" >&2
|
|
28
|
+
exit 2
|
|
29
|
+
;;
|
|
30
|
+
esac
|
|
31
|
+
|
|
32
|
+
PACKAGE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
33
|
+
DATA_DIR="${HOME}/.hasna/recordings"
|
|
34
|
+
NATIVE_DIR="${PACKAGE_ROOT}/src/native/Recordings"
|
|
35
|
+
APP_SOURCE="${NATIVE_DIR}/.build/${MODE}/Recordings.app"
|
|
36
|
+
APP_DEST="${DATA_DIR}/Recordings.app"
|
|
37
|
+
|
|
38
|
+
mkdir -p "${DATA_DIR}/audio"
|
|
39
|
+
|
|
40
|
+
if [ "$(uname -s)" != "Darwin" ]; then
|
|
41
|
+
exit 0
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
warn_or_fail() {
|
|
45
|
+
local message="$1"
|
|
46
|
+
if [ "$POSTINSTALL" -eq 1 ]; then
|
|
47
|
+
echo "Recordings.app was not installed: ${message}" >&2
|
|
48
|
+
echo "Run 'recordings app install' after fixing this machine." >&2
|
|
49
|
+
exit 0
|
|
50
|
+
fi
|
|
51
|
+
echo "${message}" >&2
|
|
52
|
+
exit 1
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if ! command -v swift >/dev/null 2>&1; then
|
|
56
|
+
warn_or_fail "Swift toolchain not found"
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
if [ ! -d "$NATIVE_DIR" ]; then
|
|
60
|
+
warn_or_fail "native app sources are missing from package: ${NATIVE_DIR}"
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
(
|
|
64
|
+
cd "$NATIVE_DIR" || exit 1
|
|
65
|
+
./build.sh "$MODE"
|
|
66
|
+
) || warn_or_fail "native app build failed"
|
|
67
|
+
|
|
68
|
+
if [ ! -d "$APP_SOURCE" ]; then
|
|
69
|
+
warn_or_fail "native build did not produce ${APP_SOURCE}"
|
|
70
|
+
fi
|
|
71
|
+
|
|
72
|
+
rm -rf "$APP_DEST"
|
|
73
|
+
mkdir -p "$DATA_DIR"
|
|
74
|
+
cp -R "$APP_SOURCE" "$APP_DEST" || warn_or_fail "failed to copy app bundle"
|
|
75
|
+
|
|
76
|
+
echo "Installed Recordings.app from package: ${APP_DEST}"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
@preconcurrency import Cocoa
|
|
2
2
|
import SwiftUI
|
|
3
3
|
import KeyboardShortcuts
|
|
4
|
+
import RecordingsLib
|
|
4
5
|
|
|
5
6
|
@main
|
|
6
7
|
struct RecordingsApp: App {
|
|
@@ -18,7 +19,10 @@ struct RecordingsApp: App {
|
|
|
18
19
|
MenuBarExtra {
|
|
19
20
|
MenuBarPopover(engine: engine, shortcuts: shortcuts, projectStore: projectStore)
|
|
20
21
|
.frame(width: 320, height: 420)
|
|
21
|
-
.onAppear {
|
|
22
|
+
.onAppear {
|
|
23
|
+
engine.projectStore = projectStore
|
|
24
|
+
engine.voiceShortcuts = shortcuts
|
|
25
|
+
}
|
|
22
26
|
} label: {
|
|
23
27
|
if engine.isRecording {
|
|
24
28
|
Image(systemName: "record.circle.fill")
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"originHash" : "
|
|
2
|
+
"originHash" : "99390ba8c7a8c4d4b60ff9bc6f60500c0cc28740d0a509ae347d8785b97022b5",
|
|
3
3
|
"pins" : [
|
|
4
4
|
{
|
|
5
5
|
"identity" : "keyboardshortcuts",
|
|
@@ -9,6 +9,24 @@
|
|
|
9
9
|
"revision" : "1aef85578fdd4f9eaeeb8d53b7b4fc31bf08fe27",
|
|
10
10
|
"version" : "2.4.0"
|
|
11
11
|
}
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"identity" : "swift-syntax",
|
|
15
|
+
"kind" : "remoteSourceControl",
|
|
16
|
+
"location" : "https://github.com/swiftlang/swift-syntax.git",
|
|
17
|
+
"state" : {
|
|
18
|
+
"revision" : "0687f71944021d616d34d922343dcef086855920",
|
|
19
|
+
"version" : "600.0.1"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"identity" : "swift-testing",
|
|
24
|
+
"kind" : "remoteSourceControl",
|
|
25
|
+
"location" : "https://github.com/apple/swift-testing.git",
|
|
26
|
+
"state" : {
|
|
27
|
+
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
|
|
28
|
+
"version" : "0.99.0"
|
|
29
|
+
}
|
|
12
30
|
}
|
|
13
31
|
],
|
|
14
32
|
"version" : 3
|
|
@@ -8,14 +8,25 @@ let package = Package(
|
|
|
8
8
|
.macOS(.v26)
|
|
9
9
|
],
|
|
10
10
|
dependencies: [
|
|
11
|
-
.package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.0.0")
|
|
11
|
+
.package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.0.0"),
|
|
12
|
+
.package(url: "https://github.com/apple/swift-testing.git", .upToNextMinor(from: "0.99.0")),
|
|
12
13
|
],
|
|
13
14
|
targets: [
|
|
14
|
-
.
|
|
15
|
-
name: "
|
|
15
|
+
.target(
|
|
16
|
+
name: "RecordingsLib",
|
|
16
17
|
dependencies: ["KeyboardShortcuts"],
|
|
17
|
-
path: "
|
|
18
|
+
path: "RecordingsLib",
|
|
18
19
|
exclude: ["Info.plist", "Recordings.entitlements"]
|
|
19
|
-
)
|
|
20
|
+
),
|
|
21
|
+
.executableTarget(
|
|
22
|
+
name: "App",
|
|
23
|
+
dependencies: ["RecordingsLib"],
|
|
24
|
+
path: "App"
|
|
25
|
+
),
|
|
26
|
+
.testTarget(
|
|
27
|
+
name: "RecordingsTests",
|
|
28
|
+
dependencies: ["RecordingsLib", .product(name: "Testing", package: "swift-testing")],
|
|
29
|
+
path: "RecordingsTests"
|
|
30
|
+
),
|
|
20
31
|
]
|
|
21
32
|
)
|
|
@@ -8,15 +8,21 @@
|
|
|
8
8
|
<string>Recordings</string>
|
|
9
9
|
<key>CFBundleDisplayName</key>
|
|
10
10
|
<string>Recordings</string>
|
|
11
|
+
<key>CFBundleExecutable</key>
|
|
12
|
+
<string>Recordings</string>
|
|
11
13
|
<key>CFBundleIdentifier</key>
|
|
12
14
|
<string>com.hasna.recordings</string>
|
|
13
15
|
<key>CFBundleVersion</key>
|
|
14
16
|
<string>1</string>
|
|
17
|
+
<key>CFBundlePackageType</key>
|
|
18
|
+
<string>APPL</string>
|
|
15
19
|
<key>CFBundleShortVersionString</key>
|
|
16
20
|
<string>0.1.0</string>
|
|
17
21
|
<key>LSMinimumSystemVersion</key>
|
|
18
22
|
<string>26.0</string>
|
|
19
23
|
<key>NSMicrophoneUsageDescription</key>
|
|
20
24
|
<string>Recordings needs microphone access to record and transcribe your speech.</string>
|
|
25
|
+
<key>NSAppleEventsUsageDescription</key>
|
|
26
|
+
<string>Recordings needs Apple Events access to paste transcribed text into your active app.</string>
|
|
21
27
|
</dict>
|
|
22
28
|
</plist>
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import SwiftUI
|
|
2
2
|
import KeyboardShortcuts
|
|
3
3
|
|
|
4
|
-
struct MenuBarPopover: View {
|
|
5
|
-
@ObservedObject var engine: RecordingEngine
|
|
6
|
-
@ObservedObject var shortcuts: VoiceShortcuts
|
|
7
|
-
@ObservedObject var projectStore: ProjectStore
|
|
4
|
+
public struct MenuBarPopover: View {
|
|
5
|
+
@ObservedObject public var engine: RecordingEngine
|
|
6
|
+
@ObservedObject public var shortcuts: VoiceShortcuts
|
|
7
|
+
@ObservedObject public var projectStore: ProjectStore
|
|
8
8
|
@State private var copiedIndex: Int?
|
|
9
9
|
@State private var filterProjectId: String?
|
|
10
10
|
|
|
11
|
+
public init(engine: RecordingEngine, shortcuts: VoiceShortcuts, projectStore: ProjectStore) {
|
|
12
|
+
self.engine = engine
|
|
13
|
+
self.shortcuts = shortcuts
|
|
14
|
+
self.projectStore = projectStore
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
private var filteredTranscriptions: [TranscriptionResult] {
|
|
12
18
|
guard let filter = filterProjectId else {
|
|
13
19
|
return engine.recentTranscriptions
|
|
@@ -18,13 +24,15 @@ struct MenuBarPopover: View {
|
|
|
18
24
|
return engine.recentTranscriptions.filter { $0.projectId == filter }
|
|
19
25
|
}
|
|
20
26
|
|
|
21
|
-
var body: some View {
|
|
27
|
+
public var body: some View {
|
|
22
28
|
VStack(spacing: 0) {
|
|
23
29
|
header
|
|
24
30
|
.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8)
|
|
25
31
|
Divider()
|
|
32
|
+
modePicker
|
|
33
|
+
.padding(.horizontal, 16).padding(.top, 10)
|
|
26
34
|
recordingArea
|
|
27
|
-
.padding(.horizontal, 16).padding(.
|
|
35
|
+
.padding(.horizontal, 16).padding(.top, 8).padding(.bottom, 12)
|
|
28
36
|
Divider()
|
|
29
37
|
if !engine.recentTranscriptions.isEmpty {
|
|
30
38
|
filterBar
|
|
@@ -85,9 +93,20 @@ struct MenuBarPopover: View {
|
|
|
85
93
|
|
|
86
94
|
// MARK: - Recording Area
|
|
87
95
|
|
|
96
|
+
private var modePicker: some View {
|
|
97
|
+
Picker("Mode", selection: $engine.mode) {
|
|
98
|
+
ForEach(RecordingMode.allCases) { mode in
|
|
99
|
+
Image(systemName: mode.icon)
|
|
100
|
+
.tag(mode)
|
|
101
|
+
.help(mode.rawValue)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
.pickerStyle(.segmented)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@ViewBuilder
|
|
88
108
|
private var recordingArea: some View {
|
|
89
|
-
|
|
90
|
-
if engine.isRecording {
|
|
109
|
+
if engine.isRecording {
|
|
91
110
|
VStack(spacing: 6) {
|
|
92
111
|
HStack {
|
|
93
112
|
Circle().fill(.red).frame(width: 8, height: 8)
|
|
@@ -97,6 +116,18 @@ struct MenuBarPopover: View {
|
|
|
97
116
|
Button("Stop") { engine.stopAndTranscribe() }
|
|
98
117
|
.controlSize(.small)
|
|
99
118
|
}
|
|
119
|
+
// Live streaming transcription text
|
|
120
|
+
if !engine.liveTranscriptionText.isEmpty {
|
|
121
|
+
Text(engine.liveTranscriptionText)
|
|
122
|
+
.font(.callout)
|
|
123
|
+
.foregroundStyle(.primary)
|
|
124
|
+
.multilineTextAlignment(.leading)
|
|
125
|
+
.lineLimit(3)
|
|
126
|
+
.padding(.vertical, 4)
|
|
127
|
+
Text("Listening...")
|
|
128
|
+
.font(.caption)
|
|
129
|
+
.foregroundStyle(.green)
|
|
130
|
+
}
|
|
100
131
|
if let project = projectStore.activeProject {
|
|
101
132
|
HStack(spacing: 4) {
|
|
102
133
|
Image(systemName: "folder.fill").foregroundStyle(.tint)
|
|
@@ -107,7 +138,7 @@ struct MenuBarPopover: View {
|
|
|
107
138
|
}
|
|
108
139
|
.padding(10)
|
|
109
140
|
.glassEffect(.regular)
|
|
110
|
-
|
|
141
|
+
} else if engine.isTranscribing {
|
|
111
142
|
VStack(spacing: 6) {
|
|
112
143
|
HStack(spacing: 8) {
|
|
113
144
|
ProgressView().controlSize(.small)
|
|
@@ -115,6 +146,15 @@ struct MenuBarPopover: View {
|
|
|
115
146
|
.foregroundStyle(.secondary)
|
|
116
147
|
Spacer()
|
|
117
148
|
}
|
|
149
|
+
// Show accumulated text during transcribing
|
|
150
|
+
if !engine.liveTranscriptionText.isEmpty {
|
|
151
|
+
Text(engine.liveTranscriptionText)
|
|
152
|
+
.font(.callout)
|
|
153
|
+
.foregroundStyle(.primary)
|
|
154
|
+
.multilineTextAlignment(.leading)
|
|
155
|
+
.lineLimit(3)
|
|
156
|
+
.padding(.vertical, 4)
|
|
157
|
+
}
|
|
118
158
|
if let project = projectStore.activeProject {
|
|
119
159
|
HStack(spacing: 4) {
|
|
120
160
|
Image(systemName: "folder.fill").foregroundStyle(.tint)
|
|
@@ -126,7 +166,7 @@ struct MenuBarPopover: View {
|
|
|
126
166
|
.frame(maxWidth: .infinity)
|
|
127
167
|
.padding(10)
|
|
128
168
|
.glassEffect(.regular)
|
|
129
|
-
|
|
169
|
+
} else {
|
|
130
170
|
VStack(spacing: 6) {
|
|
131
171
|
Button { engine.startRecording() } label: {
|
|
132
172
|
Label("Record", systemImage: "mic.circle.fill")
|
|
@@ -137,11 +177,15 @@ struct MenuBarPopover: View {
|
|
|
137
177
|
Text("or hold \(shortcut.description)")
|
|
138
178
|
.foregroundStyle(.tertiary)
|
|
139
179
|
}
|
|
180
|
+
Text(engine.statusMessage)
|
|
181
|
+
.font(.caption)
|
|
182
|
+
.foregroundStyle(engine.statusMessage == "Ready" ? Color.secondary : Color.orange)
|
|
183
|
+
.multilineTextAlignment(.center)
|
|
184
|
+
.lineLimit(2)
|
|
140
185
|
}
|
|
141
186
|
.frame(maxWidth: .infinity)
|
|
142
187
|
.padding(10)
|
|
143
188
|
.glassEffect(.regular)
|
|
144
|
-
}
|
|
145
189
|
}
|
|
146
190
|
}
|
|
147
191
|
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
@preconcurrency import AVFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
enum NativePCMRecorderError: LocalizedError {
|
|
5
|
+
case noInputDevice
|
|
6
|
+
case unsupportedInputFormat
|
|
7
|
+
case failedToCreateConverter
|
|
8
|
+
case failedToStart(String)
|
|
9
|
+
|
|
10
|
+
var errorDescription: String? {
|
|
11
|
+
switch self {
|
|
12
|
+
case .noInputDevice:
|
|
13
|
+
return "No microphone input device is available"
|
|
14
|
+
case .unsupportedInputFormat:
|
|
15
|
+
return "The selected microphone format is not supported"
|
|
16
|
+
case .failedToCreateConverter:
|
|
17
|
+
return "Could not prepare microphone audio conversion"
|
|
18
|
+
case .failedToStart(let message):
|
|
19
|
+
return "Could not start microphone capture: \(message)"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
final class NativePCMRecorder: @unchecked Sendable {
|
|
25
|
+
private let engine = AVAudioEngine()
|
|
26
|
+
private let onPCM: @Sendable (Data) -> Void
|
|
27
|
+
private var converter: AVAudioConverter?
|
|
28
|
+
private var inputFormat: AVAudioFormat?
|
|
29
|
+
private var outputFormat: AVAudioFormat?
|
|
30
|
+
private var started = false
|
|
31
|
+
|
|
32
|
+
init(onPCM: @escaping @Sendable (Data) -> Void) {
|
|
33
|
+
self.onPCM = onPCM
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func start() throws {
|
|
37
|
+
guard !started else { return }
|
|
38
|
+
|
|
39
|
+
let inputNode = engine.inputNode
|
|
40
|
+
let inputFormat = inputNode.inputFormat(forBus: 0)
|
|
41
|
+
guard inputFormat.channelCount > 0 else {
|
|
42
|
+
throw NativePCMRecorderError.noInputDevice
|
|
43
|
+
}
|
|
44
|
+
guard inputFormat.sampleRate > 0 else {
|
|
45
|
+
throw NativePCMRecorderError.unsupportedInputFormat
|
|
46
|
+
}
|
|
47
|
+
guard let outputFormat = Self.realtimeOutputFormat() else {
|
|
48
|
+
throw NativePCMRecorderError.unsupportedInputFormat
|
|
49
|
+
}
|
|
50
|
+
guard let converter = AVAudioConverter(from: inputFormat, to: outputFormat) else {
|
|
51
|
+
throw NativePCMRecorderError.failedToCreateConverter
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
self.converter = converter
|
|
55
|
+
self.inputFormat = inputFormat
|
|
56
|
+
self.outputFormat = outputFormat
|
|
57
|
+
|
|
58
|
+
inputNode.installTap(onBus: 0, bufferSize: 1_024, format: inputFormat) { [weak self] buffer, _ in
|
|
59
|
+
self?.convertAndEmit(buffer)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
do {
|
|
63
|
+
engine.prepare()
|
|
64
|
+
try engine.start()
|
|
65
|
+
started = true
|
|
66
|
+
} catch {
|
|
67
|
+
inputNode.removeTap(onBus: 0)
|
|
68
|
+
self.converter = nil
|
|
69
|
+
self.inputFormat = nil
|
|
70
|
+
self.outputFormat = nil
|
|
71
|
+
throw NativePCMRecorderError.failedToStart(error.localizedDescription)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func stop() {
|
|
76
|
+
guard started else {
|
|
77
|
+
converter = nil
|
|
78
|
+
inputFormat = nil
|
|
79
|
+
outputFormat = nil
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
engine.inputNode.removeTap(onBus: 0)
|
|
84
|
+
engine.stop()
|
|
85
|
+
converter = nil
|
|
86
|
+
inputFormat = nil
|
|
87
|
+
outputFormat = nil
|
|
88
|
+
started = false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
deinit {
|
|
92
|
+
stop()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private func convertAndEmit(_ inputBuffer: AVAudioPCMBuffer) {
|
|
96
|
+
guard let converter, let inputFormat, let outputFormat else { return }
|
|
97
|
+
|
|
98
|
+
let sampleRateRatio = outputFormat.sampleRate / inputFormat.sampleRate
|
|
99
|
+
let estimatedFrames = AVAudioFrameCount(Double(inputBuffer.frameLength) * sampleRateRatio) + 512
|
|
100
|
+
guard let outputBuffer = AVAudioPCMBuffer(
|
|
101
|
+
pcmFormat: outputFormat,
|
|
102
|
+
frameCapacity: max(estimatedFrames, 1)
|
|
103
|
+
) else {
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let inputSource = AudioConverterInputSource(buffer: inputBuffer)
|
|
108
|
+
var conversionError: NSError?
|
|
109
|
+
converter.convert(to: outputBuffer, error: &conversionError) { _, status in
|
|
110
|
+
inputSource.next(status: status)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
guard conversionError == nil else { return }
|
|
114
|
+
let data = Self.extractPCM16Data(from: outputBuffer)
|
|
115
|
+
if !data.isEmpty {
|
|
116
|
+
onPCM(data)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
static func realtimeOutputFormat() -> AVAudioFormat? {
|
|
121
|
+
AVAudioFormat(
|
|
122
|
+
commonFormat: .pcmFormatInt16,
|
|
123
|
+
sampleRate: 24_000,
|
|
124
|
+
channels: 1,
|
|
125
|
+
interleaved: true
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
static func extractPCM16Data(from buffer: AVAudioPCMBuffer) -> Data {
|
|
130
|
+
var data = Data()
|
|
131
|
+
for audioBuffer in UnsafeMutableAudioBufferListPointer(buffer.mutableAudioBufferList) {
|
|
132
|
+
guard let bytes = audioBuffer.mData?.assumingMemoryBound(to: UInt8.self),
|
|
133
|
+
audioBuffer.mDataByteSize > 0 else {
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
data.append(bytes, count: Int(audioBuffer.mDataByteSize))
|
|
137
|
+
}
|
|
138
|
+
return data
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private final class AudioConverterInputSource: @unchecked Sendable {
|
|
143
|
+
private let buffer: AVAudioPCMBuffer
|
|
144
|
+
private let lock = NSLock()
|
|
145
|
+
private var consumed = false
|
|
146
|
+
|
|
147
|
+
init(buffer: AVAudioPCMBuffer) {
|
|
148
|
+
self.buffer = buffer
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
func next(status: UnsafeMutablePointer<AVAudioConverterInputStatus>) -> AVAudioBuffer? {
|
|
152
|
+
lock.lock()
|
|
153
|
+
defer { lock.unlock() }
|
|
154
|
+
|
|
155
|
+
if consumed {
|
|
156
|
+
status.pointee = .noDataNow
|
|
157
|
+
return nil
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
consumed = true
|
|
161
|
+
status.pointee = .haveData
|
|
162
|
+
return buffer
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
enum OpenAIAPIKeyStore {
|
|
4
|
+
static func load(
|
|
5
|
+
homePath: String,
|
|
6
|
+
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
7
|
+
userDefaultKey: String? = UserDefaults.standard.string(forKey: "openAIAPIKey")
|
|
8
|
+
) -> String {
|
|
9
|
+
if let key = firstNonEmpty(environment["OPENAI_API_KEY"], environment["RECORDINGS_API_KEY"]) {
|
|
10
|
+
return key
|
|
11
|
+
}
|
|
12
|
+
if let key = firstNonEmpty(userDefaultKey) {
|
|
13
|
+
return key
|
|
14
|
+
}
|
|
15
|
+
if let key = loadConfigKey(homePath: homePath, environment: environment) {
|
|
16
|
+
return key
|
|
17
|
+
}
|
|
18
|
+
if let key = loadSecretKey(homePath: homePath) {
|
|
19
|
+
return key
|
|
20
|
+
}
|
|
21
|
+
return ""
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private static func loadConfigKey(homePath: String, environment: [String: String]) -> String? {
|
|
25
|
+
let url = URL(fileURLWithPath: homePath)
|
|
26
|
+
.appendingPathComponent(".hasna")
|
|
27
|
+
.appendingPathComponent("recordings")
|
|
28
|
+
.appendingPathComponent("config.json")
|
|
29
|
+
|
|
30
|
+
guard let data = try? Data(contentsOf: url),
|
|
31
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
32
|
+
else { return nil }
|
|
33
|
+
|
|
34
|
+
for key in ["openai_api_key", "api_key"] {
|
|
35
|
+
guard let value = json[key] as? String,
|
|
36
|
+
let resolved = resolve(value: value, environment: environment)
|
|
37
|
+
else { continue }
|
|
38
|
+
return resolved
|
|
39
|
+
}
|
|
40
|
+
return nil
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private static func loadSecretKey(homePath: String) -> String? {
|
|
44
|
+
let root = URL(fileURLWithPath: homePath).appendingPathComponent(".secrets")
|
|
45
|
+
let fileManager = FileManager.default
|
|
46
|
+
guard let enumerator = fileManager.enumerator(
|
|
47
|
+
at: root,
|
|
48
|
+
includingPropertiesForKeys: [.isRegularFileKey],
|
|
49
|
+
options: [.skipsHiddenFiles]
|
|
50
|
+
) else { return nil }
|
|
51
|
+
|
|
52
|
+
for case let url as URL in enumerator {
|
|
53
|
+
guard url.pathExtension == "env",
|
|
54
|
+
let values = try? parseEnvFile(url: url)
|
|
55
|
+
else { continue }
|
|
56
|
+
|
|
57
|
+
if let key = firstNonEmpty(values["RECORDINGS_API_KEY"], values["OPENAI_API_KEY"]) {
|
|
58
|
+
return key
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return nil
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private static func parseEnvFile(url: URL) throws -> [String: String] {
|
|
65
|
+
let content = try String(contentsOf: url, encoding: .utf8)
|
|
66
|
+
var values: [String: String] = [:]
|
|
67
|
+
|
|
68
|
+
for rawLine in content.split(whereSeparator: \.isNewline) {
|
|
69
|
+
var line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
70
|
+
guard !line.isEmpty, !line.hasPrefix("#") else { continue }
|
|
71
|
+
if line.hasPrefix("export ") {
|
|
72
|
+
line.removeFirst("export ".count)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let parts = line.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
|
|
76
|
+
guard parts.count == 2 else { continue }
|
|
77
|
+
|
|
78
|
+
let key = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
79
|
+
let value = stripQuotes(String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines))
|
|
80
|
+
values[key] = value
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return values
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private static func resolve(value: String, environment: [String: String]) -> String? {
|
|
87
|
+
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
88
|
+
guard !trimmed.isEmpty else { return nil }
|
|
89
|
+
if trimmed.hasPrefix("$"), trimmed.count > 1 {
|
|
90
|
+
return firstNonEmpty(environment[String(trimmed.dropFirst())])
|
|
91
|
+
}
|
|
92
|
+
return trimmed
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private static func stripQuotes(_ value: String) -> String {
|
|
96
|
+
guard value.count >= 2 else { return value }
|
|
97
|
+
if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
|
|
98
|
+
(value.hasPrefix("'") && value.hasSuffix("'")) {
|
|
99
|
+
return String(value.dropFirst().dropLast())
|
|
100
|
+
}
|
|
101
|
+
return value
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private static func firstNonEmpty(_ values: String?...) -> String? {
|
|
105
|
+
for value in values {
|
|
106
|
+
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
107
|
+
if !trimmed.isEmpty {
|
|
108
|
+
return trimmed
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return nil
|
|
112
|
+
}
|
|
113
|
+
}
|