Fix repetitive conversation: no model tools, new persona, repeat guard, one-word answers
Browse files- README.md +17 -3
- chunk-qht2aa6q.js +0 -0
- index.html +1 -1
- workers/model-worker.js +31 -53
- workers/repeat-guard.js +86 -0
- workers/vad-segmenter.js +2 -2
- worklets/audio-playback.js +13 -0
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 |
-
- **
|
|
|
|
| 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
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 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 |
-
*
|
| 6 |
*
|
| 7 |
* main -> worker:
|
| 8 |
-
* { kind: "load", model,
|
| 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: "
|
| 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,
|
| 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()
|
| 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 |
-
/**
|
| 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 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 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 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 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:
|
| 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:
|
| 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.
|