symbiot 0.58.1 → 0.58.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  What each Symbiot release brought, newest first. When Approve bumps the version, it writes that release here from the tasks it approved, worded as release notes by your connected AI (or in the tasks' own words without one). The app shows the releases newer than yours under "What's new" when it offers an update, and once more after you update.
4
4
 
5
+ ## 0.58.2
6
+
7
+ - **A voice that doesn't sound robotic, picked for you.** When Symbiot reads a reply out, it uses your computer's own voice only when that's a modern, natural one (Edge's Natural voices on Windows, Apple's Premium and Enhanced voices, a phone's own). Otherwise, as on Linux, where Chrome only has its older Google voices and espeak, Symbiot speaks with its own voice: Piper, a small open-source speech engine that runs on your computer. The accent follows where you are (British English for South Africa, the UK, Australia and the rest; American for the US and Canada), and both voices are public domain. Nothing to choose, no account, nothing leaves your computer. It downloads once (about 85 MB) the first time it's needed, while the system voice covers that first reply, and then answers sentence by sentence, the first in under a second.
8
+
5
9
  ## 0.58.1
6
10
 
7
11
  - **No more "couldn't reach the model" after an update.** An update restarted Symbiot in whatever folder had started it, and when that folder was later deleted, Claude Code refused to run there: Symbiot thought you'd signed out of Claude and fell back to an old API key that no longer worked. Symbiot now always runs, and asks Claude Code, from your home folder.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.58.1",
3
+ "version": "0.58.2",
4
4
  "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice \u2014 Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -50,7 +50,8 @@
50
50
  "scanworker.mjs",
51
51
  "estimate.mjs",
52
52
  "icon.ico",
53
- "claudesetup.mjs"
53
+ "claudesetup.mjs",
54
+ "voice.mjs"
54
55
  ],
55
56
  "engines": {
56
57
  "node": ">=18"
@@ -83,7 +84,7 @@
83
84
  "@anthropic-ai/sdk": "^0.128.0"
84
85
  },
85
86
  "scripts": {
86
- "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/urgent.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/subscription.mjs && node test/claudesetup.mjs && node test/launcher.mjs && node test/onboarding.mjs && node test/windows.mjs && node test/marketing.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
87
+ "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/urgent.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/voice.mjs && node test/subscription.mjs && node test/claudesetup.mjs && node test/launcher.mjs && node test/onboarding.mjs && node test/windows.mjs && node test/marketing.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
87
88
  "lint": "eslint .",
88
89
  "postinstall": "node postinstall.mjs"
89
90
  },
package/server.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  // work, on 127.0.0.1 only and behind the per-install token. (Watch on your phone
4
4
  // listens on your network separately, only for that: phone.mjs.)
5
5
  import { claudeSetup, installClaude, signInClaude, sendClaudeCode } from "./claudesetup.mjs";
6
+ import { voiceState, prepareVoice, speak, stopVoices } from "./voice.mjs";
6
7
  import { spawn, spawnSync } from "node:child_process";
7
8
  import { toggleAway, closeAway } from "./away.mjs";
8
9
  import { homedir } from "node:os";
@@ -430,6 +431,14 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
430
431
  if (u.pathname === "/api/home/answer" && req.method === "POST") { const b = await readBody(req); return json(res, homeAnswer(b.id, { pick: b.pick, text: b.text })); }
431
432
  if (u.pathname === "/api/away" && req.method === "POST") { const b = await readBody(req); return json(res, toggleAway(`http://127.0.0.1:${server.address().port}/?t=${TOKEN}`, b.open)); }
432
433
  if (u.pathname === "/api/home/next" && req.method === "POST") { const b = await readBody(req); return json(res, homeNext(b.id)); }
434
+ // Symbiot's own voice (voice.mjs), for computers without a natural one: the page asks
435
+ if (u.pathname === "/api/voice") return json(res, voiceState(u.searchParams.get("lang") || ""));
436
+ if (u.pathname === "/api/voice/prepare" && req.method === "POST") { const b = await readBody(req); return json(res, prepareVoice(b.lang || "")); }
437
+ if (u.pathname === "/api/voice/say" && req.method === "POST") {
438
+ const b = await readBody(req);
439
+ try { const wav = await speak(b.text, b.lang || ""); res.writeHead(200, { "content-type": "audio/wav", "cache-control": "no-store", "content-length": wav.length }); return res.end(wav); }
440
+ catch (e) { return json(res, { error: String((e && e.message) || e) }); }
441
+ }
433
442
  if (u.pathname === "/api/home/ask" && req.method === "POST") { const b = await readBody(req); return json(res, await homeAsk(b.question, { images: saveShots(b.images) })); }
434
443
  if (u.pathname === "/api/adapt") return json(res, adaptState({ from: String(u.searchParams.get("from") || ""), commit: u.searchParams.get("commit") === "1", ...(u.searchParams.has("touch") ? { touch: u.searchParams.get("touch") === "1" } : {}) }));
435
444
  if (u.pathname === "/api/adapt/use" && req.method === "POST") { const b = await readBody(req); return json(res, noteUse(b)); }
@@ -520,7 +529,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
520
529
  const e = attempt(1);
521
530
  return json(res, { started: true, id: e ? e.id : "", target });
522
531
  }
523
- if (u.pathname === "/api/quit") { res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
532
+ if (u.pathname === "/api/quit") { stopVoices(); res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
524
533
  } catch (e) { res.writeHead(500, { "content-type": "application/json" }); res.end(JSON.stringify({ error: String((e && e.message) || e) })); return; }
525
534
  res.writeHead(404); res.end("not found");
526
535
  });
package/ui.mjs CHANGED
@@ -3026,7 +3026,7 @@ function voiceSay(t){LQ.talkMin=false;lqTalkMode(true);LQ.talk.push({me:false,te
3026
3026
  var VOICE_WHY={'not-allowed':'Symbiot couldn’t use your microphone. Allow it for this window (the icon at the top of the window, or your system’s privacy settings), then click the mic again.','service-not-allowed':'Symbiot couldn’t use your microphone. Allow it for this window, then click the mic again.','audio-capture':'No microphone was found. Plug one in, or check it’s switched on, then try again.','network':'Talking needs an internet connection: your browser turns speech into text online.'};
3027
3027
  function voiceToggle(){
3028
3028
  if(VOICE.on){try{VOICE.rec.stop();}catch(e){}return;}
3029
- try{if(window.speechSynthesis)window.speechSynthesis.cancel();}catch(e){}
3029
+ voiceStop();
3030
3030
  var R=window.SpeechRecognition||window.webkitSpeechRecognition;if(!R)return;
3031
3031
  var r;try{r=new R();}catch(e){return;}
3032
3032
  var ask=$('lqask');VOICE.rec=r;VOICE.final='';VOICE.err='';VOICE.base=((ask&&ask.value)||'').trim();
@@ -3039,13 +3039,41 @@ r.onend=function(){VOICE.on=false;voiceMic(false);VOICE.rec=null;var said=((ask&
3039
3039
  if(why){voiceSay(why);return;}
3040
3040
  if(VOICE.final.trim()&&said){VOICE.spoke=true;lqSay();}};
3041
3041
  try{r.start();}catch(e){VOICE.on=false;voiceMic(false);}}
3042
- // read out: plain words (no markdown marks), up to a few sentences; a voice in your language, a natural one if there is
3043
- function voiceSpeak(t){if(!window.speechSynthesis||!t)return;
3044
- var clean=String(t).replace(/[*_#>|]+/g,' ').replace(new RegExp(String.fromCharCode(96),'g'),'').replace(/https?:[^ ]+/g,'a link').replace(/ +/g,' ').trim();
3042
+ // Speaking, with no voice to choose: the system's own when it has a natural one
3043
+ // (Edge on Windows, Macs with Apple's better voices, phones), in your region's accent if it has that too;
3044
+ // otherwise Symbiot's own (voice.mjs: Piper on this computer), fetched once in the
3045
+ // background the first time, with the system's voice meanwhile. Plain words only.
3046
+ var SPEAK={audio:null,n:0,own:null,asked:false};
3047
+ function voiceLang(){return navigator.language||'en-US';}
3048
+ function voiceTag(v){return String((v&&v.lang)||'').toLowerCase().split('_').join('-');}
3049
+ function voiceNatural(){if(!window.speechSynthesis)return null;var tag=voiceLang().toLowerCase().split('_').join('-'),lang=tag.slice(0,2);
3050
+ var vs=window.speechSynthesis.getVoices().filter(function(v){return voiceTag(v).indexOf(lang)===0;});
3051
+ // modern voices only: Edge's Natural ones, Apple's Premium and Enhanced. Chrome's Google voices and espeak sound robotic.
3052
+ var good=vs.filter(function(v){return /natural|neural|enhanced|premium|siri/i.test(v.name||'')&&!/espeak|google/i.test(v.name||'');});
3053
+ if(!good.length&&/android|iphone|ipad/i.test(navigator.userAgent||''))good=vs; // a phone's own voices are good ones
3054
+ good.sort(function(a,b){return (voiceTag(b)===tag)-(voiceTag(a)===tag);});
3055
+ return {best:good[0]||null,any:good[0]||vs[0]||null};}
3056
+ function voiceStop(){SPEAK.n++;try{if(window.speechSynthesis)window.speechSynthesis.cancel();}catch(e){}if(SPEAK.audio){try{SPEAK.audio.pause();}catch(e){}SPEAK.audio=null;}}
3057
+ function voiceClean(t){var clean=String(t||'').replace(/[*_#>|]+/g,' ').replace(new RegExp(String.fromCharCode(96),'g'),'').replace(/https?:[^ ]+/g,'a link').replace(/ +/g,' ').trim();
3045
3058
  if(clean.length>700){var cut=clean.slice(0,700),at=Math.max(cut.lastIndexOf('. '),cut.lastIndexOf('? '),cut.lastIndexOf('! '));clean=(at>200?cut.slice(0,at+1):cut)+' The rest is on screen.';}
3046
- try{var u=new SpeechSynthesisUtterance(clean),lang=(navigator.language||'en').slice(0,2).toLowerCase(),vs=window.speechSynthesis.getVoices().filter(function(v){return (v.lang||'').toLowerCase().indexOf(lang)===0;});
3047
- var best=vs.filter(function(v){return /natural|neural|google|online/i.test(v.name);})[0]||vs[0];if(best)u.voice=best;u.lang=navigator.language||'en-US';u.rate=1.02;
3048
- window.speechSynthesis.cancel();window.speechSynthesis.speak(u);}catch(e){}}
3059
+ return clean;}
3060
+ function voiceSystem(text,v){if(!window.speechSynthesis||!text)return;try{var u=new SpeechSynthesisUtterance(text);if(v)u.voice=v;u.lang=voiceLang();u.rate=1.02;window.speechSynthesis.cancel();window.speechSynthesis.speak(u);}catch(e){}}
3061
+ // a sentence at a time, so the first is heard while the next is made
3062
+ function voiceSentences(t){var out=[],cur='';t.split(' ').forEach(function(w){cur+=(cur?' ':'')+w;var e=w.charAt(w.length-1);if((e==='.'||e==='?'||e==='!')&&cur.length>24){out.push(cur);cur='';}});if(cur)out.push(cur);return out;}
3063
+ function voiceOwn(text,lang){var my=SPEAK.n,parts=voiceSentences(text),got=[];
3064
+ var make=function(i){if(i>=parts.length||got[i])return;got[i]=fetch('/api/voice/say',{method:'POST',headers:{'x-symbiot-token':T,'content-type':'application/json'},body:JSON.stringify({text:parts[i],lang:lang})}).then(function(r){if((r.headers.get('content-type')||'').indexOf('audio')<0)throw new Error('no audio');return r.blob();});};
3065
+ var play=function(i){if(my!==SPEAK.n||i>=parts.length)return;make(i);make(i+1);
3066
+ got[i].then(function(b){if(my!==SPEAK.n)return;var a=new Audio(URL.createObjectURL(b));SPEAK.audio=a;a.onended=function(){try{URL.revokeObjectURL(a.src);}catch(e){}play(i+1);};var pl=a.play();if(pl&&pl.catch)pl.catch(function(){});})
3067
+ .catch(function(){if(my!==SPEAK.n)return;SPEAK.own=null;var nat=voiceNatural();voiceSystem(parts.slice(i).join(' '),nat&&nat.any);});};
3068
+ play(0);}
3069
+ function voiceSpeak(t){if(!t)return;voiceStop();var clean=voiceClean(t);if(!clean)return;var nat=voiceNatural();
3070
+ if(nat&&nat.best){voiceSystem(clean,nat.best);return;}
3071
+ var lang=voiceLang(),go=function(st){if(st&&st.state==='ready'){voiceOwn(clean,lang);return;}
3072
+ if(st&&(st.state==='missing'||st.state==='failed')&&!SPEAK.asked){SPEAK.asked=true;api('/api/voice/prepare',{lang:lang}).catch(function(){});}
3073
+ voiceSystem(clean,nat&&nat.any);};
3074
+ if(SPEAK.own&&SPEAK.own.state==='ready')go(SPEAK.own);else api('/api/voice?lang='+encodeURIComponent(lang)).then(function(st){SPEAK.own=st;go(st);}).catch(function(){go(null);});}
3075
+ // Chrome lists its voices only after a first ask
3076
+ try{if(window.speechSynthesis)window.speechSynthesis.getVoices();}catch(e){}
3049
3077
  function lqSay(){var spoke=VOICE.spoke;VOICE.spoke=false;var i=$('lqask');var q=((i&&i.value)||'').trim(),shots=LQ.atts.slice();if(!q&&!shots.length)return;if(!q)q='Take a look at this screenshot.';i.value='';lqGrowAsk();LQ.atts=[];lqAttsShow();lqAct();
3050
3078
  var low=q.toLowerCase().replace(/^(please |can you |could you )/,''),hit='';
3051
3079
  Object.keys(LQNAMES).forEach(function(k){var nm=LQNAMES[k].toLowerCase();if(hit)return;['open ','show ','go to ','take me to '].forEach(function(v){if(low.indexOf(v+nm)===0||low.indexOf(v+'my '+nm)===0)hit=k;});if(low===nm)hit=k;});
package/voice.mjs ADDED
@@ -0,0 +1,113 @@
1
+ // symbiot — Symbiot's own voice, for computers whose built-in voices sound robotic.
2
+ // The page picks the voice itself: a natural voice the system already has (Windows
3
+ // and Edge, Macs, phones) is used as it is. Where there's none (Linux, mostly: only
4
+ // espeak), Symbiot speaks with Piper, a small open-source speech engine that runs on
5
+ // the computer, with a public-domain voice picked from where you are: British English
6
+ // (Cori) for the UK, South Africa, Australia and the rest, American (LJSpeech) for the
7
+ // US, Canada and the Philippines. Nothing to choose, no account, nothing leaves the
8
+ // computer. Downloaded once, the first time it's needed (~25 MB engine, ~60 MB voice),
9
+ // into ~/.config/symbiot/voice/. Kept warm while you talk, closed after 5 quiet minutes.
10
+ // SYMBIOT_VOICE_DIR / SYMBIOT_PIPER_URL / SYMBIOT_VOICES_URL point elsewhere (tests).
11
+ import { spawn, spawnSync } from "node:child_process";
12
+ import { existsSync, mkdirSync, createWriteStream, readFileSync, rmSync, renameSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { CONFIG_DIR } from "./core.mjs";
15
+
16
+ const PIPER_REL = "2023.11.14-2";
17
+ const PIPER_ASSET = { "linux-x64": "piper_linux_x86_64.tar.gz", "linux-arm64": "piper_linux_aarch64.tar.gz", "linux-arm": "piper_linux_armv7l.tar.gz", "darwin-x64": "piper_macos_x64.tar.gz", "darwin-arm64": "piper_macos_aarch64.tar.gz", "win32-x64": "piper_windows_amd64.zip" };
18
+ // public-domain voices only: free to use in Symbiot, commercially, with no conditions
19
+ const VOICES = {
20
+ gb: { id: "en_GB-cori-medium", path: "en/en_GB/cori/medium", who: "British English" },
21
+ us: { id: "en_US-ljspeech-medium", path: "en/en_US/ljspeech/medium", who: "American English" },
22
+ };
23
+ const US_LIKE = new Set(["us", "ca", "ph", "lr", "pr", "um", "as", "gu", "vi"]);
24
+ const IDLE_MS = 5 * 60 * 1000;
25
+
26
+ const vdir = () => process.env.SYMBIOT_VOICE_DIR || join(CONFIG_DIR, "voice");
27
+ const piperUrl = (asset) => (process.env.SYMBIOT_PIPER_URL || `https://github.com/rhasspy/piper/releases/download/${PIPER_REL}`) + "/" + asset;
28
+ const voiceUrl = (v, ext) => (process.env.SYMBIOT_VOICES_URL || "https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0") + `/${v.path}/${v.id}${ext}`;
29
+
30
+ // Which voice for this language ("en-ZA", "en_US", "en"): null when Piper has none we use.
31
+ function voiceFor(lang = "") {
32
+ const [l, r = ""] = String(lang).toLowerCase().replace("_", "-").split("-");
33
+ if (l !== "en") return null;
34
+ return US_LIKE.has(r) ? VOICES.us : VOICES.gb;
35
+ }
36
+ function assetFor(platform = process.platform, arch = process.arch) { return PIPER_ASSET[`${platform}-${arch}`] || null; }
37
+ function piperBin(platform = process.platform) { return join(vdir(), "piper", platform === "win32" ? "piper.exe" : "piper"); }
38
+ const modelPath = (v) => join(vdir(), "voices", v.id + ".onnx");
39
+ const haveVoice = (v) => existsSync(modelPath(v)) && existsSync(modelPath(v) + ".json");
40
+
41
+ const S = { busy: null, error: "", got: 0, total: 0 };
42
+ function voiceState(lang = "") {
43
+ const v = voiceFor(lang);
44
+ if (!v) return { state: "none", why: "no voice for this language" };
45
+ if (!assetFor()) return { state: "none", why: "not on this kind of computer" };
46
+ if (existsSync(piperBin()) && haveVoice(v)) return { state: "ready", voice: v.who };
47
+ if (S.busy) return { state: "downloading", voice: v.who, ...(S.total ? { percent: Math.min(99, Math.round((S.got / S.total) * 100)) } : {}) };
48
+ return { state: S.error ? "failed" : "missing", voice: v.who, ...(S.error ? { error: S.error } : {}) };
49
+ }
50
+
51
+ async function download(url, to) {
52
+ const r = await fetch(url, { redirect: "follow" });
53
+ if (!r.ok || !r.body) throw new Error(`couldn't download ${url.split("/").pop()} (${r.status})`);
54
+ S.total += Number(r.headers.get("content-length")) || 0;
55
+ const part = to + ".part", out = createWriteStream(part);
56
+ for await (const chunk of r.body) { S.got += chunk.length; if (!out.write(chunk)) await new Promise((ok) => out.once("drain", ok)); }
57
+ await new Promise((ok, bad) => out.end((e) => (e ? bad(e) : ok())));
58
+ renameSync(part, to);
59
+ }
60
+ // The engine and this language's voice, once. Safe to call again: it only fetches what's missing.
61
+ function prepareVoice(lang = "") {
62
+ const v = voiceFor(lang), asset = assetFor();
63
+ if (!v || !asset) return voiceState(lang);
64
+ if (S.busy) return voiceState(lang);
65
+ if (existsSync(piperBin()) && haveVoice(v)) return voiceState(lang);
66
+ S.error = ""; S.got = 0; S.total = 0;
67
+ S.busy = (async () => {
68
+ const dir = vdir(); mkdirSync(join(dir, "voices"), { recursive: true });
69
+ if (!existsSync(piperBin())) {
70
+ const arc = join(dir, asset);
71
+ await download(piperUrl(asset), arc);
72
+ // tar unpacks both: .tar.gz everywhere, and .zip on Windows 10+ (bsdtar)
73
+ const t = spawnSync("tar", [asset.endsWith(".zip") ? "-xf" : "-xzf", arc, "-C", dir], { encoding: "utf8", windowsHide: true });
74
+ rmSync(arc, { force: true });
75
+ if (t.status !== 0 || !existsSync(piperBin())) throw new Error("couldn't unpack the speech engine" + (t.stderr ? ": " + String(t.stderr).trim().split("\n").pop() : ""));
76
+ }
77
+ if (!haveVoice(v)) { await download(voiceUrl(v, ".onnx.json"), modelPath(v) + ".json"); await download(voiceUrl(v, ".onnx"), modelPath(v)); }
78
+ })().catch((e) => { S.error = String((e && e.message) || e); }).finally(() => { S.busy = null; });
79
+ return voiceState(lang);
80
+ }
81
+
82
+ // One warm engine per voice: a line of text in, the path of its .wav out, in order.
83
+ const WARM = new Map();
84
+ function engine(v) {
85
+ let w = WARM.get(v.id);
86
+ if (w && !w.dead) { clearTimeout(w.idle); w.idle = setTimeout(() => stopEngine(v.id), IDLE_MS); return w; }
87
+ const out = join(vdir(), "out"); mkdirSync(out, { recursive: true });
88
+ const ch = spawn(piperBin(), ["--model", modelPath(v), "--output_dir", out], { cwd: vdir(), stdio: ["pipe", "pipe", "ignore"], windowsHide: true });
89
+ w = { ch, wait: [], buf: "", dead: false, idle: setTimeout(() => stopEngine(v.id), IDLE_MS) };
90
+ ch.stdout.on("data", (d) => {
91
+ w.buf += d; let i;
92
+ while ((i = w.buf.indexOf("\n")) >= 0) { const line = w.buf.slice(0, i).trim(); w.buf = w.buf.slice(i + 1); if (line) { const p = w.wait.shift(); if (p) p.ok(line); } }
93
+ });
94
+ const fail = (e) => { w.dead = true; clearTimeout(w.idle); for (const p of w.wait.splice(0)) p.bad(e); WARM.delete(v.id); };
95
+ ch.on("error", fail); ch.on("exit", () => fail(new Error("the speech engine stopped")));
96
+ WARM.set(v.id, w);
97
+ return w;
98
+ }
99
+ function stopEngine(id) { const w = WARM.get(id); if (!w) return; WARM.delete(id); w.dead = true; clearTimeout(w.idle); try { w.ch.stdin.end(); w.ch.kill(); } catch {} }
100
+ function stopVoices() { for (const id of [...WARM.keys()]) stopEngine(id); }
101
+
102
+ // Speak one piece of text (a sentence or two): the WAV's bytes.
103
+ async function speak(text, lang = "") {
104
+ const v = voiceFor(lang);
105
+ if (!v || voiceState(lang).state !== "ready") throw new Error("Symbiot's voice isn't ready yet");
106
+ const line = String(text || "").replace(/\s+/g, " ").trim().slice(0, 600);
107
+ if (!line) throw new Error("nothing to say");
108
+ const w = engine(v);
109
+ const file = await new Promise((ok, bad) => { w.wait.push({ ok, bad }); w.ch.stdin.write(line + "\n"); });
110
+ try { return readFileSync(file); } finally { rmSync(file, { force: true }); }
111
+ }
112
+
113
+ export { voiceFor, assetFor, voiceState, prepareVoice, speak, stopVoices, VOICES, PIPER_REL };