@hasna/recordings 0.0.3
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/.claude/scheduled_tasks.lock +1 -0
- package/LICENSE +21 -0
- package/dist/cli/index.js +1446 -0
- package/dist/mcp/index.js +4882 -0
- package/package.json +46 -0
- package/src/__tests__/agents.test.ts +136 -0
- package/src/__tests__/config.test.ts +252 -0
- package/src/__tests__/database.test.ts +167 -0
- package/src/__tests__/enhancer.test.ts +574 -0
- package/src/__tests__/preload.ts +4 -0
- package/src/__tests__/projects.test.ts +109 -0
- package/src/__tests__/recorder.test.ts +278 -0
- package/src/__tests__/recordings.test.ts +353 -0
- package/src/__tests__/transcriber.test.ts +322 -0
- package/src/__tests__/types.test.ts +75 -0
- package/src/cli/index.ts +1078 -0
- package/src/db/agents.ts +81 -0
- package/src/db/database.ts +126 -0
- package/src/db/projects.ts +71 -0
- package/src/db/recordings.ts +219 -0
- package/src/index.ts +81 -0
- package/src/lib/config.ts +166 -0
- package/src/lib/enhancer.ts +167 -0
- package/src/lib/recorder.ts +198 -0
- package/src/lib/transcriber.ts +105 -0
- package/src/mcp/index.ts +405 -0
- package/src/native/RecordingsHelper.swift +352 -0
- package/src/types/index.ts +138 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { existsSync, readFileSync } 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, 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.RECORDINGS_DB_PATH) {
|
|
71
|
+
config.db_path = process.env.RECORDINGS_DB_PATH;
|
|
72
|
+
}
|
|
73
|
+
if (process.env.RECORDINGS_AUDIO_DIR) {
|
|
74
|
+
config.audio_dir = process.env.RECORDINGS_AUDIO_DIR;
|
|
75
|
+
}
|
|
76
|
+
if (process.env.RECORDINGS_MAX_SECONDS) {
|
|
77
|
+
config.max_recording_seconds = parseInt(
|
|
78
|
+
process.env.RECORDINGS_MAX_SECONDS,
|
|
79
|
+
10
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 3. Load API key from ~/.secrets if not set
|
|
84
|
+
if (!config.openai_api_key) {
|
|
85
|
+
config.openai_api_key = loadSecretKey("OPENAI_API_KEY");
|
|
86
|
+
}
|
|
87
|
+
if (!config.enhancement_api_key) {
|
|
88
|
+
config.enhancement_api_key =
|
|
89
|
+
config.openai_api_key || loadSecretKey("OPENAI_API_KEY");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 4. Set defaults for paths
|
|
93
|
+
if (!config.db_path) {
|
|
94
|
+
config.db_path = join(getDataDir(), "recordings.db");
|
|
95
|
+
}
|
|
96
|
+
if (!config.audio_dir) {
|
|
97
|
+
config.audio_dir = join(getDataDir(), "audio");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return config;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function findConfigFile(): string | null {
|
|
104
|
+
// Walk up from cwd looking for .recordings/config.json
|
|
105
|
+
let dir = process.cwd();
|
|
106
|
+
const root = "/";
|
|
107
|
+
while (dir !== root) {
|
|
108
|
+
const candidate = join(dir, ".recordings", "config.json");
|
|
109
|
+
if (existsSync(candidate)) return candidate;
|
|
110
|
+
const parent = join(dir, "..");
|
|
111
|
+
if (parent === dir) break;
|
|
112
|
+
dir = parent;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function getDataDir(): string {
|
|
118
|
+
// Check for .recordings in cwd hierarchy
|
|
119
|
+
let dir = process.cwd();
|
|
120
|
+
const root = "/";
|
|
121
|
+
while (dir !== root) {
|
|
122
|
+
const candidate = join(dir, ".recordings");
|
|
123
|
+
if (existsSync(candidate)) return candidate;
|
|
124
|
+
const parent = join(dir, "..");
|
|
125
|
+
if (parent === dir) break;
|
|
126
|
+
dir = parent;
|
|
127
|
+
}
|
|
128
|
+
// Fall back to ~/.recordings
|
|
129
|
+
return join(homedir(), ".recordings");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function loadSecretKey(keyName: string): string {
|
|
133
|
+
const secretsPath = join(homedir(), ".secrets");
|
|
134
|
+
if (!existsSync(secretsPath)) return "";
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const content = readFileSync(secretsPath, "utf-8");
|
|
138
|
+
const match = content.match(
|
|
139
|
+
new RegExp(`export\\s+${keyName}\\s*=\\s*"([^"]+)"`)
|
|
140
|
+
);
|
|
141
|
+
if (match) return match[1]!;
|
|
142
|
+
|
|
143
|
+
const match2 = content.match(
|
|
144
|
+
new RegExp(`export\\s+${keyName}\\s*=\\s*'([^']+)'`)
|
|
145
|
+
);
|
|
146
|
+
if (match2) return match2[1]!;
|
|
147
|
+
|
|
148
|
+
const match3 = content.match(new RegExp(`${keyName}\\s*=\\s*(.+)`));
|
|
149
|
+
if (match3) return match3[1]!.trim().replace(/^["']|["']$/g, "");
|
|
150
|
+
} catch {
|
|
151
|
+
// Ignore
|
|
152
|
+
}
|
|
153
|
+
return "";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function ensureDataDir(config: RecordingsConfig): void {
|
|
157
|
+
const { mkdirSync } = require("fs") as typeof import("fs");
|
|
158
|
+
mkdirSync(config.audio_dir, { recursive: true });
|
|
159
|
+
|
|
160
|
+
// Ensure db directory exists
|
|
161
|
+
const dbDir = config.db_path.substring(
|
|
162
|
+
0,
|
|
163
|
+
config.db_path.lastIndexOf("/")
|
|
164
|
+
);
|
|
165
|
+
if (dbDir) mkdirSync(dbDir, { recursive: true });
|
|
166
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
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
|
+
): Promise<EnhancementResult> {
|
|
97
|
+
const client = getEnhancementClient(config);
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const response = await client.chat.completions.create({
|
|
101
|
+
model: config.enhancement_model,
|
|
102
|
+
messages: [
|
|
103
|
+
{
|
|
104
|
+
role: "system",
|
|
105
|
+
content: `You are a writing assistant. The user has dictated speech that needs to be transformed into polished output.
|
|
106
|
+
|
|
107
|
+
Rules:
|
|
108
|
+
- Output ONLY the enhanced/rewritten text — no explanations, no preamble
|
|
109
|
+
- Preserve the user's intent and meaning
|
|
110
|
+
- Fix grammar, structure, and clarity
|
|
111
|
+
- If the user is giving instructions (e.g., "write an email saying..."), produce the actual output (the email), not a description of it
|
|
112
|
+
- If the user says "say it better" or similar, rewrite their preceding text to be clearer and more professional
|
|
113
|
+
- Match the appropriate tone (formal for business, casual for personal)`,
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
role: "user",
|
|
117
|
+
content: instruction,
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
temperature: 0.3,
|
|
121
|
+
max_tokens: 4096,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const enhanced =
|
|
125
|
+
response.choices[0]?.message?.content?.trim() || rawText;
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
original: rawText,
|
|
129
|
+
enhanced,
|
|
130
|
+
model: config.enhancement_model,
|
|
131
|
+
reasoning: null,
|
|
132
|
+
};
|
|
133
|
+
} catch (error) {
|
|
134
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
135
|
+
throw new EnhancementError(`Enhancement failed: ${msg}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Full pipeline: detect if enhancement is needed, enhance if so.
|
|
141
|
+
*/
|
|
142
|
+
export async function processText(
|
|
143
|
+
rawText: string,
|
|
144
|
+
config: RecordingsConfig
|
|
145
|
+
): Promise<{
|
|
146
|
+
text: string;
|
|
147
|
+
mode: "raw" | "enhanced";
|
|
148
|
+
enhancement_model: string | null;
|
|
149
|
+
}> {
|
|
150
|
+
if (!config.auto_enhance) {
|
|
151
|
+
return { text: rawText, mode: "raw", enhancement_model: null };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const detection = needsEnhancement(rawText, config);
|
|
155
|
+
|
|
156
|
+
if (!detection.needs) {
|
|
157
|
+
return { text: rawText, mode: "raw", enhancement_model: null };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const result = await enhanceText(rawText, detection.instruction, config);
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
text: result.enhanced,
|
|
164
|
+
mode: "enhanced",
|
|
165
|
+
enhancement_model: result.model,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import { createReadStream } from "fs";
|
|
3
|
+
import type { RecordingsConfig, TranscriptionResult } from "../types/index.js";
|
|
4
|
+
import { TranscriptionError } from "../types/index.js";
|
|
5
|
+
|
|
6
|
+
let _client: OpenAI | null = null;
|
|
7
|
+
|
|
8
|
+
function getClient(config: RecordingsConfig): OpenAI {
|
|
9
|
+
if (_client) return _client;
|
|
10
|
+
if (!config.openai_api_key) {
|
|
11
|
+
throw new TranscriptionError(
|
|
12
|
+
"OpenAI API key not configured. Set OPENAI_API_KEY env var or add to ~/.secrets"
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
_client = new OpenAI({ apiKey: config.openai_api_key });
|
|
16
|
+
return _client;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resetClient(): void {
|
|
20
|
+
_client = null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function transcribeAudio(
|
|
24
|
+
audioPath: string,
|
|
25
|
+
config: RecordingsConfig
|
|
26
|
+
): Promise<TranscriptionResult> {
|
|
27
|
+
const client = getClient(config);
|
|
28
|
+
const startTime = Date.now();
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const transcription = await client.audio.transcriptions.create({
|
|
32
|
+
file: createReadStream(audioPath),
|
|
33
|
+
model: config.transcription_model,
|
|
34
|
+
language: config.language || undefined,
|
|
35
|
+
response_format: "json",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const durationMs = Date.now() - startTime;
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
text: transcription.text,
|
|
42
|
+
duration_ms: durationMs,
|
|
43
|
+
model: config.transcription_model,
|
|
44
|
+
language: (transcription as unknown as Record<string, unknown>).language as string | null,
|
|
45
|
+
};
|
|
46
|
+
} catch (error) {
|
|
47
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
48
|
+
throw new TranscriptionError(`Transcription failed: ${msg}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function transcribeBuffer(
|
|
53
|
+
buffer: Buffer,
|
|
54
|
+
filename: string,
|
|
55
|
+
config: RecordingsConfig
|
|
56
|
+
): Promise<TranscriptionResult> {
|
|
57
|
+
const client = getClient(config);
|
|
58
|
+
const startTime = Date.now();
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const file = new File([new Uint8Array(buffer)], filename, {
|
|
62
|
+
type: getMimeType(filename),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const transcription = await client.audio.transcriptions.create({
|
|
66
|
+
file,
|
|
67
|
+
model: config.transcription_model,
|
|
68
|
+
language: config.language || undefined,
|
|
69
|
+
response_format: "json",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const durationMs = Date.now() - startTime;
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
text: transcription.text,
|
|
76
|
+
duration_ms: durationMs,
|
|
77
|
+
model: config.transcription_model,
|
|
78
|
+
language: (transcription as unknown as Record<string, unknown>).language as string | null,
|
|
79
|
+
};
|
|
80
|
+
} catch (error) {
|
|
81
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
82
|
+
throw new TranscriptionError(`Transcription failed: ${msg}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getMimeType(filename: string): string {
|
|
87
|
+
const ext = filename.split(".").pop()?.toLowerCase();
|
|
88
|
+
switch (ext) {
|
|
89
|
+
case "wav":
|
|
90
|
+
return "audio/wav";
|
|
91
|
+
case "mp3":
|
|
92
|
+
return "audio/mpeg";
|
|
93
|
+
case "m4a":
|
|
94
|
+
return "audio/mp4";
|
|
95
|
+
case "webm":
|
|
96
|
+
return "audio/webm";
|
|
97
|
+
case "mp4":
|
|
98
|
+
return "audio/mp4";
|
|
99
|
+
case "mpeg":
|
|
100
|
+
case "mpga":
|
|
101
|
+
return "audio/mpeg";
|
|
102
|
+
default:
|
|
103
|
+
return "audio/wav";
|
|
104
|
+
}
|
|
105
|
+
}
|