@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
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Learning from the user's own fixes: what the recognizer wrote, against what was sent.
|
|
2
|
+
|
|
3
|
+
The phone bar remembers the draft as a take left it and which spans were dictated. When the message
|
|
4
|
+
goes out, the two are diffed word by word and each fix inside a dictated span that lands on a term
|
|
5
|
+
becomes a `spoken` form of that term in `vocab.json`, so the next take is rewritten before it shows.
|
|
6
|
+
|
|
7
|
+
Most edits are not lessons, so every gate here errs on learning nothing:
|
|
8
|
+
- typography: punctuation, spaces and full-width forms never count ("中 GitHub," = "中GitHub,");
|
|
9
|
+
- casing: a case-only fix is learned only when it is a term's casing (github -> GitHub, ios -> iOS),
|
|
10
|
+
never a capital at the start of a word (the -> The);
|
|
11
|
+
- the fix must land on term-like words (vocab.term_like): "pipe line" -> "pipeline" is the user's
|
|
12
|
+
style, not a mishearing;
|
|
13
|
+
- a rewrite teaches nothing: past REWRITE_SHARE of the dictated words changed, or more than
|
|
14
|
+
MAX_TERMS fixes, and the whole send is ignored.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import difflib
|
|
20
|
+
import functools
|
|
21
|
+
import re
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
import unicodedata
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from typing import Any, Callable, TypeVar
|
|
27
|
+
|
|
28
|
+
import vocab
|
|
29
|
+
|
|
30
|
+
# A Latin word with its joiners (vocab.WORD, plus full-width forms), or one CJK character.
|
|
31
|
+
TOKEN = re.compile(r"[A-Za-z0-9\uff10-\uff19\uff21-\uff3a\uff41-\uff5a]+(?:[-_.][A-Za-z0-9]+)*|[\u3400-\u9fff]")
|
|
32
|
+
MAX_TERMS = 4
|
|
33
|
+
REWRITE_SHARE = 0.5
|
|
34
|
+
REWRITE_FLOOR = 2.0
|
|
35
|
+
MAX_HEARD_WORDS = 4
|
|
36
|
+
MAX_HEARD_CJK = 6
|
|
37
|
+
MAX_TERM_WORDS = 3
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Token:
|
|
42
|
+
text: str
|
|
43
|
+
start: int
|
|
44
|
+
end: int
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def cjk(self) -> bool:
|
|
48
|
+
return not self.text.isascii()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def tokens(text: str) -> list[Token]:
|
|
52
|
+
return [Token(unicodedata.normalize("NFKC", m.group()), m.start(), m.end()) for m in TOKEN.finditer(text)]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _inside(token: Token, spans: list[tuple[int, int]]) -> bool:
|
|
56
|
+
return any(start <= token.start and token.end <= end for start, end in spans)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _term_casing(heard: str, sent: str) -> bool:
|
|
60
|
+
"""`sent` differs from `heard` only in case, and the difference is a term's casing: a capital past
|
|
61
|
+
the first letter (GitHub, iOS) or an acronym (API). A word merely capitalized (The) is not."""
|
|
62
|
+
if heard == sent or heard.lower() != sent.lower():
|
|
63
|
+
return False
|
|
64
|
+
return any(c.isupper() for c in sent[1:])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _heard_fits(run: list[Token]) -> bool:
|
|
68
|
+
cjk = sum(t.cjk for t in run)
|
|
69
|
+
return 0 < len(run) and cjk <= MAX_HEARD_CJK and len(run) - cjk <= MAX_HEARD_WORDS
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _term_fits(run: list[Token]) -> bool:
|
|
73
|
+
if not run or len(run) > MAX_TERM_WORDS or any(t.cjk for t in run):
|
|
74
|
+
return False
|
|
75
|
+
return any(vocab.term_like(t.text) for t in run)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _words(run: list[Token]) -> float:
|
|
79
|
+
"""Length in words: a CJK character is about half of one."""
|
|
80
|
+
return sum(0.5 if t.cjk else 1.0 for t in run)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def fixes(base: str, spans: list[tuple[int, int]], sent: str) -> list[dict[str, str]]:
|
|
84
|
+
"""The `{term, spoken}` pairs one send teaches: empty when nothing qualifies or it was a rewrite."""
|
|
85
|
+
a, b = tokens(base), tokens(sent)
|
|
86
|
+
dictated = [t for t in a if _inside(t, spans)]
|
|
87
|
+
if not dictated:
|
|
88
|
+
return []
|
|
89
|
+
matcher = difflib.SequenceMatcher(None, [t.text.lower() for t in a], [t.text.lower() for t in b], autojunk=False)
|
|
90
|
+
found: list[dict[str, str]] = []
|
|
91
|
+
changed = 0.0
|
|
92
|
+
for op, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
93
|
+
heard, said = a[i1:i2], b[j1:j2]
|
|
94
|
+
if op == "equal":
|
|
95
|
+
for x, y in zip(heard, said):
|
|
96
|
+
if _inside(x, spans) and _term_casing(x.text, y.text):
|
|
97
|
+
changed += 1
|
|
98
|
+
found.append({"term": y.text, "spoken": x.text})
|
|
99
|
+
continue
|
|
100
|
+
if op == "insert":
|
|
101
|
+
# Words typed into the middle of a take change it; words typed before or after it do not.
|
|
102
|
+
if 0 < i1 < len(a) and _inside(a[i1 - 1], spans) and _inside(a[i1], spans):
|
|
103
|
+
changed += _words(said)
|
|
104
|
+
continue
|
|
105
|
+
inside = [t for t in heard if _inside(t, spans)]
|
|
106
|
+
if not inside:
|
|
107
|
+
continue
|
|
108
|
+
changed += max(_words(inside), _words(said))
|
|
109
|
+
if op == "replace" and len(inside) == len(heard) and _heard_fits(heard) and _term_fits(said):
|
|
110
|
+
found.append({"term": sent[said[0].start : said[-1].end], "spoken": base[heard[0].start : heard[-1].end]})
|
|
111
|
+
# One or two words fixed in a short take is still a fix, not a rewrite.
|
|
112
|
+
if changed > max(REWRITE_FLOOR, REWRITE_SHARE * _words(dictated)) or len(found) > MAX_TERMS:
|
|
113
|
+
return []
|
|
114
|
+
return found
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_store = threading.Lock()
|
|
118
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _locked(fn: F) -> F:
|
|
122
|
+
"""The sidecar serves requests on threads; two sends must not each write over the other's lesson."""
|
|
123
|
+
|
|
124
|
+
@functools.wraps(fn)
|
|
125
|
+
def run(*args: Any, **kwargs: Any) -> Any:
|
|
126
|
+
with _store:
|
|
127
|
+
return fn(*args, **kwargs)
|
|
128
|
+
|
|
129
|
+
return run # type: ignore[return-value]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@_locked
|
|
133
|
+
def remember(found: list[dict[str, str]]) -> list[dict[str, Any]]:
|
|
134
|
+
"""Adds each fix to `vocab.json`; returns what was added, in the shape `forget` takes back."""
|
|
135
|
+
if not found:
|
|
136
|
+
return []
|
|
137
|
+
data = vocab.load()
|
|
138
|
+
now = int(time.time() * 1000)
|
|
139
|
+
added: list[dict[str, Any]] = []
|
|
140
|
+
for fix in found:
|
|
141
|
+
term, spoken = fix["term"], fix["spoken"]
|
|
142
|
+
entry = next((e for e in data["terms"] if e["term"].lower() == term.lower()), None)
|
|
143
|
+
created = entry is None
|
|
144
|
+
if entry is None:
|
|
145
|
+
entry = {"term": term, "spoken": [], "source": "edit", "at": now}
|
|
146
|
+
data["terms"].append(entry)
|
|
147
|
+
renamed = entry["term"] if entry["term"] != term else None
|
|
148
|
+
forms = entry.setdefault("spoken", [])
|
|
149
|
+
new_form = spoken not in forms
|
|
150
|
+
if not new_form and renamed is None:
|
|
151
|
+
continue
|
|
152
|
+
# The newest spelling the user sent wins: Github stored, GitHub sent.
|
|
153
|
+
entry["term"] = term
|
|
154
|
+
if new_form:
|
|
155
|
+
forms.append(spoken)
|
|
156
|
+
entry["at"] = now
|
|
157
|
+
item: dict[str, Any] = {"term": term, "spoken": spoken, "created": created, "newForm": new_form}
|
|
158
|
+
if renamed:
|
|
159
|
+
item["renamed"] = renamed
|
|
160
|
+
added.append(item)
|
|
161
|
+
vocab.save(data)
|
|
162
|
+
return added
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@_locked
|
|
166
|
+
def forget(added: list[dict[str, Any]]) -> int:
|
|
167
|
+
"""Undoes `remember`: a term it created goes; otherwise the spoken form it added, and its old spelling."""
|
|
168
|
+
data = vocab.load()
|
|
169
|
+
undone = 0
|
|
170
|
+
for item in added:
|
|
171
|
+
term, spoken = str(item.get("term") or ""), str(item.get("spoken") or "")
|
|
172
|
+
entry = next((e for e in data["terms"] if e["term"] == term), None)
|
|
173
|
+
if entry is None:
|
|
174
|
+
continue
|
|
175
|
+
undone += 1
|
|
176
|
+
if item.get("created"):
|
|
177
|
+
data["terms"].remove(entry)
|
|
178
|
+
continue
|
|
179
|
+
if item.get("newForm") and spoken in entry.get("spoken", []):
|
|
180
|
+
entry["spoken"].remove(spoken)
|
|
181
|
+
if item.get("renamed"):
|
|
182
|
+
entry["term"] = str(item["renamed"])
|
|
183
|
+
vocab.save(data)
|
|
184
|
+
return undone
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Live dictation: a take streamed in chunks while the button is held, re-transcribed as it grows.
|
|
2
|
+
|
|
3
|
+
The page sends the audio recorded since its last chunk about once a second. Each chunk is appended
|
|
4
|
+
to the take's buffer; when there is enough new audio and the GPU is free, the whole buffer is decoded
|
|
5
|
+
again and the text so far goes back as a partial. The final chunk decodes the buffer once more,
|
|
6
|
+
through the same pipeline as a one-shot take, so a streamed take ends with the same text it would
|
|
7
|
+
have had without streaming.
|
|
8
|
+
|
|
9
|
+
Partials are a local recognizer's only: a cloud one is billed per request, so its takes are buffered
|
|
10
|
+
and decoded once, at the end. A partial never waits for the GPU; the final one does.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
import engine
|
|
23
|
+
|
|
24
|
+
# Decoding again for less new audio than this changes little on screen and keeps the GPU busy.
|
|
25
|
+
PARTIAL_EVERY_SEC = 0.8
|
|
26
|
+
PARTIAL_MIN_SEC = 1.0
|
|
27
|
+
# A take whose page went away (a dropped socket, a closed tab) is forgotten after this.
|
|
28
|
+
IDLE_SEC = 120
|
|
29
|
+
MAX_SESSIONS = 8
|
|
30
|
+
MAX_SEC = 240
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Gap(Exception):
|
|
34
|
+
"""A chunk arrived out of order: the page should send the whole take instead."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Take:
|
|
39
|
+
chunks: list[np.ndarray] = field(default_factory=list)
|
|
40
|
+
samples: int = 0
|
|
41
|
+
seq: int = 0
|
|
42
|
+
decoded: int = 0
|
|
43
|
+
conversation: str | None = None
|
|
44
|
+
draft: str | None = None
|
|
45
|
+
language: str | None = None
|
|
46
|
+
touched: float = field(default_factory=time.monotonic)
|
|
47
|
+
|
|
48
|
+
def pcm(self) -> np.ndarray:
|
|
49
|
+
return np.concatenate(self.chunks) if self.chunks else np.zeros(0, dtype=np.float32)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_takes: dict[str, Take] = {}
|
|
53
|
+
_lock = threading.Lock()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def append(session: str, seq: int, pcm: np.ndarray, first: dict[str, Any]) -> Take:
|
|
57
|
+
"""Adds one chunk; the first chunk (`seq` 0) opens the take and brings its conversation and draft."""
|
|
58
|
+
now = time.monotonic()
|
|
59
|
+
with _lock:
|
|
60
|
+
for key in [k for k, t in _takes.items() if now - t.touched > IDLE_SEC]:
|
|
61
|
+
del _takes[key]
|
|
62
|
+
take = _takes.get(session)
|
|
63
|
+
if seq == 0:
|
|
64
|
+
if take is None and len(_takes) >= MAX_SESSIONS:
|
|
65
|
+
del _takes[min(_takes, key=lambda k: _takes[k].touched)]
|
|
66
|
+
take = _takes[session] = Take(
|
|
67
|
+
conversation=first.get("conversation"), draft=first.get("context"), language=first.get("language")
|
|
68
|
+
)
|
|
69
|
+
if take is None or seq != take.seq:
|
|
70
|
+
_takes.pop(session, None)
|
|
71
|
+
raise Gap(f"chunk {seq} of {session} out of order")
|
|
72
|
+
if take.samples + len(pcm) > MAX_SEC * engine.SAMPLE_RATE:
|
|
73
|
+
_takes.pop(session, None)
|
|
74
|
+
raise Gap("take is longer than a dictation")
|
|
75
|
+
if len(pcm):
|
|
76
|
+
take.chunks.append(pcm)
|
|
77
|
+
take.samples += len(pcm)
|
|
78
|
+
take.seq += 1
|
|
79
|
+
take.touched = now
|
|
80
|
+
return take
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def finish(session: str) -> Take | None:
|
|
84
|
+
with _lock:
|
|
85
|
+
return _takes.pop(session, None)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def due(take: Take, backend: engine.Backend) -> bool:
|
|
89
|
+
"""Whether this chunk earns a partial decode."""
|
|
90
|
+
if not isinstance(backend, engine.LocalWhisper):
|
|
91
|
+
return False
|
|
92
|
+
new = (take.samples - take.decoded) / engine.SAMPLE_RATE
|
|
93
|
+
return take.samples / engine.SAMPLE_RATE >= PARTIAL_MIN_SEC and new >= PARTIAL_EVERY_SEC
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Sound-alike correction: a run the recognizer wrote that *sounds* like a vocabulary term becomes the term.
|
|
2
|
+
|
|
3
|
+
`vocab.correct` fixes spelling (\"Graph, Q, L\", \"Decorfile\"); this fixes hearing: \"Cuban Eats\" for
|
|
4
|
+
Kubernetes, \"JitHub\" for GitHub, \"reserver\" for resolver. Both sides are reduced to a Metaphone key and
|
|
5
|
+
compared; a Chinese run is read as toneless pinyin first, so a transliteration (库伯内特斯) keys the same way.
|
|
6
|
+
|
|
7
|
+
Guards, each from a false hit in the bench: a run made only of dictionary words is never touched (\"Pot\"
|
|
8
|
+
stays a pot); the keys must start alike and be long enough to mean something; a Chinese run must match
|
|
9
|
+
more closely than English, because any two syllables key to *something*.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from functools import cache
|
|
16
|
+
|
|
17
|
+
import jellyfish
|
|
18
|
+
from pypinyin import lazy_pinyin
|
|
19
|
+
|
|
20
|
+
import vocab
|
|
21
|
+
|
|
22
|
+
ASCII_RATIO = 0.85
|
|
23
|
+
CJK_RATIO = 0.92
|
|
24
|
+
MIN_KEY = 3
|
|
25
|
+
CJK_RUN = re.compile(r"[\u4e00-\u9fff]{2,6}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@cache
|
|
29
|
+
def key(text: str) -> str:
|
|
30
|
+
if CJK_RUN.fullmatch(text):
|
|
31
|
+
text = "".join(lazy_pinyin(text))
|
|
32
|
+
return jellyfish.metaphone(re.sub(r"[^A-Za-z]", "", text))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _best(heard: str, pool: list[tuple[str, str]], ratio: float) -> str | None:
|
|
36
|
+
k = key(heard)
|
|
37
|
+
if len(k) < MIN_KEY:
|
|
38
|
+
return None
|
|
39
|
+
best, score = None, ratio
|
|
40
|
+
for term_key, term in pool:
|
|
41
|
+
# A key much longer than the term's is a term plus a word that was said ("Redis TTL"): leave it.
|
|
42
|
+
if term_key[0] != k[0] or not 0.75 <= len(term_key) / len(k) <= 1.34:
|
|
43
|
+
continue
|
|
44
|
+
s = jellyfish.jaro_winkler_similarity(k, term_key)
|
|
45
|
+
if s >= score:
|
|
46
|
+
best, score = term, s
|
|
47
|
+
return best
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def correct(text: str, terms: list[str]) -> str:
|
|
51
|
+
"""Replace sound-alike runs (up to two ASCII words, or one Chinese run) with the term they key to."""
|
|
52
|
+
pool = [(key(t), t) for t in terms if t.isascii() and len(key(t)) >= MIN_KEY]
|
|
53
|
+
if not pool:
|
|
54
|
+
return text
|
|
55
|
+
spelled = {t.lower() for t in terms}
|
|
56
|
+
|
|
57
|
+
def ascii_runs(s: str) -> str:
|
|
58
|
+
tokens = list(vocab.WORD.finditer(s))
|
|
59
|
+
out, cursor, i = [], 0, 0
|
|
60
|
+
while i < len(tokens):
|
|
61
|
+
hit = None
|
|
62
|
+
for n in (2, 1):
|
|
63
|
+
run = tokens[i : i + n]
|
|
64
|
+
if len(run) < n or any(s[a.end() : b.start()] != " " for a, b in zip(run, run[1:])):
|
|
65
|
+
continue
|
|
66
|
+
words = [m.group() for m in run]
|
|
67
|
+
if not all(w.isalpha() for w in words) or all(vocab.common(w) for w in words):
|
|
68
|
+
continue
|
|
69
|
+
heard = s[run[0].start() : run[-1].end()]
|
|
70
|
+
if heard.lower() in spelled:
|
|
71
|
+
break
|
|
72
|
+
term = _best(heard, pool, ASCII_RATIO)
|
|
73
|
+
if term:
|
|
74
|
+
hit = (run[0].start(), run[-1].end(), term, n)
|
|
75
|
+
break
|
|
76
|
+
if hit:
|
|
77
|
+
out.append(s[cursor : hit[0]] + hit[2])
|
|
78
|
+
cursor = hit[1]
|
|
79
|
+
i += hit[3]
|
|
80
|
+
else:
|
|
81
|
+
i += 1
|
|
82
|
+
return "".join(out) + s[cursor:]
|
|
83
|
+
|
|
84
|
+
def cjk_run(m: re.Match[str]) -> str:
|
|
85
|
+
return _best(m.group(), pool, CJK_RATIO) or m.group()
|
|
86
|
+
|
|
87
|
+
return CJK_RUN.sub(cjk_run, ascii_runs(text))
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Optional cleanup of a finished take by a chat model: fillers out, self-corrections applied, numbers,
|
|
2
|
+
spacing and lists as written text. Off unless `config.json` has a `polish` block:
|
|
3
|
+
|
|
4
|
+
{"polish": {"model": "gpt-6-luna"}} # OpenAI, key in OPENAI_API_KEY
|
|
5
|
+
{"polish": {"baseURL": "https://openrouter.ai/api/v1", "model": "deepseek/deepseek-v4.1-flash",
|
|
6
|
+
"apiKeyEnv": "OPENROUTER_API_KEY",
|
|
7
|
+
"body": {"reasoning": {"effort": "none"}, "provider": {"sort": "throughput"}}}}
|
|
8
|
+
|
|
9
|
+
Any OpenAI-compatible `/chat/completions` works; `body` is merged into the request for the fields one
|
|
10
|
+
provider needs (gpt-6-luna is fastest with `"reasoning_effort": "none"`, which other models may reject).
|
|
11
|
+
|
|
12
|
+
`polish_prompt.md` is yap's VoiceInk/Resources/RecommendedPrompt.md (00bbde00) as written. What makes it fit
|
|
13
|
+
messages to a coding agent is its input: <CUSTOM_VOCABULARY> is the take's ranked vocabulary, the
|
|
14
|
+
conversation's identifiers first. Rewording it for agents scored lower on polish_bench.py (gpt-4.1-mini 157-160
|
|
15
|
+
of 192 against 168; even on gpt-6-luna), so the text stays yap's. A polish that fails or runs late returns
|
|
16
|
+
the take as the recognizer left it: cleanup is never worth losing what was said.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.request
|
|
25
|
+
from functools import cache
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
HERE = Path(__file__).resolve().parent
|
|
30
|
+
TIMEOUT_S = 8
|
|
31
|
+
MAX_VOCAB = 60
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@cache
|
|
35
|
+
def prompt() -> str:
|
|
36
|
+
return (HERE / "polish_prompt.md").read_text()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def enabled(config: dict[str, Any]) -> bool:
|
|
40
|
+
block = config.get("polish")
|
|
41
|
+
return isinstance(block, dict) and bool(block.get("model")) and block.get("enabled", True) is not False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def messages(text: str, terms: list[str], system: str | None = None) -> list[dict[str, str]]:
|
|
45
|
+
vocabulary = f"\n<CUSTOM_VOCABULARY>\n{', '.join(terms[:MAX_VOCAB])}\n</CUSTOM_VOCABULARY>" if terms else ""
|
|
46
|
+
return [
|
|
47
|
+
{"role": "system", "content": system or prompt()},
|
|
48
|
+
{"role": "user", "content": f"{vocabulary}\n<TRANSCRIPT>\n{text}\n</TRANSCRIPT>"},
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def request(block: dict[str, Any], text: str, terms: list[str], system: str | None = None) -> urllib.request.Request:
|
|
53
|
+
base = str(block.get("baseURL") or "https://api.openai.com/v1").rstrip("/")
|
|
54
|
+
key = block.get("apiKey") or os.environ.get(str(block.get("apiKeyEnv") or "OPENAI_API_KEY"))
|
|
55
|
+
if not key:
|
|
56
|
+
raise RuntimeError("polish has no API key: set apiKey or apiKeyEnv in config.json")
|
|
57
|
+
body: dict[str, Any] = {"model": block["model"], "messages": messages(text, terms, system)}
|
|
58
|
+
extra = block.get("body")
|
|
59
|
+
if isinstance(extra, dict):
|
|
60
|
+
body.update(extra)
|
|
61
|
+
headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
|
|
62
|
+
return urllib.request.Request(f"{base}/chat/completions", json.dumps(body).encode(), headers)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def complete(block: dict[str, Any], text: str, terms: list[str], system: str | None = None) -> str:
|
|
66
|
+
try:
|
|
67
|
+
with urllib.request.urlopen(request(block, text, terms, system), timeout=TIMEOUT_S) as res:
|
|
68
|
+
reply = json.loads(res.read())
|
|
69
|
+
except urllib.error.HTTPError as err:
|
|
70
|
+
raise RuntimeError(f"polish answered {err.code}: {err.read()[:200]!r}") from err
|
|
71
|
+
out = str(reply["choices"][0]["message"]["content"] or "").strip()
|
|
72
|
+
if not out:
|
|
73
|
+
raise RuntimeError("polish answered with no text")
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def polish(text: str, terms: list[str], config: dict[str, Any]) -> tuple[str, str | None]:
|
|
78
|
+
"""The cleaned take, or the take unchanged and why."""
|
|
79
|
+
if not text.strip() or not enabled(config):
|
|
80
|
+
return text, None
|
|
81
|
+
try:
|
|
82
|
+
return complete(config["polish"], text, terms), None
|
|
83
|
+
except Exception as err: # noqa: BLE001 - any failure keeps the recognizer's text
|
|
84
|
+
return text, str(err)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
你是语音输入的整理器。<TRANSCRIPT> 是用户口述经语音识别得到的原文,把它整理成可以直接发出去或贴进文档的书面文字。
|
|
2
|
+
|
|
3
|
+
<RULES>
|
|
4
|
+
1. 忠实:只整理,不回答、不执行、不续写。原文是问题就输出这个问题,是指令("帮我回一下…")就输出这句话本身。不加原文没有的信息、观点、总结。
|
|
5
|
+
2. 语言:保留原本的中英混用。英文术语、产品名、代码标识符原样保留,不翻译成中文,也不把中文翻成英文。
|
|
6
|
+
3. 纠错:修正识别错误,尤其是被听成汉字或拆散的英文("P 二"→PR,"Q 四"→Q4,"use effect"→useEffect,"user id"→userId,"deployed 的 pipeline" 按语境→deploy 的 pipeline)。拿不准就保留原词。参考 <CUSTOM_VOCABULARY> 的拼写。
|
|
7
|
+
4. 去噪:删掉口癖和填充词(嗯、呃、啊、那个、就是、然后 作为无意义连接时)、口吃重复。用户中途改口("不对""我说错了""应该是")时,只保留改正后的说法,删掉被否定的部分和改口的话;被否定的内容不要以"不是……""而不是……"的形式留下。
|
|
8
|
+
5. 标点与排版:
|
|
9
|
+
- 中文用全角标点,中文与英文/数字之间加一个空格。
|
|
10
|
+
- 数字:金额、百分比、端口、版本号、10 及以上的数量用阿拉伯数字("最多五十个"→最多 50 个,"端口三千"→端口 3000,"超时六十秒"→超时 60 秒);十以内的计数保持汉字(三件事、一个人);时间日期保持口语原样("周四下午三点")。
|
|
11
|
+
- 删掉句末无意义的语气词(啊、吧、呢 表示疑问时保留问号即可)。
|
|
12
|
+
6. 结构:按内容决定,不要为了格式而格式。
|
|
13
|
+
- 一两句话的内容:输出一句或一段,不加任何格式。
|
|
14
|
+
- 列举了 3 项及以上并列内容("A,然后 B,还有 C" 也算),或明确说了"第一/第二/首先/其次/最后":用列表,即使每项很短、整段只有一句话也要拆成列表。
|
|
15
|
+
- 描述了 3 步及以上的操作流程("先…然后…再…最后…"):用编号列表,每步一行。
|
|
16
|
+
- 列表的写法:引导句保留为列表前的一行;有先后顺序用 1. 2. 3.,无顺序用 - ;每项简短,不以句号结尾;只有可以直接粘到终端运行的命令才放进反引号。
|
|
17
|
+
- 讲了几件不相干的事:分段,段落之间空一行。
|
|
18
|
+
- 不用标题、不加粗、不用表格、不用 emoji。
|
|
19
|
+
7. 只输出整理后的文字,不要任何前言、解释、引号或代码块。
|
|
20
|
+
</RULES>
|
|
21
|
+
|
|
22
|
+
<EXAMPLES>
|
|
23
|
+
原文:嗯周末露营要带的东西有帐篷,然后睡袋,还有那个 power bank 记得充满,哦对还有驱蚊的
|
|
24
|
+
输出:
|
|
25
|
+
周末露营要带的东西:
|
|
26
|
+
- 帐篷
|
|
27
|
+
- 睡袋
|
|
28
|
+
- power bank(记得充满)
|
|
29
|
+
- 驱蚊用品
|
|
30
|
+
|
|
31
|
+
原文:部署的话你先 ssh 到那台机器,然后 git pull 一下,再跑一下 migrate,最后重启那个 service 就行
|
|
32
|
+
输出:
|
|
33
|
+
部署步骤:
|
|
34
|
+
1. ssh 到那台机器
|
|
35
|
+
2. `git pull`
|
|
36
|
+
3. 跑一下 migrate
|
|
37
|
+
4. 重启 service
|
|
38
|
+
|
|
39
|
+
原文:这个季度的 marketing 预算批了三十万,不对,是四十五万,下周一前报给 finance
|
|
40
|
+
输出:这个季度的 marketing 预算批了 45 万,下周一前报给 finance。
|
|
41
|
+
|
|
42
|
+
原文:首先把 unit test 补齐,其次把 README 更新一下,最后再 merge 到 main
|
|
43
|
+
输出:
|
|
44
|
+
1. 把 unit test 补齐
|
|
45
|
+
2. 更新 README
|
|
46
|
+
3. merge 到 main
|
|
47
|
+
|
|
48
|
+
原文:呃你看一下这个 bug 能不能今天修掉
|
|
49
|
+
输出:你看一下这个 bug 能不能今天修掉?
|
|
50
|
+
</EXAMPLES>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# The dictation sidecar and its bench. Not a package: run the scripts through uv, which builds the
|
|
2
|
+
# environment from this file and uv.lock (`uv run --project scripts/voice --extra local scripts/voice/asr_server.py`).
|
|
3
|
+
[project]
|
|
4
|
+
name = "rove-voice"
|
|
5
|
+
version = "0"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"jellyfish",
|
|
9
|
+
"numpy",
|
|
10
|
+
"opencc-python-reimplemented",
|
|
11
|
+
"pypinyin",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
# The local recognizer: Apple Silicon only. A cloud-only setup syncs without it.
|
|
16
|
+
local = ["mlx-whisper; sys_platform == 'darwin' and platform_machine == 'arm64'"]
|
|
17
|
+
|
|
18
|
+
[tool.uv]
|
|
19
|
+
package = false
|
|
20
|
+
|
|
21
|
+
[tool.ruff]
|
|
22
|
+
line-length = 120
|
|
23
|
+
|
|
24
|
+
[tool.ruff.lint]
|
|
25
|
+
select = ["E", "F", "W", "I"]
|
|
26
|
+
|
|
27
|
+
[tool.ruff.lint.isort]
|
|
28
|
+
known-first-party = ["asr_server", "engine", "fetch_model", "learn", "live", "phonetic", "polish", "vocab"]
|