@hasna/recordings 0.1.10 → 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.
Files changed (71) hide show
  1. package/README.md +2 -0
  2. package/dist/cli/index.js +342 -153
  3. package/dist/db/database.d.ts.map +1 -1
  4. package/dist/db/recordings.d.ts.map +1 -1
  5. package/dist/index.js +114 -33
  6. package/dist/lib/config.d.ts.map +1 -1
  7. package/dist/lib/enhancer.d.ts +2 -2
  8. package/dist/lib/enhancer.d.ts.map +1 -1
  9. package/dist/lib/recorder.d.ts.map +1 -1
  10. package/dist/lib/transcriber.d.ts +8 -2
  11. package/dist/lib/transcriber.d.ts.map +1 -1
  12. package/dist/mcp/index.js +183 -52
  13. package/dist/types/index.d.ts +3 -0
  14. package/dist/types/index.d.ts.map +1 -1
  15. package/dist/version.d.ts +2 -0
  16. package/dist/version.d.ts.map +1 -0
  17. package/package.json +20 -3
  18. package/scripts/install_macos_app.sh +76 -0
  19. package/src/native/Recordings/{Recordings → App}/RecordingsApp.swift +16 -4
  20. package/src/native/Recordings/Package.resolved +19 -1
  21. package/src/native/Recordings/Package.swift +16 -5
  22. package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +131 -0
  23. package/src/native/Recordings/{Recordings → RecordingsLib}/Info.plist +6 -0
  24. package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +361 -0
  25. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +164 -0
  26. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +113 -0
  27. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +126 -0
  28. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +383 -0
  29. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +724 -0
  30. package/src/native/Recordings/RecordingsLib/SettingsView.swift +218 -0
  31. package/src/native/Recordings/{Recordings → RecordingsLib}/VoiceShortcuts.swift +12 -8
  32. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +63 -0
  33. package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +33 -0
  34. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +92 -0
  35. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +58 -0
  36. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +113 -0
  37. package/src/native/Recordings/build.sh +6 -6
  38. package/bun.lock +0 -250
  39. package/bunfig.toml +0 -2
  40. package/src/__tests__/agents.test.ts +0 -136
  41. package/src/__tests__/config.test.ts +0 -252
  42. package/src/__tests__/database.test.ts +0 -167
  43. package/src/__tests__/enhancer.test.ts +0 -574
  44. package/src/__tests__/preload.ts +0 -4
  45. package/src/__tests__/projects.test.ts +0 -109
  46. package/src/__tests__/recorder.test.ts +0 -278
  47. package/src/__tests__/recordings.test.ts +0 -353
  48. package/src/__tests__/transcriber.test.ts +0 -322
  49. package/src/__tests__/types.test.ts +0 -75
  50. package/src/cli/index.ts +0 -1115
  51. package/src/db/agents.ts +0 -104
  52. package/src/db/database.ts +0 -163
  53. package/src/db/pg-migrations.ts +0 -82
  54. package/src/db/projects.ts +0 -71
  55. package/src/db/recordings.ts +0 -225
  56. package/src/index.ts +0 -81
  57. package/src/lib/config.ts +0 -183
  58. package/src/lib/enhancer.ts +0 -167
  59. package/src/lib/recorder.ts +0 -198
  60. package/src/lib/transcriber.ts +0 -105
  61. package/src/mcp/index.ts +0 -464
  62. package/src/native/Recordings/Recordings/FnKeyMonitor.swift +0 -122
  63. package/src/native/Recordings/Recordings/MenuBarPopover.swift +0 -188
  64. package/src/native/Recordings/Recordings/RecordingEngine.swift +0 -355
  65. package/src/native/Recordings/Recordings/SettingsView.swift +0 -93
  66. package/src/native/Recordings/test_fn.swift +0 -79
  67. package/src/native/Recordings/test_fn2.swift +0 -33
  68. package/src/native/RecordingsHelper.swift +0 -395
  69. package/src/types/index.ts +0 -144
  70. package/tsconfig.json +0 -21
  71. /package/src/native/Recordings/{Recordings → RecordingsLib}/Recordings.entitlements +0 -0
@@ -0,0 +1,218 @@
1
+ import SwiftUI
2
+ import KeyboardShortcuts
3
+
4
+ public struct SettingsView: View {
5
+ @ObservedObject public var engine: RecordingEngine
6
+ @ObservedObject public var shortcuts: VoiceShortcuts
7
+ @ObservedObject public var projectStore: ProjectStore
8
+ @AppStorage("openAIAPIKey") private var openAIAPIKey = ""
9
+
10
+ public init(engine: RecordingEngine, shortcuts: VoiceShortcuts, projectStore: ProjectStore) {
11
+ self.engine = engine
12
+ self.shortcuts = shortcuts
13
+ self.projectStore = projectStore
14
+ }
15
+
16
+ public var body: some View {
17
+ TabView {
18
+ generalTab.tabItem { Label("General", systemImage: "gear") }
19
+ projectsTab.tabItem { Label("Projects", systemImage: "folder") }
20
+ shortcutsTab.tabItem { Label("Voice Shortcuts", systemImage: "text.badge.star") }
21
+ }
22
+ .frame(width: 520, height: 440)
23
+ }
24
+
25
+ // MARK: - General
26
+
27
+ private var generalTab: some View {
28
+ Form {
29
+ Section("OpenAI") {
30
+ SecureField("API key", text: $openAIAPIKey)
31
+ .textFieldStyle(.roundedBorder)
32
+ Text("Used for realtime transcription in the menu bar app.")
33
+ .foregroundStyle(.secondary)
34
+ }
35
+
36
+ Section("Recording Shortcut") {
37
+ HStack {
38
+ Text("Shortcut")
39
+ Spacer()
40
+ KeyboardShortcuts.Recorder(for: .toggleRecording) { _ in
41
+ engine.updateStatus()
42
+ }
43
+ }
44
+ Text("Hold to record, release to transcribe and paste.")
45
+ .foregroundStyle(.secondary)
46
+ }
47
+
48
+ Section("System Prompt") {
49
+ TextEditor(text: $projectStore.settings.globalSystemPrompt)
50
+ .frame(height: 80)
51
+ .onChange(of: projectStore.settings.globalSystemPrompt) {
52
+ projectStore.save()
53
+ }
54
+ Text("Applied to all transcription enhancements.")
55
+ .foregroundStyle(.secondary)
56
+ }
57
+ }
58
+ .formStyle(.grouped).padding()
59
+ }
60
+
61
+ // MARK: - Projects
62
+
63
+ @State private var newProjectName = ""
64
+ @State private var editingProject: RecProject?
65
+
66
+ private var projectsTab: some View {
67
+ VStack(spacing: 0) {
68
+ HStack(spacing: 8) {
69
+ TextField("New project name", text: $newProjectName)
70
+ .textFieldStyle(.roundedBorder)
71
+ Button("Add") {
72
+ guard !newProjectName.isEmpty else { return }
73
+ projectStore.addProject(name: newProjectName)
74
+ newProjectName = ""
75
+ }
76
+ .disabled(newProjectName.isEmpty)
77
+ }
78
+ .padding()
79
+
80
+ Divider()
81
+
82
+ if projectStore.settings.projects.isEmpty {
83
+ Spacer()
84
+ VStack(spacing: 8) {
85
+ Image(systemName: "folder").font(.largeTitle).foregroundStyle(.quaternary)
86
+ Text("No projects yet").foregroundStyle(.secondary)
87
+ }
88
+ Spacer()
89
+ } else {
90
+ List {
91
+ ForEach(projectStore.settings.projects) { project in
92
+ ProjectRow(project: project) {
93
+ editingProject = project
94
+ }
95
+ }
96
+ .onDelete { indexSet in
97
+ for i in indexSet {
98
+ projectStore.removeProject(id: projectStore.settings.projects[i].id)
99
+ }
100
+ }
101
+ }
102
+ }
103
+ }
104
+ .sheet(item: $editingProject) { project in
105
+ ProjectEditView(project: project, store: projectStore) {
106
+ editingProject = nil
107
+ }
108
+ }
109
+ }
110
+
111
+ // MARK: - Voice Shortcuts
112
+
113
+ @State private var newTrigger = ""
114
+ @State private var newContent = ""
115
+
116
+ private var shortcutsTab: some View {
117
+ VStack(spacing: 0) {
118
+ HStack(spacing: 8) {
119
+ TextField("Trigger phrase", text: $newTrigger).textFieldStyle(.roundedBorder)
120
+ TextField("Text to insert", text: $newContent).textFieldStyle(.roundedBorder)
121
+ Button("Add") {
122
+ guard !newTrigger.isEmpty, !newContent.isEmpty else { return }
123
+ shortcuts.add(trigger: newTrigger, content: newContent)
124
+ newTrigger = ""; newContent = ""
125
+ }
126
+ .disabled(newTrigger.isEmpty || newContent.isEmpty)
127
+ }
128
+ .padding()
129
+
130
+ Divider()
131
+
132
+ if shortcuts.shortcuts.isEmpty {
133
+ Spacer()
134
+ VStack(spacing: 8) {
135
+ Image(systemName: "text.badge.star").font(.largeTitle).foregroundStyle(.quaternary)
136
+ Text("No voice shortcuts yet").foregroundStyle(.secondary)
137
+ }
138
+ Spacer()
139
+ } else {
140
+ List {
141
+ ForEach(shortcuts.shortcuts) { s in
142
+ VStack(alignment: .leading) {
143
+ Text(s.trigger).bold()
144
+ Text(s.content).foregroundStyle(.secondary).lineLimit(2)
145
+ }
146
+ }
147
+ .onDelete(perform: shortcuts.remove)
148
+ }
149
+ }
150
+ }
151
+ }
152
+ }
153
+
154
+ // MARK: - Project Row
155
+
156
+ struct ProjectRow: View {
157
+ let project: RecProject
158
+ let onEdit: () -> Void
159
+
160
+ var body: some View {
161
+ HStack {
162
+ VStack(alignment: .leading, spacing: 2) {
163
+ Text(project.name)
164
+ if let path = project.path, !path.isEmpty {
165
+ Text(path).foregroundStyle(.secondary).lineLimit(1)
166
+ }
167
+ }
168
+ Spacer()
169
+ Button("Edit") { onEdit() }
170
+ .controlSize(.small)
171
+ }
172
+ }
173
+ }
174
+
175
+ // MARK: - Project Edit
176
+
177
+ struct ProjectEditView: View {
178
+ @State var project: RecProject
179
+ let store: ProjectStore
180
+ let onDismiss: () -> Void
181
+
182
+ var body: some View {
183
+ VStack(spacing: 0) {
184
+ Form {
185
+ Section("Project") {
186
+ TextField("Name", text: $project.name)
187
+ TextField("Path", text: Binding(
188
+ get: { project.path ?? "" },
189
+ set: { project.path = $0.isEmpty ? nil : $0 }
190
+ ))
191
+ .textFieldStyle(.roundedBorder)
192
+ }
193
+
194
+ Section("System Prompt") {
195
+ TextEditor(text: Binding(
196
+ get: { project.systemPrompt ?? "" },
197
+ set: { project.systemPrompt = $0.isEmpty ? nil : $0 }
198
+ ))
199
+ .frame(height: 100)
200
+ Text("Additional context for transcription enhancement in this project.")
201
+ .foregroundStyle(.secondary)
202
+ }
203
+ }
204
+ .formStyle(.grouped)
205
+
206
+ HStack {
207
+ Spacer()
208
+ Button("Cancel") { onDismiss() }
209
+ Button("Save") {
210
+ store.updateProject(project)
211
+ onDismiss()
212
+ }
213
+ }
214
+ .padding()
215
+ }
216
+ .frame(width: 420, height: 340)
217
+ }
218
+ }
@@ -2,12 +2,12 @@ import SwiftUI
2
2
 
3
3
  // MARK: - Voice Shortcut
4
4
 
5
- struct VoiceShortcut: Identifiable, Codable {
6
- let id: UUID
7
- var trigger: String // e.g. "add disclaimer"
8
- var content: String // text to insert when trigger is spoken
5
+ public struct VoiceShortcut: Identifiable, Codable {
6
+ public let id: UUID
7
+ public var trigger: String // e.g. "add disclaimer"
8
+ public var content: String // text to insert when trigger is spoken
9
9
 
10
- init(trigger: String, content: String) {
10
+ public init(trigger: String, content: String) {
11
11
  self.id = UUID()
12
12
  self.trigger = trigger
13
13
  self.content = content
@@ -17,15 +17,15 @@ struct VoiceShortcut: Identifiable, Codable {
17
17
  // MARK: - Voice Shortcuts Manager
18
18
 
19
19
  @MainActor
20
- final class VoiceShortcuts: ObservableObject {
21
- @Published var shortcuts: [VoiceShortcut] = []
20
+ public final class VoiceShortcuts: ObservableObject {
21
+ @Published public var shortcuts: [VoiceShortcut] = []
22
22
 
23
23
  private let storageURL: URL = {
24
24
  let home = FileManager.default.homeDirectoryForCurrentUser
25
25
  return home.appendingPathComponent(".hasna/recordings/voice-shortcuts.json")
26
26
  }()
27
27
 
28
- init() {
28
+ public init() {
29
29
  load()
30
30
  }
31
31
 
@@ -64,6 +64,10 @@ final class VoiceShortcuts: ObservableObject {
64
64
  private func save() {
65
65
  do {
66
66
  let data = try JSONEncoder().encode(shortcuts)
67
+ try FileManager.default.createDirectory(
68
+ at: storageURL.deletingLastPathComponent(),
69
+ withIntermediateDirectories: true
70
+ )
67
71
  try data.write(to: storageURL, options: .atomic)
68
72
  } catch {
69
73
  print("Failed to save voice shortcuts: \(error)")
@@ -0,0 +1,63 @@
1
+ import Testing
2
+ @testable import RecordingsLib
3
+
4
+ struct CLIRunnerTests {
5
+ @Test("parseError detects ERROR prefix")
6
+ func parseError() {
7
+ #expect(CLIRunner.parseError("ERROR: OpenAI API key not configured on this Mac") == "OpenAI API key not configured on this Mac")
8
+ }
9
+
10
+ @Test("parseError returns nil for normal output")
11
+ func noError() {
12
+ #expect(CLIRunner.parseError("Hello world") == nil)
13
+ }
14
+
15
+ @Test("parseError handles API key error")
16
+ func apiKeyError() {
17
+ #expect(CLIRunner.parseError("ERROR: OpenAI API key not configured") == "OpenAI API key not configured on this Mac")
18
+ }
19
+
20
+ @Test("parseError truncates long messages to 120 chars")
21
+ func truncation() {
22
+ let longMsg = String(repeating: "a", count: 200)
23
+ let input = "ERROR: \(longMsg)"
24
+ let result = CLIRunner.parseError(input)!
25
+ #expect(result.count <= 120)
26
+ }
27
+
28
+ @Test("parseJSON extracts raw_text from JSON")
29
+ func parseJSONRawText() {
30
+ let output = """
31
+ {"raw_text": "Hello world", "processed_text": null}
32
+ """
33
+ #expect(CLIRunner.parseJSON(output) == "Hello world")
34
+ }
35
+
36
+ @Test("parseJSON prefers processed_text over raw_text")
37
+ func parseJSONProcessedText() {
38
+ let output = """
39
+ {"raw_text": "Hello world", "processed_text": "Hello World (enhanced)"}
40
+ """
41
+ #expect(CLIRunner.parseJSON(output) == "Hello World (enhanced)")
42
+ }
43
+
44
+ @Test("parseJSON falls back to plain text")
45
+ func parseJSONFallback() {
46
+ let output = "Transcribing...\nHello world\nSaved to file"
47
+ #expect(CLIRunner.parseJSON(output) == "Hello world")
48
+ }
49
+
50
+ @Test("parseJSON returns nil for empty output")
51
+ func emptyOutput() {
52
+ #expect(CLIRunner.parseJSON("") == nil)
53
+ }
54
+
55
+ @Test("parseJSON handles empty transcription")
56
+ func emptyTranscription() {
57
+ let output = """
58
+ {"raw_text": "", "processed_text": ""}
59
+ """
60
+ // Both are empty, so it should fall back to plain text extraction
61
+ #expect(CLIRunner.parseJSON(output) == nil)
62
+ }
63
+ }
@@ -0,0 +1,33 @@
1
+ import AVFoundation
2
+ import Testing
3
+ @testable import RecordingsLib
4
+
5
+ struct NativePCMRecorderTests {
6
+ @Test("Realtime output format is OpenAI-compatible 24 kHz mono PCM16")
7
+ func realtimeOutputFormat() throws {
8
+ let format = try #require(NativePCMRecorder.realtimeOutputFormat())
9
+
10
+ #expect(format.commonFormat == .pcmFormatInt16)
11
+ #expect(format.sampleRate == 24_000)
12
+ #expect(format.channelCount == 1)
13
+ #expect(format.isInterleaved)
14
+ }
15
+
16
+ @Test("Extracts PCM16 bytes from audio buffer")
17
+ func extractPCM16Data() throws {
18
+ let format = try #require(NativePCMRecorder.realtimeOutputFormat())
19
+ let buffer = try #require(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 4))
20
+ buffer.frameLength = 4
21
+
22
+ let samples = try #require(buffer.int16ChannelData?[0])
23
+ samples[0] = 1
24
+ samples[1] = -2
25
+ samples[2] = 3
26
+ samples[3] = -4
27
+
28
+ let data = NativePCMRecorder.extractPCM16Data(from: buffer)
29
+
30
+ #expect(data.count == 8)
31
+ #expect(data.withUnsafeBytes { $0.load(as: Int16.self) } == 1)
32
+ }
33
+ }
@@ -0,0 +1,92 @@
1
+ import Foundation
2
+ import Testing
3
+ @testable import RecordingsLib
4
+
5
+ struct OpenAIAPIKeyStoreTests {
6
+ @Test("Environment key has highest priority")
7
+ func environmentKeyWins() {
8
+ let key = OpenAIAPIKeyStore.load(
9
+ homePath: "/tmp/recordings-missing-home",
10
+ environment: ["OPENAI_API_KEY": "env-key"],
11
+ userDefaultKey: "stored-key"
12
+ )
13
+ #expect(key == "env-key")
14
+ }
15
+
16
+ @Test("User default key is used before config file")
17
+ func userDefaultKeyWins() throws {
18
+ let home = try makeHome()
19
+ try writeConfig(home: home, ["openai_api_key": "config-key"])
20
+
21
+ let key = OpenAIAPIKeyStore.load(
22
+ homePath: home.path,
23
+ environment: [:],
24
+ userDefaultKey: "stored-key"
25
+ )
26
+ #expect(key == "stored-key")
27
+ }
28
+
29
+ @Test("Config file key is loaded from installed app data")
30
+ func configFileKey() throws {
31
+ let home = try makeHome()
32
+ try writeConfig(home: home, ["openai_api_key": "config-key"])
33
+
34
+ let key = OpenAIAPIKeyStore.load(
35
+ homePath: home.path,
36
+ environment: [:],
37
+ userDefaultKey: nil
38
+ )
39
+ #expect(key == "config-key")
40
+ }
41
+
42
+ @Test("Config file can reference environment variable")
43
+ func configEnvReference() throws {
44
+ let home = try makeHome()
45
+ try writeConfig(home: home, ["openai_api_key": "$RECORDINGS_API_KEY"])
46
+
47
+ let key = OpenAIAPIKeyStore.load(
48
+ homePath: home.path,
49
+ environment: ["RECORDINGS_API_KEY": "referenced-key"],
50
+ userDefaultKey: nil
51
+ )
52
+ #expect(key == "referenced-key")
53
+ }
54
+
55
+ @Test("Secrets env files are searched recursively")
56
+ func recursiveSecrets() throws {
57
+ let home = try makeHome()
58
+ let secretDir = home
59
+ .appendingPathComponent(".secrets")
60
+ .appendingPathComponent("hasnaxyz")
61
+ .appendingPathComponent("openai")
62
+ try FileManager.default.createDirectory(at: secretDir, withIntermediateDirectories: true)
63
+ try "export OPENAI_API_KEY='secret-key'\n".write(
64
+ to: secretDir.appendingPathComponent("live.env"),
65
+ atomically: true,
66
+ encoding: .utf8
67
+ )
68
+
69
+ let key = OpenAIAPIKeyStore.load(
70
+ homePath: home.path,
71
+ environment: [:],
72
+ userDefaultKey: nil
73
+ )
74
+ #expect(key == "secret-key")
75
+ }
76
+
77
+ private func makeHome() throws -> URL {
78
+ let url = FileManager.default.temporaryDirectory
79
+ .appendingPathComponent("recordings-key-store-\(UUID().uuidString)")
80
+ try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
81
+ return url
82
+ }
83
+
84
+ private func writeConfig(home: URL, _ config: [String: String]) throws {
85
+ let configDir = home
86
+ .appendingPathComponent(".hasna")
87
+ .appendingPathComponent("recordings")
88
+ try FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true)
89
+ let data = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted])
90
+ try data.write(to: configDir.appendingPathComponent("config.json"))
91
+ }
92
+ }
@@ -0,0 +1,58 @@
1
+ import Testing
2
+ @testable import RecordingsLib
3
+
4
+ struct ProjectStoreTests {
5
+ @Test("matchProject by bundle ID")
6
+ func matchByBundleId() {
7
+ let projects = [
8
+ RecProject(name: "MyApp", appBundleIds: ["com.example.myapp"]),
9
+ RecProject(name: "Other", appBundleIds: ["com.example.other"]),
10
+ ]
11
+ let result = ProjectStore.matchProject(windowTitle: nil, bundleId: "com.example.myapp", projects: projects)
12
+ #expect(result?.name == "MyApp")
13
+ }
14
+
15
+ @Test("matchProject by window title containing project name")
16
+ func matchByWindowTitle() {
17
+ let projects = [
18
+ RecProject(name: "Hasna", path: nil),
19
+ RecProject(name: "Other", path: nil),
20
+ ]
21
+ let result = ProjectStore.matchProject(windowTitle: "Hasna — Takumi", bundleId: nil, projects: projects)
22
+ #expect(result?.name == "Hasna")
23
+ }
24
+
25
+ @Test("matchProject by path component in window title")
26
+ func matchByPathComponent() {
27
+ let projects = [
28
+ RecProject(name: "Backend API", path: "/projects/backend-api"),
29
+ RecProject(name: "Other", path: nil),
30
+ ]
31
+ let result = ProjectStore.matchProject(windowTitle: "backend-api — VSCode", bundleId: nil, projects: projects)
32
+ #expect(result?.name == "Backend API")
33
+ }
34
+
35
+ @Test("matchProject returns nil when no match")
36
+ func noMatch() {
37
+ let projects = [
38
+ RecProject(name: "ProjectA", appBundleIds: ["com.a"]),
39
+ ]
40
+ let result = ProjectStore.matchProject(windowTitle: "ProjectB", bundleId: "com.b", projects: projects)
41
+ #expect(result == nil)
42
+ }
43
+
44
+ @Test("matchProject returns nil for empty projects")
45
+ func emptyProjects() {
46
+ let result = ProjectStore.matchProject(windowTitle: "Anything", bundleId: "com.any", projects: [])
47
+ #expect(result == nil)
48
+ }
49
+
50
+ @Test("matchProject is case-insensitive")
51
+ func caseInsensitive() {
52
+ let projects = [
53
+ RecProject(name: "myapp", path: nil),
54
+ ]
55
+ let result = ProjectStore.matchProject(windowTitle: "MyApp - Editor", bundleId: nil, projects: projects)
56
+ #expect(result?.name == "myapp")
57
+ }
58
+ }
@@ -0,0 +1,113 @@
1
+ import Testing
2
+ @testable import RecordingsLib
3
+
4
+ // MARK: - RealtimeTranscriptionClient Event Parsing Tests
5
+
6
+ struct RealtimeTranscriptionTests {
7
+ @Test("Model ID is set to latest gpt-4o-transcribe")
8
+ func modelID() {
9
+ #expect(RealtimeTranscriptionClient.modelID == "gpt-4o-transcribe")
10
+ }
11
+
12
+ @Test("Parses transcription delta events")
13
+ func parseDelta() {
14
+ let deltaJSON = """
15
+ {"type":"conversation.item.input_audio_transcription.delta","delta":"Hello "}
16
+ """
17
+ #expect(RealtimeTranscriptionClient.parseDeltaTestHelper(deltaJSON) == "Hello ")
18
+ }
19
+
20
+ @Test("Parses transcription completed event")
21
+ func parseCompleted() {
22
+ let completedJSON = """
23
+ {"type":"conversation.item.input_audio_transcription.completed","transcript":"Hello world"}
24
+ """
25
+ #expect(RealtimeTranscriptionClient.parseDeltaTestHelper(completedJSON) == "Hello world")
26
+ }
27
+
28
+ @Test("Returns nil for non-transcription events")
29
+ func ignoreOtherEvents() {
30
+ let sessionJSON = """
31
+ {"type":"session.created","session":{"id":"abc"}}
32
+ """
33
+ #expect(RealtimeTranscriptionClient.parseDeltaTestHelper(sessionJSON) == nil)
34
+ }
35
+
36
+ @Test("Returns nil for malformed JSON")
37
+ func malformedJSON() {
38
+ #expect(RealtimeTranscriptionClient.parseDeltaTestHelper("not json") == nil)
39
+ }
40
+
41
+ @Test("Returns nil for empty delta")
42
+ func emptyDelta() {
43
+ let emptyJSON = """
44
+ {"type":"conversation.item.input_audio_transcription.delta","delta":""}
45
+ """
46
+ // Empty string delta is still valid — client should handle it
47
+ #expect(RealtimeTranscriptionClient.parseDeltaTestHelper(emptyJSON) == "")
48
+ }
49
+
50
+ @Test("Detects error events")
51
+ func detectError() {
52
+ let errorJSON = """
53
+ {"type":"error","error":{"message":"Invalid API key","code":401}}
54
+ """
55
+ #expect(RealtimeTranscriptionClient.isSessionErrorTestHelper(errorJSON) == true)
56
+ }
57
+
58
+ @Test("Does not flag normal events as errors")
59
+ func noFalsePositives() {
60
+ #expect(RealtimeTranscriptionClient.isSessionErrorTestHelper(
61
+ "{\"type\":\"conversation.item.input_audio_transcription.delta\"}"
62
+ ) == false)
63
+ }
64
+
65
+ @Test("Parse error message from error event")
66
+ func parseErrorMessage() {
67
+ let errorJSON = """
68
+ {"type":"error","error":{"message":"Model not found","code":404}}
69
+ """
70
+ #expect(RealtimeTranscriptionClient.parseErrorTestHelper(errorJSON) == "Model not found")
71
+ }
72
+
73
+ @Test("Builds strict verbatim prompt with vocabulary context")
74
+ func buildPrompt() {
75
+ let prompt = RealtimeTranscriptionClient.buildPromptTestHelper("Alumia, Takumi")
76
+ #expect(prompt.contains("verbatim"))
77
+ #expect(prompt.contains("Do not summarize"))
78
+ #expect(prompt.contains("vocabulary context"))
79
+ #expect(prompt.contains("Alumia"))
80
+ }
81
+
82
+ @Test("Builds realtime transcription session update event")
83
+ func buildSessionUpdateEvent() {
84
+ let event = RealtimeTranscriptionClient.sessionUpdateTestHelper(prompt: "Use Alumia as vocabulary", language: "en")
85
+ #expect(event["type"] as? String == "session.update")
86
+
87
+ let session = event["session"] as? [String: Any]
88
+ #expect(session?["type"] as? String == "transcription")
89
+
90
+ let audio = session?["audio"] as? [String: Any]
91
+ let input = audio?["input"] as? [String: Any]
92
+ let format = input?["format"] as? [String: Any]
93
+ #expect(format?["type"] as? String == "audio/pcm")
94
+ #expect(format?["rate"] as? Int == 24_000)
95
+
96
+ let transcription = input?["transcription"] as? [String: Any]
97
+ #expect(transcription?["model"] as? String == "gpt-4o-transcribe")
98
+ #expect(transcription?["prompt"] as? String == "Use Alumia as vocabulary")
99
+ #expect(transcription?["language"] as? String == "en")
100
+
101
+ let turnDetection = input?["turn_detection"] as? [String: Any]
102
+ #expect(turnDetection?["type"] as? String == "server_vad")
103
+
104
+ let include = session?["include"] as? [String]
105
+ #expect(include?.contains("item.input_audio_transcription.logprobs") == true)
106
+ }
107
+
108
+ @Test("Joins transcript parts without dropping spoken text")
109
+ func joinParts() {
110
+ let text = RealtimeTranscriptionClient.joinTranscriptPartsTestHelper(["Hello", "world.", " Next"])
111
+ #expect(text == "Hello world. Next")
112
+ }
113
+ }
@@ -1,4 +1,4 @@
1
- #!/bin/bash
1
+ #!/usr/bin/env bash
2
2
  # Build Recordings.app for macOS 26
3
3
  # Usage: ./build.sh [debug|release]
4
4
 
@@ -9,7 +9,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
9
9
  cd "$SCRIPT_DIR"
10
10
 
11
11
  echo "Building Recordings.app ($MODE)..."
12
- swift build -c "$MODE"
12
+ swift build -c "$MODE" --product App
13
13
 
14
14
  # Create .app bundle
15
15
  BUILD_DIR=".build/$MODE"
@@ -21,14 +21,14 @@ rm -rf "$APP_DIR"
21
21
  mkdir -p "$MACOS"
22
22
 
23
23
  # Copy binary
24
- cp "$BUILD_DIR/Recordings" "$MACOS/Recordings"
24
+ cp "$BUILD_DIR/App" "$MACOS/Recordings"
25
25
 
26
26
  # Copy Info.plist
27
- cp Recordings/Info.plist "$CONTENTS/Info.plist"
27
+ cp RecordingsLib/Info.plist "$CONTENTS/Info.plist"
28
28
 
29
29
  # Copy entitlements (for codesigning)
30
- if [ -f Recordings/Recordings.entitlements ]; then
31
- codesign --force --sign - --entitlements Recordings/Recordings.entitlements "$APP_DIR" 2>/dev/null || true
30
+ if [ -f RecordingsLib/Recordings.entitlements ]; then
31
+ codesign --force --sign - --entitlements RecordingsLib/Recordings.entitlements "$APP_DIR" 2>/dev/null || true
32
32
  fi
33
33
 
34
34
  echo "✓ Built $APP_DIR"