@voicethere/agent 0.7.11 → 0.7.12

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.
@@ -10903,6 +10903,12 @@ function isPlayStopAckMessage(value) {
10903
10903
  const msg = value;
10904
10904
  return msg.type === "play_stop_ack" && typeof msg.requestId === "string";
10905
10905
  }
10906
+ function isPlayPoseAckMessage(value) {
10907
+ if (!value || typeof value !== "object")
10908
+ return false;
10909
+ const msg = value;
10910
+ return msg.type === "play_pose_ack" && typeof msg.requestId === "string";
10911
+ }
10906
10912
  function isRecordingControlAckMessage(value) {
10907
10913
  if (!value || typeof value !== "object")
10908
10914
  return false;
@@ -10963,7 +10969,7 @@ function isParentMessage(value) {
10963
10969
  if (!value || typeof value !== "object")
10964
10970
  return false;
10965
10971
  const msg = value;
10966
- return msg.type === "session_start" || msg.type === "speech_event" || msg.type === "session_end" || msg.type === "data_channel_message" || msg.type === "data_channel_binary" || msg.type === "idle_timeout" || msg.type === "recording_control_ack" || msg.type === "play_ack" || msg.type === "play_status_ack" || msg.type === "play_stop_ack" || msg.type === "mix_control_ack" || msg.type === "stt_control_ack" || msg.type === "webhook";
10972
+ return msg.type === "session_start" || msg.type === "speech_event" || msg.type === "session_end" || msg.type === "data_channel_message" || msg.type === "data_channel_binary" || msg.type === "idle_timeout" || msg.type === "recording_control_ack" || msg.type === "play_ack" || msg.type === "play_status_ack" || msg.type === "play_stop_ack" || msg.type === "play_pose_ack" || msg.type === "mix_control_ack" || msg.type === "stt_control_ack" || msg.type === "webhook";
10967
10973
  }
10968
10974
  function isSessionScopedParentMessage(value) {
10969
10975
  return isParentMessage(value) && !isWebhookMessage(value);
@@ -10985,6 +10991,7 @@ var pendingRecordingAcks = /* @__PURE__ */ new Map();
10985
10991
  var pendingPlayAcks = /* @__PURE__ */ new Map();
10986
10992
  var pendingPlayStatusAcks = /* @__PURE__ */ new Map();
10987
10993
  var pendingPlayStopAcks = /* @__PURE__ */ new Map();
10994
+ var pendingPlayPoseAcks = /* @__PURE__ */ new Map();
10988
10995
  var pendingMixAcks = /* @__PURE__ */ new Map();
10989
10996
  var pendingSttAcks = /* @__PURE__ */ new Map();
10990
10997
  function handleRecordingControlAck(message) {
@@ -11039,6 +11046,19 @@ function handlePlayStopAck(message) {
11039
11046
  requestId: message.requestId
11040
11047
  });
11041
11048
  }
11049
+ function handlePlayPoseAck(message) {
11050
+ const pending = pendingPlayPoseAcks.get(message.requestId);
11051
+ if (!pending)
11052
+ return;
11053
+ clearTimeout(pending.timer);
11054
+ pendingPlayPoseAcks.delete(message.requestId);
11055
+ pending.resolve({
11056
+ ok: message.ok,
11057
+ playId: message.playId,
11058
+ reason: message.reason,
11059
+ requestId: message.requestId
11060
+ });
11061
+ }
11042
11062
  function handleMixControlAck(message) {
11043
11063
  const pending = pendingMixAcks.get(message.requestId);
11044
11064
  if (!pending)
@@ -11124,7 +11144,7 @@ function sendParentMessage(message) {
11124
11144
  process.send?.(message);
11125
11145
  return;
11126
11146
  }
11127
- if (msgType === "play" || msgType === "play_status" || msgType === "play_stop") {
11147
+ if (msgType === "play" || msgType === "play_status" || msgType === "play_stop" || msgType === "play_pose") {
11128
11148
  process.send?.(message);
11129
11149
  return;
11130
11150
  }
@@ -11310,6 +11330,10 @@ function defineAgent(handlers) {
11310
11330
  handlePlayStopAck(message);
11311
11331
  return;
11312
11332
  }
11333
+ if (isPlayPoseAckMessage(message)) {
11334
+ handlePlayPoseAck(message);
11335
+ return;
11336
+ }
11313
11337
  if (isMixControlAckMessage(message)) {
11314
11338
  handleMixControlAck(message);
11315
11339
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voicethere/agent",
3
- "version": "0.7.11",
3
+ "version": "0.7.12",
4
4
  "description": "VoiceThere customer agent SDK — IPC types and runtime helpers for sandboxed child bundles",
5
5
  "type": "module",
6
6
  "exports": {
@@ -95,9 +95,9 @@
95
95
  "@node-webrtc-rust/sdk": ">=0.9.0"
96
96
  },
97
97
  "devDependencies": {
98
- "@node-webrtc-rust/helpers": "0.9.5",
99
- "@node-webrtc-rust/sdk": "0.9.5",
100
- "@node-webrtc-rust/signaling": "0.9.5",
98
+ "@node-webrtc-rust/helpers": "0.9.9",
99
+ "@node-webrtc-rust/sdk": "0.9.9",
100
+ "@node-webrtc-rust/signaling": "0.9.9",
101
101
  "@types/node": "22.20.1",
102
102
  "http-server": "14.1.1",
103
103
  "ioredis": "5.11.1",
@@ -15,10 +15,10 @@ import {
15
15
 
16
16
  ## Product vs e2e
17
17
 
18
- | Kind | Dashboard create | Prebuilt seed bundle | Typical consumer |
19
- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | ----------------------- |
18
+ | Kind | Dashboard create | Prebuilt seed bundle | Typical consumer |
19
+ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | ----------------------- |
20
20
  | **product** | Yes (`echo`, `echo-dc`, `voice-starter`, `game-sync`, `voice-showcase`, `recording-consent`, `positional-tts`, `spatial-showcase`, `webhooks`, `webhooks-redis`) | Yes — `dist/templates/<id>/agent.js` | Platform project create |
21
- | **e2e** | No | No — build from sources at test time | `voicethere/e2e` smokes |
21
+ | **e2e** | No | No — build from sources at test time | `voicethere/e2e` smokes |
22
22
 
23
23
  Product templates always set `seedOnCreate: true`. CI fails if a product template is missing its prebuilt bundle after `npm run build`.
24
24
 
@@ -83,17 +83,17 @@ Sources: `positional-tts/agent.ts` (defineAgent wiring), `positional-tts/orbit.t
83
83
 
84
84
  Spatial audio showcase for `/showcase` — one agent template with three demos selected by `{ type: "join", demo }` on the control DataChannel:
85
85
 
86
- | Demo | Behavior |
87
- | ---- | -------- |
88
- | `orbit` | TTS speaker orbits the listener (`setTtsPose`); `{ type: "orbit", action: "set" \| "say" }`; agent emits `{ type: "orbit_pose" }` |
89
- | `soundboard` | Positional clip pads — browser sends `clipId` only (never URLs); agent resolves against an allowlisted `assetOrigin` |
90
- | `proximity` | Shared mix room (`createMixGroup` + `setClientPose`); `{ type: "move" }`, `{ type: "mute_peer" }`; `{ type: "room_state" }` broadcast |
86
+ | Demo | Behavior |
87
+ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
88
+ | `orbit` | Looping sine tone + TTS orbit the listener (`setPlayPose` + `setTtsPose`); STT echo off; `{ type: "orbit", action: "set" \| "say" }`; agent emits `{ type: "orbit_pose" }` |
89
+ | `soundboard` | Positional clip pads — browser sends `clipId` only (never URLs); agent resolves against an allowlisted `assetOrigin` |
90
+ | `proximity` | Shared mix room (`createMixGroup` + `setClientPose`); `{ type: "move" }`, `{ type: "mute_peer" }`; `{ type: "room_state" }` broadcast |
91
91
 
92
92
  **Protocol:** inbound `join`, `orbit`, `pad`, `pad_stop`, `pad_status`, `move`, `mute_peer`, `leave`, `ping` — outbound `showcase_ack`, `orbit_pose`, `pad_progress`, `room_state`, `room_full`, `error`.
93
93
 
94
- **Limits:** `MAX_ROOM_PEERS = 8`, `MAX_ACTIVE_PLAYS = 4` per session. Clip ids: `chime`, `bell`, `laser`, `impact`, `footsteps`, `rain-loop`, `cafe-loop`, `jingle`.
94
+ **Limits:** `MAX_ROOM_PEERS = 8`, `MAX_ACTIVE_PLAYS = 12` per session. Clip ids: `chime`, `bell`, `laser`, `impact`, `footsteps`, `rain-loop`, `cafe-loop`, `jingle`.
95
95
 
96
- Sources: `spatial-showcase/agent.ts`, `protocol.ts`, `sounds.ts`, `room.ts`, `orbit.ts`.
96
+ Sources: `spatial-showcase/agent.ts`, `protocol.ts`, `sounds.ts`, `room.ts`, `orbit.ts`, `sine.ts`.
97
97
 
98
98
  ### `webhooks.ts` (`webhooks`)
99
99
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Spatial audio showcase — orbit TTS, positional soundboard, proximity room.
2
+ * Spatial audio showcase — orbiting sine + TTS, positional soundboard, proximity room.
3
3
  *
4
4
  * Browser joins with `{ type: "join", demo, assetOrigin? }` then sends demo-specific
5
5
  * commands. Clip URLs are resolved server-side from clipId + allowlisted assetOrigin.
@@ -21,7 +21,9 @@ import {
21
21
  sendToClient,
22
22
  setClientPose,
23
23
  setListenerMute,
24
+ setPlayPose,
24
25
  setPositionalMixing,
26
+ setSttEnabled,
25
27
  setTtsPose,
26
28
  speak,
27
29
  stopPlay,
@@ -42,16 +44,23 @@ import {
42
44
  } from "./protocol.js";
43
45
  import { ProximityRoom } from "./room.js";
44
46
  import {
47
+ isShowcaseClipId,
45
48
  resolveClipUrl,
46
49
  SHOWCASE_SOUND_FILES,
47
50
  type ShowcaseClipId,
48
51
  } from "./sounds.js";
52
+ import {
53
+ buildOrbitSineInlineClipBase64,
54
+ ORBIT_SINE_INLINE_URL,
55
+ } from "./sine.js";
49
56
 
50
57
  const ORBIT_TICK_MS = 50;
51
58
  const ORBIT_POSE_EMIT_MS = 100;
52
59
  const ROOM_STATE_TICK_MS = 100;
53
60
  const LOOP_PAD_POLL_MS = 250;
54
- const MAX_ACTIVE_PLAYS = 4;
61
+ const ORBIT_SINE_POLL_MS = 250;
62
+ const ORBIT_SINE_VOLUME = 0.25;
63
+ export const MAX_ACTIVE_PLAYS = 12;
55
64
  const SHOWCASE_ROOM_GROUP_ID = "showcase-room";
56
65
 
57
66
  type LoopPadState = {
@@ -73,6 +82,8 @@ type SessionState = {
73
82
  playIds: Set<string>;
74
83
  loopPads: Map<string, LoopPadState>;
75
84
  loopPollTimers: Map<string, ReturnType<typeof setInterval>>;
85
+ orbitSinePlayId?: string;
86
+ orbitSinePollTimer?: ReturnType<typeof setInterval>;
76
87
  inProximityRoom: boolean;
77
88
  lastOrbitPoseEmitMs?: number;
78
89
  };
@@ -95,11 +106,15 @@ function ensureShowcaseRoomGroup(): Promise<boolean> {
95
106
  return showcaseRoomGroupReady;
96
107
  }
97
108
 
98
- function ack(
99
- sessionId: string,
100
- action: string,
101
- extra?: { playId?: string },
102
- ): void {
109
+ type PadAckExtra = {
110
+ playId?: string;
111
+ clipId?: ShowcaseClipId;
112
+ x?: number;
113
+ z?: number;
114
+ placement?: PlayOptions["placement"];
115
+ };
116
+
117
+ function ack(sessionId: string, action: string, extra?: PadAckExtra): void {
103
118
  sendToClient(sessionId, {
104
119
  type: "showcase_ack",
105
120
  action,
@@ -108,12 +123,18 @@ function ack(
108
123
  });
109
124
  }
110
125
 
111
- function ackError(sessionId: string, action: string, error: string): void {
126
+ function ackError(
127
+ sessionId: string,
128
+ action: string,
129
+ error: string,
130
+ extra?: { clipId?: ShowcaseClipId },
131
+ ): void {
112
132
  sendToClient(sessionId, {
113
133
  type: "showcase_ack",
114
134
  action,
115
135
  ok: false,
116
136
  error,
137
+ ...(extra?.clipId !== undefined ? { clipId: extra.clipId } : {}),
117
138
  });
118
139
  }
119
140
 
@@ -165,6 +186,19 @@ function clearRoomStateTimer(state: SessionState): void {
165
186
  }
166
187
  }
167
188
 
189
+ function stopLoopPadsForClip(
190
+ sessionId: string,
191
+ state: SessionState,
192
+ clipId: ShowcaseClipId,
193
+ ): void {
194
+ for (const [playId, pad] of [...state.loopPads.entries()]) {
195
+ if (pad.clipId === clipId) {
196
+ void stopPlay(playId);
197
+ clearLoopPad(sessionId, state, playId);
198
+ }
199
+ }
200
+ }
201
+
168
202
  function clearLoopPad(
169
203
  sessionId: string,
170
204
  state: SessionState,
@@ -194,6 +228,87 @@ function clearAllPlays(sessionId: string, state: SessionState): void {
194
228
  state.loopPollTimers.clear();
195
229
  }
196
230
 
231
+ function currentOrbitPose(state: SessionState) {
232
+ if (!state.orbitStartMs) {
233
+ return orbitTtsPose(0, state.orbit);
234
+ }
235
+ const elapsedSec = (Date.now() - state.orbitStartMs) / 1000;
236
+ return orbitTtsPose(elapsedSec, state.orbit);
237
+ }
238
+
239
+ function clearOrbitSinePoll(state: SessionState): void {
240
+ if (state.orbitSinePollTimer) {
241
+ clearInterval(state.orbitSinePollTimer);
242
+ state.orbitSinePollTimer = undefined;
243
+ }
244
+ }
245
+
246
+ async function stopOrbitSine(state: SessionState): Promise<void> {
247
+ clearOrbitSinePoll(state);
248
+ const playId = state.orbitSinePlayId;
249
+ state.orbitSinePlayId = undefined;
250
+ if (playId) {
251
+ await stopPlay(playId);
252
+ }
253
+ }
254
+
255
+ function startOrbitSineLoopPoll(sessionId: string, state: SessionState): void {
256
+ clearOrbitSinePoll(state);
257
+ const playId = state.orbitSinePlayId;
258
+ if (!playId) {
259
+ return;
260
+ }
261
+
262
+ state.orbitSinePollTimer = setInterval(() => {
263
+ void (async () => {
264
+ if (!state.orbitSinePlayId) {
265
+ clearOrbitSinePoll(state);
266
+ return;
267
+ }
268
+ const status = await getPlay(state.orbitSinePlayId);
269
+ if (!status.ok) {
270
+ return;
271
+ }
272
+ if (status.status === "ended") {
273
+ const replay = await play({
274
+ url: ORBIT_SINE_INLINE_URL,
275
+ bytes: buildOrbitSineInlineClipBase64(),
276
+ sessionIds: [sessionId],
277
+ volume: ORBIT_SINE_VOLUME,
278
+ pose: currentOrbitPose(state),
279
+ });
280
+ if (replay.ok && replay.playId) {
281
+ state.orbitSinePlayId = replay.playId;
282
+ }
283
+ }
284
+ })();
285
+ }, ORBIT_SINE_POLL_MS);
286
+ }
287
+
288
+ async function startOrbitSinePlay(
289
+ sessionId: string,
290
+ state: SessionState,
291
+ ): Promise<void> {
292
+ await stopOrbitSine(state);
293
+ const result = await play({
294
+ url: ORBIT_SINE_INLINE_URL,
295
+ bytes: buildOrbitSineInlineClipBase64(),
296
+ sessionIds: [sessionId],
297
+ volume: ORBIT_SINE_VOLUME,
298
+ pose: currentOrbitPose(state),
299
+ });
300
+ if (!result.ok || !result.playId) {
301
+ agentLog(
302
+ "warn",
303
+ `spatial-showcase orbit sine play failed: ${result.reason ?? "play_failed"}`,
304
+ sessionId,
305
+ );
306
+ return;
307
+ }
308
+ state.orbitSinePlayId = result.playId;
309
+ startOrbitSineLoopPoll(sessionId, state);
310
+ }
311
+
197
312
  function emitOrbitPose(sessionId: string, state: SessionState): void {
198
313
  if (!state.orbitStartMs) {
199
314
  return;
@@ -221,13 +336,19 @@ function emitOrbitPose(sessionId: string, state: SessionState): void {
221
336
  function startOrbitDemo(sessionId: string, state: SessionState): void {
222
337
  stopOrbitInterval(state);
223
338
  beginOrbitClock(state);
339
+ void startOrbitSinePlay(sessionId, state);
224
340
  state.orbitTimer = setInterval(() => {
225
341
  if (state.orbit.paused || !state.orbitStartMs) {
226
342
  return;
227
343
  }
228
- const elapsedSec = (Date.now() - state.orbitStartMs) / 1000;
344
+ const pose = currentOrbitPose(state);
345
+ if (state.orbitSinePlayId) {
346
+ void safeMixCall(sessionId, "setPlayPose", () =>
347
+ setPlayPose(state.orbitSinePlayId!, pose),
348
+ );
349
+ }
229
350
  void safeMixCall(sessionId, "setTtsPose", () =>
230
- setTtsPose(sessionId, orbitTtsPose(elapsedSec, state.orbit)),
351
+ setTtsPose(sessionId, pose),
231
352
  );
232
353
  emitOrbitPose(sessionId, state);
233
354
  }, ORBIT_TICK_MS);
@@ -312,6 +433,15 @@ async function startLoopPadPoll(
312
433
  clearLoopPad(sessionId, state, playId);
313
434
  state.playIds.add(replay.playId);
314
435
  state.loopPads.set(replay.playId, pad);
436
+ ack(sessionId, "pad", {
437
+ playId: replay.playId,
438
+ clipId: pad.clipId,
439
+ x: pad.x,
440
+ z: pad.z,
441
+ ...(pad.placement !== undefined
442
+ ? { placement: pad.placement }
443
+ : {}),
444
+ });
315
445
  void startLoopPadPoll(sessionId, state, replay.playId, pad);
316
446
  }
317
447
  } else if (status.status) {
@@ -336,24 +466,27 @@ export async function handlePadPlay(
336
466
  volume?: number,
337
467
  placement?: PlayOptions["placement"],
338
468
  ): Promise<void> {
339
- if (state.playIds.size >= MAX_ACTIVE_PLAYS) {
340
- ackError(sessionId, "pad", "busy");
341
- return;
342
- }
343
-
344
469
  const assetOrigin = state.assetOrigin;
345
470
  if (!assetOrigin) {
346
- ackError(sessionId, "pad", "missing_asset_origin");
471
+ ackError(sessionId, "pad", "missing_asset_origin", { clipId });
347
472
  return;
348
473
  }
349
474
 
350
475
  const url = resolveClipUrl(assetOrigin, clipId);
351
476
  if (!url) {
352
- ackError(sessionId, "pad", "invalid_asset_origin");
477
+ ackError(sessionId, "pad", "invalid_asset_origin", { clipId });
353
478
  return;
354
479
  }
355
480
 
356
481
  const sound = SHOWCASE_SOUND_FILES[clipId];
482
+ if (sound.loop) {
483
+ stopLoopPadsForClip(sessionId, state, clipId);
484
+ }
485
+
486
+ if (state.playIds.size >= MAX_ACTIVE_PLAYS) {
487
+ ackError(sessionId, "pad", "busy", { clipId });
488
+ return;
489
+ }
357
490
  const playVolume = volume ?? sound.defaultVolume;
358
491
  const options: PlayOptions = {
359
492
  url,
@@ -364,12 +497,18 @@ export async function handlePadPlay(
364
497
 
365
498
  const result = await play(options);
366
499
  if (!result.ok || !result.playId) {
367
- ackError(sessionId, "pad", result.reason ?? "play_failed");
500
+ ackError(sessionId, "pad", result.reason ?? "play_failed", { clipId });
368
501
  return;
369
502
  }
370
503
 
371
504
  state.playIds.add(result.playId);
372
- ack(sessionId, "pad", { playId: result.playId });
505
+ ack(sessionId, "pad", {
506
+ playId: result.playId,
507
+ clipId,
508
+ x,
509
+ z,
510
+ ...(placement !== undefined ? { placement } : {}),
511
+ });
373
512
 
374
513
  if (sound.loop) {
375
514
  const pad: LoopPadState = {
@@ -406,9 +545,13 @@ async function handleJoin(
406
545
  ack(sessionId, "join");
407
546
 
408
547
  switch (demo) {
409
- case "orbit":
548
+ case "orbit": {
549
+ void safeMixCall(sessionId, "setSttEnabled", () =>
550
+ setSttEnabled({ enabled: false, sessionId }),
551
+ );
410
552
  startOrbitDemo(sessionId, state);
411
553
  break;
554
+ }
412
555
  case "soundboard":
413
556
  break;
414
557
  case "proximity": {
@@ -451,8 +594,12 @@ function teardownSession(sessionId: string): void {
451
594
 
452
595
  resetOrbitSession(state);
453
596
  clearRoomStateTimer(state);
597
+ void stopOrbitSine(state);
454
598
  clearAllPlays(sessionId, state);
455
599
  void safeMixCall(sessionId, "clearTtsPose", () => clearTtsPose(sessionId));
600
+ void safeMixCall(sessionId, "setSttEnabled", () =>
601
+ setSttEnabled({ enabled: true, sessionId }),
602
+ );
456
603
 
457
604
  if (state.inProximityRoom) {
458
605
  proximityRoom.leave(sessionId);
@@ -524,6 +671,10 @@ async function dispatchMessage(
524
671
  ackError(sessionId, "pad", "wrong_demo");
525
672
  return;
526
673
  }
674
+ if (!isShowcaseClipId(message.clipId)) {
675
+ ackError(sessionId, "pad", "invalid_clip_id");
676
+ return;
677
+ }
527
678
  await handlePadPlay(
528
679
  sessionId,
529
680
  state,
@@ -641,14 +792,6 @@ defineAgent({
641
792
  });
642
793
  },
643
794
 
644
- onUserSpeechFinal({ sessionId, text }) {
645
- const state = sessions.get(sessionId);
646
- if (!state || state.demo !== "orbit") {
647
- return;
648
- }
649
- speak(sessionId, `You said: ${text}`);
650
- },
651
-
652
795
  onDataChannelMessage(ctx) {
653
796
  const message = parseShowcaseMessage(ctx.message);
654
797
  if (!message) {
@@ -164,7 +164,9 @@ export function isShowcaseDemo(value: unknown): value is ShowcaseDemo {
164
164
  return typeof value === "string" && SHOWCASE_DEMOS.has(value);
165
165
  }
166
166
 
167
- export function isShowcaseInbound(message: unknown): message is ShowcaseInbound {
167
+ export function isShowcaseInbound(
168
+ message: unknown,
169
+ ): message is ShowcaseInbound {
168
170
  return parseShowcaseMessage(message) !== null;
169
171
  }
170
172
 
@@ -0,0 +1,43 @@
1
+ /** Inline orbit sine clip — 16 kHz mono s16le, decoded size under 64 KiB play cap. */
2
+
3
+ /** 2 s keeps loop-restart gaps (250 ms poll) infrequent; 16 kHz mono still fits under 64 KiB. */
4
+ export const ORBIT_SINE_DURATION_MS = 2000;
5
+ export const ORBIT_SINE_FREQUENCY_HZ = 440;
6
+ export const ORBIT_SINE_AMPLITUDE = 8000;
7
+ export const ORBIT_SINE_INLINE_URL =
8
+ "https://example.com/inline-orbit-sine.wav";
9
+
10
+ /** Looping ~440 Hz sine WAV as base64 for {@link play} inline bytes. */
11
+ export function buildOrbitSineInlineClipBase64(
12
+ durationMs = ORBIT_SINE_DURATION_MS,
13
+ frequencyHz = ORBIT_SINE_FREQUENCY_HZ,
14
+ amplitude = ORBIT_SINE_AMPLITUDE,
15
+ ): string {
16
+ const sampleRate = 16_000;
17
+ const channels = 1;
18
+ const bytesPerSample = 2;
19
+ const numSamples = Math.floor((sampleRate * durationMs) / 1000);
20
+ const dataSize = numSamples * channels * bytesPerSample;
21
+ const buffer = Buffer.alloc(44 + dataSize);
22
+ buffer.write("RIFF", 0);
23
+ buffer.writeUInt32LE(36 + dataSize, 4);
24
+ buffer.write("WAVE", 8);
25
+ buffer.write("fmt ", 12);
26
+ buffer.writeUInt32LE(16, 16);
27
+ buffer.writeUInt16LE(1, 20);
28
+ buffer.writeUInt16LE(channels, 22);
29
+ buffer.writeUInt32LE(sampleRate, 24);
30
+ buffer.writeUInt32LE(sampleRate * channels * bytesPerSample, 28);
31
+ buffer.writeUInt16LE(channels * bytesPerSample, 32);
32
+ buffer.writeUInt16LE(16, 34);
33
+ buffer.write("data", 36);
34
+ buffer.writeUInt32LE(dataSize, 40);
35
+ for (let i = 0; i < numSamples; i++) {
36
+ const t = i / sampleRate;
37
+ const sample = Math.round(
38
+ amplitude * Math.sin(2 * Math.PI * frequencyHz * t),
39
+ );
40
+ buffer.writeInt16LE(sample, 44 + i * 2);
41
+ }
42
+ return buffer.toString("base64");
43
+ }