@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/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);