friend-assistant 0.1.40 → 0.1.41
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 +48 -8
- package/dist/voice/friend_voice/__main__.py +1 -1
- package/dist/voice/friend_voice/models.py +1 -1
- package/dist/voice/friend_voice/stt.py +40 -3
- package/dist/web/assets/index-pAv0XdRo.js +4456 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-mPlDREuW.js +0 -4456
package/dist/BUILD.json
CHANGED
package/dist/friend.mjs
CHANGED
|
@@ -3337,7 +3337,7 @@ function sanitizeLook(raw) {
|
|
|
3337
3337
|
var DEFAULT_OLLAMA = { url: "http://127.0.0.1:11434", model: "qwen2.5:7b", contextTokens: 8192, vision: false };
|
|
3338
3338
|
var DEFAULT_VOICE = {
|
|
3339
3339
|
enabled: true,
|
|
3340
|
-
sttModel: "
|
|
3340
|
+
sttModel: "small",
|
|
3341
3341
|
ttsVoice: "en_US-lessac-medium",
|
|
3342
3342
|
language: null,
|
|
3343
3343
|
speakTyped: true,
|
|
@@ -11035,7 +11035,8 @@ function createGateway(deps) {
|
|
|
11035
11035
|
const session = token ? auth.resolveSession(token) : null;
|
|
11036
11036
|
if (!session) return reply.code(401).send({ error: "Unauthorized" });
|
|
11037
11037
|
try {
|
|
11038
|
-
|
|
11038
|
+
const wake = req.query.wake === "1";
|
|
11039
|
+
return await deps.onVoiceUtterance(session, body, String(req.headers["content-type"] ?? "audio/webm"), req.ip, wake);
|
|
11039
11040
|
} catch (e) {
|
|
11040
11041
|
if (e instanceof HttpError) return reply.code(e.status).send({ error: e.message });
|
|
11041
11042
|
audit.log("owner", "voice.utterance_failed", { message: e.message });
|
|
@@ -13761,6 +13762,39 @@ var Enrollment = class {
|
|
|
13761
13762
|
}
|
|
13762
13763
|
};
|
|
13763
13764
|
|
|
13765
|
+
// ../server/src/voice/wake.ts
|
|
13766
|
+
var GREETINGS = /* @__PURE__ */ new Set(["hey", "hay", "hi", "hello", "hellow", "hallo", "hullo", "ok", "okay", "yo", "he", "\u0939\u0947", "\u0939\u093E\u092F", "\u0939\u0945\u0932\u094B", "\u0939\u0947\u0932\u094B", "\u0939\u0948\u0932\u094B", "\u0939\u0932\u094B", "\u0913\u0915\u0947"]);
|
|
13767
|
+
var WORD = /[\p{L}\p{M}\p{N}'’]+/gu;
|
|
13768
|
+
var GAP = /^[\s,.:;!?…\-–—]+/u;
|
|
13769
|
+
function distance(a, b) {
|
|
13770
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
13771
|
+
for (let i = 1; i <= a.length; i++) {
|
|
13772
|
+
let prev = row[0];
|
|
13773
|
+
row[0] = i;
|
|
13774
|
+
for (let j = 1; j <= b.length; j++) {
|
|
13775
|
+
const keep = row[j];
|
|
13776
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
13777
|
+
prev = keep;
|
|
13778
|
+
}
|
|
13779
|
+
}
|
|
13780
|
+
return row[b.length];
|
|
13781
|
+
}
|
|
13782
|
+
var sameWord = (heard, name) => heard === name || name.length >= 4 && distance(heard, name) <= 1;
|
|
13783
|
+
function matchWake(text3, name) {
|
|
13784
|
+
const no = { matched: false, rest: "" };
|
|
13785
|
+
const wanted = [...name.normalize("NFC").toLowerCase().matchAll(WORD)].map((m) => m[0]);
|
|
13786
|
+
if (wanted.length === 0) return no;
|
|
13787
|
+
const words = [...text3.normalize("NFC").matchAll(WORD)].map((m) => ({ low: m[0].toLowerCase(), end: m.index + m[0].length }));
|
|
13788
|
+
let at = 0;
|
|
13789
|
+
if (words[0] && GREETINGS.has(words[0].low)) at = 1;
|
|
13790
|
+
for (let i = 0; i < wanted.length; i++) {
|
|
13791
|
+
const heard = words[at + i];
|
|
13792
|
+
if (!heard || !sameWord(heard.low, wanted[i])) return no;
|
|
13793
|
+
}
|
|
13794
|
+
const last = words[at + wanted.length - 1];
|
|
13795
|
+
return { matched: true, rest: text3.normalize("NFC").slice(last.end).replace(GAP, "").trim() };
|
|
13796
|
+
}
|
|
13797
|
+
|
|
13764
13798
|
// ../server/src/voice/service.ts
|
|
13765
13799
|
var VoiceError = class extends Error {
|
|
13766
13800
|
};
|
|
@@ -14817,7 +14851,7 @@ async function createApp(opts) {
|
|
|
14817
14851
|
audit,
|
|
14818
14852
|
webDir: opts.webDir,
|
|
14819
14853
|
voiceAvailable,
|
|
14820
|
-
onVoiceUtterance: async (session, audio, mime, ip) => {
|
|
14854
|
+
onVoiceUtterance: async (session, audio, mime, ip, wake = false) => {
|
|
14821
14855
|
if (!voice2) throw new HttpError(503, "Voice is not available");
|
|
14822
14856
|
if (agent.isBusy()) throw new HttpError(409, "Friend is busy with the last request. Try again in a moment.");
|
|
14823
14857
|
const epoch = lockEpoch;
|
|
@@ -14835,7 +14869,13 @@ async function createApp(opts) {
|
|
|
14835
14869
|
throw e;
|
|
14836
14870
|
}
|
|
14837
14871
|
if (epoch !== lockEpoch || !auth.sessionActive(session.id)) throw new HttpError(401, "Session ended");
|
|
14838
|
-
if (!/[\p{L}\p{N}]/u.test(heard)) return { text: "" };
|
|
14872
|
+
if (!/[\p{L}\p{N}]/u.test(heard)) return wake ? { text: "", wake: false } : { text: "" };
|
|
14873
|
+
if (wake) {
|
|
14874
|
+
const m = matchWake(heard, auth.assistantName());
|
|
14875
|
+
if (!m.matched) return { text: "", wake: false };
|
|
14876
|
+
heard = m.rest;
|
|
14877
|
+
if (!/[\p{L}\p{N}]/u.test(heard)) return { text: "", wake: true };
|
|
14878
|
+
}
|
|
14839
14879
|
const crash = (e) => audit.log("system", "voice.turn_crash", { message: e.message });
|
|
14840
14880
|
const who = enrolled ? judge(embedding) : { trust: "unverified" };
|
|
14841
14881
|
const trust = who.trust;
|
|
@@ -14857,7 +14897,7 @@ async function createApp(opts) {
|
|
|
14857
14897
|
broadcast({ type: "voice.heard", text: heard, trust: trust === "owner" ? "owner" : "unverified" });
|
|
14858
14898
|
void runVoiceTurn(heard, ip, epoch, "owner", void 0, spoken).catch(crash);
|
|
14859
14899
|
}
|
|
14860
|
-
return { text: heard };
|
|
14900
|
+
return wake ? { text: heard, wake: true } : { text: heard };
|
|
14861
14901
|
},
|
|
14862
14902
|
voiceEnroll: enrollment && voice2 ? {
|
|
14863
14903
|
state: () => enrollment.state(),
|
|
@@ -15635,7 +15675,7 @@ var KEY_HELP = {
|
|
|
15635
15675
|
allowedHosts: "extra host names that may reach Friend (for example my-pc), separated by commas",
|
|
15636
15676
|
trustProxy: "true when Friend sits behind your own reverse proxy",
|
|
15637
15677
|
"voice.enabled": "true or false: use speech at all",
|
|
15638
|
-
"voice.sttModel": "listening model: tiny, base, small, \u2026 (bigger is more accurate and slower)",
|
|
15678
|
+
"voice.sttModel": "listening model: tiny, base, small, large-v3-turbo \u2026 (bigger is more accurate and slower; Marathi and far-from-mic speech do best with small or large-v3-turbo)",
|
|
15639
15679
|
"voice.ttsVoice": "speaking voice (easier: friend voice setup, or Settings)",
|
|
15640
15680
|
"voice.language": "force a spoken language such as en, hi or mr, or empty to detect it (empty is best when you mix languages)",
|
|
15641
15681
|
"voice.hindiVoice": "the voice that reads Hindi (Devanagari) text: hi_IN-priyamvada-medium, hi_IN-pratham-medium or hi_IN-rohan-medium; empty uses the main voice",
|
|
@@ -17804,7 +17844,7 @@ async function daemonCommand(args, io2, deps = realDeps) {
|
|
|
17804
17844
|
import crossSpawn5 from "cross-spawn";
|
|
17805
17845
|
var PACKAGE = "friend-assistant";
|
|
17806
17846
|
var real = {
|
|
17807
|
-
current: () => true ? "0.1.
|
|
17847
|
+
current: () => true ? "0.1.41" : "development",
|
|
17808
17848
|
latest: () => new Promise((resolve13) => {
|
|
17809
17849
|
const child = crossSpawn5("npm", ["view", PACKAGE, "version", "--prefer-online"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
17810
17850
|
let out = "";
|
|
@@ -18092,7 +18132,7 @@ async function run(argv, io2) {
|
|
|
18092
18132
|
case "version":
|
|
18093
18133
|
case "--version":
|
|
18094
18134
|
case "-v":
|
|
18095
|
-
io2.out(true ? "0.1.
|
|
18135
|
+
io2.out(true ? "0.1.41" : "development");
|
|
18096
18136
|
return 0;
|
|
18097
18137
|
case "lan":
|
|
18098
18138
|
return await lanCommand(rest, io2);
|
|
@@ -18,7 +18,7 @@ def main() -> int:
|
|
|
18
18
|
ap.add_argument("--port", type=int, default=0, help="0 picks a free port")
|
|
19
19
|
ap.add_argument("--fake", action="store_true", help="use stub engines (tests)")
|
|
20
20
|
ap.add_argument("--data-dir", default=None, help="folder holding downloaded models")
|
|
21
|
-
ap.add_argument("--stt-model", default="
|
|
21
|
+
ap.add_argument("--stt-model", default="small")
|
|
22
22
|
ap.add_argument("--tts-voice", default="en_US-lessac-medium")
|
|
23
23
|
args = ap.parse_args()
|
|
24
24
|
token = args.token or os.environ.get("FRIEND_VOICE_TOKEN")
|
|
@@ -46,7 +46,7 @@ def ensure_speaker_model(directory: Path, url: str = SPEAKER_URL, sha256: str =
|
|
|
46
46
|
return target
|
|
47
47
|
|
|
48
48
|
|
|
49
|
-
def load_engines(data_dir: Optional[str], stt_model: str = "
|
|
49
|
+
def load_engines(data_dir: Optional[str], stt_model: str = "small", tts_voice: str = "en_US-lessac-medium") -> Engines:
|
|
50
50
|
root = Path(data_dir) if data_dir else Path.home() / ".friend" / "voice" / "models"
|
|
51
51
|
root.mkdir(parents=True, exist_ok=True)
|
|
52
52
|
speaker = None
|
|
@@ -10,6 +10,17 @@ from typing import Optional
|
|
|
10
10
|
MAX_SECONDS = 90.0
|
|
11
11
|
SAMPLE_RATE = 16000
|
|
12
12
|
|
|
13
|
+
#: Speech quieter than this (RMS) is brought up toward it; a speaker far from the microphone arrives faint.
|
|
14
|
+
TARGET_RMS = 0.06
|
|
15
|
+
#: Below this the room is silent, so there is nothing to bring up (it would only make hiss louder).
|
|
16
|
+
SILENCE_RMS = 0.001
|
|
17
|
+
MAX_GAIN = 20.0
|
|
18
|
+
#: A segment is dropped when the model thinks it is probably not speech AND is not sure of its words (Whisper's own rule),
|
|
19
|
+
#: or when it is very unsure of its words whatever the speech chance: noise turns into invented words in other scripts.
|
|
20
|
+
NO_SPEECH_PROB = 0.6
|
|
21
|
+
MIN_LOGPROB = -1.0
|
|
22
|
+
HOPELESS_LOGPROB = -2.0
|
|
23
|
+
|
|
13
24
|
#: A sentence in the style wanted, given to the recognizer as a hint: Hindi and Marathi are written in Devanagari,
|
|
14
25
|
#: and the English words that people mix in stay in Latin letters.
|
|
15
26
|
HINT_PROMPTS = {
|
|
@@ -18,6 +29,29 @@ HINT_PROMPTS = {
|
|
|
18
29
|
}
|
|
19
30
|
|
|
20
31
|
|
|
32
|
+
def level(samples):
|
|
33
|
+
"""Bring faint speech up to a usable volume without clipping; leave loud enough speech and silence alone."""
|
|
34
|
+
import numpy as np
|
|
35
|
+
|
|
36
|
+
if samples.size == 0:
|
|
37
|
+
return samples
|
|
38
|
+
loudness = float(np.sqrt(np.mean(np.square(samples))))
|
|
39
|
+
if loudness >= TARGET_RMS or loudness < SILENCE_RMS:
|
|
40
|
+
return samples
|
|
41
|
+
peak = float(np.max(np.abs(samples)))
|
|
42
|
+
gain = min(TARGET_RMS / loudness, MAX_GAIN, 0.99 / peak if peak > 0 else MAX_GAIN)
|
|
43
|
+
return (samples * gain).astype(np.float32)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def believed(segment) -> bool:
|
|
47
|
+
"""Whether to keep what the model heard in this segment."""
|
|
48
|
+
no_speech = getattr(segment, "no_speech_prob", 0.0)
|
|
49
|
+
logprob = getattr(segment, "avg_logprob", 0.0)
|
|
50
|
+
if no_speech > NO_SPEECH_PROB and logprob < MIN_LOGPROB:
|
|
51
|
+
return False
|
|
52
|
+
return logprob >= HOPELESS_LOGPROB
|
|
53
|
+
|
|
54
|
+
|
|
21
55
|
def decode_audio(data: bytes, max_seconds: float = MAX_SECONDS):
|
|
22
56
|
"""Decode any container PyAV understands (webm/opus, mp4/aac, wav, ...) to mono float32 at 16 kHz.
|
|
23
57
|
|
|
@@ -49,7 +83,7 @@ def decode_audio(data: bytes, max_seconds: float = MAX_SECONDS):
|
|
|
49
83
|
take(resampler.resample(None)) # flush
|
|
50
84
|
if not pieces:
|
|
51
85
|
return np.zeros(0, dtype=np.float32)
|
|
52
|
-
return np.concatenate(pieces).astype(np.float32) / 32768.0
|
|
86
|
+
return level(np.concatenate(pieces).astype(np.float32) / 32768.0)
|
|
53
87
|
|
|
54
88
|
|
|
55
89
|
class WhisperSTT:
|
|
@@ -64,12 +98,15 @@ class WhisperSTT:
|
|
|
64
98
|
segments, info = self._model.transcribe(
|
|
65
99
|
samples,
|
|
66
100
|
language=language,
|
|
67
|
-
beam_size=
|
|
101
|
+
beam_size=5, # a wider search is slower but much better on faint or accented speech
|
|
68
102
|
vad_filter=True, # drop silence so quiet clips return an empty transcript
|
|
69
103
|
condition_on_previous_text=False,
|
|
104
|
+
no_speech_threshold=NO_SPEECH_PROB,
|
|
105
|
+
log_prob_threshold=MIN_LOGPROB,
|
|
106
|
+
compression_ratio_threshold=2.4, # repeated gibberish compresses well: the model tries again instead of keeping it
|
|
70
107
|
**extra,
|
|
71
108
|
)
|
|
72
|
-
return " ".join(s.text.strip() for s in segments).strip(), info
|
|
109
|
+
return " ".join(s.text.strip() for s in segments if believed(s)).strip(), info
|
|
73
110
|
|
|
74
111
|
def transcribe(self, audio: bytes, language: Optional[str]) -> dict:
|
|
75
112
|
samples = decode_audio(audio) # bounded decode: hostile or huge input is refused here
|