@jskit-ai/assistant-voice 0.0.0-stage → 0.1.2
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/README.md +169 -2
- package/examples/conversation/App.vue +94 -0
- package/examples/conversation/Conversation.vue +28 -0
- package/examples/conversation/README.md +181 -0
- package/examples/conversation/config.js +15 -0
- package/examples/conversation/index.html +1 -0
- package/examples/conversation/integrations.example.json +16 -0
- package/examples/conversation/main.js +58 -0
- package/examples/conversation/package.json +32 -0
- package/examples/conversation/server.js +105 -0
- package/examples/conversation/src/error.js +1 -0
- package/examples/conversation/src/placement.js +1 -0
- package/examples/conversation/src/placementTopology.js +1 -0
- package/examples/conversation/vite.config.js +9 -0
- package/package.json +67 -4
- package/src/client/ConversationDialog.vue +67 -0
- package/src/client/VoiceAvatar.vue +18 -0
- package/src/client/VoiceConversation.vue +117 -0
- package/src/client/VoiceConversationHost.vue +78 -0
- package/src/client/audioCapture.js +198 -0
- package/src/client/conversationNarration.js +108 -0
- package/src/client/conversationVoiceState.js +18 -0
- package/src/client/index.js +9 -0
- package/src/client/voiceAvatar.js +234 -0
- package/src/client/voiceController.js +124 -0
- package/src/client/voiceConversation.js +557 -0
- package/src/client/voiceLauncher.js +63 -0
- package/src/client/voicePlayback.js +58 -0
- package/src/client/voiceTransport.js +890 -0
- package/src/server/index.js +5 -0
- package/src/server/voiceAccessToken.js +79 -0
- package/src/server/voiceDaemon.js +599 -0
- package/src/server/voiceProxy.js +199 -0
- package/src/server/voiceProxyConfig.js +70 -0
- package/src/server/voiceSocketClose.js +31 -0
- package/src/shared/protocol.js +439 -0
- package/src/shared/visemes.js +130 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { WebSocket } from "ws";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
VOICE_MAX_AUDIO_FRAME_BYTES,
|
|
5
|
+
VOICE_MAX_CONTROL_BYTES,
|
|
6
|
+
assertVoiceAudioFrame,
|
|
7
|
+
parseVoiceClientControl
|
|
8
|
+
} from "../shared/protocol.js";
|
|
9
|
+
import { normalizeVoiceCloseMetadata } from "./voiceSocketClose.js";
|
|
10
|
+
|
|
11
|
+
const WEBSOCKET_CONNECTING = 0;
|
|
12
|
+
const WEBSOCKET_OPEN = 1;
|
|
13
|
+
|
|
14
|
+
async function readVoiceCatalogue(config, { fetchImpl = fetch } = {}) {
|
|
15
|
+
try {
|
|
16
|
+
if (!config?.available) throw new Error("Voice is not configured.");
|
|
17
|
+
const url = new URL("/voices", config.endpoint);
|
|
18
|
+
url.protocol = url.protocol === "wss:" ? "https:" : "http:";
|
|
19
|
+
const response = await fetchImpl(url, {
|
|
20
|
+
headers: { authorization: `Bearer ${config.token}` }, signal: AbortSignal.timeout(5000)
|
|
21
|
+
});
|
|
22
|
+
if (!response.ok) throw new Error("Voice service refused the catalogue request.");
|
|
23
|
+
const catalogue = await response.json();
|
|
24
|
+
if (!Array.isArray(catalogue.voices) || typeof catalogue.defaultVoice !== "string") throw new Error("Invalid voice catalogue.");
|
|
25
|
+
return catalogue;
|
|
26
|
+
} catch (cause) {
|
|
27
|
+
throw Object.assign(new Error("Speaking voices could not load. Check the speech service and retry."), {
|
|
28
|
+
code: "voice_unavailable", statusCode: 503, cause
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function closeSocket(socket, code, reason) {
|
|
34
|
+
if ([WEBSOCKET_CONNECTING, WEBSOCKET_OPEN].includes(socket?.readyState)) {
|
|
35
|
+
const metadata = normalizeVoiceCloseMetadata(code, reason);
|
|
36
|
+
socket.close(metadata.code, metadata.reason);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sendSocketError(socket, code, message) {
|
|
41
|
+
if (socket?.readyState === WEBSOCKET_OPEN) {
|
|
42
|
+
socket.send(JSON.stringify({ code, message, type: "error" }));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function registerVoiceProxyRoute(fastify, {
|
|
47
|
+
proxyConfig,
|
|
48
|
+
route,
|
|
49
|
+
authorize = null,
|
|
50
|
+
WebSocketCtor = WebSocket
|
|
51
|
+
} = {}) {
|
|
52
|
+
if (!fastify || typeof fastify.get !== "function") {
|
|
53
|
+
throw new TypeError("registerVoiceProxyRoute requires Fastify get().");
|
|
54
|
+
}
|
|
55
|
+
if (typeof authorize !== "function" || typeof route !== "string" || !route.startsWith("/")) {
|
|
56
|
+
throw new TypeError("registerVoiceProxyRoute requires an application route and authorization function.");
|
|
57
|
+
}
|
|
58
|
+
const config = proxyConfig || { available: false, endpoint: "", token: "" };
|
|
59
|
+
|
|
60
|
+
fastify.get(route, { websocket: true }, (browserSocket, request) => {
|
|
61
|
+
let serviceSocket = null;
|
|
62
|
+
let closed = false;
|
|
63
|
+
|
|
64
|
+
const closeBoth = (code = 1000, reason = "Voice connection closed") => {
|
|
65
|
+
if (closed) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
closed = true;
|
|
69
|
+
closeSocket(browserSocket, code, reason);
|
|
70
|
+
closeSocket(serviceSocket, code, reason);
|
|
71
|
+
};
|
|
72
|
+
browserSocket.once("close", (code, reason) => {
|
|
73
|
+
closeBoth(code || 1000, reason?.toString() || "Browser closed voice");
|
|
74
|
+
});
|
|
75
|
+
browserSocket.once("error", () => closeBoth(1011, "Browser voice connection failed"));
|
|
76
|
+
|
|
77
|
+
if (!config.available) {
|
|
78
|
+
sendSocketError(browserSocket, "voice_unavailable", "Voice is not installed on this server.");
|
|
79
|
+
closeBoth(1013, "Voice service unavailable");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
void (async () => {
|
|
84
|
+
const authorized = await authorize(request);
|
|
85
|
+
if (authorized === false) {
|
|
86
|
+
sendSocketError(browserSocket, "voice_auth_required", "Voice access was denied.");
|
|
87
|
+
closeBoth(1008, "Voice access denied");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (closed) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
serviceSocket = new WebSocketCtor(config.endpoint, {
|
|
95
|
+
headers: {
|
|
96
|
+
authorization: `Bearer ${config.token}`
|
|
97
|
+
},
|
|
98
|
+
maxPayload: Math.max(VOICE_MAX_AUDIO_FRAME_BYTES, VOICE_MAX_CONTROL_BYTES),
|
|
99
|
+
perMessageDeflate: false
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const pendingFrames = [];
|
|
103
|
+
let pendingBytes = 0;
|
|
104
|
+
const forwardToService = (raw, isBinary) => {
|
|
105
|
+
if (closed) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const frame = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
|
109
|
+
const limit = isBinary ? VOICE_MAX_AUDIO_FRAME_BYTES : VOICE_MAX_CONTROL_BYTES;
|
|
110
|
+
if (frame.byteLength > limit) {
|
|
111
|
+
sendSocketError(browserSocket, "voice_frame_too_large", "Voice message is too large.");
|
|
112
|
+
closeBoth(1009, "Voice message too large");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
if (isBinary) {
|
|
117
|
+
assertVoiceAudioFrame(frame);
|
|
118
|
+
} else {
|
|
119
|
+
parseVoiceClientControl(frame);
|
|
120
|
+
}
|
|
121
|
+
} catch (error) {
|
|
122
|
+
sendSocketError(
|
|
123
|
+
browserSocket,
|
|
124
|
+
String(error?.code || "voice_control_invalid"),
|
|
125
|
+
String(error?.message || error)
|
|
126
|
+
);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (serviceSocket.readyState === WEBSOCKET_OPEN) {
|
|
130
|
+
serviceSocket.send(frame, { binary: isBinary });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (serviceSocket.readyState !== WEBSOCKET_CONNECTING || pendingBytes + frame.byteLength > 128 * 1024) {
|
|
134
|
+
sendSocketError(browserSocket, "voice_connecting", "Voice is still connecting. Try again.");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
pendingFrames.push({ frame, isBinary });
|
|
138
|
+
pendingBytes += frame.byteLength;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
browserSocket.on("message", forwardToService);
|
|
142
|
+
|
|
143
|
+
serviceSocket.once("open", () => {
|
|
144
|
+
for (const entry of pendingFrames.splice(0)) {
|
|
145
|
+
if (serviceSocket.readyState !== WEBSOCKET_OPEN) {
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
serviceSocket.send(entry.frame, { binary: entry.isBinary });
|
|
149
|
+
}
|
|
150
|
+
pendingBytes = 0;
|
|
151
|
+
});
|
|
152
|
+
serviceSocket.on("message", (raw, isBinary) => {
|
|
153
|
+
if (browserSocket.readyState !== WEBSOCKET_OPEN) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const frame = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
|
157
|
+
const limit = isBinary ? VOICE_MAX_AUDIO_FRAME_BYTES : VOICE_MAX_CONTROL_BYTES;
|
|
158
|
+
if (frame.byteLength > limit) {
|
|
159
|
+
closeBoth(1009, "Voice service message too large");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
browserSocket.send(frame, { binary: isBinary });
|
|
163
|
+
});
|
|
164
|
+
serviceSocket.once("unexpected-response", (_request, response) => {
|
|
165
|
+
const code = Number(response?.statusCode) === 429 ? "voice_busy" : "voice_unavailable";
|
|
166
|
+
const message = code === "voice_busy"
|
|
167
|
+
? "Voice is busy. Try again shortly."
|
|
168
|
+
: "Voice service could not be reached.";
|
|
169
|
+
sendSocketError(browserSocket, code, message);
|
|
170
|
+
closeBoth(1013, message);
|
|
171
|
+
});
|
|
172
|
+
serviceSocket.once("close", (code, reason) => {
|
|
173
|
+
closeBoth(code || 1012, reason?.toString() || "Voice service closed");
|
|
174
|
+
});
|
|
175
|
+
serviceSocket.once("error", () => {
|
|
176
|
+
sendSocketError(browserSocket, "voice_unavailable", "Voice service could not be reached.");
|
|
177
|
+
closeBoth(1013, "Voice service unavailable");
|
|
178
|
+
});
|
|
179
|
+
})().catch((error) => {
|
|
180
|
+
sendSocketError(
|
|
181
|
+
browserSocket,
|
|
182
|
+
String(error?.code || "voice_session_failed"),
|
|
183
|
+
"Voice could not open for this conversation."
|
|
184
|
+
);
|
|
185
|
+
closeBoth([401, 403].includes(error?.statusCode) ? 1008 : 1011, "Voice session failed");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return Object.freeze({
|
|
190
|
+
available: config.available,
|
|
191
|
+
route
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export {
|
|
196
|
+
closeSocket,
|
|
197
|
+
readVoiceCatalogue,
|
|
198
|
+
registerVoiceProxyRoute
|
|
199
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { VOICE_ACCESS_TOKEN_VERSION, voiceTenant } from "./voiceAccessToken.js";
|
|
3
|
+
|
|
4
|
+
function voiceEndpoint(value = "") {
|
|
5
|
+
const endpoint = String(value || "").trim();
|
|
6
|
+
if (!endpoint) {
|
|
7
|
+
return "";
|
|
8
|
+
}
|
|
9
|
+
let parsed;
|
|
10
|
+
try {
|
|
11
|
+
parsed = new URL(endpoint);
|
|
12
|
+
} catch {
|
|
13
|
+
throw new Error("Voice endpoint must be a valid ws:// or wss:// URL.");
|
|
14
|
+
}
|
|
15
|
+
if (!["ws:", "wss:"].includes(parsed.protocol) || parsed.username || parsed.password || parsed.hash) {
|
|
16
|
+
throw new Error("Voice endpoint must use ws:// or wss:// without embedded credentials or fragments.");
|
|
17
|
+
}
|
|
18
|
+
return parsed.toString();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readVoiceAccessToken(filePath = "") {
|
|
22
|
+
const pathname = String(filePath || "").trim();
|
|
23
|
+
if (!pathname) {
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
const metadata = statSync(pathname);
|
|
27
|
+
if (!metadata.isFile() || metadata.size < 16 || metadata.size > 4_096) {
|
|
28
|
+
throw new Error("The voice credential file is invalid.");
|
|
29
|
+
}
|
|
30
|
+
const token = readFileSync(pathname, "utf8").trim();
|
|
31
|
+
let valid;
|
|
32
|
+
try {
|
|
33
|
+
const [version, tenant = "", signature = "", ...extra] = token.split(".");
|
|
34
|
+
valid = version === VOICE_ACCESS_TOKEN_VERSION &&
|
|
35
|
+
extra.length === 0 &&
|
|
36
|
+
voiceTenant(tenant) === tenant &&
|
|
37
|
+
/^[A-Za-z0-9_-]{43}$/u.test(signature) &&
|
|
38
|
+
Buffer.from(signature, "base64url").toString("base64url") === signature;
|
|
39
|
+
} catch {
|
|
40
|
+
valid = false;
|
|
41
|
+
}
|
|
42
|
+
if (!valid) {
|
|
43
|
+
throw new Error("The voice credential is malformed.");
|
|
44
|
+
}
|
|
45
|
+
return token;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveVoiceProxyConfig({ endpoint: value = "", accessTokenFile = "" } = {}) {
|
|
49
|
+
const endpoint = voiceEndpoint(value);
|
|
50
|
+
const tokenFile = String(accessTokenFile || "").trim();
|
|
51
|
+
if (!endpoint && !tokenFile) {
|
|
52
|
+
return Object.freeze({ available: false, endpoint: "", token: "" });
|
|
53
|
+
}
|
|
54
|
+
if (!endpoint || !tokenFile) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"Voice endpoint and accessTokenFile must be configured together."
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return Object.freeze({
|
|
60
|
+
available: true,
|
|
61
|
+
endpoint,
|
|
62
|
+
token: readVoiceAccessToken(tokenFile)
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export {
|
|
67
|
+
readVoiceAccessToken,
|
|
68
|
+
resolveVoiceProxyConfig,
|
|
69
|
+
voiceEndpoint
|
|
70
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const RESERVED_CLOSE_CODES = new Set([1004, 1005, 1006, 1015]);
|
|
2
|
+
|
|
3
|
+
function proxyCloseCode(code) {
|
|
4
|
+
const numericCode = Number(code);
|
|
5
|
+
const isStandardCode = Number.isInteger(numericCode)
|
|
6
|
+
&& numericCode >= 1000
|
|
7
|
+
&& numericCode <= 1014
|
|
8
|
+
&& !RESERVED_CLOSE_CODES.has(numericCode);
|
|
9
|
+
const isApplicationCode = Number.isInteger(numericCode)
|
|
10
|
+
&& numericCode >= 3000
|
|
11
|
+
&& numericCode <= 4999;
|
|
12
|
+
return isStandardCode || isApplicationCode ? numericCode : 1000;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function proxyCloseReason(reason) {
|
|
16
|
+
const encoded = Buffer.from(String(reason || ""), "utf8");
|
|
17
|
+
return encoded.byteLength <= 123
|
|
18
|
+
? encoded.toString("utf8")
|
|
19
|
+
: encoded.subarray(0, 120).toString("utf8");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeVoiceCloseMetadata(code, reason) {
|
|
23
|
+
return Object.freeze({
|
|
24
|
+
code: proxyCloseCode(code),
|
|
25
|
+
reason: proxyCloseReason(reason)
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
normalizeVoiceCloseMetadata
|
|
31
|
+
};
|