@superdots/airtype 0.4.0

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/src/audio.ts ADDED
@@ -0,0 +1,110 @@
1
+ import { spawn } from "child_process";
2
+
3
+ export interface Recorder {
4
+ stop: () => Promise<Buffer>;
5
+ onVolume: (cb: (level: number) => void) => void;
6
+ }
7
+
8
+ /**
9
+ * Record audio to stdout as raw PCM, calculate volume in JS,
10
+ * accumulate buffer for WAV conversion on stop.
11
+ * Single rec process — no device conflict.
12
+ */
13
+ export function startRecording(micDevice = "default"): Recorder {
14
+ // Switch macOS input device if specified
15
+ if (micDevice !== "default") {
16
+ try {
17
+ const { execSync } = require("child_process");
18
+ execSync(`SwitchAudioSource -t input -s "${micDevice}"`, { stdio: "ignore" });
19
+ } catch {}
20
+ }
21
+
22
+ // Output raw PCM to stdout, 16kHz mono 16-bit
23
+ const proc = spawn("rec", [
24
+ "-q", "-r", "16000", "-c", "1", "-b", "16",
25
+ "-t", "raw", "-", // raw PCM to stdout
26
+ ], { stdio: ["pipe", "pipe", "pipe"] });
27
+
28
+ const chunks: Buffer[] = [];
29
+ let volumeCb: ((level: number) => void) | null = null;
30
+
31
+ proc.stdout?.on("data", (chunk: Buffer) => {
32
+ chunks.push(chunk);
33
+
34
+ if (!volumeCb) return;
35
+ // Calculate RMS from raw 16-bit PCM
36
+ let sum = 0;
37
+ const samples = Math.floor(chunk.length / 2);
38
+ for (let i = 0; i < chunk.length - 1; i += 2) {
39
+ const sample = chunk.readInt16LE(i) / 32768;
40
+ sum += sample * sample;
41
+ }
42
+ const rms = Math.sqrt(sum / Math.max(samples, 1));
43
+ // Voice RMS: ~0.005-0.3, noise floor: ~0.0001
44
+ // Scale so normal speech fills ~60-80% of the bar
45
+ const level = Math.min(1, rms * 20);
46
+ volumeCb(level);
47
+ });
48
+
49
+ return {
50
+ onVolume: (cb) => { volumeCb = cb; },
51
+ stop: () =>
52
+ new Promise((resolve, reject) => {
53
+ proc.on("close", () => {
54
+ try {
55
+ const pcm = Buffer.concat(chunks);
56
+ const wav = pcmToWav(pcm, 16000, 1, 16);
57
+ resolve(wav);
58
+ } catch (e) {
59
+ reject(e);
60
+ }
61
+ });
62
+ proc.kill("SIGTERM");
63
+ }),
64
+ };
65
+ }
66
+
67
+ /** Convert raw PCM to WAV buffer */
68
+ function pcmToWav(pcm: Buffer, sampleRate: number, channels: number, bitsPerSample: number): Buffer {
69
+ const byteRate = sampleRate * channels * (bitsPerSample / 8);
70
+ const blockAlign = channels * (bitsPerSample / 8);
71
+ const header = Buffer.alloc(44);
72
+
73
+ header.write("RIFF", 0);
74
+ header.writeUInt32LE(36 + pcm.length, 4);
75
+ header.write("WAVE", 8);
76
+ header.write("fmt ", 12);
77
+ header.writeUInt32LE(16, 16); // chunk size
78
+ header.writeUInt16LE(1, 20); // PCM format
79
+ header.writeUInt16LE(channels, 22);
80
+ header.writeUInt32LE(sampleRate, 24);
81
+ header.writeUInt32LE(byteRate, 28);
82
+ header.writeUInt16LE(blockAlign, 32);
83
+ header.writeUInt16LE(bitsPerSample, 34);
84
+ header.write("data", 36);
85
+ header.writeUInt32LE(pcm.length, 40);
86
+
87
+ return Buffer.concat([header, pcm]);
88
+ }
89
+
90
+ /** Record for a fixed duration (for block tests) */
91
+ export async function recordDuration(seconds: number, micDevice = "default"): Promise<Buffer> {
92
+ const tmpFile = `/tmp/airtype-${Date.now()}.wav`;
93
+
94
+ const proc = spawn("rec", ["-q", "-r", "16000", "-c", "1", "-b", "16", tmpFile, "trim", "0", String(seconds)], {
95
+ stdio: ["pipe", "pipe", "pipe"],
96
+ });
97
+
98
+ return new Promise((resolve, reject) => {
99
+ proc.on("close", async () => {
100
+ try {
101
+ const file = Bun.file(tmpFile);
102
+ const buffer = Buffer.from(await file.arrayBuffer());
103
+ resolve(buffer);
104
+ } catch (e) {
105
+ reject(e);
106
+ }
107
+ });
108
+ proc.on("error", reject);
109
+ });
110
+ }
package/src/config.ts ADDED
@@ -0,0 +1,91 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+
5
+ /** Base directory for all airtype data: ~/.airtype/ */
6
+ export const AIRTYPE_HOME = join(homedir(), ".airtype");
7
+ export const airpath = (...parts: string[]) => join(AIRTYPE_HOME, ...parts);
8
+
9
+ // Ensure base dir exists on import
10
+ mkdirSync(AIRTYPE_HOME, { recursive: true });
11
+ mkdirSync(airpath("logs"), { recursive: true });
12
+ mkdirSync(airpath("recordings"), { recursive: true });
13
+
14
+ const CONFIG_PATH = airpath("config.json");
15
+ const ENV_PATH = airpath(".env");
16
+
17
+ export interface AirtypeConfig {
18
+ micDevice: string;
19
+ language: string;
20
+ shortcutDisplay: string;
21
+ shortcutKeys: string[];
22
+ autoEnter: boolean;
23
+ wordCount: number;
24
+ testPassed: boolean;
25
+ onboardingDone: boolean;
26
+ }
27
+
28
+ const defaults: AirtypeConfig = {
29
+ micDevice: "default",
30
+ language: "auto",
31
+ shortcutDisplay: "",
32
+ shortcutKeys: [],
33
+ autoEnter: true,
34
+ wordCount: 0,
35
+ testPassed: false,
36
+ onboardingDone: false,
37
+ };
38
+
39
+ export function loadConfig(): AirtypeConfig {
40
+ if (existsSync(CONFIG_PATH)) {
41
+ try {
42
+ return { ...defaults, ...JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) };
43
+ } catch {
44
+ return { ...defaults };
45
+ }
46
+ }
47
+ return { ...defaults };
48
+ }
49
+
50
+ export function saveConfig(config: AirtypeConfig) {
51
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
52
+ }
53
+
54
+ export function loadEnvKey(key: string): string {
55
+ if (existsSync(ENV_PATH)) {
56
+ const lines = readFileSync(ENV_PATH, "utf-8").split("\n");
57
+ for (const line of lines) {
58
+ const trimmed = line.trim();
59
+ if (trimmed.startsWith("#") || !trimmed) continue;
60
+ const eq = trimmed.indexOf("=");
61
+ if (eq > 0 && trimmed.slice(0, eq).trim() === key) {
62
+ return trimmed.slice(eq + 1).trim();
63
+ }
64
+ }
65
+ }
66
+ return process.env[key] || "";
67
+ }
68
+
69
+
70
+ export function isReady(config: AirtypeConfig): boolean {
71
+ return !!(
72
+ config.onboardingDone &&
73
+ config.shortcutKeys.length > 0 &&
74
+ config.micDevice && config.micDevice !== "default" &&
75
+ config.testPassed
76
+ );
77
+ }
78
+
79
+ const FREE_WORD_LIMIT = 10000;
80
+
81
+ /** Add words and save. Returns true if over free limit. */
82
+ export function addWords(config: AirtypeConfig, text: string): boolean {
83
+ const words = text.trim().split(/\s+/).length;
84
+ config.wordCount = (config.wordCount || 0) + words;
85
+ saveConfig(config);
86
+ return config.wordCount > FREE_WORD_LIMIT;
87
+ }
88
+
89
+ export function isOverLimit(config: AirtypeConfig): boolean {
90
+ return (config.wordCount || 0) > FREE_WORD_LIMIT;
91
+ }
package/src/keys.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { IGlobalKeyDownMap } from "node-global-key-listener";
2
+
3
+ /**
4
+ * Build a shortcut display string from the current isDown map + pressed key name.
5
+ * Uses the isDown map provided by node-global-key-listener (second callback arg).
6
+ * This is the authoritative modifier state — no manual tracking needed.
7
+ */
8
+ export function buildCombo(keyName: string, isDown: IGlobalKeyDownMap): string {
9
+ const parts: string[] = [];
10
+
11
+ if (isDown["LEFT META"] || isDown["RIGHT META"]) parts.push("Cmd");
12
+ if (isDown["LEFT CTRL"] || isDown["RIGHT CTRL"]) parts.push("Ctrl");
13
+ if (isDown["LEFT ALT"] || isDown["RIGHT ALT"]) parts.push("Alt");
14
+ if (isDown["LEFT SHIFT"] || isDown["RIGHT SHIFT"]) parts.push("Shift");
15
+
16
+ // Don't add modifier names as the key itself
17
+ if (!isModifier(keyName)) {
18
+ parts.push(keyName);
19
+ }
20
+
21
+ const result = parts.join("+");
22
+
23
+ // Raw log every key event for debugging
24
+ const fs = require("fs");
25
+ const { airpath } = require("./config.js");
26
+ const ts = new Date().toISOString();
27
+ const logLine = JSON.stringify({ ts, keyName, combo: result, isDown: Object.fromEntries(Object.entries(isDown).filter(([_, v]) => v)) }) + "\n";
28
+ try { fs.appendFileSync(airpath("logs", "keystrokes.jsonl"), logLine); } catch {}
29
+
30
+ return result;
31
+ }
32
+
33
+ /** Debounce shortcut — returns true if this is a duplicate within 200ms */
34
+ let lastCombo = "";
35
+ let lastTime = 0;
36
+
37
+ export function isDuplicate(combo: string): boolean {
38
+ const now = Date.now();
39
+ if (combo === lastCombo && now - lastTime < 200) {
40
+ return true;
41
+ }
42
+ lastCombo = combo;
43
+ lastTime = now;
44
+ return false;
45
+ }
46
+
47
+ export function isModifier(name: string): boolean {
48
+ return [
49
+ "LEFT META", "RIGHT META",
50
+ "LEFT CTRL", "RIGHT CTRL",
51
+ "LEFT ALT", "RIGHT ALT",
52
+ "LEFT SHIFT", "RIGHT SHIFT",
53
+ ].includes(name);
54
+ }
package/src/llm.ts ADDED
@@ -0,0 +1,63 @@
1
+ const PROXY_URL = "https://airtype-xi.vercel.app/api/llm";
2
+
3
+ const SYSTEM_PROMPT = `You are a voice-to-text polish assistant. Your job is to clean up raw speech transcription into well-written, professionally formatted text.
4
+
5
+ Rules:
6
+ 1. ALWAYS output in English, even if the input is in Korean or another language — translate naturally
7
+ 2. Add proper punctuation (periods, commas, question marks)
8
+ 3. Remove filler words (um, uh, 음, 어, 그)
9
+ 4. Fix grammar while preserving the original meaning exactly
10
+ 5. Preserve technical terms, proper nouns, and numbers exactly
11
+ 6. Output ONLY the polished text, no explanations
12
+ 7. Do not add or remove any meaning
13
+ 8. Fix common STT mishearings using context (e.g. technical terms the speaker likely intended)
14
+
15
+ Smart Formatting — automatically detect and apply the best structure:
16
+ - Sequential items (first/second/third, one/two/three, step 1/2/3) → numbered list (1. 2. 3.)
17
+ - Parallel items (also, and, another thing) → bullet list (- item)
18
+ - Email-like speech (dear X, regards, sincerely) → email format with line breaks
19
+ - Long continuous speech → split into paragraphs at natural topic boundaries
20
+ - Time references: "2 30 pm" → 2:30 PM, "10 am" → 10:00 AM
21
+ - Percentages: "40 percent" → 40%
22
+ - Voice punctuation commands: "new line" → \\n, "new paragraph" → \\n\\n, "period" → ., "comma" → ,, "question mark" → ?
23
+
24
+ Hesitation & Repetition Clearing — remove false starts and self-corrections, keep only the final intent:
25
+ - "I think... no wait... I believe X" → "I believe X"
26
+ - "um I know that... I knew that is good" → "I knew that is good"
27
+ - "so the thing is... what I mean is... we need X" → "We need X"
28
+ - Stuttered words: "the the the problem" → "The problem"
29
+ - Abandoned sentences followed by restart: keep only the restart`;
30
+
31
+ export interface LlmResult {
32
+ text: string;
33
+ durationMs: number;
34
+ }
35
+
36
+ export async function polish(rawText: string, model = "google/gemini-2.5-flash"): Promise<LlmResult> {
37
+ const start = Date.now();
38
+
39
+ const resp = await fetch(PROXY_URL, {
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/json" },
42
+ body: JSON.stringify({
43
+ model,
44
+ messages: [
45
+ { role: "system", content: SYSTEM_PROMPT },
46
+ { role: "user", content: `<transcription>${rawText}</transcription>` },
47
+ ],
48
+ max_tokens: 4096,
49
+ temperature: 0.3,
50
+ }),
51
+ });
52
+
53
+ const body = await resp.text();
54
+ const durationMs = Date.now() - start;
55
+
56
+ if (!resp.ok) {
57
+ throw new Error(`LLM failed (${resp.status}): ${body}`);
58
+ }
59
+
60
+ const parsed = JSON.parse(body);
61
+ const text = parsed.choices?.[0]?.message?.content || "";
62
+ return { text, durationMs };
63
+ }
@@ -0,0 +1,273 @@
1
+ import { execSync } from "child_process";
2
+ import { GlobalKeyboardListener } from "node-global-key-listener";
3
+ import { loadEnvKey, saveConfig, type AirtypeConfig } from "./config.js";
4
+ import { recordDuration } from "./audio.js";
5
+ import { transcribe } from "./stt.js";
6
+ import { polish } from "./llm.js";
7
+ import inquirer from "inquirer";
8
+
9
+ const CYAN = "\x1b[36m";
10
+ const GREEN = "\x1b[32m";
11
+ const YELLOW = "\x1b[33m";
12
+ const RED = "\x1b[31m";
13
+ const BOLD = "\x1b[1m";
14
+ const DIM = "\x1b[2m";
15
+ const RESET = "\x1b[0m";
16
+
17
+ export async function runOnboarding(config: AirtypeConfig) {
18
+ console.log(`
19
+ ${CYAN} ╭─────────────────────────────────────╮
20
+ │ │
21
+ │ ░█▀█░▀█▀░█▀▄░▀█▀░█░█░█▀█░█▀▀ │
22
+ │ ░█▀█░░█░░█▀▄░░█░░░█░░█▀▀░█▀▀ │
23
+ │ ░▀░▀░▀▀▀░▀░▀░░▀░░░▀░░▀░░░▀▀▀ │
24
+ │ │
25
+ │ hands-free transcription │
26
+ │ v0.2.0 │
27
+ ╰─────────────────────────────────────╯${RESET}
28
+
29
+ Welcome to Airtype. Let's set things up.
30
+ `);
31
+
32
+ // Step 1: Mic Permission
33
+ console.log(` ${CYAN}${BOLD}[1/7] Microphone Permission${RESET}`);
34
+ try {
35
+ execSync("rec -q -r 16000 -c 1 /tmp/airtype-test.wav trim 0 0.1", { stdio: "ignore" });
36
+ console.log(` ${GREEN}${BOLD}✓${RESET} Microphone access granted\n`);
37
+ } catch {
38
+ console.log(` ${RED}${BOLD}✗${RESET} Microphone access denied. Grant permission in System Settings.\n`);
39
+ }
40
+
41
+ // Step 2: Accessibility
42
+ console.log(` ${CYAN}${BOLD}[2/7] Accessibility Permission${RESET}`);
43
+ console.log(` ${DIM}Needed to paste text into other apps.${RESET}`);
44
+ console.log(` ${DIM}Opening System Settings...${RESET}\n`);
45
+ execSync("open 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility'");
46
+ console.log(` ${DIM}Find your terminal app and toggle it ON.${RESET}\n`);
47
+ await prompt(" Press Enter when done... ");
48
+ console.log(` ${GREEN}${BOLD}✓${RESET} Accessibility configured\n`);
49
+
50
+ // Step 3: Mic Selection
51
+ console.log(` ${CYAN}${BOLD}[3/7] Microphone Selection${RESET}\n`);
52
+ const mics = getMicrophones();
53
+ const { mic } = await inquirer.prompt([{
54
+ type: "list",
55
+ name: "mic",
56
+ message: "Select microphone:",
57
+ choices: mics,
58
+ }]);
59
+ config.micDevice = mic;
60
+ console.log(` ${GREEN}${BOLD}✓${RESET} ${mic}\n`);
61
+
62
+ // Step 4: Shortcut
63
+ console.log(` ${CYAN}${BOLD}[4/7] Shortcut Key Binding${RESET}`);
64
+ console.log(` ${DIM}Press your desired shortcut combo from anywhere.${RESET}`);
65
+ console.log(` ${DIM}Must include a modifier: Ctrl/Alt/Shift/Cmd + key${RESET}\n`);
66
+ console.log(` ${BOLD}Waiting for keypress...${RESET}`);
67
+
68
+ const shortcut = await captureShortcut();
69
+ config.shortcutDisplay = shortcut.display;
70
+ config.shortcutKeys = shortcut.keys;
71
+ console.log(` ${GREEN}${BOLD}✓${RESET} Shortcut: ${shortcut.display}\n`);
72
+
73
+ // Step 5: API Keys
74
+ console.log(` ${CYAN}${BOLD}[5/7] API Keys${RESET}`);
75
+ const envGroq = loadEnvKey("GROQ_API_KEY");
76
+ const envOr = loadEnvKey("OPENROUTER_API_KEY");
77
+ if (envGroq && envOr) {
78
+ config.groqApiKey = envGroq;
79
+ config.openrouterApiKey = envOr;
80
+ console.log(` ${DIM}Found in .env${RESET}`);
81
+ console.log(` ${GREEN}${BOLD}✓${RESET} API keys loaded\n`);
82
+ } else {
83
+ if (!config.groqApiKey) {
84
+ const { key } = await inquirer.prompt([{ type: "input", name: "key", message: "Groq API key (console.groq.com):" }]);
85
+ config.groqApiKey = key;
86
+ }
87
+ if (!config.openrouterApiKey) {
88
+ const { key } = await inquirer.prompt([{ type: "input", name: "key", message: "OpenRouter API key (openrouter.ai):" }]);
89
+ config.openrouterApiKey = key;
90
+ }
91
+ console.log(` ${GREEN}${BOLD}✓${RESET} API keys saved\n`);
92
+ }
93
+
94
+ // Step 6: Language
95
+ console.log(` ${CYAN}${BOLD}[6/7] Language${RESET}\n`);
96
+ const { lang } = await inquirer.prompt([{
97
+ type: "list",
98
+ name: "lang",
99
+ message: "Transcription language:",
100
+ choices: [
101
+ { name: "Auto-detect (recommended)", value: "auto" },
102
+ { name: "Korean (한국어)", value: "ko" },
103
+ { name: "English", value: "en" },
104
+ { name: "Japanese (日本語)", value: "ja" },
105
+ ],
106
+ }]);
107
+ config.language = lang;
108
+
109
+ // Step 7: Guided Test
110
+ console.log(`\n ${CYAN}${BOLD}[7/7] Guided Test${RESET}`);
111
+ console.log(` ${DIM}Try 3 recordings with your shortcut key.${RESET}`);
112
+ console.log(` ${DIM}Read each sentence aloud to see the result.${RESET}\n`);
113
+
114
+ const tests = [
115
+ { label: "Hesitation Clearing", sentence: "Um so I think we should... no wait... we need to fix the login bug" },
116
+ { label: "Auto Numbered List", sentence: "First update the API docs second fix the timeout bug third deploy to staging" },
117
+ { label: "Email Format", sentence: "Dear Michael new line I wanted to follow up on our meeting period Best regards Chris" },
118
+ ];
119
+
120
+ for (let i = 0; i < tests.length; i++) {
121
+ const { label, sentence } = tests[i]!;
122
+
123
+ console.log(` ${CYAN}╭─ [${i + 1}/3] ${label} ${"─".repeat(Math.max(0, 42 - label.length))}╮${RESET}`);
124
+ console.log(` ${CYAN}│${RESET} ${CYAN}│${RESET}`);
125
+ console.log(` ${CYAN}│${RESET} ${BOLD}${`Say: "${sentence}"`}${RESET}`);
126
+ console.log(` ${CYAN}│${RESET} ${CYAN}│${RESET}`);
127
+ console.log(` ${CYAN}│${RESET} Press ${BOLD}${config.shortcutDisplay}${RESET} to start, press again to stop. ${CYAN}│${RESET}`);
128
+ console.log(` ${CYAN}│${RESET} ${CYAN}│${RESET}`);
129
+ console.log(` ${CYAN}╰${"─".repeat(50)}╯${RESET}\n`);
130
+
131
+ // Wait for shortcut press → record → shortcut press → stop → process
132
+ const wavBuffer = await recordWithShortcut(config);
133
+
134
+ console.log(` ${YELLOW}Processing...${RESET}`);
135
+ try {
136
+ const sttResult = await transcribe(config.groqApiKey, wavBuffer, config.language);
137
+ const llmResult = await polish(config.openrouterApiKey, sttResult.text);
138
+
139
+ console.log(` ${DIM}You said:${RESET} ${sttResult.text.trim()}`);
140
+ console.log(` ${GREEN}${BOLD}Result:${RESET} ${BOLD}${llmResult.text}${RESET}`);
141
+ console.log(` ${DIM}${sttResult.durationMs + llmResult.durationMs}ms${RESET}\n`);
142
+ } catch (e: any) {
143
+ console.log(` ${RED}Error: ${e.message}${RESET}\n`);
144
+ }
145
+ }
146
+
147
+ console.log(` ${GREEN}${BOLD}✓${RESET} All 3 tests done! You're ready to use Airtype.\n`);
148
+
149
+ config.onboardingDone = true;
150
+ saveConfig(config);
151
+ }
152
+
153
+ // ─── Helpers ─────────────────────────────────────
154
+
155
+ function getMicrophones(): string[] {
156
+ try {
157
+ const output = execSync("system_profiler SPAudioDataType 2>/dev/null", { encoding: "utf-8" });
158
+ const names: string[] = [];
159
+ for (const line of output.split("\n")) {
160
+ const trimmed = line.trim();
161
+ if (trimmed.endsWith(":") && !trimmed.startsWith("Audio") && !trimmed.startsWith("Devices") && !trimmed.startsWith("Input") && !trimmed.startsWith("Output")) {
162
+ const name = trimmed.replace(/:$/, "").trim();
163
+ if (name && !names.includes(name)) names.push(name);
164
+ }
165
+ }
166
+ if (names.length === 0) names.push("default");
167
+ return names;
168
+ } catch {
169
+ return ["default"];
170
+ }
171
+ }
172
+
173
+ function captureShortcut(): Promise<{ display: string; keys: string[] }> {
174
+ return new Promise((resolve) => {
175
+ const listener = new GlobalKeyboardListener();
176
+ const heldMods = new Set<string>();
177
+
178
+ listener.addListener((e) => {
179
+ const name = e.name || "";
180
+
181
+ // Track modifier presses
182
+ if (["LEFT META", "RIGHT META", "LEFT CTRL", "RIGHT CTRL", "LEFT ALT", "RIGHT ALT", "LEFT SHIFT", "RIGHT SHIFT"].includes(name)) {
183
+ if (e.state === "DOWN") {
184
+ if (name.includes("META")) heldMods.add("Cmd");
185
+ if (name.includes("CTRL")) heldMods.add("Ctrl");
186
+ if (name.includes("ALT")) heldMods.add("Alt");
187
+ if (name.includes("SHIFT")) heldMods.add("Shift");
188
+ }
189
+ return;
190
+ }
191
+
192
+ // Non-modifier key pressed with at least one modifier held
193
+ if (e.state === "DOWN" && heldMods.size > 0) {
194
+ const keys = [...heldMods, name];
195
+ const display = keys.join("+");
196
+ listener.kill();
197
+ resolve({ display, keys });
198
+ }
199
+ });
200
+ });
201
+ }
202
+
203
+ function recordWithShortcut(config: AirtypeConfig): Promise<Buffer> {
204
+ return new Promise((resolve, reject) => {
205
+ const listener = new GlobalKeyboardListener();
206
+ let recording = false;
207
+ let recProc: any = null;
208
+ const heldMods = new Set<string>();
209
+ const target = config.shortcutDisplay;
210
+
211
+ listener.addListener((e) => {
212
+ const name = e.name || "";
213
+
214
+ // Track modifiers
215
+ if (["LEFT META", "RIGHT META", "LEFT CTRL", "RIGHT CTRL", "LEFT ALT", "RIGHT ALT", "LEFT SHIFT", "RIGHT SHIFT"].includes(name)) {
216
+ if (e.state === "DOWN") {
217
+ if (name.includes("META")) heldMods.add("Cmd");
218
+ if (name.includes("CTRL")) heldMods.add("Ctrl");
219
+ if (name.includes("ALT")) heldMods.add("Alt");
220
+ if (name.includes("SHIFT")) heldMods.add("Shift");
221
+ } else {
222
+ if (name.includes("META")) heldMods.delete("Cmd");
223
+ if (name.includes("CTRL")) heldMods.delete("Ctrl");
224
+ if (name.includes("ALT")) heldMods.delete("Alt");
225
+ if (name.includes("SHIFT")) heldMods.delete("Shift");
226
+ }
227
+ return;
228
+ }
229
+
230
+ if (e.state !== "DOWN") return;
231
+
232
+ const pressed = [...heldMods, name].join("+");
233
+ if (pressed !== target) return;
234
+
235
+ if (!recording) {
236
+ // Start
237
+ recording = true;
238
+ playSound("Glass");
239
+ console.log(` ${RED}${BOLD}🎙 Recording...${RESET}`);
240
+
241
+ const { spawn } = require("child_process");
242
+ const tmpFile = `/tmp/airtype-onboard-${Date.now()}.wav`;
243
+ recProc = { proc: spawn("rec", ["-q", "-r", "16000", "-c", "1", "-b", "16", tmpFile], { stdio: "pipe" }), file: tmpFile };
244
+ } else {
245
+ // Stop
246
+ playSound("Pop");
247
+ listener.kill();
248
+
249
+ const { proc, file } = recProc;
250
+ proc.on("close", async () => {
251
+ try {
252
+ const buf = Buffer.from(await Bun.file(file).arrayBuffer());
253
+ resolve(buf);
254
+ } catch (err) {
255
+ reject(err);
256
+ }
257
+ });
258
+ proc.kill("SIGTERM");
259
+ }
260
+ });
261
+ });
262
+ }
263
+
264
+ function playSound(name: string) {
265
+ try { execSync(`afplay /System/Library/Sounds/${name}.aiff &`, { stdio: "ignore" }); } catch {}
266
+ }
267
+
268
+ function prompt(msg: string): Promise<void> {
269
+ return new Promise((resolve) => {
270
+ process.stdout.write(msg);
271
+ process.stdin.once("data", () => resolve());
272
+ });
273
+ }
package/src/paste.ts ADDED
@@ -0,0 +1,85 @@
1
+ import { spawn, execSync } from "child_process";
2
+ import { appendFileSync } from "fs";
3
+ import { airpath } from "./config.js";
4
+
5
+ /** Copy text to clipboard and Cmd+V via osascript. Logs everything for debugging. */
6
+ export async function pasteText(text: string, autoEnter = true): Promise<number> {
7
+ const start = Date.now();
8
+ const logPath = airpath("logs", "paste.jsonl");
9
+
10
+ const log = (data: Record<string, any>) => {
11
+ appendFileSync(logPath, JSON.stringify({ ts: new Date().toISOString(), ...data }) + "\n");
12
+ };
13
+
14
+ // What app is focused right now?
15
+ let focusedApp = "unknown";
16
+ try {
17
+ focusedApp = execSync(
18
+ `osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true'`,
19
+ { timeout: 2000, encoding: "utf-8" }
20
+ ).trim();
21
+ } catch {}
22
+
23
+ log({ step: "start", focusedApp, textLen: text.length });
24
+
25
+ // Set clipboard via pbcopy
26
+ const pbcopy = spawn("pbcopy", [], { stdio: ["pipe", "pipe", "pipe"] });
27
+ pbcopy.stdin.write(text);
28
+ pbcopy.stdin.end();
29
+ await new Promise<void>((resolve) => pbcopy.on("close", () => resolve()));
30
+
31
+ // Verify clipboard was set
32
+ let clipboardOk = false;
33
+ try {
34
+ const clip = execSync("pbpaste", { timeout: 1000, encoding: "utf-8" });
35
+ clipboardOk = clip.length > 0 && clip.slice(0, 50) === text.slice(0, 50);
36
+ } catch {}
37
+
38
+ log({ step: "clipboard", ok: clipboardOk });
39
+
40
+ await new Promise((r) => setTimeout(r, 80));
41
+
42
+ // Cmd+V via osascript
43
+ let pasteOk = false;
44
+ let pasteError = "";
45
+ try {
46
+ const result = execSync(
47
+ `osascript -e 'tell application "System Events" to keystroke "v" using command down' 2>&1`,
48
+ { timeout: 3000, encoding: "utf-8" }
49
+ );
50
+ pasteOk = true;
51
+ log({ step: "paste", ok: true, focusedApp });
52
+ } catch (e: any) {
53
+ pasteError = e.message?.slice(0, 200) || "unknown";
54
+ log({ step: "paste", ok: false, error: pasteError, focusedApp });
55
+
56
+ // Retry once
57
+ await new Promise((r) => setTimeout(r, 200));
58
+ try {
59
+ execSync(`osascript -e 'tell application "System Events" to keystroke "v" using command down'`, { timeout: 3000 });
60
+ pasteOk = true;
61
+ log({ step: "paste-retry", ok: true });
62
+ } catch (e2: any) {
63
+ log({ step: "paste-retry", ok: false, error: e2.message?.slice(0, 200) });
64
+ }
65
+ }
66
+
67
+ if (!pasteOk) {
68
+ console.error(` ⚠ Paste failed (app: ${focusedApp}) — text is in clipboard, Cmd+V to paste`);
69
+ }
70
+
71
+ // Auto-enter after paste
72
+ if (pasteOk && autoEnter) {
73
+ await new Promise((r) => setTimeout(r, 100));
74
+ try {
75
+ execSync(`osascript -e 'tell application "System Events" to keystroke return'`, { timeout: 2000 });
76
+ log({ step: "auto-enter", ok: true });
77
+ } catch (e: any) {
78
+ log({ step: "auto-enter", ok: false, error: e.message?.slice(0, 100) });
79
+ }
80
+ }
81
+
82
+ const elapsed = Date.now() - start;
83
+ log({ step: "done", elapsed, pasteOk, autoEnter });
84
+ return elapsed;
85
+ }