Repeat guard cuts at silence gaps; trim opening silence
Browse files- README.md +3 -2
- chunk-zg54109f.js +0 -0
- index.html +1 -1
- workers/model-worker.js +46 -23
- 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
|
| 51 |
-
|
|
|
|
| 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-
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 131 |
-
|
| 132 |
-
|
| 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 |
-
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (!
|
| 151 |
-
|
|
|
|
|
|
|
| 152 |
}
|
| 153 |
-
const result = await stream.result;
|
| 154 |
-
usage = result.usage;
|
| 155 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
| 7 |
-
*
|
|
|
|
|
|
|
| 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
|
| 52 |
-
*
|
| 53 |
-
* @
|
|
|
|
| 54 |
*/
|
| 55 |
-
push(text
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|