@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,236 @@
|
|
|
1
|
+
// Streaming-transcript stability logic (STAGE 1, local streaming dictation).
|
|
2
|
+
//
|
|
3
|
+
// Rolling-window STT re-transcribes overlapping audio on every tick, so each
|
|
4
|
+
// hypothesis restates words the previous one already contained. Blindly
|
|
5
|
+
// appending every hypothesis would duplicate words; blindly replacing would
|
|
6
|
+
// flicker committed text. This module splits each hypothesis into:
|
|
7
|
+
//
|
|
8
|
+
// stable - prefix confirmed by successive hypotheses; monotonic, never
|
|
9
|
+
// rewritten once emitted (callers can render/commit it).
|
|
10
|
+
// tentative - unconfirmed tail; replaced wholesale on every update.
|
|
11
|
+
//
|
|
12
|
+
// Alignment scheme (bounded-utterance overlap join, NOT time-anchored):
|
|
13
|
+
//
|
|
14
|
+
// whisper-server `/inference` with `response_format=json` returns
|
|
15
|
+
// `{ "text": "..." }` - text only, no audio timestamps. `verbose_json`
|
|
16
|
+
// returns `{ text, segments: [{ start, end, text, ... }], ... }` where
|
|
17
|
+
// segment `start`/`end` are seconds RELATIVE TO THE SUBMITTED SNAPSHOT (each
|
|
18
|
+
// rolling window restarts at 0; verified against a live local whisper-server,
|
|
19
|
+
// see `.opencode/loopd/goals/17bca58e-.../whisper-verbose-fixture.json`).
|
|
20
|
+
// Consecutive snapshots therefore cannot share an absolute clock, and
|
|
21
|
+
// whisper's word timestamps jitter at window edges, so segments are never
|
|
22
|
+
// used as cross-window commit pointers. Instead each new hypothesis is joined
|
|
23
|
+
// onto the assembled transcript by maximal word suffix-prefix overlap:
|
|
24
|
+
//
|
|
25
|
+
// assembled = stable + tentative (previous)
|
|
26
|
+
// k = longest suffix of assembled that equals a prefix of the hypothesis
|
|
27
|
+
// novel = hypothesis after that overlap; assembled' = assembled + novel
|
|
28
|
+
//
|
|
29
|
+
// The whole previously assembled transcript becomes stable once any overlap
|
|
30
|
+
// re-observes it (every old word was just heard again); only the novel tail
|
|
31
|
+
// stays tentative. When the window rolls forward the hypothesis no longer
|
|
32
|
+
// contains the historical prefix, and the overlap anchors on the shared
|
|
33
|
+
// middle - no duplication, no loss.
|
|
34
|
+
//
|
|
35
|
+
// Revision policy: when the hypothesis shares a longer prefix with the
|
|
36
|
+
// assembled transcript FROM THE START than the suffix overlap (whisper
|
|
37
|
+
// restated the same region with different words), it is a self-correction,
|
|
38
|
+
// not new speech. Corrections inside the tentative region replace the tail;
|
|
39
|
+
// corrections touching committed (stable) words FREEZE stable - stable is
|
|
40
|
+
// never rewritten - and the tail shows the hypothesis minus whatever stable
|
|
41
|
+
// prefix it still shares, so no word is ever emitted twice.
|
|
42
|
+
//
|
|
43
|
+
// Update rule summary:
|
|
44
|
+
// 1. Empty hypothesis: keep stable, clear the tail (pause/silence).
|
|
45
|
+
// 2. c = common word-prefix(assembled, hypothesis), k = overlap(assembled,
|
|
46
|
+
// hypothesis). If c > k: revision path (see above).
|
|
47
|
+
// 3. Else: assembled' = assembled + hypothesis[k:]; stable = assembled
|
|
48
|
+
// (when k > 0, or when assembled was empty nothing is stable yet);
|
|
49
|
+
// tentative = hypothesis[k:]. With no overlap at all (k = 0, c = 0)
|
|
50
|
+
// the hypothesis is genuinely new speech after a gap: appended whole.
|
|
51
|
+
// 4. The first hypothesis only fills tentative (nothing is confirmed by a
|
|
52
|
+
// single observation).
|
|
53
|
+
//
|
|
54
|
+
// Comparison is word level, punctuation/case-insensitive; original words are
|
|
55
|
+
// preserved for display. Pure punctuation tokens must match exactly to count.
|
|
56
|
+
//
|
|
57
|
+
// Memory: O(transcript words). The tracker holds stable words, the tentative
|
|
58
|
+
// tail, and the last window hints - no history list, no per-tick
|
|
59
|
+
// accumulation. Long dictations grow only the transcript itself (the output).
|
|
60
|
+
|
|
61
|
+
export function normalizeStabilityWord(word) {
|
|
62
|
+
return String(word || "")
|
|
63
|
+
.toLowerCase()
|
|
64
|
+
.replace(/[^\p{L}\p{N}]/gu, "");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function splitWords(text) {
|
|
68
|
+
return String(text || "")
|
|
69
|
+
.trim()
|
|
70
|
+
.split(/\s+/)
|
|
71
|
+
.filter(Boolean);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sameStabilityWord(aRaw, bRaw) {
|
|
75
|
+
const a = normalizeStabilityWord(aRaw);
|
|
76
|
+
const b = normalizeStabilityWord(bRaw);
|
|
77
|
+
if (a !== b) return false;
|
|
78
|
+
// Both sides reduced to nothing (e.g. "--" vs "…"): only equal when the
|
|
79
|
+
// raw tokens are identical so punctuation noise cannot confirm words.
|
|
80
|
+
if (!a && aRaw !== bRaw) return false;
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Length (in words) of the longest common prefix of two word arrays,
|
|
86
|
+
* compared with stability normalization (case/punctuation-insensitive).
|
|
87
|
+
*/
|
|
88
|
+
export function commonWordPrefixLength(aWords, bWords) {
|
|
89
|
+
const limit = Math.min(aWords.length, bWords.length);
|
|
90
|
+
let n = 0;
|
|
91
|
+
for (let i = 0; i < limit; i++) {
|
|
92
|
+
if (!sameStabilityWord(aWords[i], bWords[i])) break;
|
|
93
|
+
n += 1;
|
|
94
|
+
}
|
|
95
|
+
return n;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Maximal k such that the last k words of `assembled` equal the first k
|
|
100
|
+
* words of `hypothesis` (stability normalization). Always >= 0.
|
|
101
|
+
*/
|
|
102
|
+
export function overlapJoinLength(assembledWords, hypothesisWords) {
|
|
103
|
+
const limit = Math.min(assembledWords.length, hypothesisWords.length);
|
|
104
|
+
for (let k = limit; k > 0; k--) {
|
|
105
|
+
let ok = true;
|
|
106
|
+
for (let i = 0; i < k; i++) {
|
|
107
|
+
if (!sameStabilityWord(assembledWords[assembledWords.length - k + i], hypothesisWords[i])) {
|
|
108
|
+
ok = false;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (ok) return k;
|
|
113
|
+
}
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Strip a stable word-prefix from hypothesis words. Returns the remaining
|
|
119
|
+
* words, or null when the hypothesis does not start with the stable prefix
|
|
120
|
+
* (whisper revised already-committed words - caller must not advance).
|
|
121
|
+
*/
|
|
122
|
+
export function stripStablePrefix(stableWords, hypothesisWords) {
|
|
123
|
+
if (stableWords.length === 0) return hypothesisWords.slice();
|
|
124
|
+
if (hypothesisWords.length < stableWords.length) return null;
|
|
125
|
+
const n = commonWordPrefixLength(stableWords, hypothesisWords.slice(0, stableWords.length));
|
|
126
|
+
if (n < stableWords.length) return null;
|
|
127
|
+
return hypothesisWords.slice(stableWords.length);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Create a stability tracker. `onUpdate`-style callbacks live in the
|
|
132
|
+
* streaming controller - this stays a pure text function for testability.
|
|
133
|
+
*
|
|
134
|
+
* `update(hypothesisText, windowHints)` accepts optional snapshot metadata
|
|
135
|
+
* ({ absoluteStartMs, absoluteEndMs, seq }) describing which audio window the
|
|
136
|
+
* hypothesis was decoded from. The hints are retained for coverage
|
|
137
|
+
* accounting (see getState().lastWindow) and future alignment work; text
|
|
138
|
+
* assembly itself is overlap-based because snapshot-relative segment times
|
|
139
|
+
* cannot anchor a cross-window commit pointer.
|
|
140
|
+
*/
|
|
141
|
+
export function createStabilityTracker() {
|
|
142
|
+
let stableWords = [];
|
|
143
|
+
let tentativeWords = [];
|
|
144
|
+
let updates = 0;
|
|
145
|
+
let lastWindow = null;
|
|
146
|
+
|
|
147
|
+
function update(hypothesisText, windowHints = null) {
|
|
148
|
+
const hypoWords = splitWords(hypothesisText);
|
|
149
|
+
updates += 1;
|
|
150
|
+
if (windowHints && typeof windowHints === "object") {
|
|
151
|
+
lastWindow = { ...windowHints };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Empty hypothesis: keep stable, clear the tail (speaker paused or the
|
|
155
|
+
// window caught only silence). It confirms nothing.
|
|
156
|
+
if (hypoWords.length === 0) {
|
|
157
|
+
tentativeWords = [];
|
|
158
|
+
return snapshot();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const assembled = stableWords.concat(tentativeWords);
|
|
162
|
+
|
|
163
|
+
// First observation of the session: everything is tentative.
|
|
164
|
+
if (assembled.length === 0) {
|
|
165
|
+
tentativeWords = hypoWords.slice();
|
|
166
|
+
return snapshot();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const k = overlapJoinLength(assembled, hypoWords);
|
|
170
|
+
const c = commonWordPrefixLength(assembled, hypoWords);
|
|
171
|
+
|
|
172
|
+
if (c > k) {
|
|
173
|
+
// Revision: the hypothesis restates the same region with different
|
|
174
|
+
// words instead of extending it with new speech.
|
|
175
|
+
if (c < stableWords.length) {
|
|
176
|
+
// Correction touches committed words: freeze stable (never rewrite)
|
|
177
|
+
// and show the hypothesis minus whatever stable prefix it still
|
|
178
|
+
// shares, so the shared head is never emitted twice.
|
|
179
|
+
const strip = commonWordPrefixLength(stableWords, hypoWords);
|
|
180
|
+
tentativeWords = hypoWords.slice(strip);
|
|
181
|
+
return snapshot();
|
|
182
|
+
}
|
|
183
|
+
// Correction is inside the tentative region: rebase the tail onto the
|
|
184
|
+
// shared prefix. Stable is untouched.
|
|
185
|
+
const rebased = assembled.slice(0, c).concat(hypoWords.slice(c));
|
|
186
|
+
tentativeWords = rebased.slice(stableWords.length);
|
|
187
|
+
return snapshot();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Normal advance (possibly after the window rolled forward and dropped
|
|
191
|
+
// the historical prefix - the overlap anchors on the shared middle).
|
|
192
|
+
const novel = hypoWords.slice(k);
|
|
193
|
+
if (assembled.length > 0 && k > 0) {
|
|
194
|
+
// Every previously assembled word was just re-observed: confirm it.
|
|
195
|
+
stableWords = assembled.slice();
|
|
196
|
+
}
|
|
197
|
+
// k = 0 with no common prefix is genuinely new speech after a gap: the
|
|
198
|
+
// whole hypothesis is novel and stable stays as-is (no confirmation).
|
|
199
|
+
tentativeWords = novel.slice();
|
|
200
|
+
return snapshot();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function snapshot() {
|
|
204
|
+
return {
|
|
205
|
+
stableText: stableWords.join(" "),
|
|
206
|
+
tentativeText: tentativeWords.join(" "),
|
|
207
|
+
updates,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Commit the tentative tail (used on stop): the final window is never
|
|
213
|
+
* re-observed, so its tail can never "stabilize" by agreement. Returns the
|
|
214
|
+
* full transcript and clears the tentative tail.
|
|
215
|
+
*/
|
|
216
|
+
function commitTail() {
|
|
217
|
+
if (tentativeWords.length > 0) {
|
|
218
|
+
stableWords = stableWords.concat(tentativeWords);
|
|
219
|
+
tentativeWords = [];
|
|
220
|
+
}
|
|
221
|
+
return snapshot();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function reset() {
|
|
225
|
+
stableWords = [];
|
|
226
|
+
tentativeWords = [];
|
|
227
|
+
updates = 0;
|
|
228
|
+
lastWindow = null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function getState() {
|
|
232
|
+
return { ...snapshot(), lastWindow };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { update, commitTail, reset, getState };
|
|
236
|
+
}
|