@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voicethere/agent",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "description": "VoiceThere customer agent SDK — IPC types and runtime helpers for sandboxed child bundles",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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:${text}`)
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
- speak(sessionId, `echo:${trimmed}`);
55
+ const echoText = formatEchoSpeak(trimmed);
56
+ if (!echoText) return;
57
+ speak(sessionId, echoText);
48
58
  },
49
59
 
50
60
  onDataChannelMessage(ctx) {
@@ -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 trimmed = text.trim();
17
- if (!trimmed) return;
18
- speak(sessionId, `echo:${trimmed}`);
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
- speak(ctx.sessionId, `echo:${text.trim()}`);
33
+ const echoText = formatEchoSpeak(text);
34
+ if (!echoText) return;
35
+ speak(ctx.sessionId, echoText);
26
36
  },
27
37
  });
@@ -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 inlineUrl =
444
- command.url?.trim() || "https://example.com/inline-clip.wav";
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: inlineUrl,
479
+ url,
447
480
  ...(command.sessionIds?.length
448
481
  ? { sessionIds: command.sessionIds }
449
482
  : {}),
450
- ...(command.bytes ? { bytes: command.bytes } : {}),
483
+ ...(bytes ? { bytes } : {}),
451
484
  ...(command.volume !== undefined ? { volume: command.volume } : {}),
452
485
  });
453
486
  if (!result.ok || !result.playId) {