ava-engine 0.0.0-stage → 0.2.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/.env.example +63 -0
- package/CLA.md +43 -0
- package/COMMERCIAL-LICENSE.md +43 -0
- package/LICENSE +73 -0
- package/README.md +240 -2
- package/THIRD_PARTY_NOTICES.md +38 -0
- package/TRADEMARKS.md +31 -0
- package/bin/ava-engine.mjs +12 -0
- package/lib/asr.mjs +147 -0
- package/lib/cli/apply.mjs +58 -0
- package/lib/cli/config.mjs +95 -0
- package/lib/cli/main.mjs +187 -0
- package/lib/cli/setup-server.mjs +195 -0
- package/lib/cli/setup.mjs +233 -0
- package/lib/fetch-models.mjs +104 -0
- package/lib/guard.mjs +92 -0
- package/lib/kokoro-model.mjs +51 -0
- package/lib/models.mjs +47 -0
- package/lib/openrouter.mjs +153 -0
- package/lib/ours.mjs +766 -0
- package/lib/platforms.mjs +201 -0
- package/lib/state.mjs +150 -0
- package/lib/stt.mjs +22 -0
- package/lib/tts.mjs +178 -0
- package/lib/workflow.mjs +18 -0
- package/package.json +80 -4
- package/public/app.js +1708 -0
- package/public/demo.js +420 -0
- package/public/favicon.svg +1 -0
- package/public/fonts/OFL.txt +96 -0
- package/public/fonts/inter-tight-var.woff2 +0 -0
- package/public/fonts/inter-var.woff2 +0 -0
- package/public/fonts/plex-mono-400.woff2 +0 -0
- package/public/fonts/plex-mono-500.woff2 +0 -0
- package/public/img/cta-1600.avif +0 -0
- package/public/img/cta-1600.webp +0 -0
- package/public/img/cta-2400.avif +0 -0
- package/public/img/cta-2400.webp +0 -0
- package/public/img/cta-960.avif +0 -0
- package/public/img/cta-960.webp +0 -0
- package/public/img/flows-1600.avif +0 -0
- package/public/img/flows-1600.webp +0 -0
- package/public/img/flows-2400.avif +0 -0
- package/public/img/flows-2400.webp +0 -0
- package/public/img/flows-960.avif +0 -0
- package/public/img/flows-960.webp +0 -0
- package/public/img/hero-1600.avif +0 -0
- package/public/img/hero-1600.webp +0 -0
- package/public/img/hero-2400.avif +0 -0
- package/public/img/hero-2400.webp +0 -0
- package/public/img/hero-960.avif +0 -0
- package/public/img/hero-960.webp +0 -0
- package/public/img/p-csm-480.avif +0 -0
- package/public/img/p-csm-480.webp +0 -0
- package/public/img/p-csm-800.avif +0 -0
- package/public/img/p-csm-800.webp +0 -0
- package/public/img/p-docs-480.avif +0 -0
- package/public/img/p-docs-480.webp +0 -0
- package/public/img/p-docs-800.avif +0 -0
- package/public/img/p-docs-800.webp +0 -0
- package/public/img/p-quiet-480.avif +0 -0
- package/public/img/p-quiet-480.webp +0 -0
- package/public/img/p-quiet-800.avif +0 -0
- package/public/img/p-quiet-800.webp +0 -0
- package/public/img/p-tickets-480.avif +0 -0
- package/public/img/p-tickets-480.webp +0 -0
- package/public/img/p-tickets-800.avif +0 -0
- package/public/img/p-tickets-800.webp +0 -0
- package/public/img/p-trial-480.avif +0 -0
- package/public/img/p-trial-480.webp +0 -0
- package/public/img/p-trial-800.avif +0 -0
- package/public/img/p-trial-800.webp +0 -0
- package/public/img/sees-1600.avif +0 -0
- package/public/img/sees-1600.webp +0 -0
- package/public/img/sees-2400.avif +0 -0
- package/public/img/sees-2400.webp +0 -0
- package/public/img/sees-960.avif +0 -0
- package/public/img/sees-960.webp +0 -0
- package/public/img/speaks-1600.avif +0 -0
- package/public/img/speaks-1600.webp +0 -0
- package/public/img/speaks-2400.avif +0 -0
- package/public/img/speaks-2400.webp +0 -0
- package/public/img/speaks-960.avif +0 -0
- package/public/img/speaks-960.webp +0 -0
- package/public/img/voice-1600.avif +0 -0
- package/public/img/voice-1600.webp +0 -0
- package/public/img/voice-2400.avif +0 -0
- package/public/img/voice-2400.webp +0 -0
- package/public/img/voice-960.avif +0 -0
- package/public/img/voice-960.webp +0 -0
- package/public/index.html +440 -0
- package/public/landing.html +975 -0
- package/public/pcm-worklet.js +11 -0
- package/public/perceptionWorker.js +103 -0
- package/public/rtc.js +60 -0
- package/public/setup.css +94 -0
- package/public/setup.html +129 -0
- package/public/setup.js +264 -0
- package/public/specialist.html +92 -0
- package/public/specialist.js +132 -0
- package/public/style.css +836 -0
- package/public/vendor/lenis.min.js +3 -0
- package/scripts/fetch-models.sh +15 -0
- package/server.mjs +502 -0
package/lib/asr.mjs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Offline streaming speech-to-text — free, on-device, no key. Two engines the
|
|
2
|
+
// user picks before the call:
|
|
3
|
+
// - "zipformer" : English streaming zipformer. Live word-by-word partials.
|
|
4
|
+
// - "sensevoice" : SenseVoice multilingual (zh/en/ja/ko/yue). Utterance-based
|
|
5
|
+
// via Silero VAD; cased + punctuated; more accurate on accents.
|
|
6
|
+
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const MODELS = process.env.AVA_MODELS_DIR || join(__dir, "../models"); // the CLI points this at ~/.ava-engine/models
|
|
15
|
+
|
|
16
|
+
// zipformer: prefer the larger model, fall back to the 20M; SHERPA_MODEL_DIR overrides
|
|
17
|
+
const ZIP_CANDIDATES = [
|
|
18
|
+
process.env.SHERPA_MODEL_DIR,
|
|
19
|
+
join(MODELS, "sherpa-onnx-streaming-zipformer-en-2023-06-26"),
|
|
20
|
+
join(MODELS, "sherpa-onnx-streaming-zipformer-en-20M-2023-02-17"),
|
|
21
|
+
].filter(Boolean);
|
|
22
|
+
const ZIP_DIR = ZIP_CANDIDATES.find((d) => existsSync(join(d, "tokens.txt")));
|
|
23
|
+
const SV_DIR = join(MODELS, "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17");
|
|
24
|
+
const VAD_MODEL = join(MODELS, "silero_vad.onnx");
|
|
25
|
+
|
|
26
|
+
let sherpa = null, online = null, offline = null, initErr = null;
|
|
27
|
+
|
|
28
|
+
function loadSherpa() {
|
|
29
|
+
if (!sherpa && !initErr) { try { sherpa = require("sherpa-onnx-node"); } catch (e) { initErr = e; console.log(" sherpa unavailable:", e.message); } }
|
|
30
|
+
return sherpa;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Which engines have their model files on disk.
|
|
34
|
+
export function availableEngines() {
|
|
35
|
+
const e = [];
|
|
36
|
+
if (ZIP_DIR) e.push("zipformer");
|
|
37
|
+
if (existsSync(join(SV_DIR, "tokens.txt")) && existsSync(VAD_MODEL)) e.push("sensevoice");
|
|
38
|
+
return e;
|
|
39
|
+
}
|
|
40
|
+
export function asrAvailable() { return !initErr && availableEngines().length > 0; }
|
|
41
|
+
export function defaultEngine() { const e = availableEngines(); return e.includes("zipformer") ? "zipformer" : (e[0] || null); }
|
|
42
|
+
export const ENGINE_LABELS = { zipformer: "English · streaming", sensevoice: "Multilingual · SenseVoice" };
|
|
43
|
+
|
|
44
|
+
function pick(dir, kind, preferInt8) {
|
|
45
|
+
const files = readdirSync(dir).filter((f) => f.startsWith(kind) && f.endsWith(".onnx"));
|
|
46
|
+
const int8 = files.find((f) => f.includes("int8")), fp = files.find((f) => !f.includes("int8"));
|
|
47
|
+
return join(dir, preferInt8 ? int8 || fp : fp || int8);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getOnline() {
|
|
51
|
+
if (online || !ZIP_DIR) return online;
|
|
52
|
+
const s = loadSherpa(); if (!s) return null;
|
|
53
|
+
online = new s.OnlineRecognizer({
|
|
54
|
+
featConfig: { sampleRate: 16000, featureDim: 80 },
|
|
55
|
+
modelConfig: {
|
|
56
|
+
transducer: { encoder: pick(ZIP_DIR, "encoder", true), decoder: pick(ZIP_DIR, "decoder", false), joiner: pick(ZIP_DIR, "joiner", true) },
|
|
57
|
+
tokens: join(ZIP_DIR, "tokens.txt"), numThreads: 2, provider: "cpu",
|
|
58
|
+
},
|
|
59
|
+
decodingMethod: "greedy_search",
|
|
60
|
+
enableEndpoint: true, rule1MinTrailingSilence: 2.4, rule2MinTrailingSilence: 0.9, rule3MinUtteranceLength: 20,
|
|
61
|
+
});
|
|
62
|
+
return online;
|
|
63
|
+
}
|
|
64
|
+
function getOffline() {
|
|
65
|
+
if (offline) return offline;
|
|
66
|
+
const s = loadSherpa(); if (!s) return null;
|
|
67
|
+
offline = new s.OfflineRecognizer({
|
|
68
|
+
featConfig: { sampleRate: 16000, featureDim: 80 },
|
|
69
|
+
modelConfig: {
|
|
70
|
+
senseVoice: { model: pick(SV_DIR, "model", true), language: process.env.SENSEVOICE_LANG || "en", useInverseTextNormalization: 1 },
|
|
71
|
+
tokens: join(SV_DIR, "tokens.txt"), numThreads: 2, provider: "cpu",
|
|
72
|
+
},
|
|
73
|
+
decodingMethod: "greedy_search",
|
|
74
|
+
});
|
|
75
|
+
return offline;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function warmAsr() {
|
|
79
|
+
if (!asrAvailable()) {
|
|
80
|
+
if (initErr) console.log(" speech recognition: the offline engine didn't load; using the browser's (run `npx ava-engine doctor`)");
|
|
81
|
+
else console.log(" speech recognition: using the browser's. For offline recognition: npx ava-engine models");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const e = availableEngines();
|
|
85
|
+
if (e.includes("zipformer") && getOnline()) console.log(" sherpa ASR ready — English streaming (zipformer)");
|
|
86
|
+
if (e.includes("sensevoice")) console.log(" sherpa ASR ready — multilingual (SenseVoice), lazy-loaded on first use");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// SenseVoice text can carry <|lang|><|emo|> tags; strip them.
|
|
90
|
+
function cleanSV(t) { return String(t || "").replace(/<\|[^|]*\|>/g, "").replace(/\s+/g, " ").trim(); }
|
|
91
|
+
// ws hands us pooled Buffers at arbitrary byte offsets — read alignment-safe.
|
|
92
|
+
function toFloat32(buf) {
|
|
93
|
+
if (buf instanceof Int16Array) { const o = new Float32Array(buf.length); for (let i = 0; i < buf.length; i++) o[i] = buf[i] / 32768; return o; }
|
|
94
|
+
const n = Math.floor(buf.length / 2); const o = new Float32Array(n); for (let i = 0; i < n; i++) o[i] = buf.readInt16LE(i * 2) / 32768; return o;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One live transcription session for a WebSocket connection. */
|
|
98
|
+
export function createAsrSession(engine) {
|
|
99
|
+
const eng = availableEngines().includes(engine) ? engine : defaultEngine();
|
|
100
|
+
if (!eng) return null;
|
|
101
|
+
return eng === "sensevoice" ? sensevoiceSession() : zipformerSession();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function zipformerSession() {
|
|
105
|
+
const rec = getOnline(); if (!rec) return null;
|
|
106
|
+
const stream = rec.createStream(); let last = "";
|
|
107
|
+
return {
|
|
108
|
+
engine: "zipformer",
|
|
109
|
+
feed(buf) {
|
|
110
|
+
const samples = toFloat32(buf);
|
|
111
|
+
stream.acceptWaveform({ sampleRate: 16000, samples });
|
|
112
|
+
while (rec.isReady(stream)) rec.decode(stream);
|
|
113
|
+
const text = (rec.getResult(stream).text || "").trim();
|
|
114
|
+
const out = {};
|
|
115
|
+
if (rec.isEndpoint(stream)) { if (text) out.final = text; rec.reset(stream); last = ""; }
|
|
116
|
+
else if (text && text !== last) { last = text; out.partial = text; }
|
|
117
|
+
return out;
|
|
118
|
+
},
|
|
119
|
+
close() { try { stream.free && stream.free(); } catch {} },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sensevoiceSession() {
|
|
124
|
+
const rec = getOffline(); const s = loadSherpa(); if (!rec || !s) return null;
|
|
125
|
+
const vad = new s.Vad({
|
|
126
|
+
sileroVad: { model: VAD_MODEL, threshold: 0.5, minSilenceDuration: 0.5, minSpeechDuration: 0.25, windowSize: 512 },
|
|
127
|
+
sampleRate: 16000, numThreads: 1, provider: "cpu",
|
|
128
|
+
}, 30);
|
|
129
|
+
return {
|
|
130
|
+
engine: "sensevoice",
|
|
131
|
+
feed(buf) {
|
|
132
|
+
vad.acceptWaveform(toFloat32(buf));
|
|
133
|
+
const out = {};
|
|
134
|
+
while (!vad.isEmpty()) {
|
|
135
|
+
const seg = vad.front(); vad.pop();
|
|
136
|
+
const st = rec.createStream();
|
|
137
|
+
st.acceptWaveform({ sampleRate: 16000, samples: seg.samples });
|
|
138
|
+
rec.decode(st);
|
|
139
|
+
const text = cleanSV(rec.getResult(st).text);
|
|
140
|
+
try { st.free && st.free(); } catch {}
|
|
141
|
+
if (text) out.final = out.final ? out.final + " " + text : text;
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
},
|
|
145
|
+
close() { try { vad.free && vad.free(); } catch {} },
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Turning setup answers into saved settings. Shared by the terminal wizard and
|
|
2
|
+
// the browser setup, so both produce exactly the same configuration.
|
|
3
|
+
import { MODELS_DIR, DEFAULT_PORT } from "./config.mjs";
|
|
4
|
+
|
|
5
|
+
export const VOICES = {
|
|
6
|
+
kokoro: { label: "Kokoro", hint: "free, runs on this computer (~90 MB download)" },
|
|
7
|
+
elevenlabs: { label: "ElevenLabs", hint: "premium cloud voices, needs an ElevenLabs key" },
|
|
8
|
+
browser: { label: "The browser's voice", hint: "no setup, sounds robotic" },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* answers: {
|
|
13
|
+
* openrouterKey?: string | null (null/"" → MOCK mode; undefined → keep the current key)
|
|
14
|
+
* voice: "kokoro" | "elevenlabs" | "browser",
|
|
15
|
+
* elevenKey?, elevenVoice?,
|
|
16
|
+
* audience: "me" | "others", password?, proxy?: boolean,
|
|
17
|
+
* port?: number
|
|
18
|
+
* }
|
|
19
|
+
*/
|
|
20
|
+
export function buildConfig(prev, a) {
|
|
21
|
+
const cfg = { ...prev };
|
|
22
|
+
const set = (k, v) => { if (v === undefined || v === null || v === "") delete cfg[k]; else cfg[k] = String(v); };
|
|
23
|
+
|
|
24
|
+
if (a.openrouterKey !== undefined) set("OPENROUTER_API_KEY", a.openrouterKey);
|
|
25
|
+
|
|
26
|
+
const voice = VOICES[a.voice] ? a.voice : "kokoro";
|
|
27
|
+
set("TTS_DEFAULT", voice);
|
|
28
|
+
set("KOKORO", voice === "kokoro" ? "on" : "off");
|
|
29
|
+
if (voice === "elevenlabs") {
|
|
30
|
+
if (a.elevenKey !== undefined) set("ELEVENLABS_API_KEY", a.elevenKey);
|
|
31
|
+
if (a.elevenVoice !== undefined) set("ELEVEN_VOICE_ID", a.elevenVoice);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (a.audience === "others") {
|
|
35
|
+
set("HOST", "");
|
|
36
|
+
if (a.password !== undefined) set("ACCESS_PASSWORD", a.password);
|
|
37
|
+
set("TRUST_PROXY", a.proxy ? "1" : "");
|
|
38
|
+
} else {
|
|
39
|
+
set("HOST", "127.0.0.1");
|
|
40
|
+
set("ACCESS_PASSWORD", "");
|
|
41
|
+
set("TRUST_PROXY", "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const port = Number(a.port);
|
|
45
|
+
set("PORT", Number.isInteger(port) && port > 0 && port < 65536 ? port : prev.PORT || DEFAULT_PORT);
|
|
46
|
+
set("AVA_MODELS_DIR", MODELS_DIR);
|
|
47
|
+
return cfg;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Problems that must be fixed before saving, as a list of human messages. */
|
|
51
|
+
export function validateAnswers(a) {
|
|
52
|
+
const errors = [];
|
|
53
|
+
if (a.audience === "others" && (!a.password || String(a.password).length < 10)) errors.push("Choose an access password of at least 10 characters.");
|
|
54
|
+
if (a.voice === "elevenlabs" && a.elevenKey === "") errors.push("Add your ElevenLabs key, or pick another voice.");
|
|
55
|
+
const port = Number(a.port);
|
|
56
|
+
if (a.port !== undefined && !(Number.isInteger(port) && port > 0 && port < 65536)) errors.push("The port must be a number between 1 and 65535.");
|
|
57
|
+
return errors;
|
|
58
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Where the installed CLI keeps its state, and small helpers the commands share.
|
|
2
|
+
//
|
|
3
|
+
// ~/.ava-engine/.env settings (API keys, password, port…), mode 600
|
|
4
|
+
// ~/.ava-engine/models/ offline speech-to-text models
|
|
5
|
+
//
|
|
6
|
+
// AVA_HOME moves the whole folder (handy for servers, Docker and tests).
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { createServer } from "node:net";
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { randomInt } from "node:crypto";
|
|
14
|
+
|
|
15
|
+
export const AVA_HOME = process.env.AVA_HOME || join(homedir(), ".ava-engine");
|
|
16
|
+
export const CONFIG_FILE = join(AVA_HOME, ".env");
|
|
17
|
+
export const MODELS_DIR = join(AVA_HOME, "models");
|
|
18
|
+
export const DEFAULT_PORT = 5190;
|
|
19
|
+
|
|
20
|
+
/** Parse a .env file into an object (no expansion, last value wins). */
|
|
21
|
+
export function readConfig(file = CONFIG_FILE) {
|
|
22
|
+
if (!existsSync(file)) return null;
|
|
23
|
+
const out = {};
|
|
24
|
+
for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
25
|
+
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
|
26
|
+
if (!m) continue;
|
|
27
|
+
let v = m[2];
|
|
28
|
+
if (/^".*"$/.test(v)) v = v.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
29
|
+
out[m[1]] = v;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const quote = (v) => (/^[A-Za-z0-9_@%+=:,./-]*$/.test(v) ? v : `"${String(v).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`);
|
|
35
|
+
|
|
36
|
+
/** Write settings to the config file (readable only by you). Empty values are dropped. */
|
|
37
|
+
export function writeConfig(values, file = CONFIG_FILE) {
|
|
38
|
+
mkdirSync(AVA_HOME, { recursive: true });
|
|
39
|
+
const lines = [
|
|
40
|
+
"# Ava Engine settings, written by `ava-engine setup`. Edit freely, or re-run setup.",
|
|
41
|
+
"# Every option is documented at https://github.com/Mr-vero/ava-engine/blob/main/.env.example",
|
|
42
|
+
"",
|
|
43
|
+
...Object.entries(values).filter(([, v]) => v !== undefined && v !== null && v !== "").map(([k, v]) => `${k}=${quote(v)}`),
|
|
44
|
+
"",
|
|
45
|
+
];
|
|
46
|
+
writeFileSync(file, lines.join("\n"), { mode: 0o600 });
|
|
47
|
+
try { chmodSync(file, 0o600); } catch {}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Check an OpenRouter key. Returns { ok, label?, limit?, error? }. */
|
|
51
|
+
export async function checkOpenRouterKey(key) {
|
|
52
|
+
try {
|
|
53
|
+
const r = await fetch("https://openrouter.ai/api/v1/key", { headers: { Authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(10_000) });
|
|
54
|
+
if (r.status === 401 || r.status === 403) return { ok: false, error: "OpenRouter rejected this key" };
|
|
55
|
+
if (!r.ok) return { ok: false, error: `OpenRouter answered ${r.status}` };
|
|
56
|
+
const d = (await r.json()).data || {};
|
|
57
|
+
return { ok: true, label: d.label, limit: d.limit, usage: d.usage };
|
|
58
|
+
} catch (e) {
|
|
59
|
+
return { ok: false, error: `couldn't reach OpenRouter (${e.name === "TimeoutError" ? "timed out" : e.message})`, offline: true };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Check an ElevenLabs key. Returns { ok, error? }. */
|
|
64
|
+
export async function checkElevenLabsKey(key) {
|
|
65
|
+
try {
|
|
66
|
+
const r = await fetch("https://api.elevenlabs.io/v1/user", { headers: { "xi-api-key": key }, signal: AbortSignal.timeout(10_000) });
|
|
67
|
+
if (r.status === 401 || r.status === 403) return { ok: false, error: "ElevenLabs rejected this key" };
|
|
68
|
+
return r.ok ? { ok: true } : { ok: false, error: `ElevenLabs answered ${r.status}` };
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return { ok: false, error: `couldn't reach ElevenLabs (${e.message})`, offline: true };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** True if nothing is listening on the port. */
|
|
75
|
+
export function portFree(port, host) {
|
|
76
|
+
return new Promise((res) => {
|
|
77
|
+
const s = createServer();
|
|
78
|
+
s.once("error", () => res(false));
|
|
79
|
+
s.listen({ port, host }, () => s.close(() => res(true)));
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function openBrowser(url) {
|
|
84
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url]]
|
|
85
|
+
: process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
|
|
86
|
+
: ["xdg-open", [url]];
|
|
87
|
+
try { spawn(cmd, args, { stdio: "ignore", detached: true }).unref(); } catch {}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A random password like "k7fq-9xm4-r3pz": 12 characters, 60 bits, no look-alike letters. */
|
|
91
|
+
export function suggestPassword() {
|
|
92
|
+
const alphabet = "abcdefghjkmnpqrstuvwxyz23456789"; // 31 chars: no i, l, o, 0, 1
|
|
93
|
+
const group = () => Array.from({ length: 4 }, () => alphabet[randomInt(alphabet.length)]).join("");
|
|
94
|
+
return `${group()}-${group()}-${group()}`;
|
|
95
|
+
}
|
package/lib/cli/main.mjs
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// The `ava-engine` command.
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { AVA_HOME, CONFIG_FILE, MODELS_DIR, DEFAULT_PORT, readConfig, checkOpenRouterKey, portFree, openBrowser } from "./config.mjs";
|
|
7
|
+
import { MODEL_PACKS, installedPacks } from "../fetch-models.mjs";
|
|
8
|
+
|
|
9
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
10
|
+
const VERSION = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version;
|
|
11
|
+
|
|
12
|
+
const HELP = `
|
|
13
|
+
ΛVΛ Ava Engine ${VERSION}: a screen-aware voice agent
|
|
14
|
+
|
|
15
|
+
Usage
|
|
16
|
+
npx ava-engine first run: setup wizard, then start. After that: start
|
|
17
|
+
npx ava-engine setup (re)run setup; asks: in the browser or the terminal
|
|
18
|
+
npx ava-engine setup --web setup in your browser
|
|
19
|
+
npx ava-engine setup --terminal
|
|
20
|
+
npx ava-engine start start the server
|
|
21
|
+
npx ava-engine doctor check your setup and tell you what to fix
|
|
22
|
+
npx ava-engine models [..] download models: ${Object.keys(MODEL_PACKS).join(", ")}, kokoro (voice)
|
|
23
|
+
|
|
24
|
+
Options
|
|
25
|
+
--port <n> port to listen on (default ${DEFAULT_PORT})
|
|
26
|
+
--open open the browser once Ava is up (default on a first run)
|
|
27
|
+
--no-open don't open the browser
|
|
28
|
+
--yes setup without questions, from environment variables
|
|
29
|
+
--models <a,b> with --yes: what to download, e.g. english-small,kokoro
|
|
30
|
+
-v, --version print the version
|
|
31
|
+
-h, --help show this help
|
|
32
|
+
|
|
33
|
+
Settings live in ${CONFIG_FILE}
|
|
34
|
+
(set AVA_HOME to keep them somewhere else). Docs: https://github.com/Mr-vero/ava-engine
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
function parseArgs(argv) {
|
|
38
|
+
const out = { _: [], flags: {} };
|
|
39
|
+
for (let i = 0; i < argv.length; i++) {
|
|
40
|
+
const a = argv[i];
|
|
41
|
+
if (a === "-h" || a === "--help") out.flags.help = true;
|
|
42
|
+
else if (a === "-v" || a === "--version") out.flags.version = true;
|
|
43
|
+
else if (a === "--yes" || a === "-y") out.flags.yes = true;
|
|
44
|
+
else if (a === "--open") out.flags.open = true;
|
|
45
|
+
else if (a === "--web") out.flags.web = true;
|
|
46
|
+
else if (a === "--terminal") out.flags.terminal = true;
|
|
47
|
+
else if (a === "--no-open") out.flags.open = false;
|
|
48
|
+
else if (a === "--port") out.flags.port = Number(argv[++i]);
|
|
49
|
+
else if (a.startsWith("--port=")) out.flags.port = Number(a.slice(7));
|
|
50
|
+
else if (a === "--models") out.flags.models = String(argv[++i] || "").split(",").filter(Boolean);
|
|
51
|
+
else if (a.startsWith("--")) throw new Error(`unknown option ${a} (see --help)`);
|
|
52
|
+
else out._.push(a);
|
|
53
|
+
}
|
|
54
|
+
if (out.flags.port !== undefined && !(Number.isInteger(out.flags.port) && out.flags.port > 0 && out.flags.port < 65536)) throw new Error("--port needs a number between 1 and 65535");
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Load the saved settings into the environment (real env vars win), then run the server. */
|
|
59
|
+
async function start({ port, open }) {
|
|
60
|
+
const cfg = readConfig() || {};
|
|
61
|
+
for (const [k, v] of Object.entries(cfg)) if (process.env[k] === undefined) process.env[k] = v;
|
|
62
|
+
process.env.AVA_MODELS_DIR ??= MODELS_DIR;
|
|
63
|
+
if (port) process.env.PORT = String(port);
|
|
64
|
+
const p = Number(process.env.PORT) || DEFAULT_PORT;
|
|
65
|
+
if (!(await portFree(p, process.env.HOST))) {
|
|
66
|
+
console.error(`\n ✗ Port ${p} is already in use. Try: npx ava-engine start --port ${p + 1}\n`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
await import("../../server.mjs");
|
|
70
|
+
if (open) {
|
|
71
|
+
const url = `http://localhost:${p}/app`;
|
|
72
|
+
for (let i = 0; i < 50; i++) { // wait until the server answers, then open the app
|
|
73
|
+
try { if ((await fetch(`http://127.0.0.1:${p}/api/health`)).ok) break; } catch {}
|
|
74
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
75
|
+
}
|
|
76
|
+
openBrowser(url);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function doctor() {
|
|
81
|
+
const cfg = readConfig();
|
|
82
|
+
const rows = [];
|
|
83
|
+
const add = (ok, label, fix) => rows.push({ ok, label, fix });
|
|
84
|
+
const [maj, min] = process.versions.node.split(".").map(Number);
|
|
85
|
+
add(maj > 22 || (maj === 22 && min >= 9) ? "ok" : "fail", `Node ${process.versions.node}`, "Ava needs Node 22.9 or newer: https://nodejs.org");
|
|
86
|
+
add(cfg ? "ok" : "fail", cfg ? `Settings: ${CONFIG_FILE}` : "No settings yet", "Run: npx ava-engine setup");
|
|
87
|
+
const c = cfg || {};
|
|
88
|
+
if (c.OPENROUTER_API_KEY) {
|
|
89
|
+
const r = await checkOpenRouterKey(c.OPENROUTER_API_KEY);
|
|
90
|
+
add(r.ok ? "ok" : r.offline ? "warn" : "fail", r.ok ? `OpenRouter key works${r.limit != null ? ` (limit $${r.limit}, used $${(r.usage ?? 0).toFixed(2)})` : ""}` : r.error, "Check the key at https://openrouter.ai/keys, then: npx ava-engine setup");
|
|
91
|
+
} else add("warn", "No OpenRouter key: MOCK mode (canned replies)", "Add one with: npx ava-engine setup");
|
|
92
|
+
if (c.TTS_DEFAULT === "elevenlabs" && !c.ELEVENLABS_API_KEY) add("fail", "Voice is ElevenLabs but there's no ElevenLabs key", "Run: npx ava-engine setup");
|
|
93
|
+
const packs = installedPacks(c.AVA_MODELS_DIR || MODELS_DIR);
|
|
94
|
+
add(packs.length ? "ok" : "warn", packs.length ? `Speech models: ${packs.map((x) => MODEL_PACKS[x].label).join(", ")}` : "No offline speech models: browser speech recognition only", "Download with: npx ava-engine models english-small");
|
|
95
|
+
try { createRequire(import.meta.url)("sherpa-onnx-node"); add("ok", "Speech engine (sherpa-onnx) loads"); }
|
|
96
|
+
catch (e) { add(packs.length ? "fail" : "warn", `Speech engine (sherpa-onnx) doesn't load: ${e.message.split("\n")[0]}`, "Reinstall: npx --yes ava-engine@latest (Linux also needs libgomp1)"); }
|
|
97
|
+
if (c.HOST !== "127.0.0.1" && !c.ACCESS_PASSWORD) add("warn", "Reachable from the network with NO password: anyone can spend your API credit", "Run setup and choose a password, or set HOST=127.0.0.1");
|
|
98
|
+
const port = Number(c.PORT) || DEFAULT_PORT;
|
|
99
|
+
const free = await portFree(port, c.HOST);
|
|
100
|
+
add(free ? "ok" : "warn", `Port ${port} ${free ? "is free" : "is in use (maybe Ava is already running)"}`, `Use another: npx ava-engine start --port ${port + 1}`);
|
|
101
|
+
|
|
102
|
+
const icon = { ok: "✓", warn: "!", fail: "✗" };
|
|
103
|
+
console.log(`\n ΛVΛ Ava Engine doctor (${VERSION})\n`);
|
|
104
|
+
for (const r of rows) console.log(` ${icon[r.ok]} ${r.label}${r.ok !== "ok" && r.fix ? `\n → ${r.fix}` : ""}`);
|
|
105
|
+
const failed = rows.some((r) => r.ok === "fail");
|
|
106
|
+
console.log(failed ? "\n Some checks failed. Fix those, then run doctor again.\n" : "\n Ready. Start Ava with: npx ava-engine\n");
|
|
107
|
+
return failed ? 1 : 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function main(argv) {
|
|
111
|
+
let args;
|
|
112
|
+
try { args = parseArgs(argv); } catch (e) { console.error(`✗ ${e.message}`); return 2; }
|
|
113
|
+
const { flags } = args;
|
|
114
|
+
if (flags.version) { console.log(VERSION); return 0; }
|
|
115
|
+
const cmd = args._[0] || (flags.help ? "help" : existsSync(CONFIG_FILE) ? "start" : "first-run");
|
|
116
|
+
const interactive = process.stdin.isTTY && process.stdout.isTTY && !flags.yes;
|
|
117
|
+
|
|
118
|
+
switch (cmd) {
|
|
119
|
+
case "help": console.log(HELP); return 0;
|
|
120
|
+
case "doctor": return doctor();
|
|
121
|
+
case "start": await start({ port: flags.port, open: flags.open === true }); return null; // keeps running
|
|
122
|
+
case "models": {
|
|
123
|
+
const { downloadPacks, downloadKokoro } = await import("./setup.mjs");
|
|
124
|
+
let packs = args._.slice(1);
|
|
125
|
+
if (!packs.length && interactive) {
|
|
126
|
+
const p = await import("@clack/prompts");
|
|
127
|
+
const have = installedPacks(MODELS_DIR);
|
|
128
|
+
const sel = await p.multiselect({ message: "Which speech models?", required: false, initialValues: have, options: Object.entries(MODEL_PACKS).map(([id, m]) => ({ value: id, label: `${m.label} · ${m.size}`, hint: have.includes(id) ? "installed" : m.hint })) });
|
|
129
|
+
if (p.isCancel(sel)) return 0;
|
|
130
|
+
packs = sel;
|
|
131
|
+
}
|
|
132
|
+
if (!packs.length) packs = ["english-small"];
|
|
133
|
+
const bad = packs.filter((x) => !MODEL_PACKS[x] && x !== "kokoro");
|
|
134
|
+
if (bad.length) { console.error(`✗ unknown model: ${bad.join(", ")}. Choose from: ${Object.keys(MODEL_PACKS).join(", ")}, kokoro`); return 2; }
|
|
135
|
+
if (packs.includes("kokoro")) await downloadKokoro();
|
|
136
|
+
await downloadPacks(packs.filter((x) => x !== "kokoro"));
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
case "setup":
|
|
140
|
+
case "first-run": {
|
|
141
|
+
const setup = await import("./setup.mjs");
|
|
142
|
+
// --web needs a browser, not a terminal, so it works over SSH and from scripts too.
|
|
143
|
+
if (!interactive && !(flags.web && !flags.yes)) {
|
|
144
|
+
await setup.runSetupNonInteractive({ models: flags.models || [], port: flags.port });
|
|
145
|
+
if (cmd === "first-run") { await start({ port: flags.port, open: flags.open === true }); return null; }
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
// Where to run the wizard: the browser (default) or this terminal.
|
|
149
|
+
let where = flags.web ? "web" : flags.terminal ? "terminal" : null;
|
|
150
|
+
if (!where) {
|
|
151
|
+
const p = await import("@clack/prompts");
|
|
152
|
+
console.log("");
|
|
153
|
+
p.intro(" ΛVΛ Ava Engine setup ");
|
|
154
|
+
where = await p.select({
|
|
155
|
+
message: "Where would you like to set up Ava?",
|
|
156
|
+
options: [
|
|
157
|
+
{ value: "web", label: "In my browser", hint: "recommended; a step-by-step page" },
|
|
158
|
+
{ value: "terminal", label: "Here in the terminal" },
|
|
159
|
+
],
|
|
160
|
+
});
|
|
161
|
+
if (p.isCancel(where)) { p.cancel("Setup cancelled. Nothing was changed."); return 0; }
|
|
162
|
+
}
|
|
163
|
+
if (where === "web") {
|
|
164
|
+
const { runWebSetup } = await import("./setup-server.mjs");
|
|
165
|
+
const r = await runWebSetup({
|
|
166
|
+
preferredPort: flags.port,
|
|
167
|
+
formPort: flags.port,
|
|
168
|
+
onReady: ({ url }) => {
|
|
169
|
+
console.log(`\n Setup is open in your browser. If it didn't open, go to:\n ${url}\n\n (Keep this terminal open. Press Ctrl+C to cancel.)\n`);
|
|
170
|
+
if (flags.open !== false) openBrowser(url);
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
console.log(" Settings saved. Starting Ava…");
|
|
174
|
+
await start({ port: r.port, open: false }); // the port chosen on the page wins; the page redirects itself
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const r = await setup.runSetup();
|
|
178
|
+
if (r.start) { await start({ port: flags.port || r.port, open: flags.open !== false }); return null; }
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
default:
|
|
182
|
+
console.error(`✗ unknown command "${cmd}" (see --help)`);
|
|
183
|
+
return 2;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export { AVA_HOME };
|