@hasna/recordings 0.1.11 → 0.1.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.
Files changed (69) hide show
  1. package/README.md +2 -0
  2. package/dist/cli/index.js +395 -37
  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 +78 -11
  6. package/dist/lib/config.d.ts.map +1 -1
  7. package/dist/lib/enhancer.d.ts.map +1 -1
  8. package/dist/lib/recorder.d.ts.map +1 -1
  9. package/dist/lib/transcriber.d.ts +8 -2
  10. package/dist/lib/transcriber.d.ts.map +1 -1
  11. package/dist/mcp/index.js +147 -30
  12. package/dist/types/index.d.ts +3 -0
  13. package/dist/types/index.d.ts.map +1 -1
  14. package/dist/version.d.ts +2 -0
  15. package/dist/version.d.ts.map +1 -0
  16. package/package.json +20 -3
  17. package/scripts/install_macos_app.sh +76 -0
  18. package/src/native/Recordings/{Recordings → App}/RecordingsApp.swift +5 -1
  19. package/src/native/Recordings/Package.resolved +21 -3
  20. package/src/native/Recordings/Package.swift +16 -5
  21. package/src/native/Recordings/{Recordings → RecordingsLib}/Info.plist +6 -0
  22. package/src/native/Recordings/{Recordings → RecordingsLib}/MenuBarPopover.swift +55 -11
  23. package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +36 -0
  24. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +164 -0
  25. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +113 -0
  26. package/src/native/Recordings/{Recordings → RecordingsLib}/ProjectStore.swift +11 -11
  27. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +383 -0
  28. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +835 -0
  29. package/src/native/Recordings/{Recordings → RecordingsLib}/SettingsView.swift +19 -5
  30. package/src/native/Recordings/{Recordings → RecordingsLib}/VoiceShortcuts.swift +12 -8
  31. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +63 -0
  32. package/src/native/Recordings/RecordingsTests/NativeAppDiagnosticsTests.swift +23 -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/.takumi/settings.local.json +0 -7
  39. package/bun.lock +0 -250
  40. package/bunfig.toml +0 -2
  41. package/src/__tests__/agents.test.ts +0 -136
  42. package/src/__tests__/config.test.ts +0 -252
  43. package/src/__tests__/database.test.ts +0 -167
  44. package/src/__tests__/enhancer.test.ts +0 -639
  45. package/src/__tests__/preload.ts +0 -4
  46. package/src/__tests__/projects.test.ts +0 -109
  47. package/src/__tests__/recorder.test.ts +0 -278
  48. package/src/__tests__/recordings.test.ts +0 -353
  49. package/src/__tests__/transcriber.test.ts +0 -322
  50. package/src/__tests__/types.test.ts +0 -75
  51. package/src/cli/index.ts +0 -988
  52. package/src/db/agents.ts +0 -104
  53. package/src/db/database.ts +0 -163
  54. package/src/db/pg-migrations.ts +0 -82
  55. package/src/db/projects.ts +0 -71
  56. package/src/db/recordings.ts +0 -225
  57. package/src/index.ts +0 -81
  58. package/src/lib/config.ts +0 -223
  59. package/src/lib/enhancer.ts +0 -173
  60. package/src/lib/recorder.ts +0 -198
  61. package/src/lib/transcriber.ts +0 -105
  62. package/src/mcp/index.ts +0 -464
  63. package/src/native/Recordings/Recordings/RecordingEngine.swift +0 -455
  64. package/src/native/Recordings/test_fn.swift +0 -79
  65. package/src/native/Recordings/test_fn2.swift +0 -33
  66. package/src/types/index.ts +0 -144
  67. package/tsconfig.json +0 -21
  68. /package/src/native/Recordings/{Recordings → RecordingsLib}/FnKeyMonitor.swift +0 -0
  69. /package/src/native/Recordings/{Recordings → RecordingsLib}/Recordings.entitlements +0 -0
package/src/index.ts DELETED
@@ -1,81 +0,0 @@
1
- // ── Types ───────────────────────────────────────────────────────────────────
2
- export type {
3
- Recording,
4
- CreateRecordingInput,
5
- RecordingFilter,
6
- ProcessingMode,
7
- Agent,
8
- Project,
9
- RecordingsConfig,
10
- TranscriptionResult,
11
- EnhancementResult,
12
- } from "./types/index.js";
13
-
14
- export {
15
- RecordingNotFoundError,
16
- RecordingError,
17
- TranscriptionError,
18
- EnhancementError,
19
- } from "./types/index.js";
20
-
21
- // ── Database ────────────────────────────────────────────────────────────────
22
- export {
23
- getDatabase,
24
- closeDatabase,
25
- resetDatabase,
26
- getDbPath,
27
- shortUuid,
28
- } from "./db/database.js";
29
-
30
- // ── Recordings CRUD ─────────────────────────────────────────────────────────
31
- export {
32
- createRecording,
33
- getRecording,
34
- listRecordings,
35
- deleteRecording,
36
- searchRecordings,
37
- getRecordingStats,
38
- } from "./db/recordings.js";
39
-
40
- // ── Agents ──────────────────────────────────────────────────────────────────
41
- export { registerAgent, getAgent, listAgents } from "./db/agents.js";
42
-
43
- // ── Projects ────────────────────────────────────────────────────────────────
44
- export {
45
- registerProject,
46
- getProject,
47
- listProjects,
48
- } from "./db/projects.js";
49
-
50
- // ── Config ──────────────────────────────────────────────────────────────────
51
- export {
52
- loadConfig,
53
- getDataDir,
54
- ensureDataDir,
55
- DEFAULT_CONFIG,
56
- } from "./lib/config.js";
57
-
58
- // ── Transcription ───────────────────────────────────────────────────────────
59
- export {
60
- transcribeAudio,
61
- transcribeBuffer,
62
- resetClient,
63
- } from "./lib/transcriber.js";
64
-
65
- // ── Enhancement ─────────────────────────────────────────────────────────────
66
- export {
67
- needsEnhancement,
68
- enhanceText,
69
- processText,
70
- resetEnhancementClient,
71
- } from "./lib/enhancer.js";
72
-
73
- // ── Recorder ────────────────────────────────────────────────────────────────
74
- export {
75
- startRecording,
76
- stopRecording,
77
- isRecording,
78
- getCurrentFile,
79
- checkRecordingDeps,
80
- recordDuration,
81
- } from "./lib/recorder.js";
package/src/lib/config.ts DELETED
@@ -1,223 +0,0 @@
1
- import { existsSync, readFileSync, mkdirSync, cpSync, readdirSync, statSync } from "fs";
2
- import { join } from "path";
3
- import { homedir } from "os";
4
- import type { RecordingsConfig } from "../types/index.js";
5
-
6
- export const DEFAULT_CONFIG: RecordingsConfig = {
7
- openai_api_key: "",
8
- enhancement_api_key: "",
9
- transcription_model: "gpt-4o-mini-transcribe",
10
- enhancement_model: "gpt-4o",
11
- language: "en",
12
- audio_format: "wav",
13
- sample_rate: 16000,
14
- record_command: "sox",
15
- hotkey: "space",
16
- auto_enhance: true,
17
- enhance_triggers: [
18
- "say it better",
19
- "rewrite this",
20
- "make it sound",
21
- "clean this up",
22
- "fix this",
23
- "rephrase",
24
- "write it properly",
25
- "make it professional",
26
- "improve this",
27
- "polish this",
28
- ],
29
- db_path: "",
30
- audio_dir: "",
31
- max_recording_seconds: 300,
32
- };
33
-
34
- export function loadConfig(configPath?: string): RecordingsConfig {
35
- const config = { ...DEFAULT_CONFIG };
36
-
37
- // 1. Load from config file
38
- const filePath =
39
- configPath || findConfigFile() || join(getDataDir(), "config.json");
40
-
41
- if (existsSync(filePath)) {
42
- try {
43
- const raw = readFileSync(filePath, "utf-8");
44
- const fileConfig = JSON.parse(raw) as Partial<RecordingsConfig>;
45
- Object.assign(config, expandEnvBackedConfig(fileConfig));
46
- } catch {
47
- // Ignore invalid config files
48
- }
49
- }
50
-
51
- // 2. Override with env vars
52
- if (process.env.OPENAI_API_KEY) {
53
- config.openai_api_key = process.env.OPENAI_API_KEY;
54
- }
55
- if (process.env.RECORDINGS_API_KEY) {
56
- config.openai_api_key = process.env.RECORDINGS_API_KEY;
57
- }
58
- if (process.env.RECORDINGS_ENHANCEMENT_KEY) {
59
- config.enhancement_api_key = process.env.RECORDINGS_ENHANCEMENT_KEY;
60
- }
61
- if (process.env.RECORDINGS_MODEL) {
62
- config.transcription_model = process.env.RECORDINGS_MODEL;
63
- }
64
- if (process.env.RECORDINGS_ENHANCEMENT_MODEL) {
65
- config.enhancement_model = process.env.RECORDINGS_ENHANCEMENT_MODEL;
66
- }
67
- if (process.env.RECORDINGS_LANGUAGE) {
68
- config.language = process.env.RECORDINGS_LANGUAGE;
69
- }
70
- if (process.env.HASNA_RECORDINGS_DB_PATH) {
71
- config.db_path = process.env.HASNA_RECORDINGS_DB_PATH;
72
- } else if (process.env.RECORDINGS_DB_PATH) {
73
- config.db_path = process.env.RECORDINGS_DB_PATH;
74
- }
75
- if (process.env.RECORDINGS_AUDIO_DIR) {
76
- config.audio_dir = process.env.RECORDINGS_AUDIO_DIR;
77
- }
78
- if (process.env.RECORDINGS_MAX_SECONDS) {
79
- config.max_recording_seconds = parseInt(
80
- process.env.RECORDINGS_MAX_SECONDS,
81
- 10
82
- );
83
- }
84
-
85
- // 3. Load API key from ~/.secrets if not set
86
- if (!config.openai_api_key) {
87
- config.openai_api_key = loadSecretKey("OPENAI_API_KEY");
88
- }
89
- if (!config.enhancement_api_key) {
90
- config.enhancement_api_key =
91
- config.openai_api_key || loadSecretKey("OPENAI_API_KEY");
92
- }
93
-
94
- // 4. Set defaults for paths
95
- if (!config.db_path) {
96
- config.db_path = join(getDataDir(), "recordings.db");
97
- }
98
- if (!config.audio_dir) {
99
- config.audio_dir = join(getDataDir(), "audio");
100
- }
101
-
102
- return config;
103
- }
104
-
105
- function expandEnvBackedConfig(config: Partial<RecordingsConfig>): Partial<RecordingsConfig> {
106
- const expanded = { ...config };
107
-
108
- for (const key of ["openai_api_key", "enhancement_api_key"] as const) {
109
- const value = expanded[key];
110
- if (typeof value === "string" && value.startsWith("$") && value.length > 1) {
111
- expanded[key] = process.env[value.slice(1)] || value;
112
- }
113
- }
114
-
115
- return expanded;
116
- }
117
-
118
- function findConfigFile(): string | null {
119
- // Walk up from cwd looking for .recordings/config.json
120
- let dir = process.cwd();
121
- const root = "/";
122
- while (dir !== root) {
123
- const candidate = join(dir, ".recordings", "config.json");
124
- if (existsSync(candidate)) return candidate;
125
- const parent = join(dir, "..");
126
- if (parent === dir) break;
127
- dir = parent;
128
- }
129
- return null;
130
- }
131
-
132
- export function getDataDir(): string {
133
- // Check for .recordings in cwd hierarchy (project-local)
134
- let dir = process.cwd();
135
- const root = "/";
136
- while (dir !== root) {
137
- const candidate = join(dir, ".recordings");
138
- if (existsSync(candidate)) return candidate;
139
- const parent = join(dir, "..");
140
- if (parent === dir) break;
141
- dir = parent;
142
- }
143
-
144
- // Global: ~/.hasna/recordings (with backward compat from ~/.recordings)
145
- const home = homedir();
146
- const newDir = join(home, ".hasna", "recordings");
147
- const oldDir = join(home, ".recordings");
148
-
149
- // Auto-migrate from old location if new dir doesn't exist yet
150
- if (!existsSync(newDir) && existsSync(oldDir)) {
151
- try {
152
- mkdirSync(join(home, ".hasna"), { recursive: true });
153
- cpSync(oldDir, newDir, { recursive: true });
154
- } catch {
155
- // Fall through to use new dir
156
- }
157
- }
158
-
159
- return newDir;
160
- }
161
-
162
- function loadSecretKey(keyName: string): string {
163
- const secretsPath = join(homedir(), ".secrets");
164
- if (!existsSync(secretsPath)) return "";
165
-
166
- for (const candidate of listSecretFiles(secretsPath)) {
167
- try {
168
- const content = readFileSync(candidate, "utf-8");
169
- const match = content.match(
170
- new RegExp(`export\\s+${keyName}\\s*=\\s*"([^"]+)"`)
171
- );
172
- if (match) return match[1]!;
173
-
174
- const match2 = content.match(
175
- new RegExp(`export\\s+${keyName}\\s*=\\s*'([^']+)'`)
176
- );
177
- if (match2) return match2[1]!;
178
-
179
- const match3 = content.match(new RegExp(`${keyName}\\s*=\\s*(.+)`));
180
- if (match3) return match3[1]!.trim().replace(/^["']|["']$/g, "");
181
- } catch {
182
- // Ignore unreadable secret files
183
- }
184
- }
185
-
186
- return "";
187
- }
188
-
189
- function listSecretFiles(path: string): string[] {
190
- try {
191
- const stats = statSync(path);
192
- if (stats.isFile()) return [path];
193
- if (!stats.isDirectory()) return [];
194
-
195
- return readdirSync(path)
196
- .sort()
197
- .flatMap((entry) => {
198
- const child = join(path, entry);
199
- try {
200
- const childStats = statSync(child);
201
- if (childStats.isDirectory()) return listSecretFiles(child);
202
- if (childStats.isFile() && child.endsWith(".env")) return [child];
203
- } catch {
204
- // Ignore entries that disappear or are unreadable
205
- }
206
- return [];
207
- });
208
- } catch {
209
- return [];
210
- }
211
- }
212
-
213
- export function ensureDataDir(config: RecordingsConfig): void {
214
- const { mkdirSync } = require("fs") as typeof import("fs");
215
- mkdirSync(config.audio_dir, { recursive: true });
216
-
217
- // Ensure db directory exists
218
- const dbDir = config.db_path.substring(
219
- 0,
220
- config.db_path.lastIndexOf("/")
221
- );
222
- if (dbDir) mkdirSync(dbDir, { recursive: true });
223
- }
@@ -1,173 +0,0 @@
1
- import OpenAI from "openai";
2
- import type {
3
- RecordingsConfig,
4
- EnhancementResult,
5
- } from "../types/index.js";
6
- import { EnhancementError } from "../types/index.js";
7
-
8
- let _enhancementClient: OpenAI | null = null;
9
-
10
- function getEnhancementClient(config: RecordingsConfig): OpenAI {
11
- if (_enhancementClient) return _enhancementClient;
12
- const key = config.enhancement_api_key || config.openai_api_key;
13
- if (!key) {
14
- throw new EnhancementError(
15
- "API key not configured for enhancement. Set OPENAI_API_KEY or RECORDINGS_ENHANCEMENT_KEY"
16
- );
17
- }
18
- _enhancementClient = new OpenAI({ apiKey: key });
19
- return _enhancementClient;
20
- }
21
-
22
- export function resetEnhancementClient(): void {
23
- _enhancementClient = null;
24
- }
25
-
26
- /**
27
- * Detect if the transcribed text needs AI enhancement.
28
- *
29
- * Detection logic:
30
- * 1. Explicit triggers: "say it better", "rewrite this", etc.
31
- * 2. Instruction patterns: "write an email saying...", "give instructions to..."
32
- * 3. Meta-commentary: text that talks ABOUT what to say rather than being the content itself
33
- */
34
- export function needsEnhancement(
35
- text: string,
36
- config: RecordingsConfig
37
- ): { needs: boolean; reason: string; instruction: string } {
38
- const lower = text.toLowerCase().trim();
39
-
40
- // Check explicit triggers from config
41
- for (const trigger of config.enhance_triggers) {
42
- if (lower.includes(trigger.toLowerCase())) {
43
- return {
44
- needs: true,
45
- reason: `Explicit trigger: "${trigger}"`,
46
- instruction: extractInstruction(text, trigger),
47
- };
48
- }
49
- }
50
-
51
- // Check instruction patterns
52
- const instructionPatterns = [
53
- /(?:write|draft|compose|create)\s+(?:an?\s+)?(?:email|message|response|reply|letter|note|text|slack|dm)/i,
54
- /(?:give|provide|send)\s+(?:them|him|her|it|the\s+agent|the\s+team)\s+(?:full\s+)?instructions/i,
55
- /(?:tell|ask)\s+(?:them|him|her|it|the\s+agent)\s+(?:to|that)/i,
56
- /(?:make\s+it|make\s+this)\s+(?:sound|look|read)\s+(?:more\s+)?(?:professional|formal|casual|friendly|better)/i,
57
- /(?:ok\s+so|okay\s+so|alright\s+so)\s+(?:say|write|tell|put)/i,
58
- /(?:i\s+need|i\s+want)\s+(?:the\s+agent|it|them|you)\s+to\s+(?:build|create|implement|design|make)/i,
59
- ];
60
-
61
- for (const pattern of instructionPatterns) {
62
- if (pattern.test(text)) {
63
- return {
64
- needs: true,
65
- reason: `Instruction pattern detected`,
66
- instruction: text,
67
- };
68
- }
69
- }
70
-
71
- return { needs: false, reason: "Direct dictation", instruction: text };
72
- }
73
-
74
- function extractInstruction(text: string, trigger: string): string {
75
- const lower = text.toLowerCase();
76
- const idx = lower.indexOf(trigger.toLowerCase());
77
- if (idx === -1) return text;
78
-
79
- // Take everything after the trigger as the instruction context
80
- const after = text.substring(idx + trigger.length).trim();
81
- const before = text.substring(0, idx).trim();
82
-
83
- // If there's content before the trigger, that's likely the raw text to enhance
84
- if (before.length > after.length && before.length > 10) {
85
- return before;
86
- }
87
-
88
- // Otherwise the whole text is the instruction
89
- return text;
90
- }
91
-
92
- export async function enhanceText(
93
- rawText: string,
94
- instruction: string,
95
- config: RecordingsConfig,
96
- systemPrompt?: string
97
- ): Promise<EnhancementResult> {
98
- const client = getEnhancementClient(config);
99
-
100
- const basePrompt = `You are a writing assistant. The user has dictated speech that needs to be transformed into polished output.
101
-
102
- Rules:
103
- - Output ONLY the enhanced/rewritten text — no explanations, no preamble
104
- - Preserve the user's intent and meaning
105
- - Fix grammar, structure, and clarity
106
- - If the user is giving instructions (e.g., "write an email saying..."), produce the actual output (the email), not a description of it
107
- - If the user says "say it better" or similar, rewrite their preceding text to be clearer and more professional
108
- - Match the appropriate tone (formal for business, casual for personal)`;
109
-
110
- const fullPrompt = systemPrompt ? `${basePrompt}\n\nAdditional context:\n${systemPrompt}` : basePrompt;
111
-
112
- try {
113
- const response = await client.chat.completions.create({
114
- model: config.enhancement_model,
115
- messages: [
116
- {
117
- role: "system",
118
- content: fullPrompt,
119
- },
120
- {
121
- role: "user",
122
- content: instruction,
123
- },
124
- ],
125
- temperature: 0.3,
126
- max_tokens: 4096,
127
- });
128
-
129
- const enhanced =
130
- response.choices[0]?.message?.content?.trim() || rawText;
131
-
132
- return {
133
- original: rawText,
134
- enhanced,
135
- model: config.enhancement_model,
136
- reasoning: null,
137
- };
138
- } catch (error) {
139
- const msg = error instanceof Error ? error.message : String(error);
140
- throw new EnhancementError(`Enhancement failed: ${msg}`);
141
- }
142
- }
143
-
144
- /**
145
- * Full pipeline: detect if enhancement is needed, enhance if so.
146
- */
147
- export async function processText(
148
- rawText: string,
149
- config: RecordingsConfig,
150
- systemPrompt?: string
151
- ): Promise<{
152
- text: string;
153
- mode: "raw" | "enhanced";
154
- enhancement_model: string | null;
155
- }> {
156
- if (!config.auto_enhance) {
157
- return { text: rawText, mode: "raw", enhancement_model: null };
158
- }
159
-
160
- const detection = needsEnhancement(rawText, config);
161
-
162
- if (!detection.needs) {
163
- return { text: rawText, mode: "raw", enhancement_model: null };
164
- }
165
-
166
- const result = await enhanceText(rawText, detection.instruction, config, systemPrompt);
167
-
168
- return {
169
- text: result.enhanced,
170
- mode: "enhanced",
171
- enhancement_model: result.model,
172
- };
173
- }
@@ -1,198 +0,0 @@
1
- import { spawn, type ChildProcess } from "child_process";
2
- import { join } from "path";
3
- import { existsSync } from "fs";
4
- import type { RecordingsConfig } from "../types/index.js";
5
- import { RecordingError } from "../types/index.js";
6
-
7
- let _recordProcess: ChildProcess | null = null;
8
- let _currentFile: string | null = null;
9
-
10
- /**
11
- * Check if sox/rec is available for recording
12
- */
13
- export async function checkRecordingDeps(): Promise<{
14
- available: boolean;
15
- tool: string;
16
- message: string;
17
- }> {
18
- // Check for sox
19
- try {
20
- const proc = Bun.spawn(["which", "sox"], {
21
- stdout: "pipe",
22
- stderr: "pipe",
23
- });
24
- await proc.exited;
25
- if (proc.exitCode === 0) {
26
- return { available: true, tool: "sox", message: "sox is available" };
27
- }
28
- } catch {
29
- // Not available
30
- }
31
-
32
- // Check for rec (part of sox)
33
- try {
34
- const proc = Bun.spawn(["which", "rec"], {
35
- stdout: "pipe",
36
- stderr: "pipe",
37
- });
38
- await proc.exited;
39
- if (proc.exitCode === 0) {
40
- return { available: true, tool: "rec", message: "rec is available" };
41
- }
42
- } catch {
43
- // Not available
44
- }
45
-
46
- // Check for ffmpeg
47
- try {
48
- const proc = Bun.spawn(["which", "ffmpeg"], {
49
- stdout: "pipe",
50
- stderr: "pipe",
51
- });
52
- await proc.exited;
53
- if (proc.exitCode === 0) {
54
- return {
55
- available: true,
56
- tool: "ffmpeg",
57
- message: "ffmpeg is available",
58
- };
59
- }
60
- } catch {
61
- // Not available
62
- }
63
-
64
- return {
65
- available: false,
66
- tool: "none",
67
- message:
68
- "No recording tool found. Install sox: brew install sox (macOS) or apt install sox (Linux)",
69
- };
70
- }
71
-
72
- /**
73
- * Start recording audio from microphone
74
- */
75
- export function startRecording(config: RecordingsConfig): string {
76
- if (_recordProcess) {
77
- throw new RecordingError("Already recording. Stop the current recording first.");
78
- }
79
-
80
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
81
- const filename = `recording-${timestamp}.${config.audio_format}`;
82
- const filepath = join(config.audio_dir, filename);
83
-
84
- // Build sox/rec command based on config
85
- const args = buildRecordArgs(filepath, config);
86
-
87
- _recordProcess = spawn(args[0]!, args.slice(1), {
88
- stdio: ["pipe", "pipe", "pipe"],
89
- });
90
-
91
- _currentFile = filepath;
92
-
93
- _recordProcess.on("error", (err) => {
94
- _recordProcess = null;
95
- _currentFile = null;
96
- throw new RecordingError(`Recording process error: ${err.message}`);
97
- });
98
-
99
- _recordProcess.on("exit", () => {
100
- _recordProcess = null;
101
- });
102
-
103
- return filepath;
104
- }
105
-
106
- /**
107
- * Stop the current recording
108
- */
109
- export function stopRecording(): string | null {
110
- if (!_recordProcess) {
111
- return null;
112
- }
113
-
114
- const filepath = _currentFile;
115
-
116
- // Send SIGINT to gracefully stop sox/rec
117
- _recordProcess.kill("SIGINT");
118
- _recordProcess = null;
119
- _currentFile = null;
120
-
121
- return filepath;
122
- }
123
-
124
- /**
125
- * Check if currently recording
126
- */
127
- export function isRecording(): boolean {
128
- return _recordProcess !== null;
129
- }
130
-
131
- /**
132
- * Get current recording file path
133
- */
134
- export function getCurrentFile(): string | null {
135
- return _currentFile;
136
- }
137
-
138
- function buildRecordArgs(filepath: string, config: RecordingsConfig): string[] {
139
- const format = config.audio_format;
140
- const rate = config.sample_rate;
141
- const maxSeconds = config.max_recording_seconds;
142
-
143
- // Use rec (sox) for recording — most reliable cross-platform
144
- // rec outputs to file, auto-detects input device
145
- return [
146
- "rec",
147
- "-r",
148
- rate.toString(),
149
- "-c",
150
- "1", // mono
151
- "-b",
152
- "16", // 16-bit
153
- filepath,
154
- "trim",
155
- "0",
156
- maxSeconds.toString(),
157
- ];
158
- }
159
-
160
- /**
161
- * Record for a specific duration (non-interactive)
162
- */
163
- export async function recordDuration(
164
- seconds: number,
165
- config: RecordingsConfig
166
- ): Promise<string> {
167
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
168
- const filename = `recording-${timestamp}.${config.audio_format}`;
169
- const filepath = join(config.audio_dir, filename);
170
-
171
- const args = [
172
- "rec",
173
- "-r",
174
- config.sample_rate.toString(),
175
- "-c",
176
- "1",
177
- "-b",
178
- "16",
179
- filepath,
180
- "trim",
181
- "0",
182
- seconds.toString(),
183
- ];
184
-
185
- const proc = Bun.spawn(args, {
186
- stdout: "pipe",
187
- stderr: "pipe",
188
- });
189
-
190
- const exitCode = await proc.exited;
191
-
192
- if (exitCode !== 0 && !existsSync(filepath)) {
193
- const stderr = await new Response(proc.stderr).text();
194
- throw new RecordingError(`Recording failed (exit ${exitCode}): ${stderr}`);
195
- }
196
-
197
- return filepath;
198
- }