@bojackduy/opencode-voice 0.1.0
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/LICENSE +21 -0
- package/README.md +729 -0
- package/index.js +124 -0
- package/lib/audio-chunker.js +231 -0
- package/lib/audio-enhance.js +172 -0
- package/lib/conversation.js +528 -0
- package/lib/live-notes.js +622 -0
- package/lib/llm-client.js +304 -0
- package/lib/logger.js +18 -0
- package/lib/notes-writer.js +224 -0
- package/lib/session.js +102 -0
- package/lib/streaming-editor.js +308 -0
- package/lib/streaming-stt.js +1322 -0
- package/lib/streaming-transcript.js +236 -0
- package/lib/stt.js +2339 -0
- package/lib/tts.js +671 -0
- package/lib/voice-model.js +122 -0
- package/lib/whisper-server.js +471 -0
- package/package.json +47 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// Focused-editor range adapter for local streaming dictation (STAGE 2).
|
|
2
|
+
//
|
|
3
|
+
// Owns ONLY the dictated range inside the focused editable field:
|
|
4
|
+
// prefix (surrounding text before the cursor at record start) +
|
|
5
|
+
// dictated (stable + tentative, replaced as hypotheses evolve) +
|
|
6
|
+
// suffix (surrounding text after the cursor at record start).
|
|
7
|
+
//
|
|
8
|
+
// Verified renderer APIs (installed @opencode-ai/plugin 1.18.25,
|
|
9
|
+
// .opencode/node_modules/@opencode-ai/plugin/dist/tui.d.ts):
|
|
10
|
+
// - `api.renderer` is typed as untyped `CliRenderer` from @opentui/core.
|
|
11
|
+
// `currentFocusedRenderable` does NOT appear in the plugin types at all -
|
|
12
|
+
// it is a runtime-only property.
|
|
13
|
+
// - The ONLY observed runtime methods on the focused renderable are
|
|
14
|
+
// `insertText(text)` and `submit()` (see lib/stt.js insertIntoFocusedInput).
|
|
15
|
+
// - `TuiPromptRef` (via session_prompt/home_prompt slots, which this repo
|
|
16
|
+
// does not use) offers `current.input` + `set()` + `submit()` - also no
|
|
17
|
+
// selection, cursor, or change-notification APIs.
|
|
18
|
+
// - No selection/cursor/change-notification behavior exists in the installed
|
|
19
|
+
// types. This adapter therefore MUST NOT assume any range-deletion,
|
|
20
|
+
// selection, or edit-notification API on the live renderer.
|
|
21
|
+
//
|
|
22
|
+
// Probing rule: a target supports live range replacement ONLY when it
|
|
23
|
+
// exposes BOTH `getText()` and `setRange(start, end, text)` functions
|
|
24
|
+
// (plus optional `getSelection()` and `isAlive()`). Anything else -
|
|
25
|
+
// including the real renderer, which exposes only `insertText` - takes the
|
|
26
|
+
// fallback path: live preview toast plus a SINGLE insert at finalize.
|
|
27
|
+
// Fallback inserts use the existing `insertIntoFocusedInput` helper and
|
|
28
|
+
// never fall through to primary-chat `appendPrompt`/`submitPrompt`.
|
|
29
|
+
//
|
|
30
|
+
// Safety rules (both paths):
|
|
31
|
+
// - The captured target is fixed for the session. If `getFocused()` returns
|
|
32
|
+
// a different identity, or `isAlive()` reports false, live mutations stop
|
|
33
|
+
// immediately; the transcript is retained for recovery and never silently
|
|
34
|
+
// redirected into another field.
|
|
35
|
+
// - If the user edits inside the dictated range (detected by comparing the
|
|
36
|
+
// field text against prefix + lastWritten + suffix), live mutations stop
|
|
37
|
+
// immediately; the transcript is retained.
|
|
38
|
+
// - `cancel()` removes ONLY plugin-owned dictated text via `setRange`
|
|
39
|
+
// (live path) and is a no-op when nothing was inserted (fallback path).
|
|
40
|
+
// User-typed prefix/suffix text is never touched.
|
|
41
|
+
// - Submission uses ONLY the captured target's own `submit()` when present.
|
|
42
|
+
// There is deliberately no fallback to primary-chat submit for a custom
|
|
43
|
+
// field. Callers implement this via `resolveStreamingSubmit(target)`.
|
|
44
|
+
// - No LLM calls anywhere in this module (local-only, zero quota).
|
|
45
|
+
|
|
46
|
+
export function canLiveReplace(target) {
|
|
47
|
+
return !!target && typeof target.getText === "function" && typeof target.setRange === "function";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function hasOwnSubmit(target) {
|
|
51
|
+
return !!target && typeof target.submit === "function";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Submission-safety decision for a captured target. Returns
|
|
55
|
+
// `{ ok: true, via: "focused-submit" }` only when the captured target
|
|
56
|
+
// itself can submit; otherwise `{ ok: false, reason }` and the caller must
|
|
57
|
+
// NOT fall through to primary-chat submit.
|
|
58
|
+
export function resolveStreamingSubmit(target) {
|
|
59
|
+
if (!target) return { ok: false, reason: "no-target" };
|
|
60
|
+
if (hasOwnSubmit(target)) return { ok: true, via: "focused-submit" };
|
|
61
|
+
return { ok: false, reason: "target-cannot-submit" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function joinDictated(stableText, tentativeText) {
|
|
65
|
+
return `${stableText || ""} ${tentativeText || ""}`.trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createStreamingEditorAdapter({ toast = null, getFocused = null } = {}) {
|
|
69
|
+
let active = false;
|
|
70
|
+
let fallback = true;
|
|
71
|
+
let detached = false;
|
|
72
|
+
let detachReason = null;
|
|
73
|
+
let target = null;
|
|
74
|
+
let prefix = "";
|
|
75
|
+
let suffix = "";
|
|
76
|
+
let lastWritten = "";
|
|
77
|
+
let dictatedText = "";
|
|
78
|
+
|
|
79
|
+
function currentFocused() {
|
|
80
|
+
try {
|
|
81
|
+
return typeof getFocused === "function" ? getFocused() : target;
|
|
82
|
+
} catch {
|
|
83
|
+
return target;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function snapshotTarget(t) {
|
|
88
|
+
// Append-mode anchoring: without a selection API the cursor position is
|
|
89
|
+
// unknowable, so the entire pre-existing text is treated as prefix and
|
|
90
|
+
// the dictated range starts at its end. Nothing pre-existing is ever
|
|
91
|
+
// overwritten.
|
|
92
|
+
try {
|
|
93
|
+
const text = t.getText() ?? "";
|
|
94
|
+
if (typeof t.getSelection === "function") {
|
|
95
|
+
const sel = t.getSelection() || {};
|
|
96
|
+
const start = Number(sel.start ?? sel.anchor ?? text.length);
|
|
97
|
+
const end = Number(sel.end ?? sel.head ?? start);
|
|
98
|
+
const s = Number.isFinite(start) ? Math.max(0, Math.min(start, text.length)) : text.length;
|
|
99
|
+
const e = Number.isFinite(end) ? Math.max(s, Math.min(end, text.length)) : s;
|
|
100
|
+
return { prefix: text.slice(0, s), suffix: text.slice(e) };
|
|
101
|
+
}
|
|
102
|
+
return { prefix: String(text), suffix: "" };
|
|
103
|
+
} catch {
|
|
104
|
+
return { prefix: "", suffix: "" };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function begin(capturedTarget) {
|
|
109
|
+
if (active) return { ok: false, reason: "already-active" };
|
|
110
|
+
if (!capturedTarget) {
|
|
111
|
+
// No editable field at record start: fallback session with no target.
|
|
112
|
+
// Partials become preview toasts; finalize has nowhere to insert and
|
|
113
|
+
// reports `no-target` instead of redirecting into primary chat.
|
|
114
|
+
active = true;
|
|
115
|
+
fallback = true;
|
|
116
|
+
detached = false;
|
|
117
|
+
detachReason = null;
|
|
118
|
+
target = null;
|
|
119
|
+
prefix = "";
|
|
120
|
+
suffix = "";
|
|
121
|
+
lastWritten = "";
|
|
122
|
+
dictatedText = "";
|
|
123
|
+
return { ok: true, fallback: true, reason: "no-target" };
|
|
124
|
+
}
|
|
125
|
+
active = true;
|
|
126
|
+
detached = false;
|
|
127
|
+
detachReason = null;
|
|
128
|
+
target = capturedTarget;
|
|
129
|
+
dictatedText = "";
|
|
130
|
+
lastWritten = "";
|
|
131
|
+
if (canLiveReplace(capturedTarget)) {
|
|
132
|
+
const snap = snapshotTarget(capturedTarget);
|
|
133
|
+
prefix = snap.prefix;
|
|
134
|
+
suffix = snap.suffix;
|
|
135
|
+
fallback = false;
|
|
136
|
+
return { ok: true, fallback: false };
|
|
137
|
+
}
|
|
138
|
+
// Insert-capable (or unknown) field without range APIs: preview + single
|
|
139
|
+
// insert at finalize. Nothing is written until finalize.
|
|
140
|
+
prefix = "";
|
|
141
|
+
suffix = "";
|
|
142
|
+
fallback = true;
|
|
143
|
+
return { ok: true, fallback: true, reason: "no-range-api" };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function detach(reason) {
|
|
147
|
+
if (!active || detached) return dictatedText;
|
|
148
|
+
detached = true;
|
|
149
|
+
detachReason = reason || "detached";
|
|
150
|
+
return dictatedText;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function checkAttached() {
|
|
154
|
+
if (!active || fallback || detached) return null;
|
|
155
|
+
const now = currentFocused();
|
|
156
|
+
if (now !== target) {
|
|
157
|
+
detach(now ? "focus-moved" : "target-closed");
|
|
158
|
+
return detachReason;
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
if (typeof target.isAlive === "function" && target.isAlive() === false) {
|
|
162
|
+
detach("target-closed");
|
|
163
|
+
return detachReason;
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
detach("target-closed");
|
|
167
|
+
return detachReason;
|
|
168
|
+
}
|
|
169
|
+
// User-edit detection: the field must still read prefix +
|
|
170
|
+
// lastWritten + suffix. Any deviation inside the dictated range (or of
|
|
171
|
+
// the anchors) means the user typed there - freeze live mutations.
|
|
172
|
+
try {
|
|
173
|
+
const current = target.getText() ?? "";
|
|
174
|
+
const expected = `${prefix}${lastWritten}${suffix}`;
|
|
175
|
+
if (current !== expected) {
|
|
176
|
+
detach("user-edit");
|
|
177
|
+
return detachReason;
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
detach("target-closed");
|
|
181
|
+
return detachReason;
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function applyPartial({ stableText = "", tentativeText = "" } = {}) {
|
|
187
|
+
if (!active) return { status: "inactive", text: "" };
|
|
188
|
+
dictatedText = joinDictated(stableText, tentativeText);
|
|
189
|
+
if (fallback || detached) {
|
|
190
|
+
if (!detached && dictatedText) {
|
|
191
|
+
try {
|
|
192
|
+
toast?.(`🎙 ${dictatedText}`, "info");
|
|
193
|
+
} catch {}
|
|
194
|
+
}
|
|
195
|
+
return { status: detached ? "detached" : "preview", text: dictatedText };
|
|
196
|
+
}
|
|
197
|
+
const lost = checkAttached();
|
|
198
|
+
if (lost) return { status: "detached", text: dictatedText, reason: lost };
|
|
199
|
+
try {
|
|
200
|
+
target.setRange(prefix.length, prefix.length + lastWritten.length, dictatedText);
|
|
201
|
+
lastWritten = dictatedText;
|
|
202
|
+
return { status: "applied", text: dictatedText };
|
|
203
|
+
} catch {
|
|
204
|
+
detach("write-failed");
|
|
205
|
+
return { status: "detached", text: dictatedText, reason: detachReason };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Commit state at finalize. Live path already shows the full text; the
|
|
210
|
+
// caller performs the single insert for the fallback path via the existing
|
|
211
|
+
// focused-input helper (never primary-chat append).
|
|
212
|
+
function finalize(fullText) {
|
|
213
|
+
if (!active) return { text: fullText || dictatedText, inserted: false, fallback };
|
|
214
|
+
const text = typeof fullText === "string" ? fullText : dictatedText;
|
|
215
|
+
dictatedText = text;
|
|
216
|
+
if (!fallback && !detached) {
|
|
217
|
+
const lost = checkAttached();
|
|
218
|
+
if (!lost) {
|
|
219
|
+
try {
|
|
220
|
+
target.setRange(prefix.length, prefix.length + lastWritten.length, text);
|
|
221
|
+
lastWritten = text;
|
|
222
|
+
} catch {
|
|
223
|
+
detach("write-failed");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const out = {
|
|
228
|
+
text,
|
|
229
|
+
inserted: !fallback && !detached,
|
|
230
|
+
fallback,
|
|
231
|
+
detached,
|
|
232
|
+
reason: detachReason,
|
|
233
|
+
};
|
|
234
|
+
active = false;
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Remove ONLY plugin-owned dictated text. Live path: clear the owned
|
|
239
|
+
// range, restoring prefix + suffix. Fallback path: nothing was inserted,
|
|
240
|
+
// so there is nothing to remove. Never touches user-typed text.
|
|
241
|
+
function cancel() {
|
|
242
|
+
if (!active) return { removedChars: 0, fallback };
|
|
243
|
+
let removedChars = 0;
|
|
244
|
+
if (!fallback && !detached && target) {
|
|
245
|
+
try {
|
|
246
|
+
const now = currentFocused();
|
|
247
|
+
if (now === target) {
|
|
248
|
+
const alive = typeof target.isAlive !== "function" || target.isAlive() !== false;
|
|
249
|
+
if (alive) {
|
|
250
|
+
const current = target.getText() ?? "";
|
|
251
|
+
if (current === `${prefix}${lastWritten}${suffix}` && lastWritten) {
|
|
252
|
+
target.setRange(prefix.length, prefix.length + lastWritten.length, "");
|
|
253
|
+
removedChars = lastWritten.length;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} catch {}
|
|
258
|
+
}
|
|
259
|
+
const wasFallback = fallback;
|
|
260
|
+
active = false;
|
|
261
|
+
detached = false;
|
|
262
|
+
detachReason = null;
|
|
263
|
+
target = null;
|
|
264
|
+
lastWritten = "";
|
|
265
|
+
dictatedText = "";
|
|
266
|
+
prefix = "";
|
|
267
|
+
suffix = "";
|
|
268
|
+
return { removedChars, fallback: wasFallback };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function reset() {
|
|
272
|
+
active = false;
|
|
273
|
+
fallback = true;
|
|
274
|
+
detached = false;
|
|
275
|
+
detachReason = null;
|
|
276
|
+
target = null;
|
|
277
|
+
prefix = "";
|
|
278
|
+
suffix = "";
|
|
279
|
+
lastWritten = "";
|
|
280
|
+
dictatedText = "";
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function getState() {
|
|
284
|
+
return {
|
|
285
|
+
active,
|
|
286
|
+
fallback,
|
|
287
|
+
detached,
|
|
288
|
+
reason: detachReason,
|
|
289
|
+
dictatedText,
|
|
290
|
+
lastWritten,
|
|
291
|
+
prefixChars: prefix.length,
|
|
292
|
+
suffixChars: suffix.length,
|
|
293
|
+
hasTarget: target !== null,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
begin,
|
|
299
|
+
applyPartial,
|
|
300
|
+
finalize,
|
|
301
|
+
cancel,
|
|
302
|
+
reset,
|
|
303
|
+
detach,
|
|
304
|
+
getState,
|
|
305
|
+
getTarget: () => target,
|
|
306
|
+
getTranscript: () => dictatedText,
|
|
307
|
+
};
|
|
308
|
+
}
|