@jskit-ai/assistant-voice 0.0.0-stage → 0.1.2
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.
- package/README.md +169 -2
- package/examples/conversation/App.vue +94 -0
- package/examples/conversation/Conversation.vue +28 -0
- package/examples/conversation/README.md +181 -0
- package/examples/conversation/config.js +15 -0
- package/examples/conversation/index.html +1 -0
- package/examples/conversation/integrations.example.json +16 -0
- package/examples/conversation/main.js +58 -0
- package/examples/conversation/package.json +32 -0
- package/examples/conversation/server.js +105 -0
- package/examples/conversation/src/error.js +1 -0
- package/examples/conversation/src/placement.js +1 -0
- package/examples/conversation/src/placementTopology.js +1 -0
- package/examples/conversation/vite.config.js +9 -0
- package/package.json +67 -4
- package/src/client/ConversationDialog.vue +67 -0
- package/src/client/VoiceAvatar.vue +18 -0
- package/src/client/VoiceConversation.vue +117 -0
- package/src/client/VoiceConversationHost.vue +78 -0
- package/src/client/audioCapture.js +198 -0
- package/src/client/conversationNarration.js +108 -0
- package/src/client/conversationVoiceState.js +18 -0
- package/src/client/index.js +9 -0
- package/src/client/voiceAvatar.js +234 -0
- package/src/client/voiceController.js +124 -0
- package/src/client/voiceConversation.js +557 -0
- package/src/client/voiceLauncher.js +63 -0
- package/src/client/voicePlayback.js +58 -0
- package/src/client/voiceTransport.js +890 -0
- package/src/server/index.js +5 -0
- package/src/server/voiceAccessToken.js +79 -0
- package/src/server/voiceDaemon.js +599 -0
- package/src/server/voiceProxy.js +199 -0
- package/src/server/voiceProxyConfig.js +70 -0
- package/src/server/voiceSocketClose.js +31 -0
- package/src/shared/protocol.js +439 -0
- package/src/shared/visemes.js +130 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
const VOICE_PROTOCOL_VERSION = 1;
|
|
2
|
+
const VOICE_DAEMON_WEBSOCKET_PATH = "/v1/voice";
|
|
3
|
+
const VOICE_INPUT_SAMPLE_RATE = 16_000;
|
|
4
|
+
const VOICE_OUTPUT_SAMPLE_RATE = 22_050;
|
|
5
|
+
const VOICE_MAX_CONTROL_BYTES = 16 * 1024;
|
|
6
|
+
const VOICE_MAX_AUDIO_FRAME_BYTES = 64 * 1024;
|
|
7
|
+
const VOICE_MAX_UTTERANCE_SECONDS = 60;
|
|
8
|
+
const VOICE_MAX_SPEECH_TEXT_CHARACTERS = 4_000;
|
|
9
|
+
const VOICE_MAX_SPEECH_CHUNK_CHARACTERS = 280;
|
|
10
|
+
const VOICE_SPEECH_EM_DASH_PAUSE_MS = 140;
|
|
11
|
+
const VOICE_SPEECH_SENTENCE_PAUSE_MS = 220;
|
|
12
|
+
const VOICE_SPEECH_LINE_PAUSE_MS = 350;
|
|
13
|
+
const VOICE_SPEECH_PARAGRAPH_PAUSE_MS = 650;
|
|
14
|
+
const VOICE_TURN_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/u;
|
|
15
|
+
const VOICE_CLIENT_CONTROL_TYPES = new Set([
|
|
16
|
+
"cancel",
|
|
17
|
+
"ping",
|
|
18
|
+
"listen.start",
|
|
19
|
+
"listen.stop",
|
|
20
|
+
"listen.commit",
|
|
21
|
+
"listen.discard",
|
|
22
|
+
"speak.start",
|
|
23
|
+
"speak.append",
|
|
24
|
+
"speak.end",
|
|
25
|
+
"speak.stop-after"
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
function voiceProtocolError(code, message) {
|
|
29
|
+
const error = new Error(message);
|
|
30
|
+
error.code = code;
|
|
31
|
+
return error;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function voiceTurnId(value = "") {
|
|
35
|
+
const turnId = String(value || "").trim();
|
|
36
|
+
if (!VOICE_TURN_ID_PATTERN.test(turnId)) {
|
|
37
|
+
throw voiceProtocolError(
|
|
38
|
+
"voice_turn_id_invalid",
|
|
39
|
+
"Voice control messages require a valid turn ID."
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return turnId;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseVoiceClientControl(raw = "") {
|
|
46
|
+
const source = Buffer.isBuffer(raw) || raw instanceof Uint8Array
|
|
47
|
+
? Buffer.from(raw)
|
|
48
|
+
: Buffer.from(String(raw || ""), "utf8");
|
|
49
|
+
if (source.byteLength < 2 || source.byteLength > VOICE_MAX_CONTROL_BYTES) {
|
|
50
|
+
throw voiceProtocolError(
|
|
51
|
+
"voice_control_size_invalid",
|
|
52
|
+
"Voice control message size is invalid."
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
let input;
|
|
56
|
+
try {
|
|
57
|
+
input = JSON.parse(source.toString("utf8"));
|
|
58
|
+
} catch {
|
|
59
|
+
throw voiceProtocolError(
|
|
60
|
+
"voice_control_json_invalid",
|
|
61
|
+
"Voice control messages must be valid JSON."
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
65
|
+
throw voiceProtocolError(
|
|
66
|
+
"voice_control_object_required",
|
|
67
|
+
"Voice control messages must be JSON objects."
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
const type = String(input.type || "").trim();
|
|
71
|
+
if (!VOICE_CLIENT_CONTROL_TYPES.has(type)) {
|
|
72
|
+
throw voiceProtocolError(
|
|
73
|
+
"voice_control_type_invalid",
|
|
74
|
+
`Unsupported voice control type: ${type || "(missing)"}.`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const turnId = voiceTurnId(input.turnId);
|
|
78
|
+
if (type === "listen.start") {
|
|
79
|
+
const sampleRate = Number(input.sampleRate);
|
|
80
|
+
if (sampleRate !== VOICE_INPUT_SAMPLE_RATE) {
|
|
81
|
+
throw voiceProtocolError(
|
|
82
|
+
"voice_input_sample_rate_invalid",
|
|
83
|
+
`Voice input must be ${VOICE_INPUT_SAMPLE_RATE} Hz mono PCM.`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return Object.freeze({ sampleRate, turnId, type, ...(input.continuous === true ? { continuous: true } : {}) });
|
|
87
|
+
}
|
|
88
|
+
if (type === "speak.stop-after") {
|
|
89
|
+
if (!Number.isSafeInteger(input.segmentIndex) || input.segmentIndex < 0) throw voiceProtocolError("voice_segment_invalid", "Choose the current spoken phrase.");
|
|
90
|
+
return Object.freeze({ type, turnId, segmentIndex: input.segmentIndex });
|
|
91
|
+
}
|
|
92
|
+
if (type === "listen.commit" || type === "listen.discard") {
|
|
93
|
+
if (!Number.isSafeInteger(input.revision) || input.revision < 1) throw voiceProtocolError("voice_revision_invalid", "Choose the current recognized utterance.");
|
|
94
|
+
return Object.freeze({ type, turnId, revision: input.revision });
|
|
95
|
+
}
|
|
96
|
+
if (type === "speak.start" || type === "speak.append") {
|
|
97
|
+
if (input.voiceId !== undefined && (type !== "speak.start" || typeof input.voiceId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/u.test(input.voiceId))) {
|
|
98
|
+
throw voiceProtocolError("voice_selection_invalid", "Choose an available voice before starting speech.");
|
|
99
|
+
}
|
|
100
|
+
const text = String(input.text || "");
|
|
101
|
+
if ((!text.trim() && !/[\r\n]/u.test(text)) || text.length > VOICE_MAX_SPEECH_TEXT_CHARACTERS) {
|
|
102
|
+
throw voiceProtocolError(
|
|
103
|
+
"voice_speech_text_invalid",
|
|
104
|
+
`Speech text must contain 1-${VOICE_MAX_SPEECH_TEXT_CHARACTERS} characters.`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return Object.freeze({ text, turnId, type, ...(input.stream === true ? { stream: true } : {}), ...(input.voiceId ? { voiceId: input.voiceId } : {}) });
|
|
108
|
+
}
|
|
109
|
+
return Object.freeze({ turnId, type });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function assertVoiceAudioFrame(frame = null) {
|
|
113
|
+
const bytes = Buffer.isBuffer(frame)
|
|
114
|
+
? frame
|
|
115
|
+
: frame instanceof Uint8Array
|
|
116
|
+
? Buffer.from(frame.buffer, frame.byteOffset, frame.byteLength)
|
|
117
|
+
: null;
|
|
118
|
+
if (
|
|
119
|
+
!bytes ||
|
|
120
|
+
bytes.byteLength < 2 ||
|
|
121
|
+
bytes.byteLength > VOICE_MAX_AUDIO_FRAME_BYTES ||
|
|
122
|
+
bytes.byteLength % 2 !== 0
|
|
123
|
+
) {
|
|
124
|
+
throw voiceProtocolError(
|
|
125
|
+
"voice_audio_frame_invalid",
|
|
126
|
+
"Voice audio frames must contain bounded 16-bit mono PCM."
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return bytes;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function pcm16LeToFloat32(frame = Buffer.alloc(0)) {
|
|
133
|
+
const bytes = assertVoiceAudioFrame(frame);
|
|
134
|
+
const samples = new Float32Array(bytes.byteLength / 2);
|
|
135
|
+
for (let index = 0; index < samples.length; index += 1) {
|
|
136
|
+
samples[index] = Math.max(-1, bytes.readInt16LE(index * 2) / 32768);
|
|
137
|
+
}
|
|
138
|
+
return samples;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function float32ToPcm16Le(samples = new Float32Array()) {
|
|
142
|
+
const source = samples instanceof Float32Array ? samples : Float32Array.from(samples || []);
|
|
143
|
+
const bytes = Buffer.allocUnsafe(source.length * 2);
|
|
144
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
145
|
+
const sample = Math.max(-1, Math.min(1, Number(source[index]) || 0));
|
|
146
|
+
const value = sample < 0 ? Math.round(sample * 32768) : Math.round(sample * 32767);
|
|
147
|
+
bytes.writeInt16LE(value, index * 2);
|
|
148
|
+
}
|
|
149
|
+
return bytes;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function normalizeRecognizedText(value = "", vocabulary = {}) {
|
|
153
|
+
const text = String(value || "").replace(/\s+/gu, " ").trim();
|
|
154
|
+
if (!text || text !== text.toUpperCase() || !/[A-Z]/u.test(text)) {
|
|
155
|
+
return text;
|
|
156
|
+
}
|
|
157
|
+
const normalized = text.toLowerCase().replace(/(^|[.!?]\s+)([a-z])/gu, (_match, prefix, letter) => (
|
|
158
|
+
`${prefix}${letter.toUpperCase()}`
|
|
159
|
+
));
|
|
160
|
+
let result = normalized
|
|
161
|
+
.replace(/\bc plus plus\b/giu, "C++")
|
|
162
|
+
.replace(/\b(?:j|jay)\s+(?:s|ess)\s+kit\b/giu, "JSKIT")
|
|
163
|
+
.replace(/\bnode\s+(?:(?:j|jay)\s+(?:s|ess)|js)\b/giu, "Node.js")
|
|
164
|
+
.replace(/\bpostgres\s+(?:s\s+)?q\s+l\b/giu, "PostgreSQL")
|
|
165
|
+
.replace(/\bweb socket\b/giu, "WebSocket")
|
|
166
|
+
.replace(/\bsherpa onnx\b/giu, "sherpa-onnx")
|
|
167
|
+
.replace(/\b(?:ai|sql|api|json|http|https|mysql|mariadb|postgresql|erd|id|uuid|env|pty|acl|hmac|onnx|npm|jskit|opencode|openai|javascript|typescript|vue|vuetify|fastify|playwright|vitest|github|git|systemd|cmake|codex|zipformer|websocket)\b/giu, (word) => ({
|
|
168
|
+
acl: "ACL",
|
|
169
|
+
ai: "AI",
|
|
170
|
+
api: "API",
|
|
171
|
+
cmake: "CMake",
|
|
172
|
+
codex: "Codex",
|
|
173
|
+
env: "ENV",
|
|
174
|
+
erd: "ERD",
|
|
175
|
+
fastify: "Fastify",
|
|
176
|
+
git: "Git",
|
|
177
|
+
github: "GitHub",
|
|
178
|
+
http: "HTTP",
|
|
179
|
+
https: "HTTPS",
|
|
180
|
+
hmac: "HMAC",
|
|
181
|
+
id: "ID",
|
|
182
|
+
javascript: "JavaScript",
|
|
183
|
+
jskit: "JSKIT",
|
|
184
|
+
json: "JSON",
|
|
185
|
+
mariadb: "MariaDB",
|
|
186
|
+
mysql: "MySQL",
|
|
187
|
+
npm: "npm",
|
|
188
|
+
onnx: "ONNX",
|
|
189
|
+
opencode: "OpenCode",
|
|
190
|
+
openai: "OpenAI",
|
|
191
|
+
playwright: "Playwright",
|
|
192
|
+
postgresql: "PostgreSQL",
|
|
193
|
+
pty: "PTY",
|
|
194
|
+
sql: "SQL",
|
|
195
|
+
systemd: "systemd",
|
|
196
|
+
typescript: "TypeScript",
|
|
197
|
+
uuid: "UUID",
|
|
198
|
+
vitest: "Vitest",
|
|
199
|
+
vue: "Vue",
|
|
200
|
+
vuetify: "Vuetify",
|
|
201
|
+
websocket: "WebSocket",
|
|
202
|
+
zipformer: "Zipformer"
|
|
203
|
+
})[word.toLowerCase()] || word);
|
|
204
|
+
for (const [phrase, spelling] of Object.entries(vocabulary)) {
|
|
205
|
+
const literal = phrase.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
206
|
+
result = result.replace(new RegExp(`\\b${literal}\\b`, "giu"), () => spelling);
|
|
207
|
+
}
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function speechTextFromAssistant(value = "", maximumCharacters = VOICE_MAX_SPEECH_TEXT_CHARACTERS) {
|
|
212
|
+
let omittedCode = false;
|
|
213
|
+
let omittedTable = false;
|
|
214
|
+
let text = String(value || "").slice(0, maximumCharacters * 2);
|
|
215
|
+
text = text.replace(/```([a-z0-9_-]*)[^\n]*\n[\s\S]*?```/giu, (_match, language) => {
|
|
216
|
+
omittedCode = true;
|
|
217
|
+
return String(language || "").toLowerCase() === "sql"
|
|
218
|
+
? " I put the SQL on screen. "
|
|
219
|
+
: " I put the code on screen. ";
|
|
220
|
+
});
|
|
221
|
+
text = text.replace(/(?:^|\n)\s*\|[^\n]+\|\s*(?=\n|$)/gu, () => {
|
|
222
|
+
omittedTable = true;
|
|
223
|
+
return "\n";
|
|
224
|
+
});
|
|
225
|
+
text = text
|
|
226
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/gu, " an image ")
|
|
227
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/gu, "$1")
|
|
228
|
+
.replace(/https?:\/\/\S+/giu, " a link ")
|
|
229
|
+
.replace(/`([^`\n]+)`/gu, "$1")
|
|
230
|
+
.replace(/^[\t ]{0,3}#{1,6}[\t ]+/gmu, "");
|
|
231
|
+
const lines = text.split(/\r?\n/u);
|
|
232
|
+
text = lines.map((line, index) => {
|
|
233
|
+
const marker = line.match(/^[\t ]*(?:[-*+]|\d+[.)])[\t ]+/u);
|
|
234
|
+
// A new list item is a paragraph-strength speech boundary. Keep it as
|
|
235
|
+
// whitespace through repeated cleanup; the daemon turns it into silence.
|
|
236
|
+
const boundary = index ? (marker ? "\n\n" : "\n") : "";
|
|
237
|
+
const words = line.slice(marker?.[0].length || 0)
|
|
238
|
+
.replace(/_+/gu, " ").replace(/[*~>]/gu, "")
|
|
239
|
+
.replace(/[^\S\r\n]+/gu, " ").trim();
|
|
240
|
+
return boundary + words;
|
|
241
|
+
}).join("").replace(/\n{3,}/gu, "\n\n");
|
|
242
|
+
if (omittedTable && !/details (?:are|remain) in (?:the )?chat/iu.test(text)) {
|
|
243
|
+
text = `${text}${text ? " " : ""}The details are in the chat.`;
|
|
244
|
+
}
|
|
245
|
+
if (omittedCode && !text) {
|
|
246
|
+
text = "I put the code on screen.";
|
|
247
|
+
}
|
|
248
|
+
return text.slice(0, maximumCharacters);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function speechPhrases(value = "") {
|
|
252
|
+
const text = String(value || "");
|
|
253
|
+
const phrases = [];
|
|
254
|
+
let start = 0;
|
|
255
|
+
const appendThrough = (end) => {
|
|
256
|
+
const phrase = text.slice(start, end).replace(/^[\t ]+|[\t ]+$/gu, "");
|
|
257
|
+
if (phrase) {
|
|
258
|
+
phrases.push(phrase);
|
|
259
|
+
}
|
|
260
|
+
start = end;
|
|
261
|
+
};
|
|
262
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
263
|
+
const character = text[index];
|
|
264
|
+
if (character === "\n") {
|
|
265
|
+
let end = index + 1;
|
|
266
|
+
while (/\s/u.test(text[end] || "")) end += 1;
|
|
267
|
+
appendThrough(end);
|
|
268
|
+
index = end - 1;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (["—", "–"].includes(character)) {
|
|
272
|
+
let end = index + 1;
|
|
273
|
+
while (/\s/u.test(text[end] || "")) end += 1;
|
|
274
|
+
if (!text.slice(index + 1, end).includes("\n")) end = index + 1;
|
|
275
|
+
appendThrough(end);
|
|
276
|
+
index = end - 1;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (![".", "!", "?"].includes(character)) {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
let end = index + 1;
|
|
283
|
+
while ([".", "!", "?"].includes(text[end])) {
|
|
284
|
+
end += 1;
|
|
285
|
+
}
|
|
286
|
+
while (["\"", "'", "”", "’", "»", ")", "]"].includes(text[end])) {
|
|
287
|
+
end += 1;
|
|
288
|
+
}
|
|
289
|
+
if (end === text.length || /\s/u.test(text[end])) {
|
|
290
|
+
let whitespaceEnd = end;
|
|
291
|
+
while (/\s/u.test(text[whitespaceEnd] || "")) whitespaceEnd += 1;
|
|
292
|
+
if (text.slice(end, whitespaceEnd).includes("\n")) end = whitespaceEnd;
|
|
293
|
+
appendThrough(end);
|
|
294
|
+
index = end - 1;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
appendThrough(text.length);
|
|
298
|
+
return phrases;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function speechPauseDurationMs(value = "") {
|
|
302
|
+
const raw = String(value || "").replace(/[\t ]+$/u, "");
|
|
303
|
+
if (/\n\s*\n$/u.test(raw)) return VOICE_SPEECH_PARAGRAPH_PAUSE_MS;
|
|
304
|
+
if (raw.endsWith("\n")) return VOICE_SPEECH_LINE_PAUSE_MS;
|
|
305
|
+
const text = raw.trim();
|
|
306
|
+
if (/[.!?]+["'”’»)\]]*$/u.test(text)) {
|
|
307
|
+
return VOICE_SPEECH_SENTENCE_PAUSE_MS;
|
|
308
|
+
}
|
|
309
|
+
if (/[—–]+$/u.test(text)) {
|
|
310
|
+
return VOICE_SPEECH_EM_DASH_PAUSE_MS;
|
|
311
|
+
}
|
|
312
|
+
return 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function splitSpeechText(value = "", maximumCharacters = VOICE_MAX_SPEECH_CHUNK_CHARACTERS) {
|
|
316
|
+
const text = speechTextFromAssistant(value);
|
|
317
|
+
const maximum = Number.isInteger(maximumCharacters) && maximumCharacters >= 80
|
|
318
|
+
? maximumCharacters
|
|
319
|
+
: VOICE_MAX_SPEECH_CHUNK_CHARACTERS;
|
|
320
|
+
if (!text) {
|
|
321
|
+
return [];
|
|
322
|
+
}
|
|
323
|
+
const chunks = [];
|
|
324
|
+
for (const phraseValue of speechPhrases(text)) {
|
|
325
|
+
let phrase = phraseValue;
|
|
326
|
+
while (phrase.length > maximum) {
|
|
327
|
+
const boundary = Math.max(
|
|
328
|
+
phrase.lastIndexOf(" ", maximum),
|
|
329
|
+
phrase.lastIndexOf(",", maximum),
|
|
330
|
+
phrase.lastIndexOf(";", maximum)
|
|
331
|
+
);
|
|
332
|
+
const splitAt = boundary >= Math.floor(maximum * 0.55) ? boundary : maximum;
|
|
333
|
+
chunks.push(phrase.slice(0, splitAt).trim());
|
|
334
|
+
phrase = phrase.slice(splitAt).replace(/^[\t ]+/u, "");
|
|
335
|
+
}
|
|
336
|
+
if (phrase) {
|
|
337
|
+
chunks.push(phrase);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return chunks;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Consume only a speakable prefix. Incomplete Markdown stays buffered so code,
|
|
344
|
+
// link targets and revised last words never leak into speech a token at a time.
|
|
345
|
+
function takeStreamingSpeech(text = "", final = false, maximumCharacters = 140) {
|
|
346
|
+
const source = String(text);
|
|
347
|
+
// Wait for the whole table before summarizing it. Splitting inside a long
|
|
348
|
+
// row would read cells aloud and repeat the summary for every later row.
|
|
349
|
+
if (/^\s*\|/u.test(source)) {
|
|
350
|
+
const lines = source.split("\n");
|
|
351
|
+
let consumed = 0;
|
|
352
|
+
for (const [index, line] of lines.entries()) {
|
|
353
|
+
if (line.trim() && !/^\s*\|/u.test(line)) break;
|
|
354
|
+
if (index === lines.length - 1 && !final) return null;
|
|
355
|
+
consumed += line.length + (index < lines.length - 1 ? 1 : 0);
|
|
356
|
+
}
|
|
357
|
+
if (consumed) {
|
|
358
|
+
const boundary = source.slice(0, consumed).match(/(?:\r?\n[\t ]*)+$/u)?.[0] || "";
|
|
359
|
+
return { text: speechTextFromAssistant(`The details are in the chat.${boundary}`), consumed };
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
let fence = false;
|
|
363
|
+
let inline = false;
|
|
364
|
+
let link = 0;
|
|
365
|
+
let target = false;
|
|
366
|
+
let end = 0;
|
|
367
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
368
|
+
const character = source[index];
|
|
369
|
+
if (source.slice(index, index + 3) === "```") { fence = !fence; index += 2; continue; }
|
|
370
|
+
if (fence) continue;
|
|
371
|
+
if (character === "`") { inline = !inline; continue; }
|
|
372
|
+
if (inline) continue;
|
|
373
|
+
if (character === "[") link += 1;
|
|
374
|
+
if (character === "]" && link) {
|
|
375
|
+
link -= 1;
|
|
376
|
+
if (source[index + 1] === "(") target = true;
|
|
377
|
+
else if (index === source.length - 1 && !final) break;
|
|
378
|
+
}
|
|
379
|
+
if (target && character === ")") target = false;
|
|
380
|
+
if (link || target) continue;
|
|
381
|
+
if (!/\s/u.test(character)) continue;
|
|
382
|
+
let whitespaceEnd = index + 1;
|
|
383
|
+
while (/\s/u.test(source[whitespaceEnd] || "")) whitespaceEnd += 1;
|
|
384
|
+
// Wait for the next word/marker so a streaming newline cannot be consumed
|
|
385
|
+
// before we know whether it introduces a paragraph or a list item.
|
|
386
|
+
const hasLineBreak = source.slice(index, whitespaceEnd).includes("\n");
|
|
387
|
+
if (!final && hasLineBreak && (whitespaceEnd === source.length ||
|
|
388
|
+
/^(?:[-*+]|\d+[.)]?)$/u.test(source.slice(whitespaceEnd)))) break;
|
|
389
|
+
const prefix = source.slice(0, index).trimEnd();
|
|
390
|
+
if (/[.!?]["'”’»)\]]*$/u.test(prefix) ||
|
|
391
|
+
(prefix.length >= Math.min(40, maximumCharacters / 2) && /[,;:—–]$/u.test(prefix)) ||
|
|
392
|
+
prefix.length >= maximumCharacters || hasLineBreak) {
|
|
393
|
+
end = whitespaceEnd;
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
index = whitespaceEnd - 1;
|
|
397
|
+
}
|
|
398
|
+
if (!end && final) end = source.length;
|
|
399
|
+
if (!end) return null;
|
|
400
|
+
let raw = source.slice(0, end);
|
|
401
|
+
if (/\n[\t ]*$/u.test(raw) && /^(?:[-*+]|\d+[.)])[\t ]+/u.test(source.slice(end))) {
|
|
402
|
+
raw = `${raw.trimEnd()}\n\n`;
|
|
403
|
+
}
|
|
404
|
+
if (final && fence) raw = raw.replace(/```[\s\S]*$/u, " I put the code on screen. ");
|
|
405
|
+
return { text: speechTextFromAssistant(raw), consumed: end };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function voiceServerMessage(type = "", fields = {}) {
|
|
409
|
+
return JSON.stringify({
|
|
410
|
+
...fields,
|
|
411
|
+
protocolVersion: VOICE_PROTOCOL_VERSION,
|
|
412
|
+
type
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export {
|
|
417
|
+
VOICE_DAEMON_WEBSOCKET_PATH,
|
|
418
|
+
VOICE_INPUT_SAMPLE_RATE,
|
|
419
|
+
VOICE_MAX_AUDIO_FRAME_BYTES,
|
|
420
|
+
VOICE_MAX_CONTROL_BYTES,
|
|
421
|
+
VOICE_MAX_SPEECH_CHUNK_CHARACTERS,
|
|
422
|
+
VOICE_MAX_SPEECH_TEXT_CHARACTERS,
|
|
423
|
+
VOICE_MAX_UTTERANCE_SECONDS,
|
|
424
|
+
VOICE_OUTPUT_SAMPLE_RATE,
|
|
425
|
+
VOICE_PROTOCOL_VERSION,
|
|
426
|
+
assertVoiceAudioFrame,
|
|
427
|
+
float32ToPcm16Le,
|
|
428
|
+
normalizeRecognizedText,
|
|
429
|
+
parseVoiceClientControl,
|
|
430
|
+
pcm16LeToFloat32,
|
|
431
|
+
speechPauseDurationMs,
|
|
432
|
+
speechPhrases,
|
|
433
|
+
speechTextFromAssistant,
|
|
434
|
+
splitSpeechText,
|
|
435
|
+
takeStreamingSpeech,
|
|
436
|
+
voiceProtocolError,
|
|
437
|
+
voiceServerMessage,
|
|
438
|
+
voiceTurnId
|
|
439
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
const MAX_VISEME_CUES = 180;
|
|
2
|
+
|
|
3
|
+
const CLUSTER_VISEMES = new Map([
|
|
4
|
+
["ough", ["round", 0.66, 1.5]],
|
|
5
|
+
["tion", ["small", 0.52, 1.35]],
|
|
6
|
+
["ch", ["small", 0.52, 0.9]],
|
|
7
|
+
["ee", ["wide", 0.62, 1.15]],
|
|
8
|
+
["ea", ["wide", 0.62, 1.15]],
|
|
9
|
+
["er", ["round", 0.58, 1.05]],
|
|
10
|
+
["ir", ["round", 0.58, 1.05]],
|
|
11
|
+
["ng", ["small", 0.48, 0.85]],
|
|
12
|
+
["oo", ["round", 0.7, 1.2]],
|
|
13
|
+
["ou", ["round", 0.72, 1.2]],
|
|
14
|
+
["ow", ["round", 0.7, 1.15]],
|
|
15
|
+
["oy", ["round", 0.66, 1.15]],
|
|
16
|
+
["ph", ["wide", 0.48, 0.9]],
|
|
17
|
+
["qu", ["round", 0.62, 1.05]],
|
|
18
|
+
["sh", ["small", 0.52, 0.9]],
|
|
19
|
+
["th", ["small", 0.5, 0.9]],
|
|
20
|
+
["ur", ["round", 0.58, 1.05]],
|
|
21
|
+
["wh", ["round", 0.58, 0.95]],
|
|
22
|
+
["zh", ["small", 0.52, 0.9]]
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
function characterViseme(character = "") {
|
|
26
|
+
if (/[pbm]/u.test(character)) return ["closed", 0.12, 0.72];
|
|
27
|
+
if (/[fv]/u.test(character)) return ["wide", 0.44, 0.82];
|
|
28
|
+
if (/[ouqw]/u.test(character)) return ["round", 0.68, 1.08];
|
|
29
|
+
if (/[eiy]/u.test(character)) return ["wide", 0.6, 1.02];
|
|
30
|
+
if (/[a]/u.test(character)) return ["open", 0.86, 1.2];
|
|
31
|
+
if (/[ckg]/u.test(character)) return ["open", 0.58, 0.82];
|
|
32
|
+
if (/[h]/u.test(character)) return ["open", 0.48, 0.7];
|
|
33
|
+
if (/[r]/u.test(character)) return ["round", 0.52, 0.84];
|
|
34
|
+
return ["small", 0.46, 0.72];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function appendUnit(units, pose, level, weight) {
|
|
38
|
+
const previous = units.at(-1);
|
|
39
|
+
if (previous?.pose === pose && previous.weight + weight <= 2.4) {
|
|
40
|
+
previous.level = Math.max(previous.level, level);
|
|
41
|
+
previous.weight += weight;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
units.push({ level, pose, weight });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function visemeUnitsFromText(value = "") {
|
|
48
|
+
const text = String(value || "").toLowerCase();
|
|
49
|
+
const units = [];
|
|
50
|
+
for (let index = 0; index < text.length;) {
|
|
51
|
+
const character = text[index];
|
|
52
|
+
if (/\s/u.test(character)) {
|
|
53
|
+
appendUnit(units, "closed", 0, 0.48);
|
|
54
|
+
index += 1;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (/[.,!?;:()[\]{}—–-]/u.test(character)) {
|
|
58
|
+
appendUnit(units, "closed", 0, /[.!?]/u.test(character) ? 1.15 : 0.72);
|
|
59
|
+
index += 1;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (!/[a-z0-9]/u.test(character)) {
|
|
63
|
+
index += 1;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
let matched = false;
|
|
67
|
+
for (const size of [4, 3, 2]) {
|
|
68
|
+
const cluster = text.slice(index, index + size);
|
|
69
|
+
const mapping = CLUSTER_VISEMES.get(cluster);
|
|
70
|
+
if (!mapping) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
appendUnit(units, ...mapping);
|
|
74
|
+
index += size;
|
|
75
|
+
matched = true;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
if (matched) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (/\d/u.test(character)) {
|
|
82
|
+
appendUnit(units, "small", 0.5, 0.76);
|
|
83
|
+
} else {
|
|
84
|
+
appendUnit(units, ...characterViseme(character));
|
|
85
|
+
}
|
|
86
|
+
index += 1;
|
|
87
|
+
}
|
|
88
|
+
if (units.length <= MAX_VISEME_CUES) {
|
|
89
|
+
return units;
|
|
90
|
+
}
|
|
91
|
+
const stride = Math.ceil(units.length / MAX_VISEME_CUES);
|
|
92
|
+
return units.filter((_unit, index) => index % stride === 0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function visemeCuesFromText(value = "", durationMs = 0) {
|
|
96
|
+
const duration = Math.max(80, Number(durationMs) || 0);
|
|
97
|
+
const units = visemeUnitsFromText(value);
|
|
98
|
+
if (!units.length) {
|
|
99
|
+
return Object.freeze([
|
|
100
|
+
Object.freeze({ atMs: 0, level: 0, pose: "closed" })
|
|
101
|
+
]);
|
|
102
|
+
}
|
|
103
|
+
const leadMs = Math.min(70, duration * 0.055);
|
|
104
|
+
const tailMs = Math.min(90, duration * 0.07);
|
|
105
|
+
const availableMs = Math.max(1, duration - leadMs - tailMs);
|
|
106
|
+
const totalWeight = units.reduce((total, unit) => total + unit.weight, 0);
|
|
107
|
+
const cues = [{ atMs: 0, level: 0, pose: "closed" }];
|
|
108
|
+
let cursor = leadMs;
|
|
109
|
+
for (const unit of units) {
|
|
110
|
+
cues.push({
|
|
111
|
+
atMs: Math.round(cursor),
|
|
112
|
+
level: Number(unit.level.toFixed(2)),
|
|
113
|
+
pose: unit.pose
|
|
114
|
+
});
|
|
115
|
+
cursor += availableMs * (unit.weight / totalWeight);
|
|
116
|
+
}
|
|
117
|
+
cues.push({
|
|
118
|
+
atMs: Math.max(0, Math.round(duration - tailMs)),
|
|
119
|
+
level: 0,
|
|
120
|
+
pose: "closed"
|
|
121
|
+
});
|
|
122
|
+
return Object.freeze(cues.map((cue) => Object.freeze(cue)));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export {
|
|
126
|
+
MAX_VISEME_CUES,
|
|
127
|
+
characterViseme,
|
|
128
|
+
visemeCuesFromText,
|
|
129
|
+
visemeUnitsFromText
|
|
130
|
+
};
|