@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.
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/dist/ambient.d.ts +80 -0
- package/dist/ambient.d.ts.map +1 -0
- package/dist/ambient.js +281 -0
- package/dist/ambient.js.map +1 -0
- package/dist/attachments.d.ts +149 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +451 -0
- package/dist/attachments.js.map +1 -0
- package/dist/audio.d.ts +80 -0
- package/dist/audio.d.ts.map +1 -0
- package/dist/audio.js +170 -0
- package/dist/audio.js.map +1 -0
- package/dist/avatar.d.ts +111 -0
- package/dist/avatar.d.ts.map +1 -0
- package/dist/avatar.js +201 -0
- package/dist/avatar.js.map +1 -0
- package/dist/callServer.d.ts +151 -0
- package/dist/callServer.d.ts.map +1 -0
- package/dist/callServer.js +1097 -0
- package/dist/callServer.js.map +1 -0
- package/dist/callTools.d.ts +154 -0
- package/dist/callTools.d.ts.map +1 -0
- package/dist/callTools.js +255 -0
- package/dist/callTools.js.map +1 -0
- package/dist/chat.d.ts +226 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +574 -0
- package/dist/chat.js.map +1 -0
- package/dist/config.d.ts +32 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +86 -0
- package/dist/config.js.map +1 -0
- package/dist/consult.d.ts +147 -0
- package/dist/consult.d.ts.map +1 -0
- package/dist/consult.js +388 -0
- package/dist/consult.js.map +1 -0
- package/dist/delivery.d.ts +81 -0
- package/dist/delivery.d.ts.map +1 -0
- package/dist/delivery.js +187 -0
- package/dist/delivery.js.map +1 -0
- package/dist/echoGuard.d.ts +56 -0
- package/dist/echoGuard.d.ts.map +1 -0
- package/dist/echoGuard.js +57 -0
- package/dist/echoGuard.js.map +1 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +10 -0
- package/dist/errors.js.map +1 -0
- package/dist/fetch.d.ts +40 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +240 -0
- package/dist/fetch.js.map +1 -0
- package/dist/gate.d.ts +80 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +249 -0
- package/dist/gate.js.map +1 -0
- package/dist/handler.d.ts +283 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +4 -0
- package/dist/handler.js.map +1 -0
- package/dist/hmac.d.ts +32 -0
- package/dist/hmac.d.ts.map +1 -0
- package/dist/hmac.js +108 -0
- package/dist/hmac.js.map +1 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +60 -0
- package/dist/index.js.map +1 -0
- package/dist/lane.d.ts +92 -0
- package/dist/lane.d.ts.map +1 -0
- package/dist/lane.js +269 -0
- package/dist/lane.js.map +1 -0
- package/dist/lipsync.d.ts +136 -0
- package/dist/lipsync.d.ts.map +1 -0
- package/dist/lipsync.js +310 -0
- package/dist/lipsync.js.map +1 -0
- package/dist/log.d.ts +18 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +20 -0
- package/dist/log.js.map +1 -0
- package/dist/media.d.ts +43 -0
- package/dist/media.d.ts.map +1 -0
- package/dist/media.js +190 -0
- package/dist/media.js.map +1 -0
- package/dist/minutes.d.ts +398 -0
- package/dist/minutes.d.ts.map +1 -0
- package/dist/minutes.js +805 -0
- package/dist/minutes.js.map +1 -0
- package/dist/outbound.d.ts +388 -0
- package/dist/outbound.d.ts.map +1 -0
- package/dist/outbound.js +933 -0
- package/dist/outbound.js.map +1 -0
- package/dist/plugins/cartesia/agent.d.ts +58 -0
- package/dist/plugins/cartesia/agent.d.ts.map +1 -0
- package/dist/plugins/cartesia/agent.js +254 -0
- package/dist/plugins/cartesia/agent.js.map +1 -0
- package/dist/plugins/cartesia/cli.d.ts +3 -0
- package/dist/plugins/cartesia/cli.d.ts.map +1 -0
- package/dist/plugins/cartesia/cli.js +10 -0
- package/dist/plugins/cartesia/cli.js.map +1 -0
- package/dist/plugins/cartesia/config.d.ts +30 -0
- package/dist/plugins/cartesia/config.d.ts.map +1 -0
- package/dist/plugins/cartesia/config.js +29 -0
- package/dist/plugins/cartesia/config.js.map +1 -0
- package/dist/plugins/cartesia/handler.d.ts +40 -0
- package/dist/plugins/cartesia/handler.d.ts.map +1 -0
- package/dist/plugins/cartesia/handler.js +142 -0
- package/dist/plugins/cartesia/handler.js.map +1 -0
- package/dist/plugins/cartesia/index.d.ts +6 -0
- package/dist/plugins/cartesia/index.d.ts.map +1 -0
- package/dist/plugins/cartesia/index.js +54 -0
- package/dist/plugins/cartesia/index.js.map +1 -0
- package/dist/plugins/deepgram/agent.d.ts +60 -0
- package/dist/plugins/deepgram/agent.d.ts.map +1 -0
- package/dist/plugins/deepgram/agent.js +302 -0
- package/dist/plugins/deepgram/agent.js.map +1 -0
- package/dist/plugins/deepgram/cli.d.ts +3 -0
- package/dist/plugins/deepgram/cli.d.ts.map +1 -0
- package/dist/plugins/deepgram/cli.js +10 -0
- package/dist/plugins/deepgram/cli.js.map +1 -0
- package/dist/plugins/deepgram/config.d.ts +39 -0
- package/dist/plugins/deepgram/config.d.ts.map +1 -0
- package/dist/plugins/deepgram/config.js +66 -0
- package/dist/plugins/deepgram/config.js.map +1 -0
- package/dist/plugins/deepgram/handler.d.ts +87 -0
- package/dist/plugins/deepgram/handler.d.ts.map +1 -0
- package/dist/plugins/deepgram/handler.js +268 -0
- package/dist/plugins/deepgram/handler.js.map +1 -0
- package/dist/plugins/deepgram/index.d.ts +11 -0
- package/dist/plugins/deepgram/index.d.ts.map +1 -0
- package/dist/plugins/deepgram/index.js +76 -0
- package/dist/plugins/deepgram/index.js.map +1 -0
- package/dist/plugins/echo/cli.d.ts +3 -0
- package/dist/plugins/echo/cli.d.ts.map +1 -0
- package/dist/plugins/echo/cli.js +10 -0
- package/dist/plugins/echo/cli.js.map +1 -0
- package/dist/plugins/echo/index.d.ts +37 -0
- package/dist/plugins/echo/index.d.ts.map +1 -0
- package/dist/plugins/echo/index.js +61 -0
- package/dist/plugins/echo/index.js.map +1 -0
- package/dist/plugins/elevenlabs/agent.d.ts +72 -0
- package/dist/plugins/elevenlabs/agent.d.ts.map +1 -0
- package/dist/plugins/elevenlabs/agent.js +305 -0
- package/dist/plugins/elevenlabs/agent.js.map +1 -0
- package/dist/plugins/elevenlabs/cli.d.ts +3 -0
- package/dist/plugins/elevenlabs/cli.d.ts.map +1 -0
- package/dist/plugins/elevenlabs/cli.js +10 -0
- package/dist/plugins/elevenlabs/cli.js.map +1 -0
- package/dist/plugins/elevenlabs/config.d.ts +27 -0
- package/dist/plugins/elevenlabs/config.d.ts.map +1 -0
- package/dist/plugins/elevenlabs/config.js +42 -0
- package/dist/plugins/elevenlabs/config.js.map +1 -0
- package/dist/plugins/elevenlabs/handler.d.ts +67 -0
- package/dist/plugins/elevenlabs/handler.d.ts.map +1 -0
- package/dist/plugins/elevenlabs/handler.js +419 -0
- package/dist/plugins/elevenlabs/handler.js.map +1 -0
- package/dist/plugins/elevenlabs/index.d.ts +11 -0
- package/dist/plugins/elevenlabs/index.d.ts.map +1 -0
- package/dist/plugins/elevenlabs/index.js +72 -0
- package/dist/plugins/elevenlabs/index.js.map +1 -0
- package/dist/plugins/livekit/cli.d.ts +3 -0
- package/dist/plugins/livekit/cli.d.ts.map +1 -0
- package/dist/plugins/livekit/cli.js +10 -0
- package/dist/plugins/livekit/cli.js.map +1 -0
- package/dist/plugins/livekit/config.d.ts +47 -0
- package/dist/plugins/livekit/config.d.ts.map +1 -0
- package/dist/plugins/livekit/config.js +46 -0
- package/dist/plugins/livekit/config.js.map +1 -0
- package/dist/plugins/livekit/handler.d.ts +59 -0
- package/dist/plugins/livekit/handler.d.ts.map +1 -0
- package/dist/plugins/livekit/handler.js +211 -0
- package/dist/plugins/livekit/handler.js.map +1 -0
- package/dist/plugins/livekit/index.d.ts +6 -0
- package/dist/plugins/livekit/index.d.ts.map +1 -0
- package/dist/plugins/livekit/index.js +68 -0
- package/dist/plugins/livekit/index.js.map +1 -0
- package/dist/plugins/livekit/room.d.ts +117 -0
- package/dist/plugins/livekit/room.d.ts.map +1 -0
- package/dist/plugins/livekit/room.js +449 -0
- package/dist/plugins/livekit/room.js.map +1 -0
- package/dist/plugins/openai/agent.d.ts +74 -0
- package/dist/plugins/openai/agent.d.ts.map +1 -0
- package/dist/plugins/openai/agent.js +264 -0
- package/dist/plugins/openai/agent.js.map +1 -0
- package/dist/plugins/openai/cli.d.ts +3 -0
- package/dist/plugins/openai/cli.d.ts.map +1 -0
- package/dist/plugins/openai/cli.js +10 -0
- package/dist/plugins/openai/cli.js.map +1 -0
- package/dist/plugins/openai/config.d.ts +29 -0
- package/dist/plugins/openai/config.d.ts.map +1 -0
- package/dist/plugins/openai/config.js +37 -0
- package/dist/plugins/openai/config.js.map +1 -0
- package/dist/plugins/openai/handler.d.ts +65 -0
- package/dist/plugins/openai/handler.d.ts.map +1 -0
- package/dist/plugins/openai/handler.js +297 -0
- package/dist/plugins/openai/handler.js.map +1 -0
- package/dist/plugins/openai/index.d.ts +6 -0
- package/dist/plugins/openai/index.d.ts.map +1 -0
- package/dist/plugins/openai/index.js +72 -0
- package/dist/plugins/openai/index.js.map +1 -0
- package/dist/plugins/openclaw/allowlist.d.ts +9 -0
- package/dist/plugins/openclaw/allowlist.d.ts.map +1 -0
- package/dist/plugins/openclaw/allowlist.js +11 -0
- package/dist/plugins/openclaw/allowlist.js.map +1 -0
- package/dist/plugins/openclaw/config.d.ts +50 -0
- package/dist/plugins/openclaw/config.d.ts.map +1 -0
- package/dist/plugins/openclaw/config.js +53 -0
- package/dist/plugins/openclaw/config.js.map +1 -0
- package/dist/plugins/openclaw/handler.d.ts +90 -0
- package/dist/plugins/openclaw/handler.d.ts.map +1 -0
- package/dist/plugins/openclaw/handler.js +146 -0
- package/dist/plugins/openclaw/handler.js.map +1 -0
- package/dist/plugins/openclaw/index.d.ts +55 -0
- package/dist/plugins/openclaw/index.d.ts.map +1 -0
- package/dist/plugins/openclaw/index.js +89 -0
- package/dist/plugins/openclaw/index.js.map +1 -0
- package/dist/plugins/openclaw/openclaw.plugin.json +74 -0
- package/dist/plugins/openclaw/package.json +14 -0
- package/dist/plugins/openclaw/realtime.d.ts +82 -0
- package/dist/plugins/openclaw/realtime.d.ts.map +1 -0
- package/dist/plugins/openclaw/realtime.js +238 -0
- package/dist/plugins/openclaw/realtime.js.map +1 -0
- package/dist/plugins/openclaw/runtime.d.ts +19 -0
- package/dist/plugins/openclaw/runtime.d.ts.map +1 -0
- package/dist/plugins/openclaw/runtime.js +160 -0
- package/dist/plugins/openclaw/runtime.js.map +1 -0
- package/dist/policy.d.ts +38 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +95 -0
- package/dist/policy.js.map +1 -0
- package/dist/protocol.d.ts +44 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +77 -0
- package/dist/protocol.js.map +1 -0
- package/dist/protocolRuntime.d.ts +19 -0
- package/dist/protocolRuntime.d.ts.map +1 -0
- package/dist/protocolRuntime.js +78 -0
- package/dist/protocolRuntime.js.map +1 -0
- package/dist/smoke.d.ts +65 -0
- package/dist/smoke.d.ts.map +1 -0
- package/dist/smoke.js +293 -0
- package/dist/smoke.js.map +1 -0
- package/dist/startup.d.ts +74 -0
- package/dist/startup.d.ts.map +1 -0
- package/dist/startup.js +112 -0
- package/dist/startup.js.map +1 -0
- package/dist/tile.d.ts +101 -0
- package/dist/tile.d.ts.map +1 -0
- package/dist/tile.js +195 -0
- package/dist/tile.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +4 -0
- package/dist/version.js.map +1 -0
- package/dist/vision.d.ts +160 -0
- package/dist/vision.d.ts.map +1 -0
- package/dist/vision.js +301 -0
- package/dist/vision.js.map +1 -0
- package/dist/visionTools.d.ts +237 -0
- package/dist/visionTools.d.ts.map +1 -0
- package/dist/visionTools.js +578 -0
- package/dist/visionTools.js.map +1 -0
- package/dist/voice.d.ts +143 -0
- package/dist/voice.d.ts.map +1 -0
- package/dist/voice.js +403 -0
- package/dist/voice.js.map +1 -0
- package/package.json +129 -0
package/dist/audio.d.ts
ADDED
|
@@ -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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"}
|
package/dist/avatar.d.ts
ADDED
|
@@ -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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"}
|