@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/README.md +71 -0
- package/bin/airtype.js +2 -0
- package/package.json +40 -0
- package/src/app.tsx +652 -0
- package/src/audio.ts +110 -0
- package/src/config.ts +91 -0
- package/src/keys.ts +54 -0
- package/src/llm.ts +63 -0
- package/src/onboarding.ts +273 -0
- package/src/paste.ts +85 -0
- package/src/report.ts +86 -0
- package/src/stt.ts +31 -0
- package/src/test-blocks.ts +190 -0
- package/src/test-raw-pcm.ts +31 -0
- package/src/test-volume.ts +16 -0
- package/src/test-volume2.ts +17 -0
package/src/report.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync } from "fs";
|
|
2
|
+
import { airpath } from "./config.js";
|
|
3
|
+
import { loadEnvKey } from "./config.js";
|
|
4
|
+
|
|
5
|
+
function getWebhook(): string {
|
|
6
|
+
return loadEnvKey("SLACK_WEBHOOK_URL") || process.env.SLACK_WEBHOOK_URL || "";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Send error report to Slack automatically */
|
|
10
|
+
export async function reportError(context: string, error: string, extra?: Record<string, any>) {
|
|
11
|
+
try {
|
|
12
|
+
const hostname = require("os").hostname();
|
|
13
|
+
const payload = {
|
|
14
|
+
text: `🔴 *Airtype Error*\n*Context:* ${context}\n*Error:* \`${error.slice(0, 500)}\`\n*Host:* ${hostname}\n*Time:* ${new Date().toISOString()}${extra ? `\n*Extra:* \`\`\`${JSON.stringify(extra, null, 2).slice(0, 500)}\`\`\`` : ""}`,
|
|
15
|
+
};
|
|
16
|
+
const wh = getWebhook(); if (!wh) return;
|
|
17
|
+
await fetch(wh, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: { "Content-Type": "application/json" },
|
|
20
|
+
body: JSON.stringify(payload),
|
|
21
|
+
});
|
|
22
|
+
} catch {
|
|
23
|
+
// Silent fail — don't crash on report failure
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Send startup + config summary */
|
|
28
|
+
export async function reportStartup(config: Record<string, any>) {
|
|
29
|
+
try {
|
|
30
|
+
const hostname = require("os").hostname();
|
|
31
|
+
const payload = {
|
|
32
|
+
text: `🟢 *Airtype Started*\n*Host:* ${hostname}\n*Shortcut:* ${config.shortcutDisplay}\n*Mic:* ${config.micDevice}\n*Auto-Enter:* ${config.autoEnter ? "ON" : "OFF"}\n*Time:* ${new Date().toISOString()}`,
|
|
33
|
+
};
|
|
34
|
+
const wh = getWebhook(); if (!wh) return;
|
|
35
|
+
await fetch(wh, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { "Content-Type": "application/json" },
|
|
38
|
+
body: JSON.stringify(payload),
|
|
39
|
+
});
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Collect recent logs and send summary */
|
|
44
|
+
export async function reportLogs() {
|
|
45
|
+
try {
|
|
46
|
+
const logsDir = airpath("logs");
|
|
47
|
+
const parts: string[] = [];
|
|
48
|
+
|
|
49
|
+
// Paste logs
|
|
50
|
+
const pastePath = `${logsDir}/paste.jsonl`;
|
|
51
|
+
if (existsSync(pastePath)) {
|
|
52
|
+
const lines = readFileSync(pastePath, "utf-8").trim().split("\n").slice(-5);
|
|
53
|
+
parts.push("*Recent paste logs:*\n```\n" + lines.join("\n") + "\n```");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Keystroke logs (last 10)
|
|
57
|
+
const keysPath = `${logsDir}/keystrokes.jsonl`;
|
|
58
|
+
if (existsSync(keysPath)) {
|
|
59
|
+
const lines = readFileSync(keysPath, "utf-8").trim().split("\n").slice(-10);
|
|
60
|
+
parts.push("*Recent keystrokes:*\n```\n" + lines.join("\n") + "\n```");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Recent recordings
|
|
64
|
+
const recDir = airpath("recordings");
|
|
65
|
+
if (existsSync(recDir)) {
|
|
66
|
+
const jsons = readdirSync(recDir).filter(f => f.endsWith(".json")).sort().slice(-3);
|
|
67
|
+
for (const f of jsons) {
|
|
68
|
+
const content = readFileSync(`${recDir}/${f}`, "utf-8").slice(0, 300);
|
|
69
|
+
parts.push(`*${f}:*\n\`\`\`\n${content}\n\`\`\``);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const hostname = require("os").hostname();
|
|
74
|
+
const wh = getWebhook(); if (!wh) return;
|
|
75
|
+
await fetch(wh, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { "Content-Type": "application/json" },
|
|
78
|
+
body: JSON.stringify({
|
|
79
|
+
text: `📋 *Airtype Log Report*\n*Host:* ${hostname}\n*Time:* ${new Date().toISOString()}\n\n${parts.join("\n\n")}`,
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
console.log(" Report sent to Slack.");
|
|
83
|
+
} catch (e: any) {
|
|
84
|
+
console.error(" Report failed:", e.message);
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/stt.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const PROXY_URL = "https://airtype-xi.vercel.app/api/stt";
|
|
2
|
+
|
|
3
|
+
export interface SttResult {
|
|
4
|
+
text: string;
|
|
5
|
+
durationMs: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function transcribe(wavBuffer: Buffer, language: string): Promise<SttResult> {
|
|
9
|
+
const start = Date.now();
|
|
10
|
+
|
|
11
|
+
const formData = new FormData();
|
|
12
|
+
formData.append("file", new Blob([wavBuffer], { type: "audio/wav" }), "audio.wav");
|
|
13
|
+
if (language && language !== "auto") {
|
|
14
|
+
formData.append("language", language);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const resp = await fetch(PROXY_URL, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
body: formData,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const body = await resp.text();
|
|
23
|
+
const durationMs = Date.now() - start;
|
|
24
|
+
|
|
25
|
+
if (!resp.ok) {
|
|
26
|
+
throw new Error(`STT failed (${resp.status}): ${body}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const parsed = JSON.parse(body);
|
|
30
|
+
return { text: parsed.text || "", durationMs };
|
|
31
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block TDD tests — run all, report PASS/FAIL.
|
|
3
|
+
* No user interaction. All automated.
|
|
4
|
+
*/
|
|
5
|
+
import { buildCombo, isModifier } from "./keys.js";
|
|
6
|
+
import { startRecording, recordDuration } from "./audio.js";
|
|
7
|
+
import { transcribe } from "./stt.js";
|
|
8
|
+
import { polish } from "./llm.js";
|
|
9
|
+
import { loadEnvKey } from "./config.js";
|
|
10
|
+
import { GlobalKeyboardListener } from "node-global-key-listener";
|
|
11
|
+
import { existsSync, readFileSync, unlinkSync, mkdirSync, writeFileSync } from "fs";
|
|
12
|
+
|
|
13
|
+
const GREEN = "\x1b[32m";
|
|
14
|
+
const RED = "\x1b[31m";
|
|
15
|
+
const BOLD = "\x1b[1m";
|
|
16
|
+
const DIM = "\x1b[2m";
|
|
17
|
+
const RESET = "\x1b[0m";
|
|
18
|
+
|
|
19
|
+
let passed = 0;
|
|
20
|
+
let failed = 0;
|
|
21
|
+
|
|
22
|
+
function pass(name: string, detail = "") {
|
|
23
|
+
passed++;
|
|
24
|
+
console.log(` ${GREEN}${BOLD}✓${RESET} ${name} ${DIM}${detail}${RESET}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fail(name: string, detail = "") {
|
|
28
|
+
failed++;
|
|
29
|
+
console.log(` ${RED}${BOLD}✗${RESET} ${name} ${detail}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function main() {
|
|
33
|
+
console.log(`\n${BOLD}=== Airtype Block Tests ===${RESET}\n`);
|
|
34
|
+
|
|
35
|
+
// ─── Block A: buildCombo ───
|
|
36
|
+
console.log(`${DIM}[A] buildCombo${RESET}`);
|
|
37
|
+
|
|
38
|
+
const isDown1: Record<string, boolean> = { "LEFT META": true, "ESCAPE": true };
|
|
39
|
+
const r1 = buildCombo("ESCAPE", isDown1);
|
|
40
|
+
r1 === "Cmd+ESCAPE" ? pass("Cmd+Esc", r1) : fail("Cmd+Esc", `got "${r1}"`);
|
|
41
|
+
|
|
42
|
+
const isDown2: Record<string, boolean> = { "LEFT CTRL": true, "LEFT SHIFT": true, "R": true };
|
|
43
|
+
const r2 = buildCombo("R", isDown2);
|
|
44
|
+
r2 === "Ctrl+Shift+R" ? pass("Ctrl+Shift+R", r2) : fail("Ctrl+Shift+R", `got "${r2}"`);
|
|
45
|
+
|
|
46
|
+
const isDown3: Record<string, boolean> = { "LEFT ALT": true };
|
|
47
|
+
const r3 = buildCombo("SPACE", isDown3);
|
|
48
|
+
r3 === "Alt+SPACE" ? pass("Alt+Space", r3) : fail("Alt+Space", `got "${r3}"`);
|
|
49
|
+
|
|
50
|
+
// Modifier-only should return just modifier
|
|
51
|
+
const r4 = buildCombo("LEFT META", { "LEFT META": true });
|
|
52
|
+
isModifier("LEFT META") ? pass("isModifier(LEFT META)", "true") : fail("isModifier", "false");
|
|
53
|
+
|
|
54
|
+
// No modifier
|
|
55
|
+
const r5 = buildCombo("A", {});
|
|
56
|
+
r5 === "A" ? pass("No modifier", r5) : fail("No modifier", `got "${r5}"`);
|
|
57
|
+
|
|
58
|
+
// ─── Block B: keystroke log ───
|
|
59
|
+
console.log(`\n${DIM}[B] keystroke log${RESET}`);
|
|
60
|
+
|
|
61
|
+
const logPath = airpath("logs", "keystrokes.jsonl");
|
|
62
|
+
// Clear and test
|
|
63
|
+
mkdirSync(airpath("logs"), { recursive: true });
|
|
64
|
+
if (existsSync(logPath)) unlinkSync(logPath);
|
|
65
|
+
|
|
66
|
+
// Calling buildCombo should write to log
|
|
67
|
+
buildCombo("TEST", { "LEFT META": true });
|
|
68
|
+
if (existsSync(logPath)) {
|
|
69
|
+
const content = readFileSync(logPath, "utf-8").trim();
|
|
70
|
+
try {
|
|
71
|
+
const parsed = JSON.parse(content);
|
|
72
|
+
parsed.combo === "Cmd+TEST" ? pass("Log written", `combo=${parsed.combo}`) : fail("Log combo wrong", parsed.combo);
|
|
73
|
+
} catch {
|
|
74
|
+
fail("Log not valid JSON", content.slice(0, 100));
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
fail("Log file not created");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Block C & D: shortcut match (simulated) ───
|
|
81
|
+
console.log(`\n${DIM}[C/D] shortcut match simulation${RESET}`);
|
|
82
|
+
|
|
83
|
+
const targetCombo = "Cmd+ESCAPE";
|
|
84
|
+
let startCalled = false;
|
|
85
|
+
let stopCalled = false;
|
|
86
|
+
let stateIsRecording = false;
|
|
87
|
+
|
|
88
|
+
// Simulate first press
|
|
89
|
+
const combo1 = buildCombo("ESCAPE", { "LEFT META": true });
|
|
90
|
+
if (combo1 === targetCombo && !stateIsRecording) {
|
|
91
|
+
startCalled = true;
|
|
92
|
+
stateIsRecording = true;
|
|
93
|
+
}
|
|
94
|
+
startCalled ? pass("First press → start", combo1) : fail("First press missed");
|
|
95
|
+
|
|
96
|
+
// Simulate second press
|
|
97
|
+
const combo2 = buildCombo("ESCAPE", { "LEFT META": true });
|
|
98
|
+
if (combo2 === targetCombo && stateIsRecording) {
|
|
99
|
+
stopCalled = true;
|
|
100
|
+
stateIsRecording = false;
|
|
101
|
+
}
|
|
102
|
+
stopCalled ? pass("Second press → stop", combo2) : fail("Second press missed");
|
|
103
|
+
|
|
104
|
+
// ─── Block E: audio recording ───
|
|
105
|
+
console.log(`\n${DIM}[E] audio recording (3s)${RESET}`);
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const wavBuffer = await recordDuration(2, "default");
|
|
109
|
+
wavBuffer.length > 100
|
|
110
|
+
? pass("WAV recorded", `${wavBuffer.length} bytes`)
|
|
111
|
+
: fail("WAV too small", `${wavBuffer.length} bytes`);
|
|
112
|
+
} catch (e: any) {
|
|
113
|
+
fail("Recording failed", e.message);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── Block F: STT ───
|
|
117
|
+
console.log(`\n${DIM}[F] STT (Groq Whisper)${RESET}`);
|
|
118
|
+
|
|
119
|
+
const groqKey = loadEnvKey("GROQ_API_KEY");
|
|
120
|
+
if (!groqKey) {
|
|
121
|
+
fail("GROQ_API_KEY not found");
|
|
122
|
+
} else {
|
|
123
|
+
try {
|
|
124
|
+
// Use a short recording
|
|
125
|
+
const wav = await recordDuration(2, "default");
|
|
126
|
+
const stt = await transcribe(groqKey, wav, "auto");
|
|
127
|
+
stt.durationMs < 10000
|
|
128
|
+
? pass("STT response", `${stt.durationMs}ms "${stt.text.trim().slice(0, 50)}"`)
|
|
129
|
+
: fail("STT too slow", `${stt.durationMs}ms`);
|
|
130
|
+
} catch (e: any) {
|
|
131
|
+
fail("STT error", e.message.slice(0, 100));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── Block G: LLM ───
|
|
136
|
+
console.log(`\n${DIM}[G] LLM (OpenRouter)${RESET}`);
|
|
137
|
+
|
|
138
|
+
const orKey = loadEnvKey("OPENROUTER_API_KEY");
|
|
139
|
+
if (!orKey) {
|
|
140
|
+
fail("OPENROUTER_API_KEY not found");
|
|
141
|
+
} else {
|
|
142
|
+
try {
|
|
143
|
+
const llm = await polish(orKey, "Um so I think we should no wait we need to fix the login bug");
|
|
144
|
+
llm.text.length > 0
|
|
145
|
+
? pass("LLM polish", `${llm.durationMs}ms "${llm.text.slice(0, 60)}"`)
|
|
146
|
+
: fail("LLM empty response");
|
|
147
|
+
} catch (e: any) {
|
|
148
|
+
fail("LLM error", e.message.slice(0, 100));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── Block H: full pipeline (no paste) ───
|
|
153
|
+
console.log(`\n${DIM}[H] full pipeline (record → STT → LLM → save)${RESET}`);
|
|
154
|
+
|
|
155
|
+
if (groqKey && orKey) {
|
|
156
|
+
try {
|
|
157
|
+
const wav = await recordDuration(2, "default");
|
|
158
|
+
const stt = await transcribe(groqKey, wav, "auto");
|
|
159
|
+
const llmResult = await polish(orKey, stt.text || "test sentence for pipeline");
|
|
160
|
+
|
|
161
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
162
|
+
const dir = airpath("recordings");
|
|
163
|
+
mkdirSync(dir, { recursive: true });
|
|
164
|
+
const jsonPath = `${dir}/test-${ts}.json`;
|
|
165
|
+
writeFileSync(jsonPath, JSON.stringify({
|
|
166
|
+
ts, rawText: stt.text, polishedText: llmResult.text,
|
|
167
|
+
sttMs: stt.durationMs, llmMs: llmResult.durationMs,
|
|
168
|
+
}, null, 2));
|
|
169
|
+
|
|
170
|
+
existsSync(jsonPath)
|
|
171
|
+
? pass("Pipeline saved", `${jsonPath}`)
|
|
172
|
+
: fail("Pipeline file not created");
|
|
173
|
+
|
|
174
|
+
const saved = JSON.parse(readFileSync(jsonPath, "utf-8"));
|
|
175
|
+
saved.polishedText
|
|
176
|
+
? pass("Pipeline result", `"${saved.polishedText.slice(0, 50)}"`)
|
|
177
|
+
: fail("Pipeline polishedText empty");
|
|
178
|
+
} catch (e: any) {
|
|
179
|
+
fail("Pipeline error", e.message.slice(0, 100));
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
fail("Pipeline skipped — missing API keys");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── Summary ───
|
|
186
|
+
console.log(`\n${BOLD}=== ${passed} passed, ${failed} failed ===${RESET}\n`);
|
|
187
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
main();
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
|
|
3
|
+
const proc = spawn("rec", ["-q", "-r", "16000", "-c", "1", "-b", "16", "-t", "raw", "-"], {
|
|
4
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
let total = 0;
|
|
8
|
+
let chunks = 0;
|
|
9
|
+
|
|
10
|
+
proc.stdout!.on("data", (chunk: Buffer) => {
|
|
11
|
+
chunks++;
|
|
12
|
+
total += chunk.length;
|
|
13
|
+
// Calculate RMS
|
|
14
|
+
let sum = 0;
|
|
15
|
+
const samples = Math.floor(chunk.length / 2);
|
|
16
|
+
for (let i = 0; i < chunk.length - 1; i += 2) {
|
|
17
|
+
const sample = chunk.readInt16LE(i) / 32768;
|
|
18
|
+
sum += sample * sample;
|
|
19
|
+
}
|
|
20
|
+
const rms = Math.sqrt(sum / Math.max(samples, 1));
|
|
21
|
+
console.log(`chunk #${chunks}: ${chunk.length}b, total=${total}, rms=${rms.toFixed(6)}`);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
setTimeout(() => {
|
|
25
|
+
proc.kill("SIGTERM");
|
|
26
|
+
}, 3000);
|
|
27
|
+
|
|
28
|
+
proc.on("close", () => {
|
|
29
|
+
console.log(`Done. ${chunks} chunks, ${total} bytes`);
|
|
30
|
+
process.exit(0);
|
|
31
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { startRecording } from "./audio.js";
|
|
2
|
+
|
|
3
|
+
console.log("Recording 3s with volume monitoring...");
|
|
4
|
+
const rec = startRecording("default");
|
|
5
|
+
|
|
6
|
+
rec.onVolume((level) => {
|
|
7
|
+
const bars = Math.round(level * 20);
|
|
8
|
+
const bar = "â–ˆ".repeat(bars) + "â–‘".repeat(20 - bars);
|
|
9
|
+
process.stdout.write(`\r [${bar}] ${level.toFixed(3)}`);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
setTimeout(async () => {
|
|
13
|
+
const wav = await rec.stop();
|
|
14
|
+
console.log(`\nDone. WAV: ${wav.length} bytes`);
|
|
15
|
+
process.exit(0);
|
|
16
|
+
}, 3000);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { startRecording } from "./audio.js";
|
|
2
|
+
|
|
3
|
+
console.log("Recording 5s — speak into your mic...");
|
|
4
|
+
const rec = startRecording("default");
|
|
5
|
+
|
|
6
|
+
let count = 0;
|
|
7
|
+
rec.onVolume((level) => {
|
|
8
|
+
count++;
|
|
9
|
+
// Print every callback
|
|
10
|
+
console.log(`vol #${count}: ${level.toFixed(4)}`);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
setTimeout(async () => {
|
|
14
|
+
const wav = await rec.stop();
|
|
15
|
+
console.log(`Done. WAV: ${wav.length} bytes, callbacks: ${count}`);
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}, 5000);
|