@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.
@@ -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
+ }