friend-assistant 0.1.40 → 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 +85 -11
- package/dist/voice/friend_voice/__main__.py +8 -2
- package/dist/voice/friend_voice/models.py +7 -1
- package/dist/voice/friend_voice/server.py +38 -3
- package/dist/voice/friend_voice/stt.py +40 -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-mPlDREuW.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 = [
|
|
@@ -3337,7 +3345,7 @@ function sanitizeLook(raw) {
|
|
|
3337
3345
|
var DEFAULT_OLLAMA = { url: "http://127.0.0.1:11434", model: "qwen2.5:7b", contextTokens: 8192, vision: false };
|
|
3338
3346
|
var DEFAULT_VOICE = {
|
|
3339
3347
|
enabled: true,
|
|
3340
|
-
sttModel: "
|
|
3348
|
+
sttModel: "small",
|
|
3341
3349
|
ttsVoice: "en_US-lessac-medium",
|
|
3342
3350
|
language: null,
|
|
3343
3351
|
speakTyped: true,
|
|
@@ -11035,7 +11043,8 @@ function createGateway(deps) {
|
|
|
11035
11043
|
const session = token ? auth.resolveSession(token) : null;
|
|
11036
11044
|
if (!session) return reply.code(401).send({ error: "Unauthorized" });
|
|
11037
11045
|
try {
|
|
11038
|
-
|
|
11046
|
+
const wake = req.query.wake === "1";
|
|
11047
|
+
return await deps.onVoiceUtterance(session, body, String(req.headers["content-type"] ?? "audio/webm"), req.ip, wake);
|
|
11039
11048
|
} catch (e) {
|
|
11040
11049
|
if (e instanceof HttpError) return reply.code(e.status).send({ error: e.message });
|
|
11041
11050
|
audit.log("owner", "voice.utterance_failed", { message: e.message });
|
|
@@ -12969,7 +12978,7 @@ function refuseForAgent(raw) {
|
|
|
12969
12978
|
if ("command" in cc) throw new SettingsError("The Claude Code command can only be changed by the owner on the settings screen.");
|
|
12970
12979
|
}
|
|
12971
12980
|
var PROVIDER_KEYS = /* @__PURE__ */ new Set(["provider", "model", "apiKey", "ollama", "openai", "claudeCode"]);
|
|
12972
|
-
var RESTART_VOICE = ["enabled"
|
|
12981
|
+
var RESTART_VOICE = ["enabled"];
|
|
12973
12982
|
function applySettings(config, raw) {
|
|
12974
12983
|
const parsed = SettingsPatch.safeParse(raw);
|
|
12975
12984
|
if (!parsed.success) {
|
|
@@ -13761,6 +13770,39 @@ var Enrollment = class {
|
|
|
13761
13770
|
}
|
|
13762
13771
|
};
|
|
13763
13772
|
|
|
13773
|
+
// ../server/src/voice/wake.ts
|
|
13774
|
+
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"]);
|
|
13775
|
+
var WORD = /[\p{L}\p{M}\p{N}'’]+/gu;
|
|
13776
|
+
var GAP = /^[\s,.:;!?…\-–—]+/u;
|
|
13777
|
+
function distance(a, b) {
|
|
13778
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
13779
|
+
for (let i = 1; i <= a.length; i++) {
|
|
13780
|
+
let prev = row[0];
|
|
13781
|
+
row[0] = i;
|
|
13782
|
+
for (let j = 1; j <= b.length; j++) {
|
|
13783
|
+
const keep = row[j];
|
|
13784
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
13785
|
+
prev = keep;
|
|
13786
|
+
}
|
|
13787
|
+
}
|
|
13788
|
+
return row[b.length];
|
|
13789
|
+
}
|
|
13790
|
+
var sameWord = (heard, name) => heard === name || name.length >= 4 && distance(heard, name) <= 1;
|
|
13791
|
+
function matchWake(text3, name) {
|
|
13792
|
+
const no = { matched: false, rest: "" };
|
|
13793
|
+
const wanted = [...name.normalize("NFC").toLowerCase().matchAll(WORD)].map((m) => m[0]);
|
|
13794
|
+
if (wanted.length === 0) return no;
|
|
13795
|
+
const words = [...text3.normalize("NFC").matchAll(WORD)].map((m) => ({ low: m[0].toLowerCase(), end: m.index + m[0].length }));
|
|
13796
|
+
let at = 0;
|
|
13797
|
+
if (words[0] && GREETINGS.has(words[0].low)) at = 1;
|
|
13798
|
+
for (let i = 0; i < wanted.length; i++) {
|
|
13799
|
+
const heard = words[at + i];
|
|
13800
|
+
if (!heard || !sameWord(heard.low, wanted[i])) return no;
|
|
13801
|
+
}
|
|
13802
|
+
const last = words[at + wanted.length - 1];
|
|
13803
|
+
return { matched: true, rest: text3.normalize("NFC").slice(last.end).replace(GAP, "").trim() };
|
|
13804
|
+
}
|
|
13805
|
+
|
|
13764
13806
|
// ../server/src/voice/service.ts
|
|
13765
13807
|
var VoiceError = class extends Error {
|
|
13766
13808
|
};
|
|
@@ -13818,7 +13860,11 @@ var HttpVoiceService = class {
|
|
|
13818
13860
|
});
|
|
13819
13861
|
return Buffer.from(await res.arrayBuffer());
|
|
13820
13862
|
}
|
|
13821
|
-
|
|
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) {
|
|
13822
13868
|
const base = this.opts.baseUrl();
|
|
13823
13869
|
if (!base) throw new VoiceError("the voice sidecar is not running");
|
|
13824
13870
|
let res;
|
|
@@ -13826,7 +13872,7 @@ var HttpVoiceService = class {
|
|
|
13826
13872
|
res = await fetch(base + path3, {
|
|
13827
13873
|
...init,
|
|
13828
13874
|
headers: { ...init.headers, authorization: `Bearer ${this.opts.token}` },
|
|
13829
|
-
signal: AbortSignal.timeout(this.opts.timeoutMs ?? 6e4)
|
|
13875
|
+
signal: AbortSignal.timeout(timeoutMs ?? this.opts.timeoutMs ?? 6e4)
|
|
13830
13876
|
});
|
|
13831
13877
|
} catch (e) {
|
|
13832
13878
|
if (e.name === "TimeoutError") throw new VoiceError("the voice sidecar timed out");
|
|
@@ -14345,6 +14391,7 @@ async function createApp(opts) {
|
|
|
14345
14391
|
function commitSettings(patch, how) {
|
|
14346
14392
|
let applied;
|
|
14347
14393
|
let voiceBefore = config.voice.ttsVoice;
|
|
14394
|
+
let sttBefore = config.voice.sttModel;
|
|
14348
14395
|
try {
|
|
14349
14396
|
const saved = loadConfig(opts.dataDir);
|
|
14350
14397
|
const check3 = applySettings(saved, patch);
|
|
@@ -14352,6 +14399,7 @@ async function createApp(opts) {
|
|
|
14352
14399
|
throw new HttpError(409, "Friend is busy with a request. Change the model when it is done.");
|
|
14353
14400
|
}
|
|
14354
14401
|
voiceBefore = config.voice.ttsVoice;
|
|
14402
|
+
sttBefore = config.voice.sttModel;
|
|
14355
14403
|
applied = applySettings(config, patch);
|
|
14356
14404
|
saveConfig(saved);
|
|
14357
14405
|
} catch (e) {
|
|
@@ -14377,6 +14425,7 @@ async function createApp(opts) {
|
|
|
14377
14425
|
else swap();
|
|
14378
14426
|
}
|
|
14379
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);
|
|
14380
14429
|
if (applied.changed.includes("voice.hindiVoice")) void warmLanguageVoice(config.voice.hindiVoice);
|
|
14381
14430
|
if (applied.changed.includes("voice.marathiVoice")) void warmLanguageVoice(config.voice.marathiVoice);
|
|
14382
14431
|
if (applied.changed.some((k) => k === "view" || k === "ownerName" || k.startsWith("look.") || k.startsWith("hud") || k.startsWith("music"))) {
|
|
@@ -14403,6 +14452,25 @@ async function createApp(opts) {
|
|
|
14403
14452
|
broadcast({ type: "alert", level: "warn", text: `Could not load the voice ${wanted} (it needs internet the first time). Using ${voiceLabel(previous)} again.` });
|
|
14404
14453
|
}
|
|
14405
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
|
+
}
|
|
14406
14474
|
async function warmLanguageVoice(wanted) {
|
|
14407
14475
|
if (!wanted || !voice2 || !voiceAvailable()) return;
|
|
14408
14476
|
broadcast({ type: "alert", level: "info", text: `Loading the voice ${voiceLabel(wanted)}. The first time it downloads about 64 MB.` });
|
|
@@ -14817,7 +14885,7 @@ async function createApp(opts) {
|
|
|
14817
14885
|
audit,
|
|
14818
14886
|
webDir: opts.webDir,
|
|
14819
14887
|
voiceAvailable,
|
|
14820
|
-
onVoiceUtterance: async (session, audio, mime, ip) => {
|
|
14888
|
+
onVoiceUtterance: async (session, audio, mime, ip, wake = false) => {
|
|
14821
14889
|
if (!voice2) throw new HttpError(503, "Voice is not available");
|
|
14822
14890
|
if (agent.isBusy()) throw new HttpError(409, "Friend is busy with the last request. Try again in a moment.");
|
|
14823
14891
|
const epoch = lockEpoch;
|
|
@@ -14835,7 +14903,13 @@ async function createApp(opts) {
|
|
|
14835
14903
|
throw e;
|
|
14836
14904
|
}
|
|
14837
14905
|
if (epoch !== lockEpoch || !auth.sessionActive(session.id)) throw new HttpError(401, "Session ended");
|
|
14838
|
-
if (!/[\p{L}\p{N}]/u.test(heard)) return { text: "" };
|
|
14906
|
+
if (!/[\p{L}\p{N}]/u.test(heard)) return wake ? { text: "", wake: false } : { text: "" };
|
|
14907
|
+
if (wake) {
|
|
14908
|
+
const m = matchWake(heard, auth.assistantName());
|
|
14909
|
+
if (!m.matched) return { text: "", wake: false };
|
|
14910
|
+
heard = m.rest;
|
|
14911
|
+
if (!/[\p{L}\p{N}]/u.test(heard)) return { text: "", wake: true };
|
|
14912
|
+
}
|
|
14839
14913
|
const crash = (e) => audit.log("system", "voice.turn_crash", { message: e.message });
|
|
14840
14914
|
const who = enrolled ? judge(embedding) : { trust: "unverified" };
|
|
14841
14915
|
const trust = who.trust;
|
|
@@ -14857,7 +14931,7 @@ async function createApp(opts) {
|
|
|
14857
14931
|
broadcast({ type: "voice.heard", text: heard, trust: trust === "owner" ? "owner" : "unverified" });
|
|
14858
14932
|
void runVoiceTurn(heard, ip, epoch, "owner", void 0, spoken).catch(crash);
|
|
14859
14933
|
}
|
|
14860
|
-
return { text: heard };
|
|
14934
|
+
return wake ? { text: heard, wake: true } : { text: heard };
|
|
14861
14935
|
},
|
|
14862
14936
|
voiceEnroll: enrollment && voice2 ? {
|
|
14863
14937
|
state: () => enrollment.state(),
|
|
@@ -15635,7 +15709,7 @@ var KEY_HELP = {
|
|
|
15635
15709
|
allowedHosts: "extra host names that may reach Friend (for example my-pc), separated by commas",
|
|
15636
15710
|
trustProxy: "true when Friend sits behind your own reverse proxy",
|
|
15637
15711
|
"voice.enabled": "true or false: use speech at all",
|
|
15638
|
-
"voice.sttModel": "listening model: tiny, base, small, \u2026 (bigger is more accurate and slower)",
|
|
15712
|
+
"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
15713
|
"voice.ttsVoice": "speaking voice (easier: friend voice setup, or Settings)",
|
|
15640
15714
|
"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
15715
|
"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 +17878,7 @@ async function daemonCommand(args, io2, deps = realDeps) {
|
|
|
17804
17878
|
import crossSpawn5 from "cross-spawn";
|
|
17805
17879
|
var PACKAGE = "friend-assistant";
|
|
17806
17880
|
var real = {
|
|
17807
|
-
current: () => true ? "0.1.
|
|
17881
|
+
current: () => true ? "0.1.42" : "development",
|
|
17808
17882
|
latest: () => new Promise((resolve13) => {
|
|
17809
17883
|
const child = crossSpawn5("npm", ["view", PACKAGE, "version", "--prefer-online"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
17810
17884
|
let out = "";
|
|
@@ -18092,7 +18166,7 @@ async function run(argv, io2) {
|
|
|
18092
18166
|
case "version":
|
|
18093
18167
|
case "--version":
|
|
18094
18168
|
case "-v":
|
|
18095
|
-
io2.out(true ? "0.1.
|
|
18169
|
+
io2.out(true ? "0.1.42" : "development");
|
|
18096
18170
|
return 0;
|
|
18097
18171
|
case "lan":
|
|
18098
18172
|
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")
|
|
@@ -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,7 +46,13 @@ def ensure_speaker_model(directory: Path, url: str = SPEAKER_URL, sha256: str =
|
|
|
46
46
|
return target
|
|
47
47
|
|
|
48
48
|
|
|
49
|
-
def
|
|
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
|
+
|
|
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)
|
|
52
58
|
speaker = None
|
|
@@ -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:
|
|
@@ -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
|