Mike0021 commited on
Commit
2c4fa39
·
1 Parent(s): df1cb82

Fix repetitive conversation: no model tools, new persona, repeat guard, one-word answers

Browse files
README.md CHANGED
@@ -29,12 +29,26 @@ you speak → Silero VAD (WASM) → LFM3-Omni-2B (WebGPU): audio in, interleaved
29
  - **Model** runs in a worker, so the avatar keeps rendering. It hears the audio
30
  directly: there is no separate transcription step, and so no user transcript.
31
  - **Barge-in**: talk over her and generation and playback stop immediately.
32
- - **The model plays the avatar** through `set_mood` and `make_hand_gesture` tools.
 
33
  - **Lip-sync** is derived from the audio by HeadAudio, as in gemma-avatar.
34
 
35
  Differences from gemma-avatar, all forced by the model: there is no voice picker
36
- (LFM3-Omni's interleaved mode has one voice and ignores voice requests) and no
37
- `make_facial_expression` tool (it made the model loop on emoji calls without speaking).
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
  ## Run
40
 
 
29
  - **Model** runs in a worker, so the avatar keeps rendering. It hears the audio
30
  directly: there is no separate transcription step, and so no user transcript.
31
  - **Barge-in**: talk over her and generation and playback stop immediately.
32
+ - **Body language** (moods, thumbs up, shrug, a wave) is read from her words as they
33
+ are heard, in `src/reactions.js`.
34
  - **Lip-sync** is derived from the audio by HeadAudio, as in gemma-avatar.
35
 
36
  Differences from gemma-avatar, all forced by the model: there is no voice picker
37
+ (LFM3-Omni's interleaved mode has one voice and ignores voice requests), and the
38
+ model doesn't call avatar tools. Offering any tool schema derails its conversation:
39
+ with the original Space's tools "let's play a quick game" starts a timer, and with
40
+ avatar tools it gestures on every turn, gives shallow replies and can loop without
41
+ speaking. The runtime, message format, generation mode and greedy decoding otherwise
42
+ match the original Space.
43
+
44
+ ## Repetition
45
+
46
+ LFM3-Omni-2B often re-asks its last question ("Red is a great choice. What's your
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
 
chunk-qht2aa6q.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-v7x5vjjc.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-qht2aa6q.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
@@ -2,13 +2,12 @@
2
  /**
3
  * LFM3-Omni on WebGPU, off the main thread so the avatar keeps its frame rate.
4
  * One conversation lives here: the history, the prefix-cached session, and the
5
- * tool loop. Everything the main thread needs comes back as a stream of events.
6
  *
7
  * main -> worker:
8
- * { kind: "load", model, tools, system }
9
  * { kind: "respond", id, audio } a user turn, 16 kHz Float32
10
  * { kind: "abort", id } barge-in: stop generating this turn
11
- * { kind: "tool-result", callId, content }
12
  * { kind: "system", system } new persona / instructions
13
  * { kind: "reset" } forget the conversation
14
  *
@@ -16,7 +15,7 @@
16
  * { kind: "progress", loaded?, total?, fraction?, status? }
17
  * { kind: "ready", device, sampleRate } { kind: "failed", name, message }
18
  * { kind: "text", id, text } { kind: "audio", id, samples }
19
- * { kind: "tool", id, callId, name, args }
20
  * { kind: "finished", id, reason, usage, generated, error? }
21
  */
22
 
@@ -25,11 +24,8 @@ import {
25
  ContextOverflowError,
26
  DeviceLostError,
27
  } from "../vendor/Lfm3OmniForConditionalGeneration.js";
 
28
 
29
- // A reply must end in speech: left to itself the model can keep calling tools
30
- // and never talk. After this many tool rounds it generates with no tools at all.
31
- const MAX_TOOL_ROUNDS = 2;
32
- const TOOL_DONE_NOTE = " Now say your reply out loud; don't call another tool.";
33
  const LOAD_ATTEMPTS = 3; // Chromium occasionally returns no adapter on a cold GPU process
34
  const LOAD_RETRY_MS = 1500;
35
  // Speech costs ~16 tokens/s each way and re-reading history after a trim takes
@@ -39,14 +35,12 @@ const HISTORY_BUDGET_TOKENS = Number(SEARCH.get("historyBudget")) || 3000;
39
 
40
  /** @type {any} */ let model = null;
41
  /** @type {any} */ let session = null;
42
- /** @type {any[]} */ let tools = [];
43
  let system = "";
44
  /** @type {any[]} */ let history = [];
45
  let idle = Promise.resolve();
 
46
  /** @type {Map<number, AbortController>} */
47
  const running = new Map();
48
- /** @type {Map<string, (content: string) => void>} */
49
- const pendingTools = new Map();
50
  const simulateRtf = Number(SEARCH.get("simulateRtf")) || 0;
51
 
52
  const post = (msg, transfer = []) => self.postMessage(msg, { transfer });
@@ -69,8 +63,7 @@ function compactHistory() {
69
  for (let i = 0; i < Math.floor(exchanges / 2); i++) dropOldestExchange();
70
  }
71
 
72
- async function load({ model: modelId, tools: toolDefs, system: sys }) {
73
- tools = toolDefs;
74
  system = sys;
75
  let lastErr;
76
  for (let attempt = 1; attempt <= LOAD_ATTEMPTS; attempt++) {
@@ -92,7 +85,7 @@ async function load({ model: modelId, tools: toolDefs, system: sys }) {
92
  });
93
  session = model.create_session();
94
  post({ kind: "progress", status: "Getting ready" });
95
- await session.prime(messages(), { tools }).result;
96
  post({ kind: "ready", device: model.deviceInfo(), sampleRate: model.audio_sampling_rate });
97
  return;
98
  } catch (err) {
@@ -107,21 +100,7 @@ async function load({ model: modelId, tools: toolDefs, system: sys }) {
107
  post({ kind: "failed", name: lastErr?.name ?? "Error", message: lastErr?.message ?? String(lastErr) });
108
  }
109
 
110
- /** @param {number} id @param {any} call @param {AbortSignal} signal */
111
- function runTool(id, call, signal) {
112
- return new Promise((resolve) => {
113
- const callId = call.id;
114
- const done = (content) => {
115
- pendingTools.delete(callId);
116
- resolve(content);
117
- };
118
- pendingTools.set(callId, done);
119
- signal.addEventListener("abort", () => done("Stopped."), { once: true });
120
- post({ kind: "tool", id, callId, name: call.function.name, args: call.function.arguments });
121
- });
122
- }
123
-
124
- /** One user turn: generate, run any tools, generate again until she's done. */
125
  async function respond({ id, audio }) {
126
  const controller = new AbortController();
127
  running.set(id, controller);
@@ -138,12 +117,10 @@ async function respond({ id, audio }) {
138
  signal.throwIfAborted();
139
  history.push({ role: "user", content: [{ type: "audio", audio }] });
140
 
141
- for (let round = 0; round < MAX_TOOL_ROUNDS + 1; round++) {
142
- const stream = session.generate(messages(), {
143
- generation_mode: "interleaved",
144
- ...(round < MAX_TOOL_ROUNDS ? { tools } : {}),
145
- signal,
146
- });
147
  let emitted = false;
148
  const started = performance.now();
149
  try {
@@ -151,6 +128,13 @@ async function respond({ id, audio }) {
151
  if (ev.type === "text") {
152
  emitted = true;
153
  post({ kind: "text", id, text: ev.text });
 
 
 
 
 
 
 
154
  } else if (ev.type === "audio") {
155
  emitted = true;
156
  generated += ev.audio.length;
@@ -163,26 +147,22 @@ async function respond({ id, audio }) {
163
  }
164
  }
165
  } catch (err) {
166
- if (!emitted && err instanceof ContextOverflowError && dropOldestExchange()) {
167
- round -= 1;
168
- continue;
169
- }
170
  throw err;
171
  }
172
  const result = await stream.result;
173
  usage = result.usage;
174
- reason = result.finish_reason;
175
- history.push(result.message);
176
- if (reason !== "tool_calls") break;
177
-
178
- for (const call of result.message.tool_calls ?? []) {
179
- const content = (call.errors?.length ? call.errors.join("; ") : await runTool(id, call, signal)) + TOOL_DONE_NOTE;
180
- history.push({ role: "tool", tool_call_id: call.id, name: call.function.name, content });
181
- }
182
- if (signal.aborted) {
183
- reason = "cancelled";
184
- break;
185
  }
 
186
  }
187
  } catch (err) {
188
  if (err?.name === "AbortError" || signal.aborted) {
@@ -211,15 +191,13 @@ self.onmessage = ({ data }) => {
211
  case "abort":
212
  running.get(data.id)?.abort();
213
  break;
214
- case "tool-result":
215
- pendingTools.get(data.callId)?.(data.content);
216
- break;
217
  case "system":
218
  system = data.system;
219
  break;
220
  case "reset":
221
  idle = idle.then(() => {
222
  history = [];
 
223
  });
224
  break;
225
  }
 
2
  /**
3
  * LFM3-Omni on WebGPU, off the main thread so the avatar keeps its frame rate.
4
  * One conversation lives here: the history, the prefix-cached session, and the
5
+ * context budget. Everything the main thread needs comes back as a stream of events.
6
  *
7
  * main -> worker:
8
+ * { kind: "load", model, system }
9
  * { kind: "respond", id, audio } a user turn, 16 kHz Float32
10
  * { kind: "abort", id } barge-in: stop generating this turn
 
11
  * { kind: "system", system } new persona / instructions
12
  * { kind: "reset" } forget the conversation
13
  *
 
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
  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;
31
  // Speech costs ~16 tokens/s each way and re-reading history after a trim takes
 
35
 
36
  /** @type {any} */ let model = null;
37
  /** @type {any} */ let session = null;
 
38
  let system = "";
39
  /** @type {any[]} */ let history = [];
40
  let idle = Promise.resolve();
41
+ let guard = new RepeatGuard();
42
  /** @type {Map<number, AbortController>} */
43
  const running = new Map();
 
 
44
  const simulateRtf = Number(SEARCH.get("simulateRtf")) || 0;
45
 
46
  const post = (msg, transfer = []) => self.postMessage(msg, { transfer });
 
63
  for (let i = 0; i < Math.floor(exchanges / 2); i++) dropOldestExchange();
64
  }
65
 
66
+ async function load({ model: modelId, system: sys }) {
 
67
  system = sys;
68
  let lastErr;
69
  for (let attempt = 1; attempt <= LOAD_ATTEMPTS; attempt++) {
 
85
  });
86
  session = model.create_session();
87
  post({ kind: "progress", status: "Getting ready" });
88
+ await session.prime(messages()).result;
89
  post({ kind: "ready", device: model.deviceInfo(), sampleRate: model.audio_sampling_rate });
90
  return;
91
  } catch (err) {
 
100
  post({ kind: "failed", name: lastErr?.name ?? "Error", message: lastErr?.message ?? String(lastErr) });
101
  }
102
 
103
+ /** One user turn. */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  async function respond({ id, audio }) {
105
  const controller = new AbortController();
106
  running.set(id, controller);
 
117
  signal.throwIfAborted();
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 {
 
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;
 
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) {
168
  if (err?.name === "AbortError" || signal.aborted) {
 
191
  case "abort":
192
  running.get(data.id)?.abort();
193
  break;
 
 
 
194
  case "system":
195
  system = data.system;
196
  break;
197
  case "reset":
198
  idle = idle.then(() => {
199
  history = [];
200
+ guard = new RepeatGuard();
201
  });
202
  break;
203
  }
workers/repeat-guard.js ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+ /**
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(
11
+ "a an and are about do does did for how i in is it me of on or s so that the to what what's whats which who would you your you're yours".split(" "),
12
+ );
13
+ const SIMILAR = 0.6;
14
+ const MIN_REPEATED_STATEMENT_WORDS = 4;
15
+
16
+ /** @param {string} sentence */
17
+ function words(sentence) {
18
+ return sentence.toLowerCase().replace(/[’']/g, "'").match(/[a-z0-9']+/g) ?? [];
19
+ }
20
+
21
+ /** @param {string} sentence */
22
+ function keywords(sentence) {
23
+ return new Set(words(sentence).filter((w) => !STOP_WORDS.has(w)));
24
+ }
25
+
26
+ /** @param {string} a @param {string} b */
27
+ function similar(a, b) {
28
+ const x = keywords(a);
29
+ const y = keywords(b);
30
+ if (x.size === 0 || y.size === 0) return words(a).join(" ") === words(b).join(" ");
31
+ const shared = [...x].filter((w) => y.has(w)).length;
32
+ return shared / new Set([...x, ...y]).size >= SIMILAR;
33
+ }
34
+
35
+ export class RepeatGuard {
36
+ constructor() {
37
+ /** Everything she has already said in this conversation. @type {string[]} */
38
+ this.said = [];
39
+ this.beginReply();
40
+ }
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. */
73
+ endReply() {
74
+ this.said.push(...this._sentences);
75
+ this.beginReply();
76
+ }
77
+
78
+ /** @param {string} sentence */
79
+ _isRepeat(sentence) {
80
+ // Never cut the only thing she has to say: silence is worse than a repeat.
81
+ if (this._sentences.length === 0) return false;
82
+ const isQuestion = sentence.endsWith("?");
83
+ if (!isQuestion && words(sentence).length < MIN_REPEATED_STATEMENT_WORDS) return false;
84
+ return this.said.some((s) => s.endsWith("?") === isQuestion && similar(s, sentence));
85
+ }
86
+ }
workers/vad-segmenter.js CHANGED
@@ -12,14 +12,14 @@ export const FRAME_SAMPLES = 512; // 32 ms at 16 kHz
12
 
13
  export const DEFAULTS = {
14
  startProb: 0.5,
15
- startFrames: 4, // 128 ms
16
  guardedStartProb: 0.75,
17
  guardedStartFrames: 9, // 288 ms
18
  endProb: 0.35,
19
  endFrames: 17, // 544 ms of quiet ends the turn
20
  trailFrames: 6, // keep 192 ms of the quiet tail
21
  prerollFrames: 12, // 384 ms before the trigger, so onsets aren't clipped
22
- minSpeechFrames: 8, // 256 ms of actual speech, or it was a misfire
23
  maxFrames: 938, // 30 s
24
  };
25
 
 
12
 
13
  export const DEFAULTS = {
14
  startProb: 0.5,
15
+ startFrames: 3, // 96 ms
16
  guardedStartProb: 0.75,
17
  guardedStartFrames: 9, // 288 ms
18
  endProb: 0.35,
19
  endFrames: 17, // 544 ms of quiet ends the turn
20
  trailFrames: 6, // keep 192 ms of the quiet tail
21
  prerollFrames: 12, // 384 ms before the trigger, so onsets aren't clipped
22
+ minSpeechFrames: 5, // 160 ms of speech, or it was a misfire: one-word answers count
23
  maxFrames: 938, // 30 s
24
  };
25
 
worklets/audio-playback.js CHANGED
@@ -11,6 +11,7 @@
11
  * { kind: "lead", samples } queue this much before starting a turn
12
  * { kind: "flush" } no more audio is coming: start regardless
13
  * { kind: "clear" } wipe the queue (barge-in)
 
14
  *
15
  * worklet -> main:
16
  * { kind: "progress", played, queued } input samples, every ~50 ms
@@ -31,6 +32,7 @@ class AudioPlaybackProcessor extends AudioWorkletProcessor {
31
  this._queued = 0;
32
  this._played = 0;
33
  this._lead = 0;
 
34
  this._flushed = false;
35
  this._playing = false;
36
  this._fadeIn = 0;
@@ -56,7 +58,12 @@ class AudioPlaybackProcessor extends AudioWorkletProcessor {
56
  this._queue.push(data.samples);
57
  this._queued += data.samples.length;
58
  break;
 
 
 
 
59
  case "clear":
 
60
  this._queue.length = 0;
61
  this._queued = 0;
62
  this._readIdx = 0;
@@ -105,6 +112,12 @@ class AudioPlaybackProcessor extends AudioWorkletProcessor {
105
 
106
  for (let i = 0; i < out.length; i++) {
107
  let sample = 0;
 
 
 
 
 
 
108
  if (this._playing) {
109
  if (this._queue.length === 0) {
110
  // Ran dry: settle from the last value so the cut can't click.
 
11
  * { kind: "lead", samples } queue this much before starting a turn
12
  * { kind: "flush" } no more audio is coming: start regardless
13
  * { kind: "clear" } wipe the queue (barge-in)
14
+ * { kind: "truncate", samples } end this reply once it has played this far
15
  *
16
  * worklet -> main:
17
  * { kind: "progress", played, queued } input samples, every ~50 ms
 
32
  this._queued = 0;
33
  this._played = 0;
34
  this._lead = 0;
35
+ this._limit = Infinity;
36
  this._flushed = false;
37
  this._playing = false;
38
  this._fadeIn = 0;
 
58
  this._queue.push(data.samples);
59
  this._queued += data.samples.length;
60
  break;
61
+ case "truncate":
62
+ this._limit = data.samples;
63
+ this._flushed = true;
64
+ break;
65
  case "clear":
66
+ this._limit = Infinity;
67
  this._queue.length = 0;
68
  this._queued = 0;
69
  this._readIdx = 0;
 
112
 
113
  for (let i = 0; i < out.length; i++) {
114
  let sample = 0;
115
+ if (this._played >= this._limit && this._queue.length > 0) {
116
+ this._queue.length = 0;
117
+ this._queued = 0;
118
+ this._readIdx = 0;
119
+ this._limit = Infinity;
120
+ }
121
  if (this._playing) {
122
  if (this._queue.length === 0) {
123
  // Ran dry: settle from the last value so the cut can't click.