Mike0021 commited on
Commit
e2d70c0
·
verified ·
1 Parent(s): 11d44e8

Repeat guard cuts at silence gaps; trim opening silence

Browse files
Files changed (5) hide show
  1. README.md +3 -2
  2. chunk-zg54109f.js +0 -0
  3. index.html +1 -1
  4. workers/model-worker.js +46 -23
  5. workers/repeat-guard.js +69 -10
README.md CHANGED
@@ -47,8 +47,9 @@ LFM3-Omni-2B often re-asks its last question ("Red is a great choice. What's you
47
  favorite color?"), including with the original Space's exact prompt; rules, examples
48
  and per-turn notes in the prompt didn't stop it. `public/workers/repeat-guard.js`
49
  catches a repeated question or sentence in the text stream, which runs ahead of
50
- playback, then ends the reply just before it and keeps it out of the history. It can't
51
- cut a reply's first sentence, or audio already heard, and it can't make her lead better.
 
52
 
53
  ## Run
54
 
 
47
  favorite color?"), including with the original Space's exact prompt; rules, examples
48
  and per-turn notes in the prompt didn't stop it. `public/workers/repeat-guard.js`
49
  catches a repeated question or sentence in the text stream, which runs ahead of
50
+ playback, then ends the reply at the silence after the last good sentence and keeps
51
+ the repeat out of subtitles and history. It leaves a reply's first sentence alone
52
+ (silence would be worse), and it can't make her lead better.
53
 
54
  ## Run
55
 
chunk-zg54109f.js ADDED
The diff for this file is too large to render. See raw diff
 
index.html CHANGED
@@ -12,7 +12,7 @@
12
  rel="stylesheet"
13
  />
14
 
15
- <link rel="stylesheet" crossorigin href="./chunk-sarxdv2b.css"><script type="module" crossorigin src="./chunk-qht2aa6q.js"></script></head>
16
  <body>
17
  <main id="app">
18
  <!-- The avatar is the hero: a full-viewport 3D stage. -->
 
12
  rel="stylesheet"
13
  />
14
 
15
+ <link rel="stylesheet" crossorigin href="./chunk-sarxdv2b.css"><script type="module" crossorigin src="./chunk-zg54109f.js"></script></head>
16
  <body>
17
  <main id="app">
18
  <!-- The avatar is the hero: a full-viewport 3D stage. -->
workers/model-worker.js CHANGED
@@ -15,7 +15,7 @@
15
  * { kind: "progress", loaded?, total?, fraction?, status? }
16
  * { kind: "ready", device, sampleRate } { kind: "failed", name, message }
17
  * { kind: "text", id, text } { kind: "audio", id, samples }
18
- * { kind: "truncate", id, samples, kept } the reply ends at this audio sample
19
  * { kind: "finished", id, reason, usage, generated, error? }
20
  */
21
 
@@ -24,7 +24,7 @@ import {
24
  ContextOverflowError,
25
  DeviceLostError,
26
  } from "../vendor/Lfm3OmniForConditionalGeneration.js";
27
- import { RepeatGuard } from "./repeat-guard.js";
28
 
29
  const LOAD_ATTEMPTS = 3; // Chromium occasionally returns no adapter on a cold GPU process
30
  const LOAD_RETRY_MS = 1500;
@@ -33,6 +33,7 @@ const LOAD_RETRY_MS = 1500;
33
  const SEARCH = new URL(location.href).searchParams;
34
  const HISTORY_BUDGET_TOKENS = Number(SEARCH.get("historyBudget")) || 3000;
35
 
 
36
  /** @type {any} */ let model = null;
37
  /** @type {any} */ let session = null;
38
  let system = "";
@@ -118,50 +119,72 @@ async function respond({ id, audio }) {
118
  history.push({ role: "user", content: [{ type: "audio", audio }] });
119
 
120
  guard.beginReply();
121
- /** @type {string | null} */ let keptOnRepeat = null;
 
 
122
  for (;;) {
123
  const stream = session.generate(messages(), { generation_mode: "interleaved", signal });
124
  let emitted = false;
125
  const started = performance.now();
126
  try {
127
  for await (const ev of stream) {
128
- if (ev.type === "text") {
129
  emitted = true;
130
- post({ kind: "text", id, text: ev.text });
131
- const repeat = guard.push(ev.text, generated);
132
- if (repeat) {
133
- keptOnRepeat = repeat.kept;
134
- post({ kind: "truncate", id, samples: repeat.cutAt, kept: repeat.kept });
135
- controller.abort();
136
- break;
137
- }
138
  } else if (ev.type === "audio") {
139
  emitted = true;
140
- generated += ev.audio.length;
141
- const samples = ev.audio.slice();
 
 
 
 
 
 
 
142
  if (simulateRtf) {
143
  const due = started + (generated / model.audio_sampling_rate / simulateRtf) * 1000;
144
  await new Promise((r) => setTimeout(r, Math.max(0, due - performance.now())));
145
  }
146
  post({ kind: "audio", id, samples }, [samples.buffer]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  }
148
  }
149
  } catch (err) {
150
- if (!emitted && err instanceof ContextOverflowError && dropOldestExchange()) continue;
151
- throw err;
 
 
152
  }
153
- const result = await stream.result;
154
- usage = result.usage;
155
- if (keptOnRepeat !== null) {
 
156
  // History holds what she said, not the repeat, or it would teach her to loop.
 
157
  reason = "stop";
158
- history.push({ role: "assistant", content: keptOnRepeat });
159
- guard.endReply();
160
  } else {
161
- reason = result.finish_reason;
 
162
  history.push(result.message);
163
- if (reason === "stop") guard.endReply();
164
  }
 
165
  break;
166
  }
167
  } catch (err) {
 
15
  * { kind: "progress", loaded?, total?, fraction?, status? }
16
  * { kind: "ready", device, sampleRate } { kind: "failed", name, message }
17
  * { kind: "text", id, text } { kind: "audio", id, samples }
18
+ * { kind: "truncate", id, samples } the reply ends at this audio sample
19
  * { kind: "finished", id, reason, usage, generated, error? }
20
  */
21
 
 
24
  ContextOverflowError,
25
  DeviceLostError,
26
  } from "../vendor/Lfm3OmniForConditionalGeneration.js";
27
+ import { RepeatGuard, findCut, firstSound } from "./repeat-guard.js";
28
 
29
  const LOAD_ATTEMPTS = 3; // Chromium occasionally returns no adapter on a cold GPU process
30
  const LOAD_RETRY_MS = 1500;
 
33
  const SEARCH = new URL(location.href).searchParams;
34
  const HISTORY_BUDGET_TOKENS = Number(SEARCH.get("historyBudget")) || 3000;
35
 
36
+ const PRE_SPEECH_SAMPLES = 1200; // keep 50 ms before her first sound
37
  /** @type {any} */ let model = null;
38
  /** @type {any} */ let session = null;
39
  let system = "";
 
119
  history.push({ role: "user", content: [{ type: "audio", audio }] });
120
 
121
  guard.beginReply();
122
+ let repeatFound = false;
123
+ let truncated = false;
124
+ /** @type {Float32Array[]} */ const heard = [];
125
  for (;;) {
126
  const stream = session.generate(messages(), { generation_mode: "interleaved", signal });
127
  let emitted = false;
128
  const started = performance.now();
129
  try {
130
  for await (const ev of stream) {
131
+ if (ev.type === "text" && !repeatFound) {
132
  emitted = true;
133
+ const { released, repeat } = guard.push(ev.text);
134
+ if (released) post({ kind: "text", id, text: released });
135
+ repeatFound = repeat;
 
 
 
 
 
136
  } else if (ev.type === "audio") {
137
  emitted = true;
138
+ let samples = ev.audio.slice();
139
+ if (generated === 0) {
140
+ // Her replies open with ~0.8 s of silence. Don't make anyone wait for it.
141
+ const start = firstSound(samples);
142
+ if (start < 0) continue;
143
+ samples = samples.slice(Math.max(0, start - PRE_SPEECH_SAMPLES));
144
+ }
145
+ generated += samples.length;
146
+ if (repeatFound || guard.kept) heard.push(samples.slice());
147
  if (simulateRtf) {
148
  const due = started + (generated / model.audio_sampling_rate / simulateRtf) * 1000;
149
  await new Promise((r) => setTimeout(r, Math.max(0, due - performance.now())));
150
  }
151
  post({ kind: "audio", id, samples }, [samples.buffer]);
152
+ if (repeatFound) {
153
+ const audio = new Float32Array(generated);
154
+ let at = 0;
155
+ for (const h of heard) {
156
+ audio.set(h, at);
157
+ at += h.length;
158
+ }
159
+ const cut = findCut(audio, model.audio_sampling_rate, guard.kept.length);
160
+ if (cut !== null) {
161
+ post({ kind: "truncate", id, samples: cut });
162
+ truncated = true;
163
+ controller.abort();
164
+ break;
165
+ }
166
+ }
167
  }
168
  }
169
  } catch (err) {
170
+ if (!truncated) {
171
+ if (!emitted && err instanceof ContextOverflowError && dropOldestExchange()) continue;
172
+ throw err;
173
+ }
174
  }
175
+ const result = await stream.result.catch(() => null);
176
+ usage = result?.usage ?? usage;
177
+ reason = result?.finish_reason ?? "stop";
178
+ if (repeatFound) {
179
  // History holds what she said, not the repeat, or it would teach her to loop.
180
+ history.push({ role: "assistant", content: guard.kept });
181
  reason = "stop";
 
 
182
  } else {
183
+ const rest = guard.flush();
184
+ if (rest) post({ kind: "text", id, text: rest });
185
  history.push(result.message);
 
186
  }
187
+ guard.endReply();
188
  break;
189
  }
190
  } catch (err) {
workers/repeat-guard.js CHANGED
@@ -3,8 +3,10 @@
3
  * LFM3-Omni-2B tends to re-ask its own last question ("Red is a great choice.
4
  * What's your favorite color?"), whatever the prompt says. Speech generates
5
  * about 3x faster than it plays, so a repeat is usually visible in the text
6
- * before it is heard. This spots it, and says where in the audio its sentence
7
- * began so the reply can end cleanly just before it. Pure logic, for testing.
 
 
8
  */
9
 
10
  const STOP_WORDS = new Set(
@@ -41,32 +43,49 @@ export class RepeatGuard {
41
 
42
  beginReply() {
43
  this._current = "";
44
- this._currentStart = 0;
45
  this._kept = "";
46
  /** @type {string[]} */
47
  this._sentences = [];
48
  }
49
 
50
  /**
51
- * Feed one text delta, with how many audio samples existed when it arrived.
52
- * @param {string} text @param {number} samples
53
- * @returns {{ cutAt: number, kept: string } | null} set when a repeat was found
 
54
  */
55
- push(text, samples) {
 
56
  for (let i = 0; i < text.length; i++) {
57
  const ch = text[i];
58
- if (!this._current.trim()) this._currentStart = samples;
59
  this._current += ch;
60
  const next = text[i + 1];
61
  const closes = ch === "?" || ch === "!" || (ch === "." && (next === undefined || /\s/.test(next)));
62
  if (!closes) continue;
63
  const sentence = this._current.trim();
 
 
64
  this._current = "";
65
- if (this._isRepeat(sentence)) return { cutAt: this._currentStart, kept: this._kept.trim() };
66
  this._kept += ` ${sentence}`;
67
  this._sentences.push(sentence);
68
  }
69
- return null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  }
71
 
72
  /** The reply is over: remember what was actually said. */
@@ -84,3 +103,43 @@ export class RepeatGuard {
84
  return this.said.some((s) => s.endsWith("?") === isQuestion && similar(s, sentence));
85
  }
86
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  * LFM3-Omni-2B tends to re-ask its own last question ("Red is a great choice.
4
  * What's your favorite color?"), whatever the prompt says. Speech generates
5
  * about 3x faster than it plays, so a repeat is usually visible in the text
6
+ * before it is heard. This spots it in the text, and finds the silence that ends
7
+ * the last good sentence in the audio, so the reply can stop cleanly there. Text
8
+ * runs several words ahead of its audio, so the two can't be matched sample for
9
+ * sample. Pure logic, for testing.
10
  */
11
 
12
  const STOP_WORDS = new Set(
 
43
 
44
  beginReply() {
45
  this._current = "";
 
46
  this._kept = "";
47
  /** @type {string[]} */
48
  this._sentences = [];
49
  }
50
 
51
  /**
52
+ * Feed one text delta. Text is released a sentence at a time, once it is known
53
+ * not to be a repeat, so a repeat never reaches the subtitles.
54
+ * @param {string} text
55
+ * @returns {{ released: string, repeat: boolean }}
56
  */
57
+ push(text) {
58
+ let released = "";
59
  for (let i = 0; i < text.length; i++) {
60
  const ch = text[i];
 
61
  this._current += ch;
62
  const next = text[i + 1];
63
  const closes = ch === "?" || ch === "!" || (ch === "." && (next === undefined || /\s/.test(next)));
64
  if (!closes) continue;
65
  const sentence = this._current.trim();
66
+ if (this._isRepeat(sentence)) return { released, repeat: true };
67
+ released += this._current;
68
  this._current = "";
 
69
  this._kept += ` ${sentence}`;
70
  this._sentences.push(sentence);
71
  }
72
+ return { released, repeat: false };
73
+ }
74
+
75
+ /** What's left unreleased when the reply ends normally. */
76
+ flush() {
77
+ const rest = this._current;
78
+ const sentence = rest.trim();
79
+ this._current = "";
80
+ if (sentence) {
81
+ this._kept += ` ${sentence}`;
82
+ this._sentences.push(sentence);
83
+ }
84
+ return rest;
85
+ }
86
+
87
+ get kept() {
88
+ return this._kept.trim();
89
  }
90
 
91
  /** The reply is over: remember what was actually said. */
 
103
  return this.said.some((s) => s.endsWith("?") === isQuestion && similar(s, sentence));
104
  }
105
  }
106
+
107
+ const SPEECH_CHARS_PER_SEC = 15;
108
+ const GAP_RMS = 0.004;
109
+ const GAP_FRAME_SEC = 0.02;
110
+ const MIN_GAP_SEC = 0.3;
111
+
112
+ /**
113
+ * Where to end a reply whose good part is `keptChars` of text. Looks for the
114
+ * first long silence around where that much speech should have finished; commas
115
+ * make shorter pauses earlier on. Null until enough audio exists to decide.
116
+ * @param {Float32Array} audio speech so far, starting at its first sound
117
+ * @param {number} sampleRate @param {number} keptChars
118
+ * @returns {number | null} sample to stop at
119
+ */
120
+ export function findCut(audio, sampleRate, keptChars) {
121
+ const estimate = keptChars / SPEECH_CHARS_PER_SEC;
122
+ const hop = Math.round(GAP_FRAME_SEC * sampleRate);
123
+ const earliest = Math.round(estimate * 0.75 * sampleRate);
124
+ const giveUpAt = Math.round((estimate * 1.6 + 0.5) * sampleRate);
125
+ let quietFrom = -1;
126
+ for (let i = earliest; i + hop <= audio.length; i += hop) {
127
+ let sum = 0;
128
+ for (let j = i; j < i + hop; j++) sum += audio[j] * audio[j];
129
+ const quiet = Math.sqrt(sum / hop) < GAP_RMS;
130
+ if (!quiet) quietFrom = -1;
131
+ else if (quietFrom < 0) quietFrom = i;
132
+ if (quietFrom >= 0 && i + hop - quietFrom >= MIN_GAP_SEC * sampleRate) return quietFrom + Math.round(0.1 * sampleRate);
133
+ if (i >= giveUpAt) return giveUpAt;
134
+ }
135
+ return null;
136
+ }
137
+
138
+ /**
139
+ * Index of a reply's first sound, so she doesn't open with silence.
140
+ * @param {Float32Array} audio @returns {number} -1 if it's all silence so far
141
+ */
142
+ export function firstSound(audio) {
143
+ for (let i = 0; i < audio.length; i++) if (Math.abs(audio[i]) > 0.01) return i;
144
+ return -1;
145
+ }