friend-assistant 0.1.41 → 0.1.42
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/dist/BUILD.json +2 -2
- package/dist/friend.mjs +39 -5
- package/dist/voice/friend_voice/__main__.py +7 -1
- package/dist/voice/friend_voice/models.py +6 -0
- package/dist/voice/friend_voice/server.py +38 -3
- package/dist/web/assets/index-DdfqW6WX.js +4456 -0
- package/dist/web/assets/index-ehxATbhd.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-CRWuir4x.css +0 -1
- package/dist/web/assets/index-pAv0XdRo.js +0 -4456
package/dist/BUILD.json
CHANGED
package/dist/friend.mjs
CHANGED
|
@@ -159,6 +159,14 @@ function resolveVoice(input) {
|
|
|
159
159
|
if (PLAIN.test(t) && PIPER_NAME.test(t)) return { id: t, label: t, listed: false };
|
|
160
160
|
return null;
|
|
161
161
|
}
|
|
162
|
+
var LISTENING_MODELS = [
|
|
163
|
+
{ id: "tiny", name: "Tiny", mb: 75, note: "fastest, mishears a lot" },
|
|
164
|
+
{ id: "base", name: "Base", mb: 145, note: "fast, struggles with Hindi, Marathi and distance" },
|
|
165
|
+
{ id: "small", name: "Small", mb: 480, note: "recommended: much better, still quick" },
|
|
166
|
+
{ id: "medium", name: "Medium", mb: 1500, note: "better again, slow on a PC without a graphics card" },
|
|
167
|
+
{ id: "large-v3-turbo", name: "Large (turbo)", mb: 1600, note: "most accurate for Marathi; needs a fast PC" }
|
|
168
|
+
];
|
|
169
|
+
var downloadSize = (mb) => `about ${mb >= 1e3 ? `${(mb / 1e3).toFixed(1)} GB` : `${mb} MB`}`;
|
|
162
170
|
|
|
163
171
|
// ../protocol/src/themes.ts
|
|
164
172
|
var THEMES = [
|
|
@@ -12970,7 +12978,7 @@ function refuseForAgent(raw) {
|
|
|
12970
12978
|
if ("command" in cc) throw new SettingsError("The Claude Code command can only be changed by the owner on the settings screen.");
|
|
12971
12979
|
}
|
|
12972
12980
|
var PROVIDER_KEYS = /* @__PURE__ */ new Set(["provider", "model", "apiKey", "ollama", "openai", "claudeCode"]);
|
|
12973
|
-
var RESTART_VOICE = ["enabled"
|
|
12981
|
+
var RESTART_VOICE = ["enabled"];
|
|
12974
12982
|
function applySettings(config, raw) {
|
|
12975
12983
|
const parsed = SettingsPatch.safeParse(raw);
|
|
12976
12984
|
if (!parsed.success) {
|
|
@@ -13852,7 +13860,11 @@ var HttpVoiceService = class {
|
|
|
13852
13860
|
});
|
|
13853
13861
|
return Buffer.from(await res.arrayBuffer());
|
|
13854
13862
|
}
|
|
13855
|
-
|
|
13863
|
+
/** Loading a model the first time downloads it (up to about 1.6 GB), so this waits far longer than a normal request. */
|
|
13864
|
+
async useListeningModel(name) {
|
|
13865
|
+
await this.request("/stt-model", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: name }) }, 30 * 6e4);
|
|
13866
|
+
}
|
|
13867
|
+
async request(path3, init, timeoutMs) {
|
|
13856
13868
|
const base = this.opts.baseUrl();
|
|
13857
13869
|
if (!base) throw new VoiceError("the voice sidecar is not running");
|
|
13858
13870
|
let res;
|
|
@@ -13860,7 +13872,7 @@ var HttpVoiceService = class {
|
|
|
13860
13872
|
res = await fetch(base + path3, {
|
|
13861
13873
|
...init,
|
|
13862
13874
|
headers: { ...init.headers, authorization: `Bearer ${this.opts.token}` },
|
|
13863
|
-
signal: AbortSignal.timeout(this.opts.timeoutMs ?? 6e4)
|
|
13875
|
+
signal: AbortSignal.timeout(timeoutMs ?? this.opts.timeoutMs ?? 6e4)
|
|
13864
13876
|
});
|
|
13865
13877
|
} catch (e) {
|
|
13866
13878
|
if (e.name === "TimeoutError") throw new VoiceError("the voice sidecar timed out");
|
|
@@ -14379,6 +14391,7 @@ async function createApp(opts) {
|
|
|
14379
14391
|
function commitSettings(patch, how) {
|
|
14380
14392
|
let applied;
|
|
14381
14393
|
let voiceBefore = config.voice.ttsVoice;
|
|
14394
|
+
let sttBefore = config.voice.sttModel;
|
|
14382
14395
|
try {
|
|
14383
14396
|
const saved = loadConfig(opts.dataDir);
|
|
14384
14397
|
const check3 = applySettings(saved, patch);
|
|
@@ -14386,6 +14399,7 @@ async function createApp(opts) {
|
|
|
14386
14399
|
throw new HttpError(409, "Friend is busy with a request. Change the model when it is done.");
|
|
14387
14400
|
}
|
|
14388
14401
|
voiceBefore = config.voice.ttsVoice;
|
|
14402
|
+
sttBefore = config.voice.sttModel;
|
|
14389
14403
|
applied = applySettings(config, patch);
|
|
14390
14404
|
saveConfig(saved);
|
|
14391
14405
|
} catch (e) {
|
|
@@ -14411,6 +14425,7 @@ async function createApp(opts) {
|
|
|
14411
14425
|
else swap();
|
|
14412
14426
|
}
|
|
14413
14427
|
if (applied.changed.includes("voice.ttsVoice")) void warmVoice(config.voice.ttsVoice, voiceBefore);
|
|
14428
|
+
if (applied.changed.includes("voice.sttModel")) void warmListening(config.voice.sttModel, sttBefore);
|
|
14414
14429
|
if (applied.changed.includes("voice.hindiVoice")) void warmLanguageVoice(config.voice.hindiVoice);
|
|
14415
14430
|
if (applied.changed.includes("voice.marathiVoice")) void warmLanguageVoice(config.voice.marathiVoice);
|
|
14416
14431
|
if (applied.changed.some((k) => k === "view" || k === "ownerName" || k.startsWith("look.") || k.startsWith("hud") || k.startsWith("music"))) {
|
|
@@ -14437,6 +14452,25 @@ async function createApp(opts) {
|
|
|
14437
14452
|
broadcast({ type: "alert", level: "warn", text: `Could not load the voice ${wanted} (it needs internet the first time). Using ${voiceLabel(previous)} again.` });
|
|
14438
14453
|
}
|
|
14439
14454
|
}
|
|
14455
|
+
async function warmListening(wanted, previous) {
|
|
14456
|
+
if (!voice2?.useListeningModel || !voiceAvailable()) return;
|
|
14457
|
+
const known = LISTENING_MODELS.find((m) => m.id === wanted);
|
|
14458
|
+
const download = known ? ` (${downloadSize(known.mb)} the first time it is downloaded)` : ". The first time it downloads";
|
|
14459
|
+
broadcast({ type: "alert", level: "info", text: `Getting the listening model ${wanted}${download}. Friend keeps listening with the old one meanwhile.` });
|
|
14460
|
+
try {
|
|
14461
|
+
await voice2.useListeningModel(wanted);
|
|
14462
|
+
broadcast({ type: "alert", level: "info", text: `The listening model ${wanted} is ready.` });
|
|
14463
|
+
} catch (e) {
|
|
14464
|
+
audit.log("system", "voice.model_failed", { model: wanted, message: e.message });
|
|
14465
|
+
if (config.voice.sttModel === wanted) {
|
|
14466
|
+
config.voice.sttModel = previous;
|
|
14467
|
+
const saved = loadConfig(opts.dataDir);
|
|
14468
|
+
saved.voice.sttModel = previous;
|
|
14469
|
+
saveConfig(saved);
|
|
14470
|
+
}
|
|
14471
|
+
broadcast({ type: "alert", level: "warn", text: `Could not load the listening model ${wanted} (it needs internet the first time). Using ${previous} again.` });
|
|
14472
|
+
}
|
|
14473
|
+
}
|
|
14440
14474
|
async function warmLanguageVoice(wanted) {
|
|
14441
14475
|
if (!wanted || !voice2 || !voiceAvailable()) return;
|
|
14442
14476
|
broadcast({ type: "alert", level: "info", text: `Loading the voice ${voiceLabel(wanted)}. The first time it downloads about 64 MB.` });
|
|
@@ -17844,7 +17878,7 @@ async function daemonCommand(args, io2, deps = realDeps) {
|
|
|
17844
17878
|
import crossSpawn5 from "cross-spawn";
|
|
17845
17879
|
var PACKAGE = "friend-assistant";
|
|
17846
17880
|
var real = {
|
|
17847
|
-
current: () => true ? "0.1.
|
|
17881
|
+
current: () => true ? "0.1.42" : "development",
|
|
17848
17882
|
latest: () => new Promise((resolve13) => {
|
|
17849
17883
|
const child = crossSpawn5("npm", ["view", PACKAGE, "version", "--prefer-online"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
17850
17884
|
let out = "";
|
|
@@ -18132,7 +18166,7 @@ async function run(argv, io2) {
|
|
|
18132
18166
|
case "version":
|
|
18133
18167
|
case "--version":
|
|
18134
18168
|
case "-v":
|
|
18135
|
-
io2.out(true ? "0.1.
|
|
18169
|
+
io2.out(true ? "0.1.42" : "development");
|
|
18136
18170
|
return 0;
|
|
18137
18171
|
case "lan":
|
|
18138
18172
|
return await lanCommand(rest, io2);
|
|
@@ -32,7 +32,13 @@ def main() -> int:
|
|
|
32
32
|
|
|
33
33
|
engines = load_engines(args.data_dir, args.stt_model, args.tts_voice)
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
if args.fake:
|
|
36
|
+
loader = lambda name: FakeEngines().stt # noqa: E731
|
|
37
|
+
else:
|
|
38
|
+
from .models import load_stt
|
|
39
|
+
|
|
40
|
+
loader = lambda name: load_stt(args.data_dir, name) # noqa: E731
|
|
41
|
+
server, port = make_server(engines, token, port=args.port, load_stt=loader)
|
|
36
42
|
print(json.dumps({"ready": True, "port": port}), flush=True)
|
|
37
43
|
|
|
38
44
|
# When the parent process goes away its end of our stdin closes: shut down instead of lingering.
|
|
@@ -46,6 +46,12 @@ def ensure_speaker_model(directory: Path, url: str = SPEAKER_URL, sha256: str =
|
|
|
46
46
|
return target
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
def load_stt(data_dir: Optional[str], name: str) -> WhisperSTT:
|
|
50
|
+
"""One listening model, downloaded into the models folder when it is not there yet."""
|
|
51
|
+
root = Path(data_dir) if data_dir else Path.home() / ".friend" / "voice" / "models"
|
|
52
|
+
return WhisperSTT(name, str(root / "whisper"))
|
|
53
|
+
|
|
54
|
+
|
|
49
55
|
def load_engines(data_dir: Optional[str], stt_model: str = "small", tts_voice: str = "en_US-lessac-medium") -> Engines:
|
|
50
56
|
root = Path(data_dir) if data_dir else Path.home() / ".friend" / "voice" / "models"
|
|
51
57
|
root.mkdir(parents=True, exist_ok=True)
|
|
@@ -4,20 +4,33 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import hmac
|
|
6
6
|
import json
|
|
7
|
+
import re
|
|
8
|
+
import threading
|
|
7
9
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
8
|
-
from typing import Tuple
|
|
10
|
+
from typing import Callable, Optional, Tuple
|
|
9
11
|
from urllib.parse import parse_qs, urlparse
|
|
10
12
|
|
|
11
13
|
from .engines import Engines
|
|
12
14
|
from .tts import check_voice_name
|
|
13
15
|
|
|
14
16
|
MAX_TTS_CHARS = 2000
|
|
17
|
+
#: What a listening model is called: tiny, base, small, large-v3-turbo, or a Hugging Face folder such as Systran/faster-whisper-small.
|
|
18
|
+
MODEL_NAME = re.compile(r"^[A-Za-z0-9_./-]{1,80}$")
|
|
15
19
|
|
|
16
20
|
|
|
17
21
|
def make_server(
|
|
18
|
-
engines: Engines,
|
|
22
|
+
engines: Engines,
|
|
23
|
+
token: str,
|
|
24
|
+
port: int = 0,
|
|
25
|
+
max_body: int = 4_000_000,
|
|
26
|
+
io_timeout: float = 30,
|
|
27
|
+
load_stt: Optional[Callable[[str], object]] = None,
|
|
19
28
|
) -> Tuple[ThreadingHTTPServer, int]:
|
|
20
|
-
"""Create (but do not start) a server bound to 127.0.0.1. Returns (server, actual_port).
|
|
29
|
+
"""Create (but do not start) a server bound to 127.0.0.1. Returns (server, actual_port).
|
|
30
|
+
|
|
31
|
+
`load_stt(name)` builds (and, the first time, downloads) a listening model; POST /stt-model swaps it in while running.
|
|
32
|
+
"""
|
|
33
|
+
swapping = threading.Lock() # one model change at a time
|
|
21
34
|
|
|
22
35
|
class Handler(BaseHTTPRequestHandler):
|
|
23
36
|
protocol_version = "HTTP/1.1"
|
|
@@ -85,6 +98,8 @@ def make_server(
|
|
|
85
98
|
return self._tts()
|
|
86
99
|
if url.path == "/embed":
|
|
87
100
|
return self._embed()
|
|
101
|
+
if url.path == "/stt-model":
|
|
102
|
+
return self._stt_model()
|
|
88
103
|
self._drain(64 * 1024)
|
|
89
104
|
self._json(404, {"error": "not found"}, close=True)
|
|
90
105
|
|
|
@@ -118,6 +133,26 @@ def make_server(
|
|
|
118
133
|
except Exception as e: # engine failures are reported, never crash the server
|
|
119
134
|
self._json(500, {"error": str(e)})
|
|
120
135
|
|
|
136
|
+
def _stt_model(self) -> None:
|
|
137
|
+
raw = self._read_body()
|
|
138
|
+
if raw is None:
|
|
139
|
+
return
|
|
140
|
+
if load_stt is None:
|
|
141
|
+
return self._json(501, {"error": "this speech engine cannot change its listening model"})
|
|
142
|
+
try:
|
|
143
|
+
name = json.loads(raw)["model"]
|
|
144
|
+
except (ValueError, KeyError, TypeError):
|
|
145
|
+
return self._json(400, {"error": "expected JSON with a model field"})
|
|
146
|
+
if not isinstance(name, str) or not MODEL_NAME.match(name) or ".." in name:
|
|
147
|
+
return self._json(400, {"error": "that is not a listening model name"})
|
|
148
|
+
with swapping:
|
|
149
|
+
try:
|
|
150
|
+
fresh = load_stt(name) # may take minutes the first time (download); the old model keeps listening meanwhile
|
|
151
|
+
except Exception as e:
|
|
152
|
+
return self._json(500, {"error": str(e) or "the listening model could not be loaded"})
|
|
153
|
+
engines.stt = fresh
|
|
154
|
+
self._json(200, {"ok": True, "model": name})
|
|
155
|
+
|
|
121
156
|
def _embed(self) -> None:
|
|
122
157
|
audio = self._read_body()
|
|
123
158
|
if audio is None:
|