@voicethere/agent 0.7.4 → 0.7.6
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/package.json +1 -1
- package/templates/crash.ts +12 -2
- package/templates/echo-smoke.ts +14 -4
- package/templates/mix-smoke.ts +45 -12
package/package.json
CHANGED
package/templates/crash.ts
CHANGED
|
@@ -6,11 +6,19 @@
|
|
|
6
6
|
* - `{ type: "crash_exit" }` → process.exit(1)
|
|
7
7
|
* - `{ type: "ping", id }` → `{ type: "pong", id }`
|
|
8
8
|
* - `onUserSpeechFinal` text starting with "crash" → throw
|
|
9
|
-
* - other finals → speak(`echo
|
|
9
|
+
* - other finals → speak(`echo. ${text}`)
|
|
10
10
|
* - onSessionStart → speak("ready") after 1s (voice ready waiter)
|
|
11
11
|
*/
|
|
12
12
|
import { defineAgent, sendToClient, speak } from "@voicethere/agent";
|
|
13
13
|
|
|
14
|
+
/** TTS echo prefix with a sentence boundary so Piper does not glue words. */
|
|
15
|
+
export function formatEchoSpeak(text: string): string {
|
|
16
|
+
const trimmed = text.trim();
|
|
17
|
+
if (!trimmed) return "";
|
|
18
|
+
// Never glue `echo:` onto the next word; Piper skips "echo colon" on `echo:One`.
|
|
19
|
+
return `echo. ${trimmed}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
14
22
|
export const CRASH_AGENT_MESSAGE =
|
|
15
23
|
"e2e crash-agent: intentional handler failure";
|
|
16
24
|
|
|
@@ -44,7 +52,9 @@ defineAgent({
|
|
|
44
52
|
if (/^crash\b/i.test(trimmed)) {
|
|
45
53
|
throw new Error(CRASH_AGENT_MESSAGE);
|
|
46
54
|
}
|
|
47
|
-
|
|
55
|
+
const echoText = formatEchoSpeak(trimmed);
|
|
56
|
+
if (!echoText) return;
|
|
57
|
+
speak(sessionId, echoText);
|
|
48
58
|
},
|
|
49
59
|
|
|
50
60
|
onDataChannelMessage(ctx) {
|
package/templates/echo-smoke.ts
CHANGED
|
@@ -5,6 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { defineAgent, parseChatText, speak } from "@voicethere/agent";
|
|
7
7
|
|
|
8
|
+
/** TTS echo prefix with a sentence boundary so Piper does not glue words. */
|
|
9
|
+
export function formatEchoSpeak(text: string): string {
|
|
10
|
+
const trimmed = text.trim();
|
|
11
|
+
if (!trimmed) return "";
|
|
12
|
+
// Never glue `echo:` onto the next word; Piper skips "echo colon" on `echo:One`.
|
|
13
|
+
return `echo. ${trimmed}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
8
16
|
defineAgent({
|
|
9
17
|
onSessionStart({ sessionId }) {
|
|
10
18
|
setTimeout(() => {
|
|
@@ -13,15 +21,17 @@ defineAgent({
|
|
|
13
21
|
},
|
|
14
22
|
|
|
15
23
|
onUserSpeechFinal({ sessionId, text }) {
|
|
16
|
-
const
|
|
17
|
-
if (!
|
|
18
|
-
speak(sessionId,
|
|
24
|
+
const echoText = formatEchoSpeak(text);
|
|
25
|
+
if (!echoText) return;
|
|
26
|
+
speak(sessionId, echoText);
|
|
19
27
|
},
|
|
20
28
|
|
|
21
29
|
onDataChannelMessage(ctx) {
|
|
22
30
|
const text = parseChatText(ctx.message);
|
|
23
31
|
if (!text?.trim()) return;
|
|
24
32
|
if (text.trim().toLowerCase() === "ping") return;
|
|
25
|
-
|
|
33
|
+
const echoText = formatEchoSpeak(text);
|
|
34
|
+
if (!echoText) return;
|
|
35
|
+
speak(ctx.sessionId, echoText);
|
|
26
36
|
},
|
|
27
37
|
});
|
package/templates/mix-smoke.ts
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* - `{ type: "mix", action: "set_tts_pose", clientId, pose }`
|
|
14
14
|
* - `{ type: "mix", action: "speak", clientId, text }`
|
|
15
15
|
* - `{ type: "mix", action: "clear_tts_pose", clientId }`
|
|
16
|
-
* - `{ type: "mix", action: "play", sessionIds?, bytes?, url?, volume? }`
|
|
16
|
+
* - `{ type: "mix", action: "play", sessionIds?, bytes?, url?, volume? }` — trigger only;
|
|
17
|
+
* clip WAV is generated in-template unless bytes/url override.
|
|
17
18
|
*
|
|
18
19
|
* Acks: `{ type: "mix_ack", action, ok: true, statuses?, ... }` or `{ ok: false, error }`.
|
|
19
20
|
* Ignores `ping` / chat strings (voice-control readiness).
|
|
@@ -37,6 +38,43 @@ import {
|
|
|
37
38
|
/** E2E fixture marker — rewritten before each fixture upload when needed. */
|
|
38
39
|
export const FIXTURE_MARKER = "mix-smoke-fixture-a";
|
|
39
40
|
|
|
41
|
+
/** Inline clip: 16 kHz mono s16le — decoded size stays under 64 KiB play cap. */
|
|
42
|
+
export const CLIP_WAV_DURATION_MS = 2000;
|
|
43
|
+
export const CLIP_WAV_AMPLITUDE = 14_000;
|
|
44
|
+
export const INLINE_CLIP_DUMMY_URL = "https://example.com/inline-clip.wav";
|
|
45
|
+
|
|
46
|
+
/** Loud mono WAV (s16le 16 kHz) for default inline clip play. */
|
|
47
|
+
export function buildMixSmokeInlineClipBase64(
|
|
48
|
+
durationMs = CLIP_WAV_DURATION_MS,
|
|
49
|
+
amplitude = CLIP_WAV_AMPLITUDE,
|
|
50
|
+
): string {
|
|
51
|
+
const sampleRate = 16_000;
|
|
52
|
+
const channels = 1;
|
|
53
|
+
const bytesPerSample = 2;
|
|
54
|
+
const numSamples = Math.floor((sampleRate * durationMs) / 1000);
|
|
55
|
+
const dataSize = numSamples * channels * bytesPerSample;
|
|
56
|
+
const buffer = Buffer.alloc(44 + dataSize);
|
|
57
|
+
buffer.write("RIFF", 0);
|
|
58
|
+
buffer.writeUInt32LE(36 + dataSize, 4);
|
|
59
|
+
buffer.write("WAVE", 8);
|
|
60
|
+
buffer.write("fmt ", 12);
|
|
61
|
+
buffer.writeUInt32LE(16, 16);
|
|
62
|
+
buffer.writeUInt16LE(1, 20);
|
|
63
|
+
buffer.writeUInt16LE(channels, 22);
|
|
64
|
+
buffer.writeUInt32LE(sampleRate, 24);
|
|
65
|
+
buffer.writeUInt32LE(sampleRate * channels * bytesPerSample, 28);
|
|
66
|
+
buffer.writeUInt16LE(channels * bytesPerSample, 32);
|
|
67
|
+
buffer.writeUInt16LE(16, 34);
|
|
68
|
+
buffer.write("data", 36);
|
|
69
|
+
buffer.writeUInt32LE(dataSize, 40);
|
|
70
|
+
for (let i = 0; i < numSamples; i++) {
|
|
71
|
+
buffer.writeInt16LE(amplitude, 44 + i * 2);
|
|
72
|
+
}
|
|
73
|
+
return buffer.toString("base64");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const INLINE_CLIP = buildMixSmokeInlineClipBase64();
|
|
77
|
+
|
|
40
78
|
export type MixPose = {
|
|
41
79
|
position: { x: number; y: number; z: number };
|
|
42
80
|
orientation: { x: number; y: number; z: number; w: number };
|
|
@@ -212,13 +250,6 @@ export function isMixCommand(message: unknown): message is MixCommand {
|
|
|
212
250
|
url?: unknown;
|
|
213
251
|
volume?: unknown;
|
|
214
252
|
};
|
|
215
|
-
const hasBytes =
|
|
216
|
-
typeof playMsg.bytes === "string" && playMsg.bytes.trim().length > 0;
|
|
217
|
-
const hasUrl =
|
|
218
|
-
typeof playMsg.url === "string" && playMsg.url.trim().length > 0;
|
|
219
|
-
if (!hasBytes && !hasUrl) {
|
|
220
|
-
return false;
|
|
221
|
-
}
|
|
222
253
|
if (
|
|
223
254
|
playMsg.bytes !== undefined &&
|
|
224
255
|
(typeof playMsg.bytes !== "string" || playMsg.bytes.trim().length === 0)
|
|
@@ -440,14 +471,16 @@ async function handleMixCommand(
|
|
|
440
471
|
return;
|
|
441
472
|
}
|
|
442
473
|
case "play": {
|
|
443
|
-
const
|
|
444
|
-
|
|
474
|
+
const trimmedBytes = command.bytes?.trim();
|
|
475
|
+
const trimmedUrl = command.url?.trim();
|
|
476
|
+
const url = trimmedUrl || INLINE_CLIP_DUMMY_URL;
|
|
477
|
+
const bytes = trimmedBytes || (!trimmedUrl ? INLINE_CLIP : undefined);
|
|
445
478
|
const result = await play({
|
|
446
|
-
url
|
|
479
|
+
url,
|
|
447
480
|
...(command.sessionIds?.length
|
|
448
481
|
? { sessionIds: command.sessionIds }
|
|
449
482
|
: {}),
|
|
450
|
-
...(
|
|
483
|
+
...(bytes ? { bytes } : {}),
|
|
451
484
|
...(command.volume !== undefined ? { volume: command.volume } : {}),
|
|
452
485
|
});
|
|
453
486
|
if (!result.ok || !result.playId) {
|