@sma1lboy/rove 0.9.281-next.31 → 0.9.282
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/cli/chunk-03a986ba.js +8 -0
- package/dist/cli/{chunk-j54c5a0a.js → chunk-084a4eq5.js} +1 -1
- package/dist/cli/{chunk-y7mjhjby.js → chunk-0gbg7bg8.js} +1 -1
- package/dist/cli/{chunk-6cfs0nqe.js → chunk-0mr6cckm.js} +1 -1
- package/dist/cli/{chunk-j8d9sk5j.js → chunk-0ramhr3v.js} +1 -1
- package/dist/cli/{chunk-rvjnafyt.js → chunk-0ws6j7zf.js} +1 -1
- package/dist/cli/{chunk-1g6vhgfe.js → chunk-16e6jqq6.js} +1 -1
- package/dist/cli/{chunk-vbvbwjad.js → chunk-1k6expt9.js} +1 -1
- package/dist/cli/{chunk-f262ywvg.js → chunk-231mqmk5.js} +1 -1
- package/dist/cli/{chunk-5g6v6tq5.js → chunk-3m6m1994.js} +1 -1
- package/dist/cli/{chunk-f0sahrfw.js → chunk-3xz0dmnc.js} +17 -14
- package/dist/cli/{chunk-nqwza8b0.js → chunk-4d5xp2p0.js} +1 -1
- package/dist/cli/{chunk-mjzjah9d.js → chunk-4g463eqt.js} +1 -1
- package/dist/cli/{chunk-hfvzvj91.js → chunk-4yr1534j.js} +1 -1
- package/dist/cli/{chunk-7aaw17vr.js → chunk-55e3pt18.js} +1 -1
- package/dist/cli/{chunk-hd1szhba.js → chunk-5bnhecx3.js} +1 -1
- package/dist/cli/{chunk-t2p22ww7.js → chunk-5z02j1em.js} +1 -1
- package/dist/cli/{chunk-r98h6b42.js → chunk-6jj8perx.js} +2 -2
- package/dist/cli/{chunk-5dq0n3h7.js → chunk-7r1wt7qp.js} +1 -1
- package/dist/cli/chunk-7zwehmay.js +2 -0
- package/dist/cli/{chunk-4drm07mp.js → chunk-8ccc9v77.js} +1 -1
- package/dist/cli/chunk-8ew9sqwa.js +2 -0
- package/dist/cli/{chunk-w003rpr2.js → chunk-8kspedny.js} +1 -1
- package/dist/cli/chunk-8ng1756g.js +3 -0
- package/dist/cli/{chunk-f7egph8g.js → chunk-92hpkjkk.js} +1 -1
- package/dist/cli/{chunk-ebdp0z1d.js → chunk-95kk75wh.js} +1 -1
- package/dist/cli/{chunk-np398f2a.js → chunk-97er67dc.js} +1 -1
- package/dist/cli/{chunk-qc9sj5wh.js → chunk-9q8wwfs1.js} +1 -1
- package/dist/cli/{chunk-zkby63ej.js → chunk-ae8dj2fd.js} +1 -1
- package/dist/cli/chunk-b4egks20.js +7 -0
- package/dist/cli/{chunk-s1xn6wav.js → chunk-b64w1jh6.js} +1 -1
- package/dist/cli/{chunk-x85rcep5.js → chunk-bbq6wc47.js} +1 -1
- package/dist/cli/{chunk-w2tcb3rn.js → chunk-h0wp2ghf.js} +1 -1
- package/dist/cli/{chunk-fkgemeww.js → chunk-h81h0arp.js} +2 -2
- package/dist/cli/{chunk-12wn9jtf.js → chunk-h8v2jzth.js} +1 -1
- package/dist/cli/{chunk-02x0ahxk.js → chunk-h9cjzh9j.js} +1 -1
- package/dist/cli/{chunk-9f34p80j.js → chunk-jp354m6j.js} +1 -1
- package/dist/cli/chunk-jxqhq9va.js +3 -0
- package/dist/cli/{chunk-bv050ssb.js → chunk-k3h4cpdy.js} +1 -1
- package/dist/cli/{chunk-jk4f0xsh.js → chunk-nb0be8dz.js} +1 -1
- package/dist/cli/{chunk-gk8efsx0.js → chunk-nbf3attz.js} +1 -1
- package/dist/cli/{chunk-wz307hf6.js → chunk-nh4dd7q4.js} +1 -1
- package/dist/cli/chunk-pg48v2kc.js +2 -0
- package/dist/cli/chunk-ph9m6y5j.js +8 -0
- package/dist/cli/{chunk-zp0q931y.js → chunk-pm6j59da.js} +1 -1
- package/dist/cli/{chunk-amq030gb.js → chunk-pv3c4vgt.js} +2 -2
- package/dist/cli/{chunk-zv0rrhck.js → chunk-q012n1t1.js} +1 -1
- package/dist/cli/{chunk-p11emdb2.js → chunk-qkdc5tc7.js} +1 -1
- package/dist/cli/{chunk-am5cpb9n.js → chunk-qx6d4bbb.js} +1 -1
- package/dist/cli/{chunk-hmsvt5nj.js → chunk-rqm2kn0c.js} +1 -1
- package/dist/cli/{chunk-7nnpp89t.js → chunk-s5eat30g.js} +1 -1
- package/dist/cli/{chunk-azt0c2b5.js → chunk-s7tbwn04.js} +1 -1
- package/dist/cli/{chunk-txvjg1g9.js → chunk-swcfg2vb.js} +3 -3
- package/dist/cli/chunk-swzdd5aj.js +2 -0
- package/dist/cli/chunk-t2418v0z.js +3 -0
- package/dist/cli/{chunk-5kgq379z.js → chunk-tcdz7sx9.js} +1 -1
- package/dist/cli/{chunk-qwwt4ch2.js → chunk-tg4swhr1.js} +1 -1
- package/dist/cli/{chunk-qzhfs668.js → chunk-v5bn5fnd.js} +1 -1
- package/dist/cli/{chunk-byh32qnx.js → chunk-v8c4n8by.js} +1 -1
- package/dist/cli/{chunk-bw2y7670.js → chunk-vcj2x6ne.js} +1 -1
- package/dist/cli/{chunk-6mk6ceb6.js → chunk-w3f993d8.js} +2 -2
- package/dist/cli/{chunk-nrx8pf9p.js → chunk-x35dfb1x.js} +1 -1
- package/dist/cli/{chunk-ggjmcqpt.js → chunk-x94e0rw4.js} +3 -3
- package/dist/cli/chunk-xaam0d8r.js +4 -0
- package/dist/cli/{chunk-mqvkd2g1.js → chunk-xqvyzv62.js} +1 -1
- package/dist/cli/chunk-y8n6trr4.js +2 -0
- package/dist/cli/{chunk-4z1a65bz.js → chunk-ydaj339x.js} +1 -1
- package/dist/cli/{chunk-k2j0tg1h.js → chunk-yeq2pc68.js} +7 -3
- package/dist/cli/{chunk-3a7x7jg7.js → chunk-z204mctb.js} +16 -17
- package/dist/cli/{chunk-cakyme4s.js → chunk-z8rvxvx5.js} +1 -1
- package/dist/cli/{chunk-3hxm56h6.js → chunk-za8pnw78.js} +1 -1
- package/dist/cli/{chunk-t7dyf50m.js → chunk-zd61a32g.js} +1 -1
- package/dist/cli/{chunk-8hernv9y.js → chunk-ze4v7eda.js} +1 -1
- package/dist/cli/{chunk-q6tsx6zc.js → chunk-zjsfr4y7.js} +1 -1
- package/dist/cli/chunk-zr058kbe.js +3 -0
- package/dist/cli/{chunk-jkgp816v.js → chunk-zypkjg5t.js} +1 -1
- package/dist/cli/index.js +3 -3
- package/dist/cli/rove.js +3 -3
- package/dist/skills/rove/SKILL.md +4 -2
- package/dist/skills/rove/references/api-flags.md +7 -4
- package/dist/voice/asr_server.py +318 -0
- package/dist/voice/engine.py +174 -0
- package/dist/voice/fetch_model.py +30 -0
- package/dist/voice/learn.py +184 -0
- package/dist/voice/live.py +93 -0
- package/dist/voice/phonetic.py +87 -0
- package/dist/voice/polish.py +84 -0
- package/dist/voice/polish_prompt.md +50 -0
- package/dist/voice/pyproject.toml +28 -0
- package/dist/voice/uv.lock +815 -0
- package/dist/voice/vocab.py +193 -0
- package/dist/web/assets/index-Bo92_NP5.css +1 -0
- package/dist/web/assets/index-Bpal5sTS.js +154 -0
- package/dist/web/index.html +3 -3
- package/dist/web/relay/index.html +5 -5
- package/package.json +4 -3
- package/dist/cli/chunk-0g1h35mr.js +0 -2
- package/dist/cli/chunk-1wwqf6kx.js +0 -3
- package/dist/cli/chunk-26ydgwym.js +0 -2
- package/dist/cli/chunk-2pfh3zm0.js +0 -33
- package/dist/cli/chunk-2wrt80ft.js +0 -3
- package/dist/cli/chunk-7fya3y0n.js +0 -2
- package/dist/cli/chunk-d2wndk4q.js +0 -2
- package/dist/cli/chunk-mey3vksj.js +0 -2
- package/dist/cli/chunk-nv8eyjep.js +0 -3
- package/dist/cli/chunk-ve76p2mx.js +0 -3
- package/dist/cli/chunk-wfgrn8p2.js +0 -4
- package/dist/cli/chunk-xmjknfy2.js +0 -7
- package/dist/web/assets/index-Dc0Khk0q.js +0 -149
- package/dist/web/assets/index-T8cCSdnk.css +0 -1
package/dist/cli/rove.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{join as l}from"node:path";import{createInterface as a}from"node:readline/promises";import{pathToFileURL as ss}from"node:url";import{spawnSync as Q}from"node:child_process";import{constants as
|
|
3
|
-
`)[0]?.trim()??""};function J(s){return s.trim().match(/^v?(\d+\.\d+\.\d+)/)?.[1]??null}function X(s,t=$){let c=J(s),W=J(t);if(!c||!W)return!0;let b=c.split(".").map(Number),g=W.split(".").map(Number);for(let
|
|
2
|
+
import{join as l}from"node:path";import{createInterface as a}from"node:readline/promises";import{pathToFileURL as ss}from"node:url";import{spawnSync as Q}from"node:child_process";import{constants as P,accessSync as V}from"node:fs";import{homedir as o}from"node:os";import{basename as R,delimiter as I,dirname as U,join as y}from"node:path";import{fileURLToPath as T}from"node:url";var H={$schema:"https://json.schemastore.org/package.json",name:"@sma1lboy/rove",version:"0.9.282",description:"Rove — the agent multiplexer for your terminal. Run coding agents on parallel tasks with isolated worktrees and persistent sessions.",keywords:["terminal","tui","cli","multiplexer","ai-agents","coding-agent","ai-coding-assistant","agentic-ai","parallel-agents","git-worktree","claude-code","codex","llm","developer-tools"],type:"module",packageManager:"bun@1.3.13",bin:{rove:"dist/cli/rove.js"},files:["dist/cli","dist/completions","dist/web","dist/web-ui","dist/skills","dist/voice","dist/*.wav","README.md","LICENSE"],publishConfig:{access:"public"},repository:{type:"git",url:"git+https://github.com/Sma1lboy/rove.git"},homepage:"https://rove.run",bugs:{url:"https://github.com/Sma1lboy/rove/issues"},engines:{bun:">=1.3.11"},scripts:{dev:"ROVE_DEV=1 bun --conditions=browser ./src/cli/rove.ts","dev:mock":"ROVE_DEV=1 bun ./src/tui-react/mock/host.tsx","dev:sandbox":"bun run scripts/dev-sandbox.ts run","dev:sandbox:reset":"bun run scripts/dev-sandbox.ts reset",build:"bun run scripts/build.ts","build:rubric":"bun run scripts/build-tier-rubric.ts",compile:"bun run scripts/compile.ts",typecheck:"tsc --noEmit","check-i18n":"bun run scripts/check-i18n.ts",test:"bun run test:fast && bun run test:socket","perf:golden":"bun scripts/perf-golden.ts","pty:soak":"bun scripts/pty-soak.ts","test:fast":"vitest run --passWithNoTests --minWorkers=1 --maxWorkers=8","test:socket":"ROVE_INCLUDE_SOCKET=1 vitest run test/daemon --pool forks --minWorkers=1 --maxWorkers=4 --passWithNoTests","test:socket:coverage":"ROVE_INCLUDE_SOCKET=1 ROVE_COVERAGE_DAEMON=1 vitest run test/daemon --coverage --pool forks --minWorkers=1 --maxWorkers=4 --passWithNoTests","test:behavior":"ROVE_INCLUDE_BEHAVIOR=1 vitest run test/behavior --pool forks --minWorkers=1 --maxWorkers=1 --retry=2 --passWithNoTests","test:render":"bun scripts/render-track.mjs",coverage:"vitest run --coverage --passWithNoTests",bench:"vitest bench --run",lint:"biome check .",knip:"knip-bun",postinstall:"bun run scripts/check-preview-deps.ts || true",prepublishOnly:"bun run typecheck && bun run build","plugin-sandbox":"bun run scripts/plugin-sandbox.ts"},"//":"biome.json + bunfig.toml live at the monorepo root since they apply repo-wide; bun.lock also lives at root (workspace-shared).",dependencies:{"@ansi-tools/parser":"^1.0.15","@opentui/core":"0.4.3","@opentui/react":"0.4.3","@xterm/addon-serialize":"^0.14.0","@xterm/addon-unicode11":"^0.9.0","@xterm/headless":"^6.0.0",react:"^19.2.8","smol-toml":"^1.7.1",ws:"^8.21.3"},optionalDependencies:{"node-pty":"^1.1.0"},devDependencies:{"@biomejs/biome":"1.9.4","@sma1lboy/rove-daemon":"0.7.23","@tsconfig/bun":"1.0.11","@types/bun":"1.3.14","@types/node":"25.6.2","@types/react":"^19.2.0","@vitest/coverage-v8":"2.1.9",knip:"^6.14.2","react-devtools-core":"^7.0.1","rove-bridge":"0.0.43","rove-weixin":"0.0.43",typescript:"5.8.2",vitest:"2.1.9"},trustedDependencies:[]};var q="rove";var k=q;var z="ROVE_BUN",_="ROVE_NO_BUN_BOOTSTRAP",F="ROVE_SKIP_BUN_CHECK",$=H.engines.bun.match(/\d+\.\d+\.\d+/)?.[0]??"0.0.0",L=(s)=>{try{return V(s,P.X_OK),!0}catch{return!1}},B=(s)=>s==="win32"?"bun.exe":"bun",f=(s)=>{let t=Q(s,["--version"],{encoding:"utf8",timeout:5000});if(t.error||t.status!==0)return null;return t.stdout?.split(`
|
|
3
|
+
`)[0]?.trim()??""};function J(s){return s.trim().match(/^v?(\d+\.\d+\.\d+)/)?.[1]??null}function X(s,t=$){let c=J(s),W=J(t);if(!c||!W)return!0;let b=c.split(".").map(Number),g=W.split(".").map(Number);for(let i=0;i<3;i++){let r=b[i]??0,x=g[i]??0;if(r!==x)return r>x}return!0}function Y(s=process.env){return s[F]==="1"}function A(s={}){let t=s.env??process.env,c=s.platform??process.platform,W=s.home??t.HOME??t.USERPROFILE??o(),b=B(c),g=[],i=t[z]?.trim();if(i)g.push(i);for(let G of(t.PATH??t.Path??"").split(I))if(G)g.push(y(G,b));let r=t.BUN_INSTALL?.trim();if(r)g.push(y(r,"bin",b));if(W)g.push(y(W,".bun","bin",b));let x=s.launcherDir;if(x)g.push(y(x,"..","..","node_modules","bun","bin",b)),g.push(y(x,"..","..","..","bun","bin",b));return g}function N(s={}){let t=s.isExecutable??L;return A(s).find((c)=>t(c))??null}function Z(s={}){let t=s.isExecutable??L,c=s.bunVersionOf??f;if(Y(s.env??process.env))return{bun:N(s),stale:null,unusable:null};let W=null,b=null;for(let g of A(s)){if(!t(g))continue;let i=c(g);if(i===null){b??=g;continue}if(X(i))return{bun:g,stale:W,unusable:b};W??={path:g,version:J(i)??i}}return{bun:null,stale:W,unusable:b}}function h(s){return U(T(s))}function C(s){return R(T(s)).replace(/\.[cm]?[jt]s$/,"")}function n(s=process.platform){return s==="win32"?["powershell","-NoProfile","-Command","irm bun.sh/install.ps1 | iex"]:["bash","-c","curl -fsSL https://bun.sh/install | bash"]}function D(s=k,t=process.platform){let c=t==="win32"?'powershell -c "irm bun.sh/install.ps1 | iex"':"curl -fsSL https://bun.sh/install | bash";return[`${s}: Rove runs on the Bun runtime, and no Bun was found on this machine.`,"","Install Bun, then run this command again:",` ${c}`," npm install -g bun # any platform, if you already have npm","","Or install Bun and Rove together in one step:"," curl -fsSL https://rove.run/install.sh | sh","",`Already have Bun somewhere unusual? Point Rove at it: ${z}=/path/to/bun`,""].join(`
|
|
4
4
|
`)}function j(s,t,c=k){return[`${c}: this machine's Bun is too old for Rove — found ${t} at ${s}, need ${$} or newer.`,"","Rove's terminals are built on Bun's PTY API. An older Bun ignores it silently,","so every terminal and engine tab would open empty and stay empty.","","Upgrade Bun, then run this command again:"," bun upgrade # Bun installed itself (~/.bun)"," brew upgrade bun # Homebrew"," npm install -g bun@latest # npm-managed Bun","",`Have a newer Bun elsewhere? Point Rove at it: ${z}=/path/to/bun`,`Run on this one anyway (unsupported, terminals will be blank): ${F}=1`,""].join(`
|
|
5
5
|
`)}function v(s,t=k){return[`${t}: the Bun at ${s} could not be run — \`${s} --version\` failed or never returned.`,"","Rove skipped it rather than starting through it, which would hang or fail","with a message from that binary instead of this one.","","Reinstall Bun, then run this command again:"," curl -fsSL https://bun.sh/install | bash","",`Have a working Bun elsewhere? Point Rove at it: ${z}=/path/to/bun`,""].join(`
|
|
6
6
|
`)}function w(s=process.env,t=process.stdin,c=process.stdout){if(s[_]==="1")return!1;if(s.CI==="true"||s.CI==="1")return!1;return Boolean(t.isTTY&&c.isTTY)}function M(s={},t=Q){let c=s.platform??process.platform,[W,...b]=n(c);if(!W)return null;let g=t(W,b,{stdio:"inherit"});if(g.error||g.status!==0)return null;return Z(s).bun}function u(s){if(typeof s.status==="number")return s.status;if(s.signal)return 128+(Object.hasOwn(K,s.signal)?K[s.signal]:0);return 1}var K={SIGINT:2,SIGQUIT:3,SIGKILL:9,SIGTERM:15};function S(s,t,c,W=Q){let b=W(s,[t,...c],{stdio:"inherit"});if(b.error)return process.stderr.write(`rove: could not start Bun at ${s}: ${b.error.message}. Check that this path is executable, or reinstall Bun from https://bun.sh.
|
|
7
|
-
`),1;return u(b)}var
|
|
7
|
+
`),1;return u(b)}var O=h(import.meta.url),p=C(import.meta.url),d=l(O,`${p}-run.js`);async function ts(s){let t=a({input:process.stdin,output:process.stdout});try{let c=(await t.question(s)).trim().toLowerCase();return c===""||c==="y"||c==="yes"}finally{t.close()}}async function cs(){let s={launcherDir:O},t=Z(s);if(t.bun||!w())return t;let c=t.stale?`${p}: Bun ${t.stale.version} is too old for Rove (needs ${$}+). Install the latest Bun now? [Y/n] `:`${p}: Rove runs on the Bun runtime, and none is installed. Install Bun now? [Y/n] `;if(!await ts(c))return t;return{bun:M(s),stale:t.stale,unusable:t.unusable}}var m=globalThis.Bun;if(m){let s=m.version;if(s&&!Y()&&!X(s))process.stderr.write(j(process.execPath,s,p)),process.exit(1);await import(ss(d).href)}else{let{bun:s,stale:t,unusable:c}=await cs();if(!s){let W=t?j(t.path,t.version,p):c?v(c,p):D(p);process.stderr.write(W),process.exit(1)}process.exit(S(s,d,process.argv.slice(2)))}
|
|
@@ -3,7 +3,7 @@ name: rove
|
|
|
3
3
|
description: Use when controlling Rove tasks, parallel coding attempts, hosted agent sessions, task lifecycle, or the daemon-owned issue tracker from a shell. Also the ONLY channel for messaging another agent session on this machine — `rove api send`, never a peer/MCP side channel.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
<!-- rove-skill-version:
|
|
6
|
+
<!-- rove-skill-version: 109 — bump in lockstep with ROVE_SKILL_VERSION (src/lib/skill-install.ts). -->
|
|
7
7
|
|
|
8
8
|
# Rove shell control
|
|
9
9
|
|
|
@@ -309,9 +309,11 @@ Find your section by what you were ASKED, not by Rove's noun for it:
|
|
|
309
309
|
| a prompt ends `[ROVE] … report with: rove api report`; tick a task unattended and want proof it reported, or skip a tick the task can't use | drive (`report`, `send --expect-report`, `send --if-runnable`) / read (`deliveries`, `should-run`) |
|
|
310
310
|
| make / rename / retarget / land / close a task | create / edit / lifecycle |
|
|
311
311
|
| materialize or adopt a checkout | worktree |
|
|
312
|
-
| which engines exist,
|
|
312
|
+
| which engines exist, report a Rove problem or idea | discover / feedback |
|
|
313
313
|
| an error code came back, `nextCommandArgs` | Error codes |
|
|
314
314
|
|
|
315
|
+
When the user says anything in Rove itself is awkward, broken or missing, offer to file it right away: draft a short title and body, show it, and on their OK run `rove feedback --kind bug|idea|question --title … --body-file -` (body on stdin), then share the URL. If it prints a prefilled link instead, hand that over and say to run `gh auth login`. Only for Rove; another project's bug is a `send` to its main task.
|
|
316
|
+
|
|
315
317
|
Reach for `schema` when the binary and that file disagree.
|
|
316
318
|
|
|
317
319
|
```bash
|
|
@@ -433,7 +433,7 @@ schema --verb --group --all
|
|
|
433
433
|
engine-list (none)
|
|
434
434
|
repo-match --origin --root --allow-fork
|
|
435
435
|
repo-list (none)
|
|
436
|
-
feedback --title(REQ) --body(REQ) --
|
|
436
|
+
feedback --title(REQ) --body(REQ) --kind(bug)
|
|
437
437
|
```
|
|
438
438
|
<!-- generated:end -->
|
|
439
439
|
|
|
@@ -452,9 +452,12 @@ resolves to (`{ repo, fork?, checkouts, searched }`); `repo-list` lists a
|
|
|
452
452
|
machine's repos by id (`{ repos: [{ id, primary, checkouts }] }`) — pass
|
|
453
453
|
`--machine <alias>` to ask another machine.
|
|
454
454
|
|
|
455
|
-
`feedback`
|
|
456
|
-
|
|
457
|
-
|
|
455
|
+
`feedback` files a GitHub issue on the Rove repo through `gh` (`--kind` picks the
|
|
456
|
+
label: bug, idea → enhancement, question) and appends an environment block.
|
|
457
|
+
It returns `{ filed: true, url, number }`; with no usable `gh` it returns
|
|
458
|
+
`{ filed: false, url, hint }` where `url` is a prefilled new-issue link — hand
|
|
459
|
+
it to the user. Rove's own product feedback channel, not a way to file work
|
|
460
|
+
for another project (that is a `send` to their main task; see SKILL.md).
|
|
458
461
|
|
|
459
462
|
## Error codes
|
|
460
463
|
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Dictation endpoint for rove's web client.
|
|
2
|
+
|
|
3
|
+
The phone records 16 kHz mono PCM, rove-bridge POSTs the WAV here with the text of the
|
|
4
|
+
conversation being dictated into, and this process returns the transcript. The recognizer is
|
|
5
|
+
local Whisper unless `~/.rove/voice/config.json` names a cloud endpoint (`engine.py`); the
|
|
6
|
+
vocabulary is `vocab.py`, sound-alike fixes `phonetic.py`. Everything this process keeps lives
|
|
7
|
+
under `~/.rove/voice`.
|
|
8
|
+
|
|
9
|
+
Run: `uv run --project scripts/voice --extra local scripts/voice/asr_server.py` (or Settings → Voice).
|
|
10
|
+
Binds 127.0.0.1 only; the bridge is the one public surface.
|
|
11
|
+
|
|
12
|
+
POST /transcribe {wav: hex, conversation?, context?, language?} -> {text, raw, language, ...}
|
|
13
|
+
POST /stream {session, seq, pcm: hex int16, final?, conversation?, context?, language?}
|
|
14
|
+
-> {partial} per chunk, the /transcribe reply at final (live.py)
|
|
15
|
+
GET /health
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import io
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import secrets
|
|
24
|
+
import sys
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
import wave
|
|
28
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
|
|
34
|
+
import engine
|
|
35
|
+
import learn
|
|
36
|
+
import live
|
|
37
|
+
import phonetic
|
|
38
|
+
import polish
|
|
39
|
+
import vocab
|
|
40
|
+
|
|
41
|
+
PORT = int(os.environ.get("ROVE_VOICE_PORT", "8791"))
|
|
42
|
+
MIN_AUDIO_SEC = 0.25
|
|
43
|
+
SILENCE_RMS = 0.004 # 16-bit speech sits far above this; fan noise and dead mics do not.
|
|
44
|
+
MAX_AUDIO_SEC = 240
|
|
45
|
+
|
|
46
|
+
_ready = threading.Event()
|
|
47
|
+
_warm_ms: float | None = None
|
|
48
|
+
_error: str | None = None
|
|
49
|
+
_backend: engine.Backend | None = None
|
|
50
|
+
_config: dict[str, Any] = {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_token() -> str:
|
|
54
|
+
"""One shared secret so a stray local process cannot drive the microphone path."""
|
|
55
|
+
path = engine.HOME / "token"
|
|
56
|
+
engine.HOME.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
if not path.exists():
|
|
58
|
+
path.write_text(secrets.token_hex(24))
|
|
59
|
+
path.chmod(0o600)
|
|
60
|
+
return path.read_text().strip()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def decode_wav(data: bytes) -> np.ndarray:
|
|
64
|
+
with wave.open(io.BytesIO(data)) as wav:
|
|
65
|
+
if wav.getnchannels() != 1:
|
|
66
|
+
raise ValueError("expected mono audio")
|
|
67
|
+
width = wav.getsampwidth()
|
|
68
|
+
rate = wav.getframerate()
|
|
69
|
+
frames = wav.readframes(wav.getnframes())
|
|
70
|
+
if width != 2:
|
|
71
|
+
raise ValueError(f"expected 16-bit samples, got {width * 8}-bit")
|
|
72
|
+
if rate != engine.SAMPLE_RATE:
|
|
73
|
+
raise ValueError(f"expected {engine.SAMPLE_RATE} Hz, got {rate}")
|
|
74
|
+
return np.frombuffer(frames, dtype="<i2").astype(np.float32) / 32768.0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def silent(pcm: np.ndarray) -> bool:
|
|
78
|
+
return not len(pcm) or float(np.sqrt(np.mean(np.square(pcm)))) < SILENCE_RMS
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def dictate(
|
|
82
|
+
backend: engine.Backend,
|
|
83
|
+
pcm: np.ndarray,
|
|
84
|
+
*,
|
|
85
|
+
conversation: str | None,
|
|
86
|
+
draft: str | None,
|
|
87
|
+
language: str | None,
|
|
88
|
+
config: dict[str, Any],
|
|
89
|
+
final: bool = True,
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
"""One dictation: rank the vocabulary, recognize, normalize the script, then fix the English:
|
|
92
|
+
stored mishearings first, then whatever lands close to a term the recognizer was given. A final
|
|
93
|
+
take is then cleaned up by a chat model when config.json asks for it (polish.py); a live take's
|
|
94
|
+
partials never are."""
|
|
95
|
+
stored = vocab.load()["terms"]
|
|
96
|
+
terms = vocab.rank(conversation, draft, stored, backend.max_terms)
|
|
97
|
+
# Measured on large-v3: terms in the prompt cost a term when they were not said (68 -> 67 with the
|
|
98
|
+
# conversation's, 68 -> 64 with 15 learned ones) and gain less (70) than using them only to correct
|
|
99
|
+
# (72, and 68 when not said). Cloud recognizers take the prompt without that cost, so they get it by default.
|
|
100
|
+
in_prompt = config.get("promptContext", not isinstance(backend, engine.LocalWhisper))
|
|
101
|
+
hinted = terms if in_prompt else vocab.rank(None, draft, stored, backend.max_terms, rest=False)
|
|
102
|
+
raw, detected = backend.transcribe(pcm, hinted, language)
|
|
103
|
+
text = engine.simplified(raw) if config.get("simplified", True) else raw
|
|
104
|
+
text = vocab.correct(vocab.apply_spoken(text, stored), terms, fuzzy=config.get("fuzzy", True))
|
|
105
|
+
# Bench: +1 to +3 terms when the conversation holds them, no change otherwise (phonetic.py).
|
|
106
|
+
if config.get("phonetic", True):
|
|
107
|
+
text = phonetic.correct(text, terms)
|
|
108
|
+
out = {"text": text, "raw": raw, "language": detected, "terms": len(terms)}
|
|
109
|
+
if final and polish.enabled(config):
|
|
110
|
+
started = time.monotonic()
|
|
111
|
+
polished, error = polish.polish(text, terms, config)
|
|
112
|
+
out |= {"text": polished, "unpolished": text, "polishMs": round((time.monotonic() - started) * 1000)}
|
|
113
|
+
if error:
|
|
114
|
+
out["polishError"] = error
|
|
115
|
+
sys.stderr.write(f"voice: polish failed, kept the recognizer's text: {error}\n")
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def warm() -> None:
|
|
120
|
+
"""A cold graph costs seconds; pay it at boot, not on the first press."""
|
|
121
|
+
global _warm_ms, _error, _backend, _config
|
|
122
|
+
started = time.monotonic()
|
|
123
|
+
try:
|
|
124
|
+
_config = engine.load_config()
|
|
125
|
+
_backend = engine.backend(_config)
|
|
126
|
+
if isinstance(_backend, engine.LocalWhisper):
|
|
127
|
+
_backend.transcribe(np.zeros(engine.SAMPLE_RATE, dtype=np.float32), [], "en")
|
|
128
|
+
engine.simplified("說")
|
|
129
|
+
except Exception as err: # noqa: BLE001 - surfaced on /health, the server stays up
|
|
130
|
+
_error = str(err)
|
|
131
|
+
sys.stderr.write(f"voice: backend failed: {err}\n")
|
|
132
|
+
return
|
|
133
|
+
_warm_ms = round((time.monotonic() - started) * 1000)
|
|
134
|
+
_ready.set()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class Handler(BaseHTTPRequestHandler):
|
|
138
|
+
token = ""
|
|
139
|
+
|
|
140
|
+
def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003 - stdlib signature
|
|
141
|
+
sys.stderr.write("voice %s\n" % (fmt % args))
|
|
142
|
+
|
|
143
|
+
def reply(self, code: int, body: dict[str, Any]) -> None:
|
|
144
|
+
data = json.dumps(body, ensure_ascii=False).encode()
|
|
145
|
+
self.send_response(code)
|
|
146
|
+
self.send_header("content-type", "application/json")
|
|
147
|
+
self.send_header("content-length", str(len(data)))
|
|
148
|
+
self.end_headers()
|
|
149
|
+
self.wfile.write(data)
|
|
150
|
+
|
|
151
|
+
def do_GET(self) -> None: # noqa: N802 - stdlib signature
|
|
152
|
+
# /health stays open: the bridge probes it before the user says anything.
|
|
153
|
+
if self.path != "/health":
|
|
154
|
+
self.reply(404, {"error": "not_found"})
|
|
155
|
+
return
|
|
156
|
+
name = _backend.name if _backend else None
|
|
157
|
+
body = {"ready": _ready.is_set(), "model": name, "warm_ms": _warm_ms, "error": _error}
|
|
158
|
+
# Which copy answers: two bridges (release and a checkout) may each have started one.
|
|
159
|
+
self.reply(200, {**body, "pid": os.getpid(), "code": str(Path(__file__).resolve().parent)})
|
|
160
|
+
|
|
161
|
+
def body(self) -> dict[str, Any] | None:
|
|
162
|
+
if self.headers.get("x-voice-token", "") != self.token:
|
|
163
|
+
self.reply(401, {"error": "bad_token"})
|
|
164
|
+
return None
|
|
165
|
+
length = int(self.headers.get("content-length") or 0)
|
|
166
|
+
if length <= 0 or length > 32 * 1024 * 1024:
|
|
167
|
+
self.reply(400, {"error": "bad_size"})
|
|
168
|
+
return None
|
|
169
|
+
try:
|
|
170
|
+
return json.loads(self.rfile.read(length))
|
|
171
|
+
except ValueError:
|
|
172
|
+
self.reply(400, {"error": "bad_json"})
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
def do_POST(self) -> None: # noqa: N802 - stdlib signature
|
|
176
|
+
routes = {
|
|
177
|
+
"/transcribe": self.transcribe,
|
|
178
|
+
"/stream": self.stream,
|
|
179
|
+
"/learn": self.learn_edit,
|
|
180
|
+
"/unlearn": self.unlearn_edit,
|
|
181
|
+
}
|
|
182
|
+
route = routes.get(self.path)
|
|
183
|
+
if route is None:
|
|
184
|
+
self.reply(404, {"error": "not_found"})
|
|
185
|
+
return
|
|
186
|
+
request = self.body()
|
|
187
|
+
if request is not None:
|
|
188
|
+
route(request)
|
|
189
|
+
|
|
190
|
+
def transcribe(self, request: dict[str, Any]) -> None:
|
|
191
|
+
try:
|
|
192
|
+
pcm = decode_wav(bytes.fromhex(request["wav"]))
|
|
193
|
+
except Exception as err: # noqa: BLE001 - every failure is the caller's audio
|
|
194
|
+
self.reply(400, {"error": f"bad_audio: {err}"})
|
|
195
|
+
return
|
|
196
|
+
self.finish_take(pcm, request.get("conversation"), request.get("context"), request.get("language"))
|
|
197
|
+
|
|
198
|
+
def finish_take(self, pcm: np.ndarray, conversation: str | None, draft: str | None, language: str | None) -> None:
|
|
199
|
+
seconds = round(len(pcm) / engine.SAMPLE_RATE, 2)
|
|
200
|
+
if seconds < MIN_AUDIO_SEC:
|
|
201
|
+
self.reply(200, {"text": "", "reason": "too_short", "seconds": seconds})
|
|
202
|
+
return
|
|
203
|
+
if seconds > MAX_AUDIO_SEC:
|
|
204
|
+
self.reply(200, {"text": "", "reason": "too_long", "seconds": seconds})
|
|
205
|
+
return
|
|
206
|
+
if silent(pcm):
|
|
207
|
+
self.reply(200, {"text": "", "reason": "silent", "seconds": seconds})
|
|
208
|
+
return
|
|
209
|
+
if _backend is None or not _ready.is_set():
|
|
210
|
+
self.reply(503, {"error": _error or "warming up"})
|
|
211
|
+
return
|
|
212
|
+
started = time.monotonic()
|
|
213
|
+
try:
|
|
214
|
+
out = dictate(_backend, pcm, conversation=conversation, draft=draft, language=language, config=_config)
|
|
215
|
+
except Exception as err: # noqa: BLE001 - report, never take the server down
|
|
216
|
+
self.reply(500, {"error": f"transcribe: {err}"})
|
|
217
|
+
return
|
|
218
|
+
ms = round((time.monotonic() - started) * 1000)
|
|
219
|
+
# Counts only, never the words: enough to see the conversation arrived and what a take cost.
|
|
220
|
+
heard = len(conversation or "")
|
|
221
|
+
terms = out["terms"]
|
|
222
|
+
sys.stderr.write(f"voice: {seconds}s audio, {heard} chars of conversation, {terms} terms, {ms} ms\n")
|
|
223
|
+
self.reply(200, {**out, "seconds": seconds, "ms": ms})
|
|
224
|
+
|
|
225
|
+
def stream(self, request: dict[str, Any]) -> None:
|
|
226
|
+
"""One chunk of a live take (live.py): the text so far while it grows, the transcript at `final`."""
|
|
227
|
+
session, seq = request.get("session"), request.get("seq")
|
|
228
|
+
if not isinstance(session, str) or not isinstance(seq, int):
|
|
229
|
+
self.reply(400, {"error": "bad_args"})
|
|
230
|
+
return
|
|
231
|
+
try:
|
|
232
|
+
raw = bytes.fromhex(request.get("pcm") or "")
|
|
233
|
+
pcm = np.frombuffer(raw[: len(raw) // 2 * 2], dtype="<i2").astype(np.float32) / 32768.0
|
|
234
|
+
take = live.append(session, seq, pcm, request)
|
|
235
|
+
except live.Gap as err:
|
|
236
|
+
self.reply(409, {"error": f"gap: {err}"})
|
|
237
|
+
return
|
|
238
|
+
except ValueError as err:
|
|
239
|
+
self.reply(400, {"error": f"bad_audio: {err}"})
|
|
240
|
+
return
|
|
241
|
+
if request.get("final"):
|
|
242
|
+
live.finish(session)
|
|
243
|
+
self.finish_take(take.pcm(), take.conversation, take.draft, take.language)
|
|
244
|
+
return
|
|
245
|
+
backend = _backend
|
|
246
|
+
if backend is None or not _ready.is_set() or not live.due(take, backend):
|
|
247
|
+
self.reply(200, {"partial": None})
|
|
248
|
+
return
|
|
249
|
+
# Never queue behind another decode: the next chunk asks again.
|
|
250
|
+
if not engine.GPU.acquire(blocking=False):
|
|
251
|
+
self.reply(200, {"partial": None})
|
|
252
|
+
return
|
|
253
|
+
try:
|
|
254
|
+
take.decoded = take.samples
|
|
255
|
+
pcm = take.pcm()
|
|
256
|
+
text = (
|
|
257
|
+
""
|
|
258
|
+
if silent(pcm)
|
|
259
|
+
else dictate(
|
|
260
|
+
backend,
|
|
261
|
+
pcm,
|
|
262
|
+
conversation=take.conversation,
|
|
263
|
+
draft=take.draft,
|
|
264
|
+
language=take.language,
|
|
265
|
+
config=_config,
|
|
266
|
+
final=False,
|
|
267
|
+
)["text"]
|
|
268
|
+
)
|
|
269
|
+
except Exception as err: # noqa: BLE001 - a failed partial only means no partial
|
|
270
|
+
sys.stderr.write(f"voice: partial failed: {err}\n")
|
|
271
|
+
text = None
|
|
272
|
+
finally:
|
|
273
|
+
engine.GPU.release()
|
|
274
|
+
self.reply(200, {"partial": text})
|
|
275
|
+
|
|
276
|
+
def learn_edit(self, request: dict[str, Any]) -> None:
|
|
277
|
+
"""A sent message against the draft its takes left: the fixes inside them join the vocabulary."""
|
|
278
|
+
base, sent, spans = request.get("base"), request.get("sent"), request.get("spans")
|
|
279
|
+
if not isinstance(base, str) or not isinstance(sent, str) or not isinstance(spans, list):
|
|
280
|
+
self.reply(400, {"error": "bad_args"})
|
|
281
|
+
return
|
|
282
|
+
if not _config.get("learn", True):
|
|
283
|
+
self.reply(200, {"learned": []})
|
|
284
|
+
return
|
|
285
|
+
pairs = [(int(s[0]), int(s[1])) for s in spans if isinstance(s, list) and len(s) == 2]
|
|
286
|
+
added = learn.remember(learn.fixes(base, pairs, sent))
|
|
287
|
+
sys.stderr.write(f"voice: learned {len(added)} from an edit\n")
|
|
288
|
+
self.reply(200, {"learned": added})
|
|
289
|
+
|
|
290
|
+
def unlearn_edit(self, request: dict[str, Any]) -> None:
|
|
291
|
+
learned = request.get("learned")
|
|
292
|
+
if not isinstance(learned, list):
|
|
293
|
+
self.reply(400, {"error": "bad_args"})
|
|
294
|
+
return
|
|
295
|
+
self.reply(200, {"undone": learn.forget([x for x in learned if isinstance(x, dict)])})
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def main() -> None:
|
|
299
|
+
Handler.token = read_token()
|
|
300
|
+
try:
|
|
301
|
+
server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
|
|
302
|
+
except OSError as err:
|
|
303
|
+
# Another sidecar already answers here; it serves every bridge on this machine.
|
|
304
|
+
sys.stderr.write(f"voice: port {PORT} is taken ({err}); leaving it to that process\n")
|
|
305
|
+
raise SystemExit(0) from err
|
|
306
|
+
pidfile = engine.HOME / "sidecar.pid"
|
|
307
|
+
pidfile.write_text(str(os.getpid()))
|
|
308
|
+
threading.Thread(target=warm, daemon=True).start()
|
|
309
|
+
sys.stderr.write(f"voice listening on http://127.0.0.1:{PORT}\n")
|
|
310
|
+
try:
|
|
311
|
+
server.serve_forever()
|
|
312
|
+
finally:
|
|
313
|
+
if pidfile.exists() and pidfile.read_text().strip() == str(os.getpid()):
|
|
314
|
+
pidfile.unlink()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
if __name__ == "__main__":
|
|
318
|
+
main()
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""The recognizer behind the sidecar: local Whisper by default, or any OpenAI-compatible
|
|
2
|
+
`/audio/transcriptions` endpoint when `~/.rove/voice/config.json` names one.
|
|
3
|
+
|
|
4
|
+
config.json, all keys optional:
|
|
5
|
+
{"backend": "local", "model": "mlx-community/whisper-large-v3-turbo"} # faster, see LOCAL_MODEL
|
|
6
|
+
{"backend": "cloud", "baseURL": "https://openrouter.ai/api/v1", "model": "microsoft/mai-transcribe-2",
|
|
7
|
+
"apiKeyEnv": "OPENROUTER_API_KEY"} # or "apiKey": "..."; the key never leaves this Mac
|
|
8
|
+
{"simplified": false} # keep Traditional characters as recognized
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import io
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import threading
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.request
|
|
20
|
+
import uuid
|
|
21
|
+
import wave
|
|
22
|
+
from functools import cache
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Protocol
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
HOME = Path(os.environ.get("ROVE_VOICE_HOME") or Path.home() / ".rove" / "voice")
|
|
29
|
+
SAMPLE_RATE = 16_000
|
|
30
|
+
# Code-switch bench (bench.py, 82 terms): large-v3 68, 74 with the conversation's terms, p50 1.8 s;
|
|
31
|
+
# turbo 63, 69 at best, p50 1.0 s. Turbo is the faster pick in config.json; it loses most when context is thin.
|
|
32
|
+
LOCAL_MODEL = "mlx-community/whisper-large-v3-mlx"
|
|
33
|
+
# Without this hint large-v3 often answers Mandarin in Traditional script; English is unaffected.
|
|
34
|
+
ZH_GUARD = "以下是普通话的句子。"
|
|
35
|
+
CLOUD_TIMEOUT_S = 30
|
|
36
|
+
# One decode at a time on the GPU: the server answers on threads, and a live take's partials run
|
|
37
|
+
# beside other requests. Reentrant so a caller can hold it across a whole dictation (live.py).
|
|
38
|
+
GPU = threading.RLock()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Backend(Protocol):
|
|
42
|
+
name: str
|
|
43
|
+
max_terms: int
|
|
44
|
+
|
|
45
|
+
def transcribe(self, pcm: np.ndarray, terms: list[str], language: str | None) -> tuple[str, str | None]: ...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_config() -> dict[str, Any]:
|
|
49
|
+
path = HOME / "config.json"
|
|
50
|
+
if not path.exists():
|
|
51
|
+
return {}
|
|
52
|
+
return json.loads(path.read_text())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class LocalWhisper:
|
|
56
|
+
"""mlx-whisper on the Mac's GPU. The terms ride in the prompt, which Whisper reads as preceding
|
|
57
|
+
text: spelled-right terms there are the cheapest bias a decoder without a boost list accepts."""
|
|
58
|
+
|
|
59
|
+
max_terms = 60
|
|
60
|
+
|
|
61
|
+
def __init__(self, model: str) -> None:
|
|
62
|
+
self.model = model
|
|
63
|
+
self.name = f"local:{model}"
|
|
64
|
+
|
|
65
|
+
def transcribe(self, pcm: np.ndarray, terms: list[str], language: str | None) -> tuple[str, str | None]:
|
|
66
|
+
import mlx_whisper
|
|
67
|
+
|
|
68
|
+
prompt = "\n".join([ZH_GUARD, ", ".join(terms)]) if terms else ZH_GUARD
|
|
69
|
+
with GPU:
|
|
70
|
+
result = mlx_whisper.transcribe(
|
|
71
|
+
pcm,
|
|
72
|
+
path_or_hf_repo=self.model,
|
|
73
|
+
initial_prompt=prompt,
|
|
74
|
+
language=language or None,
|
|
75
|
+
word_timestamps=False,
|
|
76
|
+
condition_on_previous_text=False,
|
|
77
|
+
verbose=None,
|
|
78
|
+
)
|
|
79
|
+
return str(result.get("text") or "").strip(), result.get("language")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def provider_options(model: str, terms: list[str]) -> dict[str, Any] | None:
|
|
83
|
+
"""OpenRouter routes one body to many providers, and each reads the vocabulary from its own field:
|
|
84
|
+
mai-transcribe-2 ignores `prompt` and goes 59 → 68 of 82 terms with `azure.phraseList`."""
|
|
85
|
+
if model.startswith("microsoft/mai-transcribe"):
|
|
86
|
+
return {"azure": {"phraseList": {"phrases": terms}}}
|
|
87
|
+
if model.startswith("openai/gpt-4o"):
|
|
88
|
+
return {"openai": {"prompt": ", ".join(terms)}}
|
|
89
|
+
if model == "openai/whisper-large-v3":
|
|
90
|
+
return {slug: {"prompt": ", ".join(terms)} for slug in ("groq", "deepinfra/us", "together")}
|
|
91
|
+
return None # gemini rejects a prompt outright
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def wav_bytes(pcm: np.ndarray) -> bytes:
|
|
95
|
+
out = io.BytesIO()
|
|
96
|
+
with wave.open(out, "wb") as wav:
|
|
97
|
+
wav.setnchannels(1)
|
|
98
|
+
wav.setsampwidth(2)
|
|
99
|
+
wav.setframerate(SAMPLE_RATE)
|
|
100
|
+
wav.writeframes((np.clip(pcm, -1, 1) * 32767).astype("<i2").tobytes())
|
|
101
|
+
return out.getvalue()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class CloudASR:
|
|
105
|
+
"""One OpenAI-compatible transcription endpoint. OpenRouter gets its JSON body so the
|
|
106
|
+
vocabulary reaches the provider's own field; everything else gets the standard multipart
|
|
107
|
+
form with the vocabulary as `prompt`."""
|
|
108
|
+
|
|
109
|
+
max_terms = 100
|
|
110
|
+
|
|
111
|
+
def __init__(self, config: dict[str, Any]) -> None:
|
|
112
|
+
self.base = str(config.get("baseURL") or "https://openrouter.ai/api/v1").rstrip("/")
|
|
113
|
+
self.model = str(config.get("model") or "microsoft/mai-transcribe-2")
|
|
114
|
+
key = config.get("apiKey") or os.environ.get(str(config.get("apiKeyEnv") or "OPENROUTER_API_KEY"))
|
|
115
|
+
if not key:
|
|
116
|
+
raise RuntimeError("cloud backend has no API key: set apiKey or apiKeyEnv in config.json")
|
|
117
|
+
self.key = str(key)
|
|
118
|
+
self.name = f"cloud:{self.model}"
|
|
119
|
+
|
|
120
|
+
def request(self, audio: bytes, terms: list[str], language: str | None) -> urllib.request.Request:
|
|
121
|
+
url = f"{self.base}/audio/transcriptions"
|
|
122
|
+
auth = {"Authorization": f"Bearer {self.key}"}
|
|
123
|
+
if "openrouter.ai" in self.base:
|
|
124
|
+
body: dict[str, Any] = {
|
|
125
|
+
"model": self.model,
|
|
126
|
+
"input_audio": {"data": base64.b64encode(audio).decode(), "format": "wav"},
|
|
127
|
+
}
|
|
128
|
+
if language:
|
|
129
|
+
body["language"] = language
|
|
130
|
+
options = provider_options(self.model, terms) if terms else None
|
|
131
|
+
if options:
|
|
132
|
+
body["provider"] = {"options": options}
|
|
133
|
+
return urllib.request.Request(url, json.dumps(body).encode(), {**auth, "Content-Type": "application/json"})
|
|
134
|
+
fields = {"model": self.model, **({"language": language} if language else {})}
|
|
135
|
+
if terms:
|
|
136
|
+
fields["prompt"] = ", ".join(terms)
|
|
137
|
+
boundary = uuid.uuid4().hex
|
|
138
|
+
parts = [
|
|
139
|
+
f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="audio.wav"\r\n'
|
|
140
|
+
"Content-Type: audio/wav\r\n\r\n".encode()
|
|
141
|
+
+ audio
|
|
142
|
+
+ b"\r\n"
|
|
143
|
+
]
|
|
144
|
+
for name, value in fields.items():
|
|
145
|
+
parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode())
|
|
146
|
+
parts.append(f"--{boundary}--\r\n".encode())
|
|
147
|
+
headers = {**auth, "Content-Type": f"multipart/form-data; boundary={boundary}"}
|
|
148
|
+
return urllib.request.Request(url, b"".join(parts), headers)
|
|
149
|
+
|
|
150
|
+
def transcribe(self, pcm: np.ndarray, terms: list[str], language: str | None) -> tuple[str, str | None]:
|
|
151
|
+
try:
|
|
152
|
+
with urllib.request.urlopen(self.request(wav_bytes(pcm), terms, language), timeout=CLOUD_TIMEOUT_S) as res:
|
|
153
|
+
body = json.loads(res.read())
|
|
154
|
+
except urllib.error.HTTPError as err:
|
|
155
|
+
raise RuntimeError(f"{self.name} answered {err.code}: {err.read()[:200]!r}") from err
|
|
156
|
+
return str(body.get("text") or "").strip(), body.get("language")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def backend(config: dict[str, Any]) -> Backend:
|
|
160
|
+
if config.get("backend") == "cloud":
|
|
161
|
+
return CloudASR(config)
|
|
162
|
+
return LocalWhisper(os.environ.get("ROVE_VOICE_MODEL") or str(config.get("model") or LOCAL_MODEL))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@cache
|
|
166
|
+
def _t2s() -> Any:
|
|
167
|
+
from opencc import OpenCC
|
|
168
|
+
|
|
169
|
+
return OpenCC("t2s")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def simplified(text: str) -> str:
|
|
173
|
+
"""Whisper drifts into Traditional characters on some Mandarin clips; English passes through."""
|
|
174
|
+
return _t2s().convert(text) if any("一" <= c <= "鿿" for c in text) else text
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Download the local recognizer once, so the sidecar's first start does not wait on the network.
|
|
2
|
+
|
|
3
|
+
Run by the bridge's install job (`rove-bridge/src/ops/voice-setup.ts`) after `uv sync --extra local`.
|
|
4
|
+
Prints one progress line per file; writes `~/.rove/voice/installed.json` when the model is complete.
|
|
5
|
+
A rerun resumes: the Hugging Face cache keeps what already arrived.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
import engine
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main() -> None:
|
|
18
|
+
from huggingface_hub import snapshot_download
|
|
19
|
+
|
|
20
|
+
config = engine.load_config()
|
|
21
|
+
model = str(config.get("model") or engine.LOCAL_MODEL) if config.get("backend") != "cloud" else engine.LOCAL_MODEL
|
|
22
|
+
print(f"downloading {model}", flush=True)
|
|
23
|
+
path = snapshot_download(model)
|
|
24
|
+
engine.HOME.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
(engine.HOME / "installed.json").write_text(json.dumps({"model": model, "path": path, "at": int(time.time())}))
|
|
26
|
+
print(f"installed {model}", flush=True)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
sys.exit(main())
|