@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,198 @@
|
|
|
1
|
+
const CAPTURE_WORKLET_NAME = "jskit-voice-capture";
|
|
2
|
+
const CAPTURE_FLUSH_TIMEOUT_MS = 500;
|
|
3
|
+
const CAPTURE_FRAME_SAMPLES = 1_024;
|
|
4
|
+
const CAPTURE_WORKLET_SOURCE = `
|
|
5
|
+
class AssistantVoiceCapture extends AudioWorkletProcessor {
|
|
6
|
+
constructor() {
|
|
7
|
+
super();
|
|
8
|
+
this.accepting = true;
|
|
9
|
+
this.muted = false;
|
|
10
|
+
this.frames = [];
|
|
11
|
+
this.length = 0;
|
|
12
|
+
this.port.onmessage = (event) => {
|
|
13
|
+
if (event.data?.type === "mute") {
|
|
14
|
+
this.muted = event.data.muted;
|
|
15
|
+
this.frames = [];
|
|
16
|
+
this.length = 0;
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (event.data?.type !== "flush") return;
|
|
20
|
+
this.accepting = false;
|
|
21
|
+
this.emit();
|
|
22
|
+
this.port.postMessage({ type: "flushed" });
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
emit() {
|
|
27
|
+
if (!this.length) return;
|
|
28
|
+
const samples = new Float32Array(this.length);
|
|
29
|
+
let offset = 0;
|
|
30
|
+
for (const frame of this.frames) {
|
|
31
|
+
samples.set(frame, offset);
|
|
32
|
+
offset += frame.length;
|
|
33
|
+
}
|
|
34
|
+
this.frames = [];
|
|
35
|
+
this.length = 0;
|
|
36
|
+
this.port.postMessage(samples, [samples.buffer]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
process(inputs) {
|
|
40
|
+
if (!this.accepting || this.muted) return true;
|
|
41
|
+
const input = inputs[0]?.[0];
|
|
42
|
+
if (!input?.length) return true;
|
|
43
|
+
this.frames.push(Float32Array.from(input));
|
|
44
|
+
this.length += input.length;
|
|
45
|
+
if (this.length >= ${CAPTURE_FRAME_SAMPLES}) this.emit();
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
registerProcessor("${CAPTURE_WORKLET_NAME}", AssistantVoiceCapture);
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
function float32ToPcm16ArrayBuffer(samples) {
|
|
53
|
+
const output = new ArrayBuffer(samples.length * 2);
|
|
54
|
+
const view = new DataView(output);
|
|
55
|
+
for (let index = 0; index < samples.length; index += 1) {
|
|
56
|
+
const sample = Math.max(-1, Math.min(1, Number(samples[index]) || 0));
|
|
57
|
+
view.setInt16(index * 2, sample < 0 ? sample * 32768 : sample * 32767, true);
|
|
58
|
+
}
|
|
59
|
+
return output;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function createMicrophoneCapture({ onLevel, onPcm, targetSampleRate = 16_000 } = {}) {
|
|
63
|
+
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
|
64
|
+
if (!AudioContextCtor) {
|
|
65
|
+
throw new Error("This browser does not support microphone audio processing.");
|
|
66
|
+
}
|
|
67
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
68
|
+
audio: {
|
|
69
|
+
autoGainControl: true,
|
|
70
|
+
channelCount: 1,
|
|
71
|
+
echoCancellation: true,
|
|
72
|
+
noiseSuppression: true,
|
|
73
|
+
sampleRate: { ideal: targetSampleRate }
|
|
74
|
+
},
|
|
75
|
+
video: false
|
|
76
|
+
});
|
|
77
|
+
const stopTracks = () => {
|
|
78
|
+
for (const track of stream.getTracks()) {
|
|
79
|
+
track.stop();
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
let context = null;
|
|
83
|
+
try {
|
|
84
|
+
context = new AudioContextCtor({
|
|
85
|
+
latencyHint: "interactive",
|
|
86
|
+
sampleRate: targetSampleRate
|
|
87
|
+
});
|
|
88
|
+
await context.resume();
|
|
89
|
+
if (Number(context.sampleRate) !== targetSampleRate) {
|
|
90
|
+
throw new Error(`This browser could not capture ${targetSampleRate} Hz microphone audio.`);
|
|
91
|
+
}
|
|
92
|
+
const workletUrl = URL.createObjectURL(new Blob([CAPTURE_WORKLET_SOURCE], {
|
|
93
|
+
type: "text/javascript"
|
|
94
|
+
}));
|
|
95
|
+
try {
|
|
96
|
+
await context.audioWorklet.addModule(workletUrl);
|
|
97
|
+
} finally {
|
|
98
|
+
URL.revokeObjectURL(workletUrl);
|
|
99
|
+
}
|
|
100
|
+
const source = context.createMediaStreamSource(stream);
|
|
101
|
+
const capture = new AudioWorkletNode(context, CAPTURE_WORKLET_NAME, {
|
|
102
|
+
channelCount: 1,
|
|
103
|
+
numberOfInputs: 1,
|
|
104
|
+
numberOfOutputs: 1,
|
|
105
|
+
outputChannelCount: [1]
|
|
106
|
+
});
|
|
107
|
+
const silence = context.createGain();
|
|
108
|
+
silence.gain.value = 0;
|
|
109
|
+
let finishFlush = null;
|
|
110
|
+
let muted = false;
|
|
111
|
+
capture.port.onmessage = (event) => {
|
|
112
|
+
if (event.data?.type === "flushed") {
|
|
113
|
+
finishFlush?.();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (muted) return;
|
|
117
|
+
const samples = event.data instanceof Float32Array
|
|
118
|
+
? event.data
|
|
119
|
+
: new Float32Array(event.data);
|
|
120
|
+
let squaredTotal = 0;
|
|
121
|
+
for (const sample of samples) {
|
|
122
|
+
squaredTotal += sample * sample;
|
|
123
|
+
}
|
|
124
|
+
onLevel?.(samples.length
|
|
125
|
+
? Math.min(1, Math.sqrt(squaredTotal / samples.length) * 7)
|
|
126
|
+
: 0);
|
|
127
|
+
onPcm?.(float32ToPcm16ArrayBuffer(samples));
|
|
128
|
+
};
|
|
129
|
+
source.connect(capture);
|
|
130
|
+
capture.connect(silence);
|
|
131
|
+
silence.connect(context.destination);
|
|
132
|
+
|
|
133
|
+
let closePromise = null;
|
|
134
|
+
const flush = () => new Promise((resolve) => {
|
|
135
|
+
let finished = false;
|
|
136
|
+
const timeout = globalThis.setTimeout(() => {
|
|
137
|
+
if (!finished) {
|
|
138
|
+
finished = true;
|
|
139
|
+
finishFlush = null;
|
|
140
|
+
resolve();
|
|
141
|
+
}
|
|
142
|
+
}, CAPTURE_FLUSH_TIMEOUT_MS);
|
|
143
|
+
finishFlush = () => {
|
|
144
|
+
if (finished) return;
|
|
145
|
+
finished = true;
|
|
146
|
+
globalThis.clearTimeout(timeout);
|
|
147
|
+
finishFlush = null;
|
|
148
|
+
resolve();
|
|
149
|
+
};
|
|
150
|
+
try {
|
|
151
|
+
capture.port.postMessage({ type: "flush" });
|
|
152
|
+
} catch {
|
|
153
|
+
finishFlush();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
return Object.freeze({
|
|
157
|
+
setMuted(value) {
|
|
158
|
+
if (muted === Boolean(value)) return;
|
|
159
|
+
muted = Boolean(value);
|
|
160
|
+
for (const track of stream.getTracks()) track.enabled = !muted;
|
|
161
|
+
capture.port.postMessage({ type: "mute", muted });
|
|
162
|
+
if (muted) onLevel?.(0);
|
|
163
|
+
},
|
|
164
|
+
close() {
|
|
165
|
+
if (!closePromise) {
|
|
166
|
+
closePromise = (async () => {
|
|
167
|
+
try {
|
|
168
|
+
await flush();
|
|
169
|
+
} finally {
|
|
170
|
+
onLevel?.(0);
|
|
171
|
+
capture.port.onmessage = null;
|
|
172
|
+
try {
|
|
173
|
+
source.disconnect();
|
|
174
|
+
capture.disconnect();
|
|
175
|
+
silence.disconnect();
|
|
176
|
+
} finally {
|
|
177
|
+
stopTracks();
|
|
178
|
+
await context.close();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
})();
|
|
182
|
+
}
|
|
183
|
+
return closePromise;
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
} catch (error) {
|
|
187
|
+
stopTracks();
|
|
188
|
+
if (context && context.state !== "closed") {
|
|
189
|
+
await context.close().catch(() => null);
|
|
190
|
+
}
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export {
|
|
196
|
+
createMicrophoneCapture,
|
|
197
|
+
float32ToPcm16ArrayBuffer
|
|
198
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
VOICE_MAX_SPEECH_TEXT_CHARACTERS,
|
|
3
|
+
speechPauseDurationMs,
|
|
4
|
+
speechPhrases,
|
|
5
|
+
speechTextFromAssistant,
|
|
6
|
+
splitSpeechText
|
|
7
|
+
} from "../shared/protocol.js";
|
|
8
|
+
|
|
9
|
+
const ACTIVITY_SPEECH_CHARACTERS = 300;
|
|
10
|
+
|
|
11
|
+
function latestConversationFinalNarrationEntry(turns = []) {
|
|
12
|
+
const conversationTurns = Array.isArray(turns) ? turns : [];
|
|
13
|
+
for (let turnIndex = conversationTurns.length - 1; turnIndex >= 0; turnIndex -= 1) {
|
|
14
|
+
const turn = conversationTurns[turnIndex];
|
|
15
|
+
const turnId = String(turn?.turnId || turnIndex + 1).trim();
|
|
16
|
+
const finalText = String(turn?.assistant?.text || "").trim();
|
|
17
|
+
if (finalText && turn.assistant.status !== "inProgress") {
|
|
18
|
+
return {
|
|
19
|
+
key: `final:${turnId}`,
|
|
20
|
+
kind: "final",
|
|
21
|
+
text: finalText
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createConversationNarrationTracker() {
|
|
29
|
+
let primed = false;
|
|
30
|
+
let activityTurnId = "";
|
|
31
|
+
const finalKeys = new Set();
|
|
32
|
+
const activityText = new Map();
|
|
33
|
+
|
|
34
|
+
function reset() {
|
|
35
|
+
primed = false;
|
|
36
|
+
activityTurnId = "";
|
|
37
|
+
finalKeys.clear();
|
|
38
|
+
activityText.clear();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function observe(turns = [], {
|
|
42
|
+
includeFinals = false,
|
|
43
|
+
settled = false,
|
|
44
|
+
enabled = true,
|
|
45
|
+
vocalizeThinking = false,
|
|
46
|
+
vocalizeInterimTurns = false
|
|
47
|
+
} = {}) {
|
|
48
|
+
const conversationTurns = Array.isArray(turns) ? turns : [];
|
|
49
|
+
const turn = conversationTurns.at(-1);
|
|
50
|
+
const turnId = String(turn?.turnId || conversationTurns.length).trim();
|
|
51
|
+
if (turnId !== activityTurnId) {
|
|
52
|
+
activityTurnId = turnId;
|
|
53
|
+
activityText.clear();
|
|
54
|
+
}
|
|
55
|
+
const activity = (Array.isArray(turn?.messages) ? turn.messages : [])
|
|
56
|
+
.filter((message) => ["thinking", "commentary"].includes(message?.role));
|
|
57
|
+
if (!activity.length) {
|
|
58
|
+
activity.push(...[
|
|
59
|
+
...(Array.isArray(turn?.thinking) ? turn.thinking : []),
|
|
60
|
+
...(Array.isArray(turn?.commentary) ? turn.commentary : [])
|
|
61
|
+
].sort((left, right) => String(left?.at || "").localeCompare(String(right?.at || ""))));
|
|
62
|
+
}
|
|
63
|
+
const entries = [];
|
|
64
|
+
for (const [index, message] of activity.entries()) {
|
|
65
|
+
const kind = message?.role;
|
|
66
|
+
if (!["thinking", "commentary"].includes(kind) || message.status === "inProgress") continue;
|
|
67
|
+
const key = `${turnId}:${kind}:${message.messageId || index}`;
|
|
68
|
+
const rawText = String(message.text || "");
|
|
69
|
+
// Track the whole message; only individual outgoing utterances are bounded.
|
|
70
|
+
const phrases = speechPhrases(speechTextFromAssistant(rawText,
|
|
71
|
+
Math.max(rawText.length, VOICE_MAX_SPEECH_TEXT_CHARACTERS)));
|
|
72
|
+
const fullText = phrases.join(" ");
|
|
73
|
+
const complete = settled || index < activity.length - 1 || Boolean(turn?.assistant);
|
|
74
|
+
if (!complete && !speechPauseDurationMs(phrases.at(-1))) phrases.pop();
|
|
75
|
+
const text = phrases.join(" ");
|
|
76
|
+
const previous = activityText.get(key) || "";
|
|
77
|
+
// A later stream observation must not retire a tail already flushed after settling.
|
|
78
|
+
if (text.length < previous.length && fullText.startsWith(previous)) continue;
|
|
79
|
+
activityText.set(key, text);
|
|
80
|
+
const selected = kind === "thinking" ? vocalizeThinking : vocalizeInterimTurns;
|
|
81
|
+
// Observe disabled/old content too, so enabling speech never replays it.
|
|
82
|
+
// A rewritten prefix is also retired instead of speaking it twice.
|
|
83
|
+
if (!primed || !enabled || !selected || !text.startsWith(previous)) continue;
|
|
84
|
+
for (const chunk of splitSpeechText(text.slice(previous.length), ACTIVITY_SPEECH_CHARACTERS)) {
|
|
85
|
+
entries.push({ kind, text: chunk });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const final = latestConversationFinalNarrationEntry(conversationTurns);
|
|
90
|
+
if (!primed) {
|
|
91
|
+
if (final) finalKeys.add(final.key);
|
|
92
|
+
primed = true;
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
if (includeFinals && final && !finalKeys.has(final.key)) {
|
|
96
|
+
finalKeys.add(final.key);
|
|
97
|
+
return enabled ? [final] : [];
|
|
98
|
+
}
|
|
99
|
+
return entries;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { observe, reset };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export {
|
|
106
|
+
createConversationNarrationTracker,
|
|
107
|
+
latestConversationFinalNarrationEntry
|
|
108
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Canonical history is authoritative; only user and assistant output is spoken. */
|
|
2
|
+
export function projectConversationVoiceState({ turns = [], status = "ready", interimReply = null } = {}) {
|
|
3
|
+
const messages = [];
|
|
4
|
+
let streamingReply = null;
|
|
5
|
+
for (const turn of turns) {
|
|
6
|
+
if (turn.user) messages.push({ ...turn.user, role: "user", id: turn.user.messageId || `${turn.turnId}:user` });
|
|
7
|
+
if (turn.assistant?.text) {
|
|
8
|
+
const reply = { ...turn.assistant, role: "assistant", id: turn.assistant.outputId || `${turn.turnId}:assistant` };
|
|
9
|
+
if (turn.pending || ["starting", "inProgress"].includes(reply.status)) {
|
|
10
|
+
// Production notifications kept live output separate until their saved reply.
|
|
11
|
+
if (reply.origin === "application") Object.assign(reply, { id: `${reply.id}:stream`, autonomous: true });
|
|
12
|
+
streamingReply = reply;
|
|
13
|
+
}
|
|
14
|
+
else messages.push(reply);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return { messages, streamingReply: interimReply ? { ...interimReply, id: interimReply.outputId || interimReply.id } : streamingReply, status };
|
|
18
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { projectConversationVoiceState } from "./conversationVoiceState.js";
|
|
2
|
+
export { createVoiceConversationController } from "./voiceController.js";
|
|
3
|
+
export { default as VoiceConversationHost } from "./VoiceConversationHost.vue";
|
|
4
|
+
export { default as ConversationDialog } from "./ConversationDialog.vue";
|
|
5
|
+
export { default as VoiceConversation } from "./VoiceConversation.vue";
|
|
6
|
+
|
|
7
|
+
export { useVoiceLauncher } from "./voiceLauncher.js";
|
|
8
|
+
export { useVoiceAvatar } from "./voiceAvatar.js";
|
|
9
|
+
export { default as VoiceAvatar } from "./VoiceAvatar.vue";
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
2
|
+
|
|
3
|
+
const IDLE_GESTURES = Object.freeze([
|
|
4
|
+
"glance-left",
|
|
5
|
+
"tiny-nod",
|
|
6
|
+
"glance-right",
|
|
7
|
+
"brow-lift",
|
|
8
|
+
"glance-up",
|
|
9
|
+
"curious-tilt",
|
|
10
|
+
"smile-brighten",
|
|
11
|
+
"friendly-lean",
|
|
12
|
+
"shoulder-lift",
|
|
13
|
+
"earring-sway"
|
|
14
|
+
]);
|
|
15
|
+
const IDLE_MOUTH_POSES = Object.freeze({
|
|
16
|
+
"tiny-nod": "warm-smile",
|
|
17
|
+
"curious-tilt": "warm-smile",
|
|
18
|
+
"smile-brighten": "smile",
|
|
19
|
+
"friendly-lean": "warm-smile"
|
|
20
|
+
});
|
|
21
|
+
const IDLE_GESTURE_DURATION_MS = 1_100;
|
|
22
|
+
const LISTENING_EXPRESSIONS = Object.freeze([
|
|
23
|
+
"attentive",
|
|
24
|
+
"tiny-nod",
|
|
25
|
+
"follow-left",
|
|
26
|
+
"tilt-left",
|
|
27
|
+
"follow-right",
|
|
28
|
+
"tilt-right"
|
|
29
|
+
]);
|
|
30
|
+
const LISTENING_MOUTH_POSES = Object.freeze({
|
|
31
|
+
"attentive": "soft-smile",
|
|
32
|
+
"tiny-nod": "soft-smile"
|
|
33
|
+
});
|
|
34
|
+
const PRE_THINKING_EXPRESSIONS = Object.freeze([
|
|
35
|
+
"settle",
|
|
36
|
+
"glance-left",
|
|
37
|
+
"center",
|
|
38
|
+
"glance-right",
|
|
39
|
+
"tiny-tilt",
|
|
40
|
+
"ready"
|
|
41
|
+
]);
|
|
42
|
+
const PRE_THINKING_MOUTH_POSES = Object.freeze({
|
|
43
|
+
"ready": "soft-smile",
|
|
44
|
+
"tiny-tilt": "thoughtful"
|
|
45
|
+
});
|
|
46
|
+
const THINKING_EXPRESSIONS = Object.freeze([
|
|
47
|
+
"look-up-left",
|
|
48
|
+
"focus",
|
|
49
|
+
"look-up-right",
|
|
50
|
+
"weigh-left",
|
|
51
|
+
"weigh-right",
|
|
52
|
+
"idea"
|
|
53
|
+
]);
|
|
54
|
+
const THINKING_MOUTH_POSES = Object.freeze({
|
|
55
|
+
"idea": "soft-smile",
|
|
56
|
+
"weigh-left": "closed",
|
|
57
|
+
"weigh-right": "closed"
|
|
58
|
+
});
|
|
59
|
+
const ACTIVE_EXPRESSION_DURATION_MS = Object.freeze({
|
|
60
|
+
listening: 1_350,
|
|
61
|
+
"pre-thinking": 1_450,
|
|
62
|
+
thinking: 1_700
|
|
63
|
+
});
|
|
64
|
+
const ACTIVE_EXPRESSION_SEQUENCES = Object.freeze({
|
|
65
|
+
listening: LISTENING_EXPRESSIONS,
|
|
66
|
+
"pre-thinking": PRE_THINKING_EXPRESSIONS,
|
|
67
|
+
thinking: THINKING_EXPRESSIONS
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export function useVoiceAvatar(props) {
|
|
71
|
+
const blinking = ref(false);
|
|
72
|
+
const activeExpression = ref("");
|
|
73
|
+
const idleGesture = ref("");
|
|
74
|
+
const reducedMotion = ref(false);
|
|
75
|
+
let blinkTimer = 0;
|
|
76
|
+
let activeExpressionTimer = 0;
|
|
77
|
+
const activeExpressionIndexes = {
|
|
78
|
+
listening: Math.floor(Math.random() * LISTENING_EXPRESSIONS.length),
|
|
79
|
+
"pre-thinking": Math.floor(Math.random() * PRE_THINKING_EXPRESSIONS.length),
|
|
80
|
+
thinking: Math.floor(Math.random() * THINKING_EXPRESSIONS.length)
|
|
81
|
+
};
|
|
82
|
+
let idleGestureIndex = Math.floor(Math.random() * IDLE_GESTURES.length);
|
|
83
|
+
let idleGestureTimer = 0;
|
|
84
|
+
let reopenTimer = 0;
|
|
85
|
+
let reducedMotionQuery = null;
|
|
86
|
+
|
|
87
|
+
const expression = computed(() => String(props.state || "idle"));
|
|
88
|
+
const visibleMouthPose = computed(() => {
|
|
89
|
+
if (expression.value === "idle") {
|
|
90
|
+
return IDLE_MOUTH_POSES[idleGesture.value] || "soft-smile";
|
|
91
|
+
}
|
|
92
|
+
if (expression.value === "thinking") {
|
|
93
|
+
return THINKING_MOUTH_POSES[activeExpression.value] || "thoughtful";
|
|
94
|
+
}
|
|
95
|
+
if (expression.value === "pre-thinking") {
|
|
96
|
+
return PRE_THINKING_MOUTH_POSES[activeExpression.value] || "closed";
|
|
97
|
+
}
|
|
98
|
+
if (expression.value === "listening") {
|
|
99
|
+
return LISTENING_MOUTH_POSES[activeExpression.value] || "closed";
|
|
100
|
+
}
|
|
101
|
+
if (expression.value !== "speaking") {
|
|
102
|
+
return "closed";
|
|
103
|
+
}
|
|
104
|
+
const pose = String(props.mouthPose || "closed");
|
|
105
|
+
if (props.mouthLevel < 0.045 || !["open", "round", "small", "wide"].includes(pose)) {
|
|
106
|
+
return "closed";
|
|
107
|
+
}
|
|
108
|
+
return pose;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
function clearBlinkTimers() {
|
|
112
|
+
window.clearTimeout(blinkTimer);
|
|
113
|
+
window.clearTimeout(reopenTimer);
|
|
114
|
+
blinkTimer = 0;
|
|
115
|
+
reopenTimer = 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function clearIdleGesture() {
|
|
119
|
+
window.clearTimeout(idleGestureTimer);
|
|
120
|
+
idleGestureTimer = 0;
|
|
121
|
+
idleGesture.value = "";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function clearActiveExpression() {
|
|
125
|
+
window.clearTimeout(activeExpressionTimer);
|
|
126
|
+
activeExpressionTimer = 0;
|
|
127
|
+
activeExpression.value = "";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function scheduleBlink(delay = 2_800 + (Math.random() * 3_400)) {
|
|
131
|
+
window.clearTimeout(blinkTimer);
|
|
132
|
+
if (reducedMotion.value) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
blinkTimer = window.setTimeout(() => {
|
|
136
|
+
const doubleBlink = Math.random() < 0.16;
|
|
137
|
+
blinking.value = true;
|
|
138
|
+
reopenTimer = window.setTimeout(() => {
|
|
139
|
+
blinking.value = false;
|
|
140
|
+
if (!doubleBlink) {
|
|
141
|
+
scheduleBlink();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
blinkTimer = window.setTimeout(() => {
|
|
145
|
+
blinking.value = true;
|
|
146
|
+
reopenTimer = window.setTimeout(() => {
|
|
147
|
+
blinking.value = false;
|
|
148
|
+
scheduleBlink();
|
|
149
|
+
}, 115);
|
|
150
|
+
}, 155);
|
|
151
|
+
}, 135);
|
|
152
|
+
}, delay);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function scheduleIdleGesture(delay = 2_400 + (Math.random() * 2_800)) {
|
|
156
|
+
window.clearTimeout(idleGestureTimer);
|
|
157
|
+
if (reducedMotion.value || expression.value !== "idle") {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
idleGestureTimer = window.setTimeout(() => {
|
|
161
|
+
idleGesture.value = IDLE_GESTURES[idleGestureIndex];
|
|
162
|
+
idleGestureIndex = (idleGestureIndex + 1) % IDLE_GESTURES.length;
|
|
163
|
+
idleGestureTimer = window.setTimeout(() => {
|
|
164
|
+
idleGesture.value = "";
|
|
165
|
+
scheduleIdleGesture();
|
|
166
|
+
}, IDLE_GESTURE_DURATION_MS);
|
|
167
|
+
}, delay);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function showNextActiveExpression() {
|
|
171
|
+
const state = expression.value;
|
|
172
|
+
const sequence = ACTIVE_EXPRESSION_SEQUENCES[state] || [];
|
|
173
|
+
if (!sequence.length) {
|
|
174
|
+
clearActiveExpression();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const index = activeExpressionIndexes[state] || 0;
|
|
178
|
+
activeExpression.value = sequence[index];
|
|
179
|
+
activeExpressionIndexes[state] = (index + 1) % sequence.length;
|
|
180
|
+
if (!reducedMotion.value) {
|
|
181
|
+
activeExpressionTimer = window.setTimeout(
|
|
182
|
+
showNextActiveExpression,
|
|
183
|
+
ACTIVE_EXPRESSION_DURATION_MS[state]
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function updateReducedMotion(event) {
|
|
189
|
+
reducedMotion.value = Boolean(event.matches);
|
|
190
|
+
clearBlinkTimers();
|
|
191
|
+
clearActiveExpression();
|
|
192
|
+
clearIdleGesture();
|
|
193
|
+
blinking.value = false;
|
|
194
|
+
if (ACTIVE_EXPRESSION_SEQUENCES[expression.value]) {
|
|
195
|
+
showNextActiveExpression();
|
|
196
|
+
}
|
|
197
|
+
if (!reducedMotion.value) {
|
|
198
|
+
scheduleBlink();
|
|
199
|
+
scheduleIdleGesture();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
watch(expression, () => {
|
|
204
|
+
clearActiveExpression();
|
|
205
|
+
clearIdleGesture();
|
|
206
|
+
if (ACTIVE_EXPRESSION_SEQUENCES[expression.value]) {
|
|
207
|
+
showNextActiveExpression();
|
|
208
|
+
} else if (!reducedMotion.value) {
|
|
209
|
+
scheduleIdleGesture(900 + (Math.random() * 1_200));
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
onMounted(() => {
|
|
214
|
+
reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
215
|
+
reducedMotion.value = reducedMotionQuery.matches;
|
|
216
|
+
reducedMotionQuery.addEventListener?.("change", updateReducedMotion);
|
|
217
|
+
if (!reducedMotion.value) {
|
|
218
|
+
scheduleBlink(1_800 + (Math.random() * 1_800));
|
|
219
|
+
scheduleIdleGesture(900 + (Math.random() * 1_500));
|
|
220
|
+
}
|
|
221
|
+
if (ACTIVE_EXPRESSION_SEQUENCES[expression.value]) {
|
|
222
|
+
showNextActiveExpression();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
onBeforeUnmount(() => {
|
|
227
|
+
clearBlinkTimers();
|
|
228
|
+
clearActiveExpression();
|
|
229
|
+
clearIdleGesture();
|
|
230
|
+
reducedMotionQuery?.removeEventListener?.("change", updateReducedMotion);
|
|
231
|
+
reducedMotionQuery = null;
|
|
232
|
+
});
|
|
233
|
+
return { blinking, activeExpression, idleGesture, expression, visibleMouthPose, reducedMotion };
|
|
234
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { effectScope, shallowReactive, watch } from "vue";
|
|
2
|
+
import { useVoiceConversation } from "./voiceConversation.js";
|
|
3
|
+
|
|
4
|
+
/** One controller per application shell. Bindings retain their own conversation. */
|
|
5
|
+
export function createVoiceConversationController({ connectSpeech, createSession = useVoiceConversation } = {}) {
|
|
6
|
+
if (typeof connectSpeech !== "function") throw new TypeError("Voice requires an application speech connection.");
|
|
7
|
+
const state = shallowReactive({ binding: null, session: null, visible: false, nextTarget: null, busy: false, error: "" });
|
|
8
|
+
let scope;
|
|
9
|
+
let revision = 0;
|
|
10
|
+
let operations = Promise.resolve();
|
|
11
|
+
let disposed = false;
|
|
12
|
+
|
|
13
|
+
function serialize(operation) {
|
|
14
|
+
const result = operations.then(operation);
|
|
15
|
+
operations = result.catch(() => {});
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function release() {
|
|
20
|
+
const session = state.session;
|
|
21
|
+
// Keep the old owner visible until microphone and playback cleanup finishes.
|
|
22
|
+
if (session) await session.close();
|
|
23
|
+
scope?.stop();
|
|
24
|
+
scope = null;
|
|
25
|
+
await state.binding?.release?.();
|
|
26
|
+
state.binding = null;
|
|
27
|
+
state.session = null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function activate(binding, requestRevision) {
|
|
31
|
+
const socketUrl = await connectSpeech(binding);
|
|
32
|
+
if (disposed || requestRevision !== revision) return false;
|
|
33
|
+
if (!socketUrl) throw new Error("A speech service is not configured for this conversation.");
|
|
34
|
+
await release();
|
|
35
|
+
if (disposed || requestRevision !== revision) return false;
|
|
36
|
+
const nextScope = effectScope(true);
|
|
37
|
+
try {
|
|
38
|
+
binding.retain?.();
|
|
39
|
+
const session = nextScope.run(() => createSession(binding, { socketUrl }));
|
|
40
|
+
scope = nextScope;
|
|
41
|
+
state.binding = binding;
|
|
42
|
+
state.session = session;
|
|
43
|
+
state.visible = true;
|
|
44
|
+
state.nextTarget = null;
|
|
45
|
+
nextScope.run(() => watch(() => binding.available, (available) => {
|
|
46
|
+
if (available !== false) return;
|
|
47
|
+
state.error = "This conversation is no longer available for voice.";
|
|
48
|
+
void end({ discard: true });
|
|
49
|
+
}, { immediate: true }));
|
|
50
|
+
return true;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
nextScope.stop();
|
|
53
|
+
binding.release?.();
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function open({ conversation } = {}) {
|
|
59
|
+
if (!conversation?.id || !conversation.state || typeof conversation.submitText !== "function") {
|
|
60
|
+
return Promise.reject(new TypeError("Voice requires a conversation identity, state and text submission."));
|
|
61
|
+
}
|
|
62
|
+
if (disposed) return Promise.reject(new Error("The voice host has been disposed."));
|
|
63
|
+
const requestRevision = ++revision;
|
|
64
|
+
return serialize(async () => {
|
|
65
|
+
if (requestRevision !== revision || disposed) return false;
|
|
66
|
+
state.error = "";
|
|
67
|
+
if (state.binding?.id === conversation.id) {
|
|
68
|
+
state.visible = true;
|
|
69
|
+
state.nextTarget = null;
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
if (conversation.available === false) throw new Error("This conversation is not available for voice.");
|
|
73
|
+
if (state.session?.hasUnsentSpeech.value) {
|
|
74
|
+
state.nextTarget = conversation;
|
|
75
|
+
state.visible = true;
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
state.busy = true;
|
|
79
|
+
try { return await activate(conversation, requestRevision); }
|
|
80
|
+
catch (error) { state.error = error.message; throw error; }
|
|
81
|
+
finally { state.busy = false; }
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function switchTarget({ discard = false } = {}) {
|
|
86
|
+
const target = state.nextTarget;
|
|
87
|
+
if (!target) return Promise.resolve(false);
|
|
88
|
+
if (!discard && state.session?.hasUnsentSpeech.value) return Promise.resolve(false);
|
|
89
|
+
if (state.session?.sending.value) return Promise.resolve(false);
|
|
90
|
+
const requestRevision = ++revision;
|
|
91
|
+
return serialize(async () => {
|
|
92
|
+
state.busy = true;
|
|
93
|
+
try { return await activate(target, requestRevision); }
|
|
94
|
+
catch (error) { state.error = error.message; throw error; }
|
|
95
|
+
finally { state.busy = false; }
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function end({ discard = false } = {}) {
|
|
100
|
+
if (!discard && state.session?.hasUnsentSpeech.value) {
|
|
101
|
+
state.visible = true;
|
|
102
|
+
state.error = "Finish or discard your recording before ending voice.";
|
|
103
|
+
return Promise.resolve(false);
|
|
104
|
+
}
|
|
105
|
+
++revision;
|
|
106
|
+
return serialize(async () => {
|
|
107
|
+
state.busy = true;
|
|
108
|
+
try {
|
|
109
|
+
await release();
|
|
110
|
+
state.visible = false;
|
|
111
|
+
state.nextTarget = null;
|
|
112
|
+
return true;
|
|
113
|
+
} finally { state.busy = false; }
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
state, open, switchTarget, end,
|
|
119
|
+
minimize() { state.visible = false; },
|
|
120
|
+
reveal() { if (state.session) state.visible = true; },
|
|
121
|
+
cancelSwitch() { state.nextTarget = null; },
|
|
122
|
+
async dispose() { disposed = true; await end({ discard: true }); }
|
|
123
|
+
};
|
|
124
|
+
}
|