@voicethere/agent 0.7.12 → 0.7.13

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.
@@ -1137,6 +1137,8 @@ function resolveClipUrl(assetOrigin, clipId) {
1137
1137
  var SHOWCASE_DEMOS = /* @__PURE__ */ new Set(["orbit", "soundboard", "proximity"]);
1138
1138
  var MAX_SAY_TEXT_LENGTH = 200;
1139
1139
  var MAX_XZ = 5;
1140
+ var ORBIT_SINE_MIN_FREQUENCY_HZ = 110;
1141
+ var ORBIT_SINE_MAX_FREQUENCY_HZ = 1760;
1140
1142
  function isFiniteNumber(value) {
1141
1143
  return typeof value === "number" && Number.isFinite(value);
1142
1144
  }
@@ -1146,6 +1148,9 @@ function clampVolume(value) {
1146
1148
  function isInXzRange(value) {
1147
1149
  return value >= -MAX_XZ && value <= MAX_XZ;
1148
1150
  }
1151
+ function isOrbitSineFrequencyHz(value) {
1152
+ return value >= ORBIT_SINE_MIN_FREQUENCY_HZ && value <= ORBIT_SINE_MAX_FREQUENCY_HZ;
1153
+ }
1149
1154
  function isMixPlacement(value) {
1150
1155
  return value === "center" || value === "left" || value === "right" || value === "front" || value === "behind" || value === "below" || value === "above";
1151
1156
  }
@@ -1194,15 +1199,33 @@ function parseShowcaseMessage(message) {
1194
1199
  if (set.paused !== void 0 && typeof set.paused !== "boolean") {
1195
1200
  return null;
1196
1201
  }
1202
+ if (set.frequencyHz !== void 0 && (!isFiniteNumber(set.frequencyHz) || !isOrbitSineFrequencyHz(set.frequencyHz))) {
1203
+ return null;
1204
+ }
1205
+ if (set.sinePlaying !== void 0 && typeof set.sinePlaying !== "boolean") {
1206
+ return null;
1207
+ }
1197
1208
  return {
1198
1209
  type: "orbit",
1199
1210
  action: "set",
1200
1211
  ...set.radius !== void 0 ? { radius: set.radius } : {},
1201
1212
  ...set.periodSec !== void 0 ? { periodSec: set.periodSec } : {},
1202
1213
  ...set.elevation !== void 0 ? { elevation: set.elevation } : {},
1203
- ...set.paused !== void 0 ? { paused: set.paused } : {}
1214
+ ...set.paused !== void 0 ? { paused: set.paused } : {},
1215
+ ...set.frequencyHz !== void 0 ? { frequencyHz: set.frequencyHz } : {},
1216
+ ...set.sinePlaying !== void 0 ? { sinePlaying: set.sinePlaying } : {}
1204
1217
  };
1205
1218
  }
1219
+ if (orbit.action === "place") {
1220
+ const place = message;
1221
+ if (!isFiniteNumber(place.x) || !isInXzRange(place.x)) {
1222
+ return null;
1223
+ }
1224
+ if (!isFiniteNumber(place.z) || !isInXzRange(place.z)) {
1225
+ return null;
1226
+ }
1227
+ return { type: "orbit", action: "place", x: place.x, z: place.z };
1228
+ }
1206
1229
  if (orbit.action === "say") {
1207
1230
  const say = message;
1208
1231
  if (typeof say.text !== "string" || say.text.length === 0 || say.text.length > MAX_SAY_TEXT_LENGTH) {
@@ -1254,6 +1277,24 @@ function parseShowcaseMessage(message) {
1254
1277
  }
1255
1278
  return { type: "pad_status", playId: status.playId.trim() };
1256
1279
  }
1280
+ case "pad_move": {
1281
+ const padMove = message;
1282
+ if (!isNonEmptyString(padMove.playId)) {
1283
+ return null;
1284
+ }
1285
+ if (!isFiniteNumber(padMove.x) || !isInXzRange(padMove.x)) {
1286
+ return null;
1287
+ }
1288
+ if (!isFiniteNumber(padMove.z) || !isInXzRange(padMove.z)) {
1289
+ return null;
1290
+ }
1291
+ return {
1292
+ type: "pad_move",
1293
+ playId: padMove.playId.trim(),
1294
+ x: padMove.x,
1295
+ z: padMove.z
1296
+ };
1297
+ }
1257
1298
  case "move": {
1258
1299
  const move = message;
1259
1300
  if (!isFiniteNumber(move.x) || !isInXzRange(move.x)) {
@@ -1368,12 +1409,22 @@ var ProximityRoom = class {
1368
1409
  var ORBIT_SINE_DURATION_MS = 2e3;
1369
1410
  var ORBIT_SINE_FREQUENCY_HZ = 440;
1370
1411
  var ORBIT_SINE_AMPLITUDE = 8e3;
1412
+ var ORBIT_SINE_SAMPLE_RATE = 16e3;
1371
1413
  var ORBIT_SINE_INLINE_URL = "https://example.com/inline-orbit-sine.wav";
1372
- function buildOrbitSineInlineClipBase64(durationMs = ORBIT_SINE_DURATION_MS, frequencyHz = ORBIT_SINE_FREQUENCY_HZ, amplitude = ORBIT_SINE_AMPLITUDE) {
1373
- const sampleRate = 16e3;
1414
+ function orbitSineSampleCount(durationMs = ORBIT_SINE_DURATION_MS, frequencyHz = ORBIT_SINE_FREQUENCY_HZ, sampleRate = ORBIT_SINE_SAMPLE_RATE) {
1415
+ const targetCycles = durationMs / 1e3 * frequencyHz;
1416
+ const cycles = Math.max(1, Math.floor(targetCycles));
1417
+ return Math.round(cycles * sampleRate / frequencyHz);
1418
+ }
1419
+ function orbitSineClipDurationMs(durationMs = ORBIT_SINE_DURATION_MS, frequencyHz = ORBIT_SINE_FREQUENCY_HZ, sampleRate = ORBIT_SINE_SAMPLE_RATE) {
1420
+ const numSamples = orbitSineSampleCount(durationMs, frequencyHz, sampleRate);
1421
+ return numSamples / sampleRate * 1e3;
1422
+ }
1423
+ function buildOrbitSineInlineClipBase64(durationMs = ORBIT_SINE_DURATION_MS, frequencyHz = ORBIT_SINE_FREQUENCY_HZ, amplitude = ORBIT_SINE_AMPLITUDE, phaseRad = 0) {
1424
+ const sampleRate = ORBIT_SINE_SAMPLE_RATE;
1374
1425
  const channels = 1;
1375
1426
  const bytesPerSample = 2;
1376
- const numSamples = Math.floor(sampleRate * durationMs / 1e3);
1427
+ const numSamples = orbitSineSampleCount(durationMs, frequencyHz, sampleRate);
1377
1428
  const dataSize = numSamples * channels * bytesPerSample;
1378
1429
  const buffer = Buffer.alloc(44 + dataSize);
1379
1430
  buffer.write("RIFF", 0);
@@ -1392,7 +1443,7 @@ function buildOrbitSineInlineClipBase64(durationMs = ORBIT_SINE_DURATION_MS, fre
1392
1443
  for (let i = 0; i < numSamples; i++) {
1393
1444
  const t = i / sampleRate;
1394
1445
  const sample = Math.round(
1395
- amplitude * Math.sin(2 * Math.PI * frequencyHz * t)
1446
+ amplitude * Math.sin(2 * Math.PI * frequencyHz * t + phaseRad)
1396
1447
  );
1397
1448
  buffer.writeInt16LE(sample, 44 + i * 2);
1398
1449
  }
@@ -1405,6 +1456,7 @@ var ORBIT_POSE_EMIT_MS = 100;
1405
1456
  var ROOM_STATE_TICK_MS = 100;
1406
1457
  var LOOP_PAD_POLL_MS = 250;
1407
1458
  var ORBIT_SINE_POLL_MS = 250;
1459
+ var ORBIT_SINE_GAPLESS_LEAD_MS = 200;
1408
1460
  var ORBIT_SINE_VOLUME = 0.25;
1409
1461
  var MAX_ACTIVE_PLAYS = 12;
1410
1462
  var SHOWCASE_ROOM_GROUP_ID = "showcase-room";
@@ -1467,6 +1519,9 @@ function defaultSessionState(demo, assetOrigin) {
1467
1519
  playIds: /* @__PURE__ */ new Set(),
1468
1520
  loopPads: /* @__PURE__ */ new Map(),
1469
1521
  loopPollTimers: /* @__PURE__ */ new Map(),
1522
+ orbitSineFrequencyHz: 440,
1523
+ orbitSineEnabled: true,
1524
+ orbitSinePhaseRad: 0,
1470
1525
  inProximityRoom: false
1471
1526
  };
1472
1527
  }
@@ -1508,27 +1563,114 @@ function clearAllPlays(sessionId, state) {
1508
1563
  state.loopPollTimers.clear();
1509
1564
  }
1510
1565
  function currentOrbitPose(state) {
1566
+ if (state.orbitManualPose) {
1567
+ const elevation = state.orbit.elevation ?? 0;
1568
+ return {
1569
+ position: {
1570
+ x: state.orbitManualPose.x,
1571
+ y: elevation,
1572
+ z: state.orbitManualPose.z
1573
+ },
1574
+ orientation: { x: 0, y: 0, z: 0, w: 1 }
1575
+ };
1576
+ }
1511
1577
  if (!state.orbitStartMs) {
1512
1578
  return orbitTtsPose(0, state.orbit);
1513
1579
  }
1514
1580
  const elapsedSec = (Date.now() - state.orbitStartMs) / 1e3;
1515
1581
  return orbitTtsPose(elapsedSec, state.orbit);
1516
1582
  }
1583
+ function orbitPoseFromXz(state, x, z) {
1584
+ const elevation = state.orbit.elevation ?? 0;
1585
+ return {
1586
+ position: { x, y: elevation, z },
1587
+ orientation: { x: 0, y: 0, z: 0, w: 1 }
1588
+ };
1589
+ }
1590
+ function syncOrbitClockFromManualPose(state) {
1591
+ const manual = state.orbitManualPose;
1592
+ if (!manual) {
1593
+ return;
1594
+ }
1595
+ const periodSec = state.orbit.periodSec ?? 2 * Math.PI;
1596
+ const angleRad = Math.atan2(manual.z, manual.x);
1597
+ const elapsedSec = angleRad * periodSec / (2 * Math.PI);
1598
+ state.orbitStartMs = Date.now() - elapsedSec * 1e3;
1599
+ state.orbitManualPose = void 0;
1600
+ }
1517
1601
  function clearOrbitSinePoll(state) {
1518
1602
  if (state.orbitSinePollTimer) {
1519
1603
  clearInterval(state.orbitSinePollTimer);
1520
1604
  state.orbitSinePollTimer = void 0;
1521
1605
  }
1522
1606
  }
1607
+ function clearOrbitSineRestartTimer(state) {
1608
+ if (state.orbitSineRestartTimer) {
1609
+ clearTimeout(state.orbitSineRestartTimer);
1610
+ state.orbitSineRestartTimer = void 0;
1611
+ }
1612
+ }
1523
1613
  async function stopOrbitSine(state) {
1524
1614
  clearOrbitSinePoll(state);
1615
+ clearOrbitSineRestartTimer(state);
1525
1616
  const playId = state.orbitSinePlayId;
1526
1617
  state.orbitSinePlayId = void 0;
1527
1618
  if (playId) {
1528
1619
  await stopPlay(playId);
1529
1620
  }
1530
1621
  }
1622
+ function orbitSineClipBytes(state) {
1623
+ return buildOrbitSineInlineClipBase64(
1624
+ ORBIT_SINE_DURATION_MS,
1625
+ state.orbitSineFrequencyHz,
1626
+ void 0,
1627
+ state.orbitSinePhaseRad
1628
+ );
1629
+ }
1630
+ function orbitSineClipDuration(state) {
1631
+ return orbitSineClipDurationMs(
1632
+ ORBIT_SINE_DURATION_MS,
1633
+ state.orbitSineFrequencyHz
1634
+ );
1635
+ }
1636
+ function scheduleOrbitSineGaplessRestart(sessionId, state) {
1637
+ clearOrbitSineRestartTimer(state);
1638
+ const durationMs = orbitSineClipDuration(state);
1639
+ const delayMs = Math.max(0, durationMs - ORBIT_SINE_GAPLESS_LEAD_MS);
1640
+ state.orbitSineRestartTimer = setTimeout(() => {
1641
+ state.orbitSineRestartTimer = void 0;
1642
+ void restartOrbitSineGapless(sessionId, state);
1643
+ }, delayMs);
1644
+ }
1645
+ async function restartOrbitSineGapless(sessionId, state) {
1646
+ if (!state.orbitSineEnabled || !state.orbitSinePlayId) {
1647
+ return;
1648
+ }
1649
+ const previousPlayId = state.orbitSinePlayId;
1650
+ const result = await play({
1651
+ url: ORBIT_SINE_INLINE_URL,
1652
+ bytes: orbitSineClipBytes(state),
1653
+ sessionIds: [sessionId],
1654
+ volume: ORBIT_SINE_VOLUME,
1655
+ pose: currentOrbitPose(state)
1656
+ });
1657
+ if (!result.ok || !result.playId) {
1658
+ agentLog(
1659
+ "warn",
1660
+ `spatial-showcase orbit sine gapless restart failed: ${result.reason ?? "play_failed"}`,
1661
+ sessionId
1662
+ );
1663
+ startOrbitSineLoopPoll(sessionId, state);
1664
+ return;
1665
+ }
1666
+ state.orbitSinePlayId = result.playId;
1667
+ void stopPlay(previousPlayId);
1668
+ scheduleOrbitSineGaplessRestart(sessionId, state);
1669
+ }
1531
1670
  function startOrbitSineLoopPoll(sessionId, state) {
1671
+ if (state.orbitSineRestartTimer) {
1672
+ return;
1673
+ }
1532
1674
  clearOrbitSinePoll(state);
1533
1675
  const playId = state.orbitSinePlayId;
1534
1676
  if (!playId) {
@@ -1536,7 +1678,7 @@ function startOrbitSineLoopPoll(sessionId, state) {
1536
1678
  }
1537
1679
  state.orbitSinePollTimer = setInterval(() => {
1538
1680
  void (async () => {
1539
- if (!state.orbitSinePlayId) {
1681
+ if (!state.orbitSinePlayId || state.orbitSineRestartTimer) {
1540
1682
  clearOrbitSinePoll(state);
1541
1683
  return;
1542
1684
  }
@@ -1545,25 +1687,19 @@ function startOrbitSineLoopPoll(sessionId, state) {
1545
1687
  return;
1546
1688
  }
1547
1689
  if (status.status === "ended") {
1548
- const replay = await play({
1549
- url: ORBIT_SINE_INLINE_URL,
1550
- bytes: buildOrbitSineInlineClipBase64(),
1551
- sessionIds: [sessionId],
1552
- volume: ORBIT_SINE_VOLUME,
1553
- pose: currentOrbitPose(state)
1554
- });
1555
- if (replay.ok && replay.playId) {
1556
- state.orbitSinePlayId = replay.playId;
1557
- }
1690
+ await restartOrbitSineGapless(sessionId, state);
1558
1691
  }
1559
1692
  })();
1560
1693
  }, ORBIT_SINE_POLL_MS);
1561
1694
  }
1562
1695
  async function startOrbitSinePlay(sessionId, state) {
1696
+ if (!state.orbitSineEnabled) {
1697
+ return;
1698
+ }
1563
1699
  await stopOrbitSine(state);
1564
1700
  const result = await play({
1565
1701
  url: ORBIT_SINE_INLINE_URL,
1566
- bytes: buildOrbitSineInlineClipBase64(),
1702
+ bytes: orbitSineClipBytes(state),
1567
1703
  sessionIds: [sessionId],
1568
1704
  volume: ORBIT_SINE_VOLUME,
1569
1705
  pose: currentOrbitPose(state)
@@ -1577,10 +1713,11 @@ async function startOrbitSinePlay(sessionId, state) {
1577
1713
  return;
1578
1714
  }
1579
1715
  state.orbitSinePlayId = result.playId;
1716
+ scheduleOrbitSineGaplessRestart(sessionId, state);
1580
1717
  startOrbitSineLoopPoll(sessionId, state);
1581
1718
  }
1582
1719
  function emitOrbitPose(sessionId, state) {
1583
- if (!state.orbitStartMs) {
1720
+ if (!state.orbitStartMs && !state.orbitManualPose) {
1584
1721
  return;
1585
1722
  }
1586
1723
  const now = Date.now();
@@ -1588,9 +1725,8 @@ function emitOrbitPose(sessionId, state) {
1588
1725
  return;
1589
1726
  }
1590
1727
  state.lastOrbitPoseEmitMs = now;
1591
- const elapsedSec = (now - state.orbitStartMs) / 1e3;
1592
- const pose = orbitTtsPose(elapsedSec, state.orbit);
1593
- const angleRad = 2 * Math.PI * elapsedSec / (state.orbit.periodSec ?? 2 * Math.PI);
1728
+ const pose = currentOrbitPose(state);
1729
+ const angleRad = state.orbitManualPose ? Math.atan2(state.orbitManualPose.z, state.orbitManualPose.x) : 2 * Math.PI * (now - state.orbitStartMs) / 1e3 / (state.orbit.periodSec ?? 2 * Math.PI);
1594
1730
  sendToClient(sessionId, {
1595
1731
  type: "orbit_pose",
1596
1732
  angleDeg: angleRad * 180 / Math.PI,
@@ -1598,12 +1734,22 @@ function emitOrbitPose(sessionId, state) {
1598
1734
  z: pose.position.z
1599
1735
  });
1600
1736
  }
1737
+ function emitOrbitPlacePose(sessionId, state, x, z) {
1738
+ state.lastOrbitPoseEmitMs = Date.now();
1739
+ const angleRad = Math.atan2(z, x);
1740
+ sendToClient(sessionId, {
1741
+ type: "orbit_pose",
1742
+ angleDeg: angleRad * 180 / Math.PI,
1743
+ x,
1744
+ z
1745
+ });
1746
+ }
1601
1747
  function startOrbitDemo(sessionId, state) {
1602
1748
  stopOrbitInterval(state);
1603
1749
  beginOrbitClock(state);
1604
1750
  void startOrbitSinePlay(sessionId, state);
1605
1751
  state.orbitTimer = setInterval(() => {
1606
- if (state.orbit.paused || !state.orbitStartMs) {
1752
+ if (state.orbit.paused || !state.orbitStartMs && !state.orbitManualPose) {
1607
1753
  return;
1608
1754
  }
1609
1755
  const pose = currentOrbitPose(state);
@@ -1758,6 +1904,24 @@ async function handlePadPlay(sessionId, state, clipId, x, z, volume, placement)
1758
1904
  void startLoopPadPoll(sessionId, state, result.playId, pad);
1759
1905
  }
1760
1906
  }
1907
+ async function handlePadMove(sessionId, state, playId, x, z) {
1908
+ if (!state.playIds.has(playId)) {
1909
+ ackError(sessionId, "pad_move", "unknown_play");
1910
+ return;
1911
+ }
1912
+ const poseResult = await setPlayPose(playId, poseAt(x, z));
1913
+ if (!poseResult.ok) {
1914
+ ackError(sessionId, "pad_move", poseResult.reason ?? "set_pose_failed");
1915
+ return;
1916
+ }
1917
+ const loopPad = state.loopPads.get(playId);
1918
+ if (loopPad) {
1919
+ loopPad.x = x;
1920
+ loopPad.z = z;
1921
+ delete loopPad.placement;
1922
+ }
1923
+ ack(sessionId, "pad_move", { playId, x, z });
1924
+ }
1761
1925
  async function handleJoin(sessionId, demo, assetOrigin) {
1762
1926
  teardownSession(sessionId);
1763
1927
  const state = defaultSessionState(demo, assetOrigin);
@@ -1884,11 +2048,54 @@ async function dispatchMessage(sessionId, message) {
1884
2048
  state.orbit.elevation = message.elevation;
1885
2049
  }
1886
2050
  if (message.paused !== void 0) {
2051
+ if (message.paused === false && state.orbitManualPose) {
2052
+ syncOrbitClockFromManualPose(state);
2053
+ }
1887
2054
  state.orbit.paused = message.paused;
1888
2055
  }
2056
+ if (message.frequencyHz !== void 0) {
2057
+ state.orbitSineFrequencyHz = message.frequencyHz;
2058
+ if (state.orbitSineEnabled && state.orbitSinePlayId) {
2059
+ void startOrbitSinePlay(sessionId, state);
2060
+ }
2061
+ }
2062
+ if (message.sinePlaying !== void 0) {
2063
+ state.orbitSineEnabled = message.sinePlaying;
2064
+ if (message.sinePlaying) {
2065
+ if (!state.orbitSinePlayId) {
2066
+ void startOrbitSinePlay(sessionId, state);
2067
+ }
2068
+ } else {
2069
+ void stopOrbitSine(state);
2070
+ }
2071
+ }
1889
2072
  ack(sessionId, "orbit_set");
1890
2073
  return;
1891
2074
  }
2075
+ if (message.action === "place") {
2076
+ state.orbit.paused = true;
2077
+ state.orbitManualPose = { x: message.x, z: message.z };
2078
+ const pose = orbitPoseFromXz(state, message.x, message.z);
2079
+ if (state.orbitSinePlayId) {
2080
+ void safeMixCall(
2081
+ sessionId,
2082
+ "setPlayPose",
2083
+ () => setPlayPose(state.orbitSinePlayId, pose)
2084
+ );
2085
+ }
2086
+ void safeMixCall(
2087
+ sessionId,
2088
+ "setTtsPose",
2089
+ () => setTtsPose(sessionId, pose)
2090
+ );
2091
+ emitOrbitPlacePose(sessionId, state, message.x, message.z);
2092
+ ack(sessionId, "orbit_place");
2093
+ return;
2094
+ }
2095
+ sendToClient(sessionId, {
2096
+ type: "orbit_say_status",
2097
+ status: "playing"
2098
+ });
1892
2099
  speak(sessionId, message.text);
1893
2100
  ack(sessionId, "orbit_say");
1894
2101
  return;
@@ -1945,6 +2152,20 @@ async function dispatchMessage(sessionId, message) {
1945
2152
  }
1946
2153
  return;
1947
2154
  }
2155
+ case "pad_move": {
2156
+ if (state.demo !== "soundboard") {
2157
+ ackError(sessionId, "pad_move", "wrong_demo");
2158
+ return;
2159
+ }
2160
+ await handlePadMove(
2161
+ sessionId,
2162
+ state,
2163
+ message.playId,
2164
+ message.x,
2165
+ message.z
2166
+ );
2167
+ return;
2168
+ }
1948
2169
  case "move": {
1949
2170
  if (state.demo !== "proximity") {
1950
2171
  ackError(sessionId, "move", "wrong_demo");
@@ -2027,6 +2248,23 @@ defineAgent({
2027
2248
  }
2028
2249
  void dispatchMessage(ctx.sessionId, message);
2029
2250
  },
2251
+ onSpeechEvent(ctx, event) {
2252
+ const state = sessions.get(ctx.sessionId);
2253
+ if (!state || state.demo !== "orbit") {
2254
+ return;
2255
+ }
2256
+ if (event.type === "agent_speaking_start") {
2257
+ sendToClient(ctx.sessionId, {
2258
+ type: "orbit_say_status",
2259
+ status: "playing"
2260
+ });
2261
+ } else if (event.type === "agent_speaking_end") {
2262
+ sendToClient(ctx.sessionId, {
2263
+ type: "orbit_say_status",
2264
+ status: "ended"
2265
+ });
2266
+ }
2267
+ },
2030
2268
  onSessionEnd({ sessionId }) {
2031
2269
  teardownSession(sessionId);
2032
2270
  agentLog("info", `spatial-showcase session_end ${sessionId}`, sessionId);
@@ -2034,5 +2272,6 @@ defineAgent({
2034
2272
  });
2035
2273
  export {
2036
2274
  MAX_ACTIVE_PLAYS,
2275
+ handlePadMove,
2037
2276
  handlePadPlay
2038
2277
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voicethere/agent",
3
- "version": "0.7.12",
3
+ "version": "0.7.13",
4
4
  "description": "VoiceThere customer agent SDK — IPC types and runtime helpers for sandboxed child bundles",
5
5
  "type": "module",
6
6
  "exports": {
@@ -51,7 +51,9 @@ import {
51
51
  } from "./sounds.js";
52
52
  import {
53
53
  buildOrbitSineInlineClipBase64,
54
+ ORBIT_SINE_DURATION_MS,
54
55
  ORBIT_SINE_INLINE_URL,
56
+ orbitSineClipDurationMs,
55
57
  } from "./sine.js";
56
58
 
57
59
  const ORBIT_TICK_MS = 50;
@@ -59,6 +61,7 @@ const ORBIT_POSE_EMIT_MS = 100;
59
61
  const ROOM_STATE_TICK_MS = 100;
60
62
  const LOOP_PAD_POLL_MS = 250;
61
63
  const ORBIT_SINE_POLL_MS = 250;
64
+ const ORBIT_SINE_GAPLESS_LEAD_MS = 200;
62
65
  const ORBIT_SINE_VOLUME = 0.25;
63
66
  export const MAX_ACTIVE_PLAYS = 12;
64
67
  const SHOWCASE_ROOM_GROUP_ID = "showcase-room";
@@ -84,6 +87,11 @@ type SessionState = {
84
87
  loopPollTimers: Map<string, ReturnType<typeof setInterval>>;
85
88
  orbitSinePlayId?: string;
86
89
  orbitSinePollTimer?: ReturnType<typeof setInterval>;
90
+ orbitSineRestartTimer?: ReturnType<typeof setTimeout>;
91
+ orbitSineFrequencyHz: number;
92
+ orbitSineEnabled: boolean;
93
+ orbitSinePhaseRad: number;
94
+ orbitManualPose?: { x: number; z: number };
87
95
  inProximityRoom: boolean;
88
96
  lastOrbitPoseEmitMs?: number;
89
97
  };
@@ -175,6 +183,9 @@ function defaultSessionState(
175
183
  playIds: new Set(),
176
184
  loopPads: new Map(),
177
185
  loopPollTimers: new Map(),
186
+ orbitSineFrequencyHz: 440,
187
+ orbitSineEnabled: true,
188
+ orbitSinePhaseRad: 0,
178
189
  inProximityRoom: false,
179
190
  };
180
191
  }
@@ -229,6 +240,17 @@ function clearAllPlays(sessionId: string, state: SessionState): void {
229
240
  }
230
241
 
231
242
  function currentOrbitPose(state: SessionState) {
243
+ if (state.orbitManualPose) {
244
+ const elevation = state.orbit.elevation ?? 0;
245
+ return {
246
+ position: {
247
+ x: state.orbitManualPose.x,
248
+ y: elevation,
249
+ z: state.orbitManualPose.z,
250
+ },
251
+ orientation: { x: 0, y: 0, z: 0, w: 1 },
252
+ };
253
+ }
232
254
  if (!state.orbitStartMs) {
233
255
  return orbitTtsPose(0, state.orbit);
234
256
  }
@@ -236,6 +258,30 @@ function currentOrbitPose(state: SessionState) {
236
258
  return orbitTtsPose(elapsedSec, state.orbit);
237
259
  }
238
260
 
261
+ function orbitPoseFromXz(
262
+ state: SessionState,
263
+ x: number,
264
+ z: number,
265
+ ): ReturnType<typeof orbitTtsPose> {
266
+ const elevation = state.orbit.elevation ?? 0;
267
+ return {
268
+ position: { x, y: elevation, z },
269
+ orientation: { x: 0, y: 0, z: 0, w: 1 },
270
+ };
271
+ }
272
+
273
+ function syncOrbitClockFromManualPose(state: SessionState): void {
274
+ const manual = state.orbitManualPose;
275
+ if (!manual) {
276
+ return;
277
+ }
278
+ const periodSec = state.orbit.periodSec ?? 2 * Math.PI;
279
+ const angleRad = Math.atan2(manual.z, manual.x);
280
+ const elapsedSec = (angleRad * periodSec) / (2 * Math.PI);
281
+ state.orbitStartMs = Date.now() - elapsedSec * 1000;
282
+ state.orbitManualPose = undefined;
283
+ }
284
+
239
285
  function clearOrbitSinePoll(state: SessionState): void {
240
286
  if (state.orbitSinePollTimer) {
241
287
  clearInterval(state.orbitSinePollTimer);
@@ -243,8 +289,16 @@ function clearOrbitSinePoll(state: SessionState): void {
243
289
  }
244
290
  }
245
291
 
292
+ function clearOrbitSineRestartTimer(state: SessionState): void {
293
+ if (state.orbitSineRestartTimer) {
294
+ clearTimeout(state.orbitSineRestartTimer);
295
+ state.orbitSineRestartTimer = undefined;
296
+ }
297
+ }
298
+
246
299
  async function stopOrbitSine(state: SessionState): Promise<void> {
247
300
  clearOrbitSinePoll(state);
301
+ clearOrbitSineRestartTimer(state);
248
302
  const playId = state.orbitSinePlayId;
249
303
  state.orbitSinePlayId = undefined;
250
304
  if (playId) {
@@ -252,7 +306,68 @@ async function stopOrbitSine(state: SessionState): Promise<void> {
252
306
  }
253
307
  }
254
308
 
309
+ function orbitSineClipBytes(state: SessionState): string {
310
+ return buildOrbitSineInlineClipBase64(
311
+ ORBIT_SINE_DURATION_MS,
312
+ state.orbitSineFrequencyHz,
313
+ undefined,
314
+ state.orbitSinePhaseRad,
315
+ );
316
+ }
317
+
318
+ function orbitSineClipDuration(state: SessionState): number {
319
+ return orbitSineClipDurationMs(
320
+ ORBIT_SINE_DURATION_MS,
321
+ state.orbitSineFrequencyHz,
322
+ );
323
+ }
324
+
325
+ function scheduleOrbitSineGaplessRestart(
326
+ sessionId: string,
327
+ state: SessionState,
328
+ ): void {
329
+ clearOrbitSineRestartTimer(state);
330
+ const durationMs = orbitSineClipDuration(state);
331
+ const delayMs = Math.max(0, durationMs - ORBIT_SINE_GAPLESS_LEAD_MS);
332
+ state.orbitSineRestartTimer = setTimeout(() => {
333
+ state.orbitSineRestartTimer = undefined;
334
+ void restartOrbitSineGapless(sessionId, state);
335
+ }, delayMs);
336
+ }
337
+
338
+ async function restartOrbitSineGapless(
339
+ sessionId: string,
340
+ state: SessionState,
341
+ ): Promise<void> {
342
+ if (!state.orbitSineEnabled || !state.orbitSinePlayId) {
343
+ return;
344
+ }
345
+ const previousPlayId = state.orbitSinePlayId;
346
+ const result = await play({
347
+ url: ORBIT_SINE_INLINE_URL,
348
+ bytes: orbitSineClipBytes(state),
349
+ sessionIds: [sessionId],
350
+ volume: ORBIT_SINE_VOLUME,
351
+ pose: currentOrbitPose(state),
352
+ });
353
+ if (!result.ok || !result.playId) {
354
+ agentLog(
355
+ "warn",
356
+ `spatial-showcase orbit sine gapless restart failed: ${result.reason ?? "play_failed"}`,
357
+ sessionId,
358
+ );
359
+ startOrbitSineLoopPoll(sessionId, state);
360
+ return;
361
+ }
362
+ state.orbitSinePlayId = result.playId;
363
+ void stopPlay(previousPlayId);
364
+ scheduleOrbitSineGaplessRestart(sessionId, state);
365
+ }
366
+
255
367
  function startOrbitSineLoopPoll(sessionId: string, state: SessionState): void {
368
+ if (state.orbitSineRestartTimer) {
369
+ return;
370
+ }
256
371
  clearOrbitSinePoll(state);
257
372
  const playId = state.orbitSinePlayId;
258
373
  if (!playId) {
@@ -261,7 +376,7 @@ function startOrbitSineLoopPoll(sessionId: string, state: SessionState): void {
261
376
 
262
377
  state.orbitSinePollTimer = setInterval(() => {
263
378
  void (async () => {
264
- if (!state.orbitSinePlayId) {
379
+ if (!state.orbitSinePlayId || state.orbitSineRestartTimer) {
265
380
  clearOrbitSinePoll(state);
266
381
  return;
267
382
  }
@@ -270,16 +385,7 @@ function startOrbitSineLoopPoll(sessionId: string, state: SessionState): void {
270
385
  return;
271
386
  }
272
387
  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
- }
388
+ await restartOrbitSineGapless(sessionId, state);
283
389
  }
284
390
  })();
285
391
  }, ORBIT_SINE_POLL_MS);
@@ -289,10 +395,13 @@ async function startOrbitSinePlay(
289
395
  sessionId: string,
290
396
  state: SessionState,
291
397
  ): Promise<void> {
398
+ if (!state.orbitSineEnabled) {
399
+ return;
400
+ }
292
401
  await stopOrbitSine(state);
293
402
  const result = await play({
294
403
  url: ORBIT_SINE_INLINE_URL,
295
- bytes: buildOrbitSineInlineClipBase64(),
404
+ bytes: orbitSineClipBytes(state),
296
405
  sessionIds: [sessionId],
297
406
  volume: ORBIT_SINE_VOLUME,
298
407
  pose: currentOrbitPose(state),
@@ -306,11 +415,12 @@ async function startOrbitSinePlay(
306
415
  return;
307
416
  }
308
417
  state.orbitSinePlayId = result.playId;
418
+ scheduleOrbitSineGaplessRestart(sessionId, state);
309
419
  startOrbitSineLoopPoll(sessionId, state);
310
420
  }
311
421
 
312
422
  function emitOrbitPose(sessionId: string, state: SessionState): void {
313
- if (!state.orbitStartMs) {
423
+ if (!state.orbitStartMs && !state.orbitManualPose) {
314
424
  return;
315
425
  }
316
426
  const now = Date.now();
@@ -321,10 +431,12 @@ function emitOrbitPose(sessionId: string, state: SessionState): void {
321
431
  return;
322
432
  }
323
433
  state.lastOrbitPoseEmitMs = now;
324
- const elapsedSec = (now - state.orbitStartMs) / 1000;
325
- const pose = orbitTtsPose(elapsedSec, state.orbit);
326
- const angleRad =
327
- (2 * Math.PI * elapsedSec) / (state.orbit.periodSec ?? 2 * Math.PI);
434
+ const pose = currentOrbitPose(state);
435
+ const angleRad = state.orbitManualPose
436
+ ? Math.atan2(state.orbitManualPose.z, state.orbitManualPose.x)
437
+ : (2 * Math.PI * (now - state.orbitStartMs!)) /
438
+ 1000 /
439
+ (state.orbit.periodSec ?? 2 * Math.PI);
328
440
  sendToClient(sessionId, {
329
441
  type: "orbit_pose",
330
442
  angleDeg: (angleRad * 180) / Math.PI,
@@ -333,12 +445,28 @@ function emitOrbitPose(sessionId: string, state: SessionState): void {
333
445
  });
334
446
  }
335
447
 
448
+ function emitOrbitPlacePose(
449
+ sessionId: string,
450
+ state: SessionState,
451
+ x: number,
452
+ z: number,
453
+ ): void {
454
+ state.lastOrbitPoseEmitMs = Date.now();
455
+ const angleRad = Math.atan2(z, x);
456
+ sendToClient(sessionId, {
457
+ type: "orbit_pose",
458
+ angleDeg: (angleRad * 180) / Math.PI,
459
+ x,
460
+ z,
461
+ });
462
+ }
463
+
336
464
  function startOrbitDemo(sessionId: string, state: SessionState): void {
337
465
  stopOrbitInterval(state);
338
466
  beginOrbitClock(state);
339
467
  void startOrbitSinePlay(sessionId, state);
340
468
  state.orbitTimer = setInterval(() => {
341
- if (state.orbit.paused || !state.orbitStartMs) {
469
+ if (state.orbit.paused || (!state.orbitStartMs && !state.orbitManualPose)) {
342
470
  return;
343
471
  }
344
472
  const pose = currentOrbitPose(state);
@@ -524,6 +652,34 @@ export async function handlePadPlay(
524
652
  }
525
653
  }
526
654
 
655
+ export async function handlePadMove(
656
+ sessionId: string,
657
+ state: SessionState,
658
+ playId: string,
659
+ x: number,
660
+ z: number,
661
+ ): Promise<void> {
662
+ if (!state.playIds.has(playId)) {
663
+ ackError(sessionId, "pad_move", "unknown_play");
664
+ return;
665
+ }
666
+
667
+ const poseResult = await setPlayPose(playId, poseAt(x, z));
668
+ if (!poseResult.ok) {
669
+ ackError(sessionId, "pad_move", poseResult.reason ?? "set_pose_failed");
670
+ return;
671
+ }
672
+
673
+ const loopPad = state.loopPads.get(playId);
674
+ if (loopPad) {
675
+ loopPad.x = x;
676
+ loopPad.z = z;
677
+ delete loopPad.placement;
678
+ }
679
+
680
+ ack(sessionId, "pad_move", { playId, x, z });
681
+ }
682
+
527
683
  async function handleJoin(
528
684
  sessionId: string,
529
685
  demo: ShowcaseDemo,
@@ -657,11 +813,50 @@ async function dispatchMessage(
657
813
  state.orbit.elevation = message.elevation;
658
814
  }
659
815
  if (message.paused !== undefined) {
816
+ if (message.paused === false && state.orbitManualPose) {
817
+ syncOrbitClockFromManualPose(state);
818
+ }
660
819
  state.orbit.paused = message.paused;
661
820
  }
821
+ if (message.frequencyHz !== undefined) {
822
+ state.orbitSineFrequencyHz = message.frequencyHz;
823
+ if (state.orbitSineEnabled && state.orbitSinePlayId) {
824
+ void startOrbitSinePlay(sessionId, state);
825
+ }
826
+ }
827
+ if (message.sinePlaying !== undefined) {
828
+ state.orbitSineEnabled = message.sinePlaying;
829
+ if (message.sinePlaying) {
830
+ if (!state.orbitSinePlayId) {
831
+ void startOrbitSinePlay(sessionId, state);
832
+ }
833
+ } else {
834
+ void stopOrbitSine(state);
835
+ }
836
+ }
662
837
  ack(sessionId, "orbit_set");
663
838
  return;
664
839
  }
840
+ if (message.action === "place") {
841
+ state.orbit.paused = true;
842
+ state.orbitManualPose = { x: message.x, z: message.z };
843
+ const pose = orbitPoseFromXz(state, message.x, message.z);
844
+ if (state.orbitSinePlayId) {
845
+ void safeMixCall(sessionId, "setPlayPose", () =>
846
+ setPlayPose(state.orbitSinePlayId!, pose),
847
+ );
848
+ }
849
+ void safeMixCall(sessionId, "setTtsPose", () =>
850
+ setTtsPose(sessionId, pose),
851
+ );
852
+ emitOrbitPlacePose(sessionId, state, message.x, message.z);
853
+ ack(sessionId, "orbit_place");
854
+ return;
855
+ }
856
+ sendToClient(sessionId, {
857
+ type: "orbit_say_status",
858
+ status: "playing",
859
+ });
665
860
  speak(sessionId, message.text);
666
861
  ack(sessionId, "orbit_say");
667
862
  return;
@@ -718,6 +913,20 @@ async function dispatchMessage(
718
913
  }
719
914
  return;
720
915
  }
916
+ case "pad_move": {
917
+ if (state.demo !== "soundboard") {
918
+ ackError(sessionId, "pad_move", "wrong_demo");
919
+ return;
920
+ }
921
+ await handlePadMove(
922
+ sessionId,
923
+ state,
924
+ message.playId,
925
+ message.x,
926
+ message.z,
927
+ );
928
+ return;
929
+ }
721
930
  case "move": {
722
931
  if (state.demo !== "proximity") {
723
932
  ackError(sessionId, "move", "wrong_demo");
@@ -800,6 +1009,24 @@ defineAgent({
800
1009
  void dispatchMessage(ctx.sessionId, message);
801
1010
  },
802
1011
 
1012
+ onSpeechEvent(ctx, event) {
1013
+ const state = sessions.get(ctx.sessionId);
1014
+ if (!state || state.demo !== "orbit") {
1015
+ return;
1016
+ }
1017
+ if (event.type === "agent_speaking_start") {
1018
+ sendToClient(ctx.sessionId, {
1019
+ type: "orbit_say_status",
1020
+ status: "playing",
1021
+ });
1022
+ } else if (event.type === "agent_speaking_end") {
1023
+ sendToClient(ctx.sessionId, {
1024
+ type: "orbit_say_status",
1025
+ status: "ended",
1026
+ });
1027
+ }
1028
+ },
1029
+
803
1030
  onSessionEnd({ sessionId }) {
804
1031
  teardownSession(sessionId);
805
1032
  agentLog("info", `spatial-showcase session_end ${sessionId}`, sessionId);
@@ -8,6 +8,8 @@ const SHOWCASE_DEMOS = new Set<string>(["orbit", "soundboard", "proximity"]);
8
8
 
9
9
  export const MAX_SAY_TEXT_LENGTH = 200;
10
10
  export const MAX_XZ = 5;
11
+ export const ORBIT_SINE_MIN_FREQUENCY_HZ = 110;
12
+ export const ORBIT_SINE_MAX_FREQUENCY_HZ = 1760;
11
13
 
12
14
  export type ShowcaseJoinMessage = {
13
15
  type: "join";
@@ -22,6 +24,8 @@ export type ShowcaseOrbitSetMessage = {
22
24
  periodSec?: number;
23
25
  elevation?: number;
24
26
  paused?: boolean;
27
+ frequencyHz?: number;
28
+ sinePlaying?: boolean;
25
29
  };
26
30
 
27
31
  export type ShowcaseOrbitSayMessage = {
@@ -30,6 +34,13 @@ export type ShowcaseOrbitSayMessage = {
30
34
  text: string;
31
35
  };
32
36
 
37
+ export type ShowcaseOrbitPlaceMessage = {
38
+ type: "orbit";
39
+ action: "place";
40
+ x: number;
41
+ z: number;
42
+ };
43
+
33
44
  export type ShowcasePadMessage = {
34
45
  type: "pad";
35
46
  clipId: string;
@@ -49,6 +60,13 @@ export type ShowcasePadStatusMessage = {
49
60
  playId: string;
50
61
  };
51
62
 
63
+ export type ShowcasePadMoveMessage = {
64
+ type: "pad_move";
65
+ playId: string;
66
+ x: number;
67
+ z: number;
68
+ };
69
+
52
70
  export type ShowcaseMoveMessage = {
53
71
  type: "move";
54
72
  x: number;
@@ -74,9 +92,11 @@ export type ShowcaseInbound =
74
92
  | ShowcaseJoinMessage
75
93
  | ShowcaseOrbitSetMessage
76
94
  | ShowcaseOrbitSayMessage
95
+ | ShowcaseOrbitPlaceMessage
77
96
  | ShowcasePadMessage
78
97
  | ShowcasePadStopMessage
79
98
  | ShowcasePadStatusMessage
99
+ | ShowcasePadMoveMessage
80
100
  | ShowcaseMoveMessage
81
101
  | ShowcaseMutePeerMessage
82
102
  | ShowcaseLeaveMessage
@@ -98,6 +118,11 @@ export type ShowcaseOrbitPose = {
98
118
  z: number;
99
119
  };
100
120
 
121
+ export type ShowcaseOrbitSayStatus = {
122
+ type: "orbit_say_status";
123
+ status: "playing" | "ended";
124
+ };
125
+
101
126
  export type ShowcasePadProgress = {
102
127
  type: "pad_progress";
103
128
  playId: string;
@@ -127,6 +152,7 @@ export type ShowcaseError = {
127
152
  export type ShowcaseOutbound =
128
153
  | ShowcaseAck
129
154
  | ShowcaseOrbitPose
155
+ | ShowcaseOrbitSayStatus
130
156
  | ShowcasePadProgress
131
157
  | ShowcaseRoomState
132
158
  | ShowcaseRoomFull
@@ -144,6 +170,12 @@ function isInXzRange(value: number): boolean {
144
170
  return value >= -MAX_XZ && value <= MAX_XZ;
145
171
  }
146
172
 
173
+ function isOrbitSineFrequencyHz(value: number): boolean {
174
+ return (
175
+ value >= ORBIT_SINE_MIN_FREQUENCY_HZ && value <= ORBIT_SINE_MAX_FREQUENCY_HZ
176
+ );
177
+ }
178
+
147
179
  function isMixPlacement(value: unknown): value is MixPlacement {
148
180
  return (
149
181
  value === "center" ||
@@ -221,6 +253,19 @@ export function parseShowcaseMessage(message: unknown): ShowcaseInbound | null {
221
253
  if (set.paused !== undefined && typeof set.paused !== "boolean") {
222
254
  return null;
223
255
  }
256
+ if (
257
+ set.frequencyHz !== undefined &&
258
+ (!isFiniteNumber(set.frequencyHz) ||
259
+ !isOrbitSineFrequencyHz(set.frequencyHz))
260
+ ) {
261
+ return null;
262
+ }
263
+ if (
264
+ set.sinePlaying !== undefined &&
265
+ typeof set.sinePlaying !== "boolean"
266
+ ) {
267
+ return null;
268
+ }
224
269
  return {
225
270
  type: "orbit",
226
271
  action: "set",
@@ -228,8 +273,24 @@ export function parseShowcaseMessage(message: unknown): ShowcaseInbound | null {
228
273
  ...(set.periodSec !== undefined ? { periodSec: set.periodSec } : {}),
229
274
  ...(set.elevation !== undefined ? { elevation: set.elevation } : {}),
230
275
  ...(set.paused !== undefined ? { paused: set.paused } : {}),
276
+ ...(set.frequencyHz !== undefined
277
+ ? { frequencyHz: set.frequencyHz }
278
+ : {}),
279
+ ...(set.sinePlaying !== undefined
280
+ ? { sinePlaying: set.sinePlaying }
281
+ : {}),
231
282
  };
232
283
  }
284
+ if (orbit.action === "place") {
285
+ const place = message as ShowcaseOrbitPlaceMessage;
286
+ if (!isFiniteNumber(place.x) || !isInXzRange(place.x)) {
287
+ return null;
288
+ }
289
+ if (!isFiniteNumber(place.z) || !isInXzRange(place.z)) {
290
+ return null;
291
+ }
292
+ return { type: "orbit", action: "place", x: place.x, z: place.z };
293
+ }
233
294
  if (orbit.action === "say") {
234
295
  const say = message as ShowcaseOrbitSayMessage;
235
296
  if (
@@ -287,6 +348,24 @@ export function parseShowcaseMessage(message: unknown): ShowcaseInbound | null {
287
348
  }
288
349
  return { type: "pad_status", playId: status.playId.trim() };
289
350
  }
351
+ case "pad_move": {
352
+ const padMove = message as ShowcasePadMoveMessage;
353
+ if (!isNonEmptyString(padMove.playId)) {
354
+ return null;
355
+ }
356
+ if (!isFiniteNumber(padMove.x) || !isInXzRange(padMove.x)) {
357
+ return null;
358
+ }
359
+ if (!isFiniteNumber(padMove.z) || !isInXzRange(padMove.z)) {
360
+ return null;
361
+ }
362
+ return {
363
+ type: "pad_move",
364
+ playId: padMove.playId.trim(),
365
+ x: padMove.x,
366
+ z: padMove.z,
367
+ };
368
+ }
290
369
  case "move": {
291
370
  const move = message as ShowcaseMoveMessage;
292
371
  if (!isFiniteNumber(move.x) || !isInXzRange(move.x)) {
@@ -1,22 +1,45 @@
1
1
  /** Inline orbit sine clip — 16 kHz mono s16le, decoded size under 64 KiB play cap. */
2
2
 
3
- /** 2 s keeps loop-restart gaps (250 ms poll) infrequent; 16 kHz mono still fits under 64 KiB. */
3
+ /** 2 s keeps loop-restart gaps infrequent; 16 kHz mono still fits under 64 KiB. */
4
4
  export const ORBIT_SINE_DURATION_MS = 2000;
5
5
  export const ORBIT_SINE_FREQUENCY_HZ = 440;
6
6
  export const ORBIT_SINE_AMPLITUDE = 8000;
7
+ export const ORBIT_SINE_SAMPLE_RATE = 16_000;
7
8
  export const ORBIT_SINE_INLINE_URL =
8
9
  "https://example.com/inline-orbit-sine.wav";
9
10
 
10
- /** Looping ~440 Hz sine WAV as base64 for {@link play} inline bytes. */
11
+ /** Snap duration to an integer number of cycles so the clip loops at a zero crossing. */
12
+ export function orbitSineSampleCount(
13
+ durationMs = ORBIT_SINE_DURATION_MS,
14
+ frequencyHz = ORBIT_SINE_FREQUENCY_HZ,
15
+ sampleRate = ORBIT_SINE_SAMPLE_RATE,
16
+ ): number {
17
+ const targetCycles = (durationMs / 1000) * frequencyHz;
18
+ const cycles = Math.max(1, Math.floor(targetCycles));
19
+ return Math.round((cycles * sampleRate) / frequencyHz);
20
+ }
21
+
22
+ /** Duration in ms for a clip built with {@link orbitSineSampleCount}. */
23
+ export function orbitSineClipDurationMs(
24
+ durationMs = ORBIT_SINE_DURATION_MS,
25
+ frequencyHz = ORBIT_SINE_FREQUENCY_HZ,
26
+ sampleRate = ORBIT_SINE_SAMPLE_RATE,
27
+ ): number {
28
+ const numSamples = orbitSineSampleCount(durationMs, frequencyHz, sampleRate);
29
+ return (numSamples / sampleRate) * 1000;
30
+ }
31
+
32
+ /** Looping sine WAV as base64 for {@link play} inline bytes. */
11
33
  export function buildOrbitSineInlineClipBase64(
12
34
  durationMs = ORBIT_SINE_DURATION_MS,
13
35
  frequencyHz = ORBIT_SINE_FREQUENCY_HZ,
14
36
  amplitude = ORBIT_SINE_AMPLITUDE,
37
+ phaseRad = 0,
15
38
  ): string {
16
- const sampleRate = 16_000;
39
+ const sampleRate = ORBIT_SINE_SAMPLE_RATE;
17
40
  const channels = 1;
18
41
  const bytesPerSample = 2;
19
- const numSamples = Math.floor((sampleRate * durationMs) / 1000);
42
+ const numSamples = orbitSineSampleCount(durationMs, frequencyHz, sampleRate);
20
43
  const dataSize = numSamples * channels * bytesPerSample;
21
44
  const buffer = Buffer.alloc(44 + dataSize);
22
45
  buffer.write("RIFF", 0);
@@ -35,7 +58,7 @@ export function buildOrbitSineInlineClipBase64(
35
58
  for (let i = 0; i < numSamples; i++) {
36
59
  const t = i / sampleRate;
37
60
  const sample = Math.round(
38
- amplitude * Math.sin(2 * Math.PI * frequencyHz * t),
61
+ amplitude * Math.sin(2 * Math.PI * frequencyHz * t + phaseRad),
39
62
  );
40
63
  buffer.writeInt16LE(sample, 44 + i * 2);
41
64
  }