@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.
@@ -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
+ }