@komaa/standin-sdk 0.1.0

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.
Files changed (269) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +141 -0
  3. package/dist/ambient.d.ts +80 -0
  4. package/dist/ambient.d.ts.map +1 -0
  5. package/dist/ambient.js +281 -0
  6. package/dist/ambient.js.map +1 -0
  7. package/dist/attachments.d.ts +149 -0
  8. package/dist/attachments.d.ts.map +1 -0
  9. package/dist/attachments.js +451 -0
  10. package/dist/attachments.js.map +1 -0
  11. package/dist/audio.d.ts +80 -0
  12. package/dist/audio.d.ts.map +1 -0
  13. package/dist/audio.js +170 -0
  14. package/dist/audio.js.map +1 -0
  15. package/dist/avatar.d.ts +111 -0
  16. package/dist/avatar.d.ts.map +1 -0
  17. package/dist/avatar.js +201 -0
  18. package/dist/avatar.js.map +1 -0
  19. package/dist/callServer.d.ts +151 -0
  20. package/dist/callServer.d.ts.map +1 -0
  21. package/dist/callServer.js +1097 -0
  22. package/dist/callServer.js.map +1 -0
  23. package/dist/callTools.d.ts +154 -0
  24. package/dist/callTools.d.ts.map +1 -0
  25. package/dist/callTools.js +255 -0
  26. package/dist/callTools.js.map +1 -0
  27. package/dist/chat.d.ts +226 -0
  28. package/dist/chat.d.ts.map +1 -0
  29. package/dist/chat.js +574 -0
  30. package/dist/chat.js.map +1 -0
  31. package/dist/config.d.ts +32 -0
  32. package/dist/config.d.ts.map +1 -0
  33. package/dist/config.js +86 -0
  34. package/dist/config.js.map +1 -0
  35. package/dist/consult.d.ts +147 -0
  36. package/dist/consult.d.ts.map +1 -0
  37. package/dist/consult.js +388 -0
  38. package/dist/consult.js.map +1 -0
  39. package/dist/delivery.d.ts +81 -0
  40. package/dist/delivery.d.ts.map +1 -0
  41. package/dist/delivery.js +187 -0
  42. package/dist/delivery.js.map +1 -0
  43. package/dist/echoGuard.d.ts +56 -0
  44. package/dist/echoGuard.d.ts.map +1 -0
  45. package/dist/echoGuard.js +57 -0
  46. package/dist/echoGuard.js.map +1 -0
  47. package/dist/errors.d.ts +5 -0
  48. package/dist/errors.d.ts.map +1 -0
  49. package/dist/errors.js +10 -0
  50. package/dist/errors.js.map +1 -0
  51. package/dist/fetch.d.ts +40 -0
  52. package/dist/fetch.d.ts.map +1 -0
  53. package/dist/fetch.js +240 -0
  54. package/dist/fetch.js.map +1 -0
  55. package/dist/gate.d.ts +80 -0
  56. package/dist/gate.d.ts.map +1 -0
  57. package/dist/gate.js +249 -0
  58. package/dist/gate.js.map +1 -0
  59. package/dist/handler.d.ts +283 -0
  60. package/dist/handler.d.ts.map +1 -0
  61. package/dist/handler.js +4 -0
  62. package/dist/handler.js.map +1 -0
  63. package/dist/hmac.d.ts +32 -0
  64. package/dist/hmac.d.ts.map +1 -0
  65. package/dist/hmac.js +108 -0
  66. package/dist/hmac.js.map +1 -0
  67. package/dist/index.d.ts +59 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +60 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/lane.d.ts +92 -0
  72. package/dist/lane.d.ts.map +1 -0
  73. package/dist/lane.js +269 -0
  74. package/dist/lane.js.map +1 -0
  75. package/dist/lipsync.d.ts +136 -0
  76. package/dist/lipsync.d.ts.map +1 -0
  77. package/dist/lipsync.js +310 -0
  78. package/dist/lipsync.js.map +1 -0
  79. package/dist/log.d.ts +18 -0
  80. package/dist/log.d.ts.map +1 -0
  81. package/dist/log.js +20 -0
  82. package/dist/log.js.map +1 -0
  83. package/dist/media.d.ts +43 -0
  84. package/dist/media.d.ts.map +1 -0
  85. package/dist/media.js +190 -0
  86. package/dist/media.js.map +1 -0
  87. package/dist/minutes.d.ts +398 -0
  88. package/dist/minutes.d.ts.map +1 -0
  89. package/dist/minutes.js +805 -0
  90. package/dist/minutes.js.map +1 -0
  91. package/dist/outbound.d.ts +388 -0
  92. package/dist/outbound.d.ts.map +1 -0
  93. package/dist/outbound.js +933 -0
  94. package/dist/outbound.js.map +1 -0
  95. package/dist/plugins/cartesia/agent.d.ts +58 -0
  96. package/dist/plugins/cartesia/agent.d.ts.map +1 -0
  97. package/dist/plugins/cartesia/agent.js +254 -0
  98. package/dist/plugins/cartesia/agent.js.map +1 -0
  99. package/dist/plugins/cartesia/cli.d.ts +3 -0
  100. package/dist/plugins/cartesia/cli.d.ts.map +1 -0
  101. package/dist/plugins/cartesia/cli.js +10 -0
  102. package/dist/plugins/cartesia/cli.js.map +1 -0
  103. package/dist/plugins/cartesia/config.d.ts +30 -0
  104. package/dist/plugins/cartesia/config.d.ts.map +1 -0
  105. package/dist/plugins/cartesia/config.js +29 -0
  106. package/dist/plugins/cartesia/config.js.map +1 -0
  107. package/dist/plugins/cartesia/handler.d.ts +40 -0
  108. package/dist/plugins/cartesia/handler.d.ts.map +1 -0
  109. package/dist/plugins/cartesia/handler.js +142 -0
  110. package/dist/plugins/cartesia/handler.js.map +1 -0
  111. package/dist/plugins/cartesia/index.d.ts +6 -0
  112. package/dist/plugins/cartesia/index.d.ts.map +1 -0
  113. package/dist/plugins/cartesia/index.js +54 -0
  114. package/dist/plugins/cartesia/index.js.map +1 -0
  115. package/dist/plugins/deepgram/agent.d.ts +60 -0
  116. package/dist/plugins/deepgram/agent.d.ts.map +1 -0
  117. package/dist/plugins/deepgram/agent.js +302 -0
  118. package/dist/plugins/deepgram/agent.js.map +1 -0
  119. package/dist/plugins/deepgram/cli.d.ts +3 -0
  120. package/dist/plugins/deepgram/cli.d.ts.map +1 -0
  121. package/dist/plugins/deepgram/cli.js +10 -0
  122. package/dist/plugins/deepgram/cli.js.map +1 -0
  123. package/dist/plugins/deepgram/config.d.ts +39 -0
  124. package/dist/plugins/deepgram/config.d.ts.map +1 -0
  125. package/dist/plugins/deepgram/config.js +66 -0
  126. package/dist/plugins/deepgram/config.js.map +1 -0
  127. package/dist/plugins/deepgram/handler.d.ts +87 -0
  128. package/dist/plugins/deepgram/handler.d.ts.map +1 -0
  129. package/dist/plugins/deepgram/handler.js +268 -0
  130. package/dist/plugins/deepgram/handler.js.map +1 -0
  131. package/dist/plugins/deepgram/index.d.ts +11 -0
  132. package/dist/plugins/deepgram/index.d.ts.map +1 -0
  133. package/dist/plugins/deepgram/index.js +76 -0
  134. package/dist/plugins/deepgram/index.js.map +1 -0
  135. package/dist/plugins/echo/cli.d.ts +3 -0
  136. package/dist/plugins/echo/cli.d.ts.map +1 -0
  137. package/dist/plugins/echo/cli.js +10 -0
  138. package/dist/plugins/echo/cli.js.map +1 -0
  139. package/dist/plugins/echo/index.d.ts +37 -0
  140. package/dist/plugins/echo/index.d.ts.map +1 -0
  141. package/dist/plugins/echo/index.js +61 -0
  142. package/dist/plugins/echo/index.js.map +1 -0
  143. package/dist/plugins/elevenlabs/agent.d.ts +72 -0
  144. package/dist/plugins/elevenlabs/agent.d.ts.map +1 -0
  145. package/dist/plugins/elevenlabs/agent.js +305 -0
  146. package/dist/plugins/elevenlabs/agent.js.map +1 -0
  147. package/dist/plugins/elevenlabs/cli.d.ts +3 -0
  148. package/dist/plugins/elevenlabs/cli.d.ts.map +1 -0
  149. package/dist/plugins/elevenlabs/cli.js +10 -0
  150. package/dist/plugins/elevenlabs/cli.js.map +1 -0
  151. package/dist/plugins/elevenlabs/config.d.ts +27 -0
  152. package/dist/plugins/elevenlabs/config.d.ts.map +1 -0
  153. package/dist/plugins/elevenlabs/config.js +42 -0
  154. package/dist/plugins/elevenlabs/config.js.map +1 -0
  155. package/dist/plugins/elevenlabs/handler.d.ts +67 -0
  156. package/dist/plugins/elevenlabs/handler.d.ts.map +1 -0
  157. package/dist/plugins/elevenlabs/handler.js +419 -0
  158. package/dist/plugins/elevenlabs/handler.js.map +1 -0
  159. package/dist/plugins/elevenlabs/index.d.ts +11 -0
  160. package/dist/plugins/elevenlabs/index.d.ts.map +1 -0
  161. package/dist/plugins/elevenlabs/index.js +72 -0
  162. package/dist/plugins/elevenlabs/index.js.map +1 -0
  163. package/dist/plugins/livekit/cli.d.ts +3 -0
  164. package/dist/plugins/livekit/cli.d.ts.map +1 -0
  165. package/dist/plugins/livekit/cli.js +10 -0
  166. package/dist/plugins/livekit/cli.js.map +1 -0
  167. package/dist/plugins/livekit/config.d.ts +47 -0
  168. package/dist/plugins/livekit/config.d.ts.map +1 -0
  169. package/dist/plugins/livekit/config.js +46 -0
  170. package/dist/plugins/livekit/config.js.map +1 -0
  171. package/dist/plugins/livekit/handler.d.ts +59 -0
  172. package/dist/plugins/livekit/handler.d.ts.map +1 -0
  173. package/dist/plugins/livekit/handler.js +211 -0
  174. package/dist/plugins/livekit/handler.js.map +1 -0
  175. package/dist/plugins/livekit/index.d.ts +6 -0
  176. package/dist/plugins/livekit/index.d.ts.map +1 -0
  177. package/dist/plugins/livekit/index.js +68 -0
  178. package/dist/plugins/livekit/index.js.map +1 -0
  179. package/dist/plugins/livekit/room.d.ts +117 -0
  180. package/dist/plugins/livekit/room.d.ts.map +1 -0
  181. package/dist/plugins/livekit/room.js +449 -0
  182. package/dist/plugins/livekit/room.js.map +1 -0
  183. package/dist/plugins/openai/agent.d.ts +74 -0
  184. package/dist/plugins/openai/agent.d.ts.map +1 -0
  185. package/dist/plugins/openai/agent.js +264 -0
  186. package/dist/plugins/openai/agent.js.map +1 -0
  187. package/dist/plugins/openai/cli.d.ts +3 -0
  188. package/dist/plugins/openai/cli.d.ts.map +1 -0
  189. package/dist/plugins/openai/cli.js +10 -0
  190. package/dist/plugins/openai/cli.js.map +1 -0
  191. package/dist/plugins/openai/config.d.ts +29 -0
  192. package/dist/plugins/openai/config.d.ts.map +1 -0
  193. package/dist/plugins/openai/config.js +37 -0
  194. package/dist/plugins/openai/config.js.map +1 -0
  195. package/dist/plugins/openai/handler.d.ts +65 -0
  196. package/dist/plugins/openai/handler.d.ts.map +1 -0
  197. package/dist/plugins/openai/handler.js +297 -0
  198. package/dist/plugins/openai/handler.js.map +1 -0
  199. package/dist/plugins/openai/index.d.ts +6 -0
  200. package/dist/plugins/openai/index.d.ts.map +1 -0
  201. package/dist/plugins/openai/index.js +72 -0
  202. package/dist/plugins/openai/index.js.map +1 -0
  203. package/dist/plugins/openclaw/allowlist.d.ts +9 -0
  204. package/dist/plugins/openclaw/allowlist.d.ts.map +1 -0
  205. package/dist/plugins/openclaw/allowlist.js +11 -0
  206. package/dist/plugins/openclaw/allowlist.js.map +1 -0
  207. package/dist/plugins/openclaw/config.d.ts +50 -0
  208. package/dist/plugins/openclaw/config.d.ts.map +1 -0
  209. package/dist/plugins/openclaw/config.js +53 -0
  210. package/dist/plugins/openclaw/config.js.map +1 -0
  211. package/dist/plugins/openclaw/handler.d.ts +90 -0
  212. package/dist/plugins/openclaw/handler.d.ts.map +1 -0
  213. package/dist/plugins/openclaw/handler.js +146 -0
  214. package/dist/plugins/openclaw/handler.js.map +1 -0
  215. package/dist/plugins/openclaw/index.d.ts +55 -0
  216. package/dist/plugins/openclaw/index.d.ts.map +1 -0
  217. package/dist/plugins/openclaw/index.js +89 -0
  218. package/dist/plugins/openclaw/index.js.map +1 -0
  219. package/dist/plugins/openclaw/openclaw.plugin.json +74 -0
  220. package/dist/plugins/openclaw/package.json +14 -0
  221. package/dist/plugins/openclaw/realtime.d.ts +82 -0
  222. package/dist/plugins/openclaw/realtime.d.ts.map +1 -0
  223. package/dist/plugins/openclaw/realtime.js +238 -0
  224. package/dist/plugins/openclaw/realtime.js.map +1 -0
  225. package/dist/plugins/openclaw/runtime.d.ts +19 -0
  226. package/dist/plugins/openclaw/runtime.d.ts.map +1 -0
  227. package/dist/plugins/openclaw/runtime.js +160 -0
  228. package/dist/plugins/openclaw/runtime.js.map +1 -0
  229. package/dist/policy.d.ts +38 -0
  230. package/dist/policy.d.ts.map +1 -0
  231. package/dist/policy.js +95 -0
  232. package/dist/policy.js.map +1 -0
  233. package/dist/protocol.d.ts +44 -0
  234. package/dist/protocol.d.ts.map +1 -0
  235. package/dist/protocol.js +77 -0
  236. package/dist/protocol.js.map +1 -0
  237. package/dist/protocolRuntime.d.ts +19 -0
  238. package/dist/protocolRuntime.d.ts.map +1 -0
  239. package/dist/protocolRuntime.js +78 -0
  240. package/dist/protocolRuntime.js.map +1 -0
  241. package/dist/smoke.d.ts +65 -0
  242. package/dist/smoke.d.ts.map +1 -0
  243. package/dist/smoke.js +293 -0
  244. package/dist/smoke.js.map +1 -0
  245. package/dist/startup.d.ts +74 -0
  246. package/dist/startup.d.ts.map +1 -0
  247. package/dist/startup.js +112 -0
  248. package/dist/startup.js.map +1 -0
  249. package/dist/tile.d.ts +101 -0
  250. package/dist/tile.d.ts.map +1 -0
  251. package/dist/tile.js +195 -0
  252. package/dist/tile.js.map +1 -0
  253. package/dist/version.d.ts +2 -0
  254. package/dist/version.d.ts.map +1 -0
  255. package/dist/version.js +4 -0
  256. package/dist/version.js.map +1 -0
  257. package/dist/vision.d.ts +160 -0
  258. package/dist/vision.d.ts.map +1 -0
  259. package/dist/vision.js +301 -0
  260. package/dist/vision.js.map +1 -0
  261. package/dist/visionTools.d.ts +237 -0
  262. package/dist/visionTools.d.ts.map +1 -0
  263. package/dist/visionTools.js +578 -0
  264. package/dist/visionTools.js.map +1 -0
  265. package/dist/voice.d.ts +143 -0
  266. package/dist/voice.d.ts.map +1 -0
  267. package/dist/voice.js +403 -0
  268. package/dist/voice.js.map +1 -0
  269. package/package.json +129 -0
@@ -0,0 +1,80 @@
1
+ /** 16-bit mono. */
2
+ export declare const BYTES_PER_SAMPLE = 2;
3
+ /** The nominal frame StandIn sends: 20 ms of PCM16 at 16 kHz = 320 samples. */
4
+ export declare const FRAME_MS = 20;
5
+ export declare const FRAME_BYTES: number;
6
+ /**
7
+ * What the realtime speech-to-speech models speak. Named here rather than in a
8
+ * plugin because the RATIO is what forces the residual buffer below, and that is
9
+ * an audio concern rather than a provider one.
10
+ */
11
+ export declare const REALTIME_SAMPLE_RATE_HZ = 24000;
12
+ /**
13
+ * Duration of a PCM16 mono buffer in milliseconds.
14
+ *
15
+ * Use this for a playout clock rather than counting frames: outbound chunk
16
+ * lengths are NOT fixed, so a frame count drifts against real time.
17
+ */
18
+ export declare function frameDurationMs(pcm: Buffer): number;
19
+ /**
20
+ * Linear-interpolation resample of PCM16 mono.
21
+ *
22
+ * An odd trailing byte is dropped rather than throwing: a truncated frame is a
23
+ * glitch, but an exception in the audio path is a dropped call.
24
+ */
25
+ export declare function resamplePcm16(pcm: Buffer, srcHz: number, dstHz: number): Buffer;
26
+ /**
27
+ * Chops arbitrary-length PCM buffers into whole wire frames, carrying the
28
+ * remainder.
29
+ *
30
+ * Resampled 24 kHz deltas do not divide evenly into the wire's 640-byte frame,
31
+ * so without a residual the leftover bytes are dropped and every turn loses a
32
+ * few milliseconds at the seams. Over a call that is audible as clipped word
33
+ * endings.
34
+ *
35
+ * ```ts
36
+ * const aligner = new FrameAligner();
37
+ * for (const chunk of providerAudio) { // arbitrary lengths
38
+ * for (const frame of aligner.push(chunk)) { // whole 640-byte frames
39
+ * await session.sendAudio(frame);
40
+ * }
41
+ * }
42
+ * const tail = aligner.flush(); // end of turn
43
+ * if (tail) await session.sendAudio(tail);
44
+ * ```
45
+ */
46
+ export declare class FrameAligner {
47
+ #private;
48
+ constructor(frameBytes?: number);
49
+ /** Bytes held back, waiting for a whole frame. */
50
+ get pending(): number;
51
+ /** Add a buffer; return whatever whole frames are now available. */
52
+ push(pcm: Buffer): Buffer[];
53
+ /**
54
+ * Zero-pad and return the residual at end of turn, or undefined when empty.
55
+ *
56
+ * Padding rather than dropping: the tail of the last word matters more than a
57
+ * few milliseconds of silence.
58
+ */
59
+ flush(): Buffer | undefined;
60
+ /**
61
+ * Drop the residual without emitting it - use on a barge-in, where the
62
+ * held-back bytes belong to a turn the caller just interrupted.
63
+ */
64
+ reset(): void;
65
+ }
66
+ /**
67
+ * Root-mean-square amplitude of PCM16 mono little-endian, normalised to 0.0 - 1.0.
68
+ *
69
+ * How loud a frame is, which is what an echo guard, a barge-in check and a voice
70
+ * segmenter each need. It lives here because all three want it and every plugin
71
+ * that wanted it had been writing it again.
72
+ *
73
+ * Dependency-free for the same reason the resampler above is: this runs on every
74
+ * inbound frame of every call.
75
+ *
76
+ * An odd trailing byte is dropped rather than throwing. A truncated frame is a
77
+ * glitch; an exception in the audio path is a dropped call.
78
+ */
79
+ export declare function pcm16Rms(pcm: Buffer): number;
80
+ //# sourceMappingURL=audio.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audio.d.ts","sourceRoot":"","sources":["../src/audio.ts"],"names":[],"mappings":"AA2BA,mBAAmB;AACnB,eAAO,MAAM,gBAAgB,IAAI,CAAC;AAElC,+EAA+E;AAC/E,eAAO,MAAM,QAAQ,KAAK,CAAC;AAC3B,eAAO,MAAM,WAAW,QAC+B,CAAC;AAExD;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,QAAS,CAAC;AAE9C;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,GACZ,MAAM,CA8BR;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,qBAAa,YAAY;;gBAIX,UAAU,GAAE,MAAoB;IAI5C,kDAAkD;IAClD,IAAI,OAAO,IAAI,MAAM,CAEpB;IAED,oEAAoE;IACpE,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE;IAa3B;;;;;OAKG;IACH,KAAK,IAAI,MAAM,GAAG,SAAS;IAO3B;;;OAGG;IACH,KAAK,IAAI,IAAI;CAGd;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAS5C"}
package/dist/audio.js ADDED
@@ -0,0 +1,170 @@
1
+ // Copyright (c) 2026 Komaa DigiTech
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * The audio the wire carries, and the two helpers every plugin needs.
5
+ *
6
+ * The StandIn wire is PCM 16 kHz, 16-bit, mono, little-endian in both
7
+ * directions. Almost nothing else is. The realtime speech-to-speech models speak
8
+ * 24 kHz, most TTS vendors emit 22.05 or 24 kHz, and none of them chunk on the
9
+ * wire's frame boundary. So every plugin that is not a pure passthrough
10
+ * ends up writing the same two things:
11
+ *
12
+ * - a resampler, because the rates differ
13
+ * - a frame aligner, because a resampled buffer does not divide evenly into the
14
+ * wire's 640-byte frame, and dropping the remainder clips the end of every turn
15
+ *
16
+ * They live here rather than in each plugin because they are properties of the
17
+ * WIRE, not of any framework - the same reason the sequence number and the
18
+ * outbound timeline live in {@link CallServer}. A plugin that has to reimplement
19
+ * them is a plugin the SDK failed.
20
+ *
21
+ * Byte-for-byte equivalent to the Python SDK's `standin/sdk/audio.py`; the
22
+ * shared conformance vectors assert both produce identical output.
23
+ */
24
+ import { SAMPLE_RATE_HZ } from "./protocol.js";
25
+ /** 16-bit mono. */
26
+ export const BYTES_PER_SAMPLE = 2;
27
+ /** The nominal frame StandIn sends: 20 ms of PCM16 at 16 kHz = 320 samples. */
28
+ export const FRAME_MS = 20;
29
+ export const FRAME_BYTES = (SAMPLE_RATE_HZ * BYTES_PER_SAMPLE * FRAME_MS) / 1000; // 640
30
+ /**
31
+ * What the realtime speech-to-speech models speak. Named here rather than in a
32
+ * plugin because the RATIO is what forces the residual buffer below, and that is
33
+ * an audio concern rather than a provider one.
34
+ */
35
+ export const REALTIME_SAMPLE_RATE_HZ = 24_000;
36
+ /**
37
+ * Duration of a PCM16 mono buffer in milliseconds.
38
+ *
39
+ * Use this for a playout clock rather than counting frames: outbound chunk
40
+ * lengths are NOT fixed, so a frame count drifts against real time.
41
+ */
42
+ export function frameDurationMs(pcm) {
43
+ return (pcm.length / (SAMPLE_RATE_HZ * BYTES_PER_SAMPLE)) * 1000.0;
44
+ }
45
+ /**
46
+ * Linear-interpolation resample of PCM16 mono.
47
+ *
48
+ * An odd trailing byte is dropped rather than throwing: a truncated frame is a
49
+ * glitch, but an exception in the audio path is a dropped call.
50
+ */
51
+ export function resamplePcm16(pcm, srcHz, dstHz) {
52
+ if (srcHz === dstHz || pcm.length === 0)
53
+ return pcm;
54
+ const usable = pcm.length % 2 === 0 ? pcm : pcm.subarray(0, pcm.length - 1);
55
+ const nIn = usable.length / 2;
56
+ if (nIn === 0)
57
+ return Buffer.alloc(0);
58
+ // Math.round matches Python's round() for the .5 cases these rates produce
59
+ // (2:3 and 3:2 never land exactly on .5), so both SDKs agree on n_out.
60
+ const nOut = Math.max(1, Math.round((nIn * dstHz) / srcHz));
61
+ const step = nIn / nOut;
62
+ const out = Buffer.alloc(nOut * 2);
63
+ for (let i = 0; i < nOut; i++) {
64
+ const pos = i * step;
65
+ const j = Math.trunc(pos);
66
+ let value;
67
+ if (j >= nIn - 1) {
68
+ value = usable.readInt16LE((nIn - 1) * 2);
69
+ }
70
+ else {
71
+ const a = usable.readInt16LE(j * 2);
72
+ const b = usable.readInt16LE((j + 1) * 2);
73
+ // Math.trunc, not Math.floor: Python's int() truncates toward zero, and
74
+ // interpolating between a negative and a less-negative sample makes the
75
+ // difference visible.
76
+ value = Math.trunc(a + (b - a) * (pos - j));
77
+ }
78
+ out.writeInt16LE(value, i * 2);
79
+ }
80
+ return out;
81
+ }
82
+ /**
83
+ * Chops arbitrary-length PCM buffers into whole wire frames, carrying the
84
+ * remainder.
85
+ *
86
+ * Resampled 24 kHz deltas do not divide evenly into the wire's 640-byte frame,
87
+ * so without a residual the leftover bytes are dropped and every turn loses a
88
+ * few milliseconds at the seams. Over a call that is audible as clipped word
89
+ * endings.
90
+ *
91
+ * ```ts
92
+ * const aligner = new FrameAligner();
93
+ * for (const chunk of providerAudio) { // arbitrary lengths
94
+ * for (const frame of aligner.push(chunk)) { // whole 640-byte frames
95
+ * await session.sendAudio(frame);
96
+ * }
97
+ * }
98
+ * const tail = aligner.flush(); // end of turn
99
+ * if (tail) await session.sendAudio(tail);
100
+ * ```
101
+ */
102
+ export class FrameAligner {
103
+ #frameBytes;
104
+ #buf = Buffer.alloc(0);
105
+ constructor(frameBytes = FRAME_BYTES) {
106
+ this.#frameBytes = frameBytes;
107
+ }
108
+ /** Bytes held back, waiting for a whole frame. */
109
+ get pending() {
110
+ return this.#buf.length;
111
+ }
112
+ /** Add a buffer; return whatever whole frames are now available. */
113
+ push(pcm) {
114
+ this.#buf =
115
+ this.#buf.length === 0
116
+ ? Buffer.from(pcm)
117
+ : Buffer.concat([this.#buf, pcm]);
118
+ const out = [];
119
+ while (this.#buf.length >= this.#frameBytes) {
120
+ out.push(this.#buf.subarray(0, this.#frameBytes));
121
+ this.#buf = this.#buf.subarray(this.#frameBytes);
122
+ }
123
+ return out;
124
+ }
125
+ /**
126
+ * Zero-pad and return the residual at end of turn, or undefined when empty.
127
+ *
128
+ * Padding rather than dropping: the tail of the last word matters more than a
129
+ * few milliseconds of silence.
130
+ */
131
+ flush() {
132
+ if (this.#buf.length === 0)
133
+ return undefined;
134
+ const tail = Buffer.concat([this.#buf], this.#frameBytes);
135
+ this.#buf = Buffer.alloc(0);
136
+ return tail;
137
+ }
138
+ /**
139
+ * Drop the residual without emitting it - use on a barge-in, where the
140
+ * held-back bytes belong to a turn the caller just interrupted.
141
+ */
142
+ reset() {
143
+ this.#buf = Buffer.alloc(0);
144
+ }
145
+ }
146
+ /**
147
+ * Root-mean-square amplitude of PCM16 mono little-endian, normalised to 0.0 - 1.0.
148
+ *
149
+ * How loud a frame is, which is what an echo guard, a barge-in check and a voice
150
+ * segmenter each need. It lives here because all three want it and every plugin
151
+ * that wanted it had been writing it again.
152
+ *
153
+ * Dependency-free for the same reason the resampler above is: this runs on every
154
+ * inbound frame of every call.
155
+ *
156
+ * An odd trailing byte is dropped rather than throwing. A truncated frame is a
157
+ * glitch; an exception in the audio path is a dropped call.
158
+ */
159
+ export function pcm16Rms(pcm) {
160
+ const samples = Math.floor(pcm.length / 2);
161
+ if (samples === 0)
162
+ return 0;
163
+ let sum = 0;
164
+ for (let i = 0; i < samples; i += 1) {
165
+ const sample = pcm.readInt16LE(i * 2) / 32768;
166
+ sum += sample * sample;
167
+ }
168
+ return Math.sqrt(sum / samples);
169
+ }
170
+ //# sourceMappingURL=audio.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audio.js","sourceRoot":"","sources":["../src/audio.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The emotions StandIn knows by name. An open set: sending something else is
3
+ * allowed and renders as neutral, so a newer sender and an older service still
4
+ * interoperate.
5
+ */
6
+ export declare const EMOTIONS: readonly ["neutral", "happy", "sad", "surprised", "thinking"];
7
+ /** A well-known emotion, or any other string the service may learn later. */
8
+ export type Emotion = (typeof EMOTIONS)[number] | (string & {});
9
+ /**
10
+ * Visemes use the Azure Speech numbering, 0 to 21, which is what the avatar
11
+ * expects. A mark outside that range is dropped rather than sent.
12
+ */
13
+ export declare const MAX_VISEME_ID = 21;
14
+ /**
15
+ * An emotion reaches the avatar tile, and the string usually came from a model
16
+ * that whoever is on the call is steering. Bounded here, where the message is
17
+ * built, so a plugin cannot forget to bound it.
18
+ */
19
+ export declare const MAX_EMOTION_CHARS = 40;
20
+ /** One viseme mark: milliseconds from the start of the utterance, and which mouth shape to hold. */
21
+ export interface SpeechMark {
22
+ /** Time offset in ms, relative to the utterance's audio start. */
23
+ readonly tMs: number;
24
+ /** Viseme id in the Azure Speech numbering, 0-21. */
25
+ readonly visemeId: number;
26
+ }
27
+ /**
28
+ * Build an `expression`: the emotion the avatar should wear.
29
+ *
30
+ * Affects the video tile only, never the audio. Throws on an empty value or one
31
+ * longer than {@link MAX_EMOTION_CHARS}; everything else is the service's to
32
+ * interpret, and an unknown emotion renders as neutral.
33
+ */
34
+ export declare function expression(emotion: Emotion): string;
35
+ /** The emotions {@link inferEmotion} can read out of a reply. */
36
+ export type InferredEmotion = "surprised" | "sad" | "happy" | "neutral";
37
+ /**
38
+ * Read an emotion out of a reply, for {@link expression} to carry.
39
+ *
40
+ * A lexicon rather than a model: it runs on every turn of a live call, so it has
41
+ * to cost nothing and add no latency. First match wins in priority order,
42
+ * surprised then sad then happy, which is deliberate - a "wow!" must not be
43
+ * masked by a polite "thanks", and an apology must not be masked by an
44
+ * incidental "nice". A reply that is genuinely mixed resolves to the
45
+ * higher-priority one, and on a streaming path the next chunk re-reads it.
46
+ *
47
+ * **The lexicon is English only.** An Arabic or other non-English reply always
48
+ * infers "neutral". That is by design and not a bug to work around: a language
49
+ * guess that got it wrong would put the wrong face on, and neutral is always a
50
+ * safe face.
51
+ */
52
+ export declare function inferEmotion(text: string | null | undefined): InferredEmotion;
53
+ /**
54
+ * Decides WHEN an emotion is worth sending, so the face changes and nothing
55
+ * else does.
56
+ *
57
+ * One instance per call. Both methods return the emotion to hand to
58
+ * {@link expression}, or null when nothing should be sent:
59
+ *
60
+ * ```ts
61
+ * const cue = new ExpressionCue();
62
+ * // on every assistant transcript, partial and final alike
63
+ * const emotion = cue.cue(chunk);
64
+ * if (emotion !== null) await call.express(emotion);
65
+ *
66
+ * // around a tool that keeps the caller waiting
67
+ * const waiting = cue.thinking(true);
68
+ * try {
69
+ * ...
70
+ * } finally {
71
+ * const done = cue.thinking(false);
72
+ * }
73
+ * ```
74
+ *
75
+ * Re-cue on partials: waiting for the final transcript leaves the face stale
76
+ * for the whole time an apologetic reply is already being spoken. The de-dupe
77
+ * on the last value sent is what keeps a word-by-word stream from sending
78
+ * dozens of identical cues.
79
+ */
80
+ export declare class ExpressionCue {
81
+ #private;
82
+ /** The emotion last handed out, or null while none has been. */
83
+ get lastSent(): Emotion | null;
84
+ /**
85
+ * Read `text` and return the emotion to send, or null when it is unchanged.
86
+ *
87
+ * Answers null for everything while {@link thinking} is on, so a transcript
88
+ * chunk arriving mid-tool cannot make the avatar look done while it is still
89
+ * working.
90
+ */
91
+ cue(text: string | null | undefined): Emotion | null;
92
+ /**
93
+ * Enter or leave the waiting face around a tool call.
94
+ *
95
+ * Only transitions produce anything; setting the same state twice is a no-op.
96
+ * Leaving it returns "neutral" when the thinking face is still the last thing
97
+ * sent, because the model may stay silent after a tool result and there would
98
+ * be no transcript left to re-cue from. Call it from a `finally` so a tool
99
+ * that threw still puts the face back.
100
+ */
101
+ thinking(on: boolean): Emotion | null;
102
+ }
103
+ /**
104
+ * Build a `speech.marks`: the viseme timeline for one utterance.
105
+ *
106
+ * Marks are sorted ascending and marks outside the viseme range are dropped,
107
+ * because the avatar reads the timeline in order and one bad entry would
108
+ * desynchronise the mouth for the rest of the utterance.
109
+ */
110
+ export declare function speechMarks(marks: Iterable<SpeechMark>): string;
111
+ //# sourceMappingURL=avatar.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"avatar.d.ts","sourceRoot":"","sources":["../src/avatar.ts"],"names":[],"mappings":"AAqCA;;;;GAIG;AACH,eAAO,MAAM,QAAQ,+DAMX,CAAC;AAEX,6EAA6E;AAC7E,MAAM,MAAM,OAAO,GAAG,CAAC,OAAO,QAAQ,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC;AAEhE;;;GAGG;AACH,eAAO,MAAM,aAAa,KAAK,CAAC;AAEhC;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAEpC,oGAAoG;AACpG,MAAM,WAAW,UAAU;IACzB,kEAAkE;IAClE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,qDAAqD;IACrD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CASnD;AAED,iEAAiE;AACjE,MAAM,MAAM,eAAe,GAAG,WAAW,GAAG,KAAK,GAAG,OAAO,GAAG,SAAS,CAAC;AAcxE;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,eAAe,CAW7E;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,qBAAa,aAAa;;IAIxB,gEAAgE;IAChE,IAAI,QAAQ,IAAI,OAAO,GAAG,IAAI,CAE7B;IAED;;;;;;OAMG;IACH,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,GAAG,IAAI;IAQpD;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE,EAAE,OAAO,GAAG,OAAO,GAAG,IAAI;CAWtC;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,MAAM,CAO/D"}
package/dist/avatar.js ADDED
@@ -0,0 +1,201 @@
1
+ // Copyright (c) 2026 Komaa DigiTech
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * The avatar lane: the face the caller sees while your agent talks.
5
+ *
6
+ * StandIn renders the avatar tile. Your worker does not draw it, stream it, or
7
+ * know how it is made - it sends two hints and the service does the rest:
8
+ *
9
+ * - {@link expression} names the emotion the face should wear.
10
+ * - {@link speechMarks} carries the viseme timeline for one utterance, which is
11
+ * what makes the mouth match the words.
12
+ *
13
+ * Both are **additive and best-effort**. A service that does not implement one
14
+ * ignores it, an unknown emotion falls back to neutral, and neither ever affects
15
+ * the audio the caller hears.
16
+ *
17
+ * Reach for {@link speechMarks} whenever you can put a timeline behind it. Real
18
+ * viseme timings from the provider are best, and an ESTIMATE spread over the
19
+ * audio the turn actually sent is the right default on a realtime path, where no
20
+ * provider offers timings at all: a mouth on a measured clock beats a still one.
21
+ * What is still worse than none is a timeline whose DURATION was guessed, from
22
+ * text length or a words-per-minute rate, because that one drifts away from the
23
+ * voice as the sentence goes on. `lipsync.ts` is where both live.
24
+ *
25
+ * {@link inferEmotion} and {@link ExpressionCue} sit here rather than in their
26
+ * own module because what they produce is {@link expression}'s argument: the
27
+ * first reads an emotion out of the reply text, the second decides when that is
28
+ * worth sending.
29
+ *
30
+ * Identical in shape to the Python SDK's `standin.avatar`, translated to TS
31
+ * naming.
32
+ */
33
+ import { encode } from "./protocolRuntime.js";
34
+ import { TYPE_EXPRESSION, TYPE_SPEECH_MARKS } from "./protocol.js";
35
+ /**
36
+ * The emotions StandIn knows by name. An open set: sending something else is
37
+ * allowed and renders as neutral, so a newer sender and an older service still
38
+ * interoperate.
39
+ */
40
+ export const EMOTIONS = [
41
+ "neutral",
42
+ "happy",
43
+ "sad",
44
+ "surprised",
45
+ "thinking",
46
+ ];
47
+ /**
48
+ * Visemes use the Azure Speech numbering, 0 to 21, which is what the avatar
49
+ * expects. A mark outside that range is dropped rather than sent.
50
+ */
51
+ export const MAX_VISEME_ID = 21;
52
+ /**
53
+ * An emotion reaches the avatar tile, and the string usually came from a model
54
+ * that whoever is on the call is steering. Bounded here, where the message is
55
+ * built, so a plugin cannot forget to bound it.
56
+ */
57
+ export const MAX_EMOTION_CHARS = 40;
58
+ /**
59
+ * Build an `expression`: the emotion the avatar should wear.
60
+ *
61
+ * Affects the video tile only, never the audio. Throws on an empty value or one
62
+ * longer than {@link MAX_EMOTION_CHARS}; everything else is the service's to
63
+ * interpret, and an unknown emotion renders as neutral.
64
+ */
65
+ export function expression(emotion) {
66
+ const text = emotion.trim();
67
+ if (text === "")
68
+ throw new Error("expression needs an emotion");
69
+ if (text.length > MAX_EMOTION_CHARS) {
70
+ throw new Error(`an emotion must be at most ${MAX_EMOTION_CHARS} characters`);
71
+ }
72
+ return encode({ type: TYPE_EXPRESSION, emotion: text });
73
+ }
74
+ // Two or more of them together. One "?" is a question, "?!" is a reaction.
75
+ const SURPRISED_MARKS = /[?!]{2,}/;
76
+ // Word tests are \b-bounded throughout, or "nicety" reads as happy and
77
+ // "greatly" masks an apology.
78
+ const SURPRISED_WORDS = /\b(?:wow|whoa|woah|oh no|oh my|no way|unbelievable|incredible|astonish\w*|surpris\w*)\b/;
79
+ const SAD_WORDS = /\b(?:sorry|apolog\w*|unfortunately|regret\w*|afraid|sadly|bad news|failed|unable to|i can't|i cannot|i'm unable)\b/;
80
+ const HAPPY_WORDS = /\b(?:glad|great|awesome|wonderful|fantastic|excellent|congrat\w*|happy|love|perfect|good news|success\w*|thank\w*|welcome|nice|well done)\b/;
81
+ /**
82
+ * Read an emotion out of a reply, for {@link expression} to carry.
83
+ *
84
+ * A lexicon rather than a model: it runs on every turn of a live call, so it has
85
+ * to cost nothing and add no latency. First match wins in priority order,
86
+ * surprised then sad then happy, which is deliberate - a "wow!" must not be
87
+ * masked by a polite "thanks", and an apology must not be masked by an
88
+ * incidental "nice". A reply that is genuinely mixed resolves to the
89
+ * higher-priority one, and on a streaming path the next chunk re-reads it.
90
+ *
91
+ * **The lexicon is English only.** An Arabic or other non-English reply always
92
+ * infers "neutral". That is by design and not a bug to work around: a language
93
+ * guess that got it wrong would put the wrong face on, and neutral is always a
94
+ * safe face.
95
+ */
96
+ export function inferEmotion(text) {
97
+ const raw = (text ?? "").trim();
98
+ if (raw === "")
99
+ return "neutral";
100
+ if (SURPRISED_MARKS.test(raw))
101
+ return "surprised";
102
+ // Models emit the typographic apostrophe routinely, and "I can’t" is the most
103
+ // common apologetic phrasing there is.
104
+ const words = raw.toLowerCase().replace(/’/g, "'");
105
+ if (SURPRISED_WORDS.test(words))
106
+ return "surprised";
107
+ if (SAD_WORDS.test(words))
108
+ return "sad";
109
+ if (HAPPY_WORDS.test(words))
110
+ return "happy";
111
+ return "neutral";
112
+ }
113
+ /**
114
+ * Decides WHEN an emotion is worth sending, so the face changes and nothing
115
+ * else does.
116
+ *
117
+ * One instance per call. Both methods return the emotion to hand to
118
+ * {@link expression}, or null when nothing should be sent:
119
+ *
120
+ * ```ts
121
+ * const cue = new ExpressionCue();
122
+ * // on every assistant transcript, partial and final alike
123
+ * const emotion = cue.cue(chunk);
124
+ * if (emotion !== null) await call.express(emotion);
125
+ *
126
+ * // around a tool that keeps the caller waiting
127
+ * const waiting = cue.thinking(true);
128
+ * try {
129
+ * ...
130
+ * } finally {
131
+ * const done = cue.thinking(false);
132
+ * }
133
+ * ```
134
+ *
135
+ * Re-cue on partials: waiting for the final transcript leaves the face stale
136
+ * for the whole time an apologetic reply is already being spoken. The de-dupe
137
+ * on the last value sent is what keeps a word-by-word stream from sending
138
+ * dozens of identical cues.
139
+ */
140
+ export class ExpressionCue {
141
+ #lastSent = null;
142
+ #thinking = false;
143
+ /** The emotion last handed out, or null while none has been. */
144
+ get lastSent() {
145
+ return this.#lastSent;
146
+ }
147
+ /**
148
+ * Read `text` and return the emotion to send, or null when it is unchanged.
149
+ *
150
+ * Answers null for everything while {@link thinking} is on, so a transcript
151
+ * chunk arriving mid-tool cannot make the avatar look done while it is still
152
+ * working.
153
+ */
154
+ cue(text) {
155
+ if (this.#thinking)
156
+ return null;
157
+ const emotion = inferEmotion(text);
158
+ if (emotion === this.#lastSent)
159
+ return null;
160
+ this.#lastSent = emotion;
161
+ return emotion;
162
+ }
163
+ /**
164
+ * Enter or leave the waiting face around a tool call.
165
+ *
166
+ * Only transitions produce anything; setting the same state twice is a no-op.
167
+ * Leaving it returns "neutral" when the thinking face is still the last thing
168
+ * sent, because the model may stay silent after a tool result and there would
169
+ * be no transcript left to re-cue from. Call it from a `finally` so a tool
170
+ * that threw still puts the face back.
171
+ */
172
+ thinking(on) {
173
+ if (on === this.#thinking)
174
+ return null;
175
+ this.#thinking = on;
176
+ if (on) {
177
+ this.#lastSent = "thinking";
178
+ return "thinking";
179
+ }
180
+ if (this.#lastSent !== "thinking")
181
+ return null;
182
+ this.#lastSent = "neutral";
183
+ return "neutral";
184
+ }
185
+ }
186
+ /**
187
+ * Build a `speech.marks`: the viseme timeline for one utterance.
188
+ *
189
+ * Marks are sorted ascending and marks outside the viseme range are dropped,
190
+ * because the avatar reads the timeline in order and one bad entry would
191
+ * desynchronise the mouth for the rest of the utterance.
192
+ */
193
+ export function speechMarks(marks) {
194
+ const cleaned = [...marks]
195
+ .filter((m) => m.tMs >= 0 && m.visemeId >= 0 && m.visemeId <= MAX_VISEME_ID)
196
+ .map((m) => ({ tMs: Math.trunc(m.tMs), visemeId: Math.trunc(m.visemeId) }))
197
+ .sort((a, b) => a.tMs - b.tMs || a.visemeId - b.visemeId);
198
+ // A reserved timeline anchor the lane does not use yet. Both SDKs send 0.
199
+ return encode({ type: TYPE_SPEECH_MARKS, ts: 0, marks: cleaned });
200
+ }
201
+ //# sourceMappingURL=avatar.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"avatar.js","sourceRoot":"","sources":["../src/avatar.ts"],"names":[],"mappings":"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"}