@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.
Files changed (110) hide show
  1. package/dist/cli/chunk-03a986ba.js +8 -0
  2. package/dist/cli/{chunk-j54c5a0a.js → chunk-084a4eq5.js} +1 -1
  3. package/dist/cli/{chunk-y7mjhjby.js → chunk-0gbg7bg8.js} +1 -1
  4. package/dist/cli/{chunk-6cfs0nqe.js → chunk-0mr6cckm.js} +1 -1
  5. package/dist/cli/{chunk-j8d9sk5j.js → chunk-0ramhr3v.js} +1 -1
  6. package/dist/cli/{chunk-rvjnafyt.js → chunk-0ws6j7zf.js} +1 -1
  7. package/dist/cli/{chunk-1g6vhgfe.js → chunk-16e6jqq6.js} +1 -1
  8. package/dist/cli/{chunk-vbvbwjad.js → chunk-1k6expt9.js} +1 -1
  9. package/dist/cli/{chunk-f262ywvg.js → chunk-231mqmk5.js} +1 -1
  10. package/dist/cli/{chunk-5g6v6tq5.js → chunk-3m6m1994.js} +1 -1
  11. package/dist/cli/{chunk-f0sahrfw.js → chunk-3xz0dmnc.js} +17 -14
  12. package/dist/cli/{chunk-nqwza8b0.js → chunk-4d5xp2p0.js} +1 -1
  13. package/dist/cli/{chunk-mjzjah9d.js → chunk-4g463eqt.js} +1 -1
  14. package/dist/cli/{chunk-hfvzvj91.js → chunk-4yr1534j.js} +1 -1
  15. package/dist/cli/{chunk-7aaw17vr.js → chunk-55e3pt18.js} +1 -1
  16. package/dist/cli/{chunk-hd1szhba.js → chunk-5bnhecx3.js} +1 -1
  17. package/dist/cli/{chunk-t2p22ww7.js → chunk-5z02j1em.js} +1 -1
  18. package/dist/cli/{chunk-r98h6b42.js → chunk-6jj8perx.js} +2 -2
  19. package/dist/cli/{chunk-5dq0n3h7.js → chunk-7r1wt7qp.js} +1 -1
  20. package/dist/cli/chunk-7zwehmay.js +2 -0
  21. package/dist/cli/{chunk-4drm07mp.js → chunk-8ccc9v77.js} +1 -1
  22. package/dist/cli/chunk-8ew9sqwa.js +2 -0
  23. package/dist/cli/{chunk-w003rpr2.js → chunk-8kspedny.js} +1 -1
  24. package/dist/cli/chunk-8ng1756g.js +3 -0
  25. package/dist/cli/{chunk-f7egph8g.js → chunk-92hpkjkk.js} +1 -1
  26. package/dist/cli/{chunk-ebdp0z1d.js → chunk-95kk75wh.js} +1 -1
  27. package/dist/cli/{chunk-np398f2a.js → chunk-97er67dc.js} +1 -1
  28. package/dist/cli/{chunk-qc9sj5wh.js → chunk-9q8wwfs1.js} +1 -1
  29. package/dist/cli/{chunk-zkby63ej.js → chunk-ae8dj2fd.js} +1 -1
  30. package/dist/cli/chunk-b4egks20.js +7 -0
  31. package/dist/cli/{chunk-s1xn6wav.js → chunk-b64w1jh6.js} +1 -1
  32. package/dist/cli/{chunk-x85rcep5.js → chunk-bbq6wc47.js} +1 -1
  33. package/dist/cli/{chunk-w2tcb3rn.js → chunk-h0wp2ghf.js} +1 -1
  34. package/dist/cli/{chunk-fkgemeww.js → chunk-h81h0arp.js} +2 -2
  35. package/dist/cli/{chunk-12wn9jtf.js → chunk-h8v2jzth.js} +1 -1
  36. package/dist/cli/{chunk-02x0ahxk.js → chunk-h9cjzh9j.js} +1 -1
  37. package/dist/cli/{chunk-9f34p80j.js → chunk-jp354m6j.js} +1 -1
  38. package/dist/cli/chunk-jxqhq9va.js +3 -0
  39. package/dist/cli/{chunk-bv050ssb.js → chunk-k3h4cpdy.js} +1 -1
  40. package/dist/cli/{chunk-jk4f0xsh.js → chunk-nb0be8dz.js} +1 -1
  41. package/dist/cli/{chunk-gk8efsx0.js → chunk-nbf3attz.js} +1 -1
  42. package/dist/cli/{chunk-wz307hf6.js → chunk-nh4dd7q4.js} +1 -1
  43. package/dist/cli/chunk-pg48v2kc.js +2 -0
  44. package/dist/cli/chunk-ph9m6y5j.js +8 -0
  45. package/dist/cli/{chunk-zp0q931y.js → chunk-pm6j59da.js} +1 -1
  46. package/dist/cli/{chunk-amq030gb.js → chunk-pv3c4vgt.js} +2 -2
  47. package/dist/cli/{chunk-zv0rrhck.js → chunk-q012n1t1.js} +1 -1
  48. package/dist/cli/{chunk-p11emdb2.js → chunk-qkdc5tc7.js} +1 -1
  49. package/dist/cli/{chunk-am5cpb9n.js → chunk-qx6d4bbb.js} +1 -1
  50. package/dist/cli/{chunk-hmsvt5nj.js → chunk-rqm2kn0c.js} +1 -1
  51. package/dist/cli/{chunk-7nnpp89t.js → chunk-s5eat30g.js} +1 -1
  52. package/dist/cli/{chunk-azt0c2b5.js → chunk-s7tbwn04.js} +1 -1
  53. package/dist/cli/{chunk-txvjg1g9.js → chunk-swcfg2vb.js} +3 -3
  54. package/dist/cli/chunk-swzdd5aj.js +2 -0
  55. package/dist/cli/chunk-t2418v0z.js +3 -0
  56. package/dist/cli/{chunk-5kgq379z.js → chunk-tcdz7sx9.js} +1 -1
  57. package/dist/cli/{chunk-qwwt4ch2.js → chunk-tg4swhr1.js} +1 -1
  58. package/dist/cli/{chunk-qzhfs668.js → chunk-v5bn5fnd.js} +1 -1
  59. package/dist/cli/{chunk-byh32qnx.js → chunk-v8c4n8by.js} +1 -1
  60. package/dist/cli/{chunk-bw2y7670.js → chunk-vcj2x6ne.js} +1 -1
  61. package/dist/cli/{chunk-6mk6ceb6.js → chunk-w3f993d8.js} +2 -2
  62. package/dist/cli/{chunk-nrx8pf9p.js → chunk-x35dfb1x.js} +1 -1
  63. package/dist/cli/{chunk-ggjmcqpt.js → chunk-x94e0rw4.js} +3 -3
  64. package/dist/cli/chunk-xaam0d8r.js +4 -0
  65. package/dist/cli/{chunk-mqvkd2g1.js → chunk-xqvyzv62.js} +1 -1
  66. package/dist/cli/chunk-y8n6trr4.js +2 -0
  67. package/dist/cli/{chunk-4z1a65bz.js → chunk-ydaj339x.js} +1 -1
  68. package/dist/cli/{chunk-k2j0tg1h.js → chunk-yeq2pc68.js} +7 -3
  69. package/dist/cli/{chunk-3a7x7jg7.js → chunk-z204mctb.js} +16 -17
  70. package/dist/cli/{chunk-cakyme4s.js → chunk-z8rvxvx5.js} +1 -1
  71. package/dist/cli/{chunk-3hxm56h6.js → chunk-za8pnw78.js} +1 -1
  72. package/dist/cli/{chunk-t7dyf50m.js → chunk-zd61a32g.js} +1 -1
  73. package/dist/cli/{chunk-8hernv9y.js → chunk-ze4v7eda.js} +1 -1
  74. package/dist/cli/{chunk-q6tsx6zc.js → chunk-zjsfr4y7.js} +1 -1
  75. package/dist/cli/chunk-zr058kbe.js +3 -0
  76. package/dist/cli/{chunk-jkgp816v.js → chunk-zypkjg5t.js} +1 -1
  77. package/dist/cli/index.js +3 -3
  78. package/dist/cli/rove.js +3 -3
  79. package/dist/skills/rove/SKILL.md +4 -2
  80. package/dist/skills/rove/references/api-flags.md +7 -4
  81. package/dist/voice/asr_server.py +318 -0
  82. package/dist/voice/engine.py +174 -0
  83. package/dist/voice/fetch_model.py +30 -0
  84. package/dist/voice/learn.py +184 -0
  85. package/dist/voice/live.py +93 -0
  86. package/dist/voice/phonetic.py +87 -0
  87. package/dist/voice/polish.py +84 -0
  88. package/dist/voice/polish_prompt.md +50 -0
  89. package/dist/voice/pyproject.toml +28 -0
  90. package/dist/voice/uv.lock +815 -0
  91. package/dist/voice/vocab.py +193 -0
  92. package/dist/web/assets/index-Bo92_NP5.css +1 -0
  93. package/dist/web/assets/index-Bpal5sTS.js +154 -0
  94. package/dist/web/index.html +3 -3
  95. package/dist/web/relay/index.html +5 -5
  96. package/package.json +4 -3
  97. package/dist/cli/chunk-0g1h35mr.js +0 -2
  98. package/dist/cli/chunk-1wwqf6kx.js +0 -3
  99. package/dist/cli/chunk-26ydgwym.js +0 -2
  100. package/dist/cli/chunk-2pfh3zm0.js +0 -33
  101. package/dist/cli/chunk-2wrt80ft.js +0 -3
  102. package/dist/cli/chunk-7fya3y0n.js +0 -2
  103. package/dist/cli/chunk-d2wndk4q.js +0 -2
  104. package/dist/cli/chunk-mey3vksj.js +0 -2
  105. package/dist/cli/chunk-nv8eyjep.js +0 -3
  106. package/dist/cli/chunk-ve76p2mx.js +0 -3
  107. package/dist/cli/chunk-wfgrn8p2.js +0 -4
  108. package/dist/cli/chunk-xmjknfy2.js +0 -7
  109. package/dist/web/assets/index-Dc0Khk0q.js +0 -149
  110. 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"]