@jskit-ai/assistant-voice 0.1.2 → 0.1.4
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/examples/conversation/App.vue +16 -3
- package/examples/conversation/package.json +8 -8
- package/package.json +2 -1
- package/src/client/VoiceConversation.vue +41 -64
- package/src/client/VoiceConversationControls.vue +101 -0
- package/src/client/VoiceConversationHost.vue +17 -8
- package/src/client/index.js +1 -0
- package/src/client/voiceConversation.js +92 -43
- package/src/client/voiceTransport.js +58 -10
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
import { inject, nextTick, onScopeDispose, ref } from "vue";
|
|
3
3
|
import { createVoiceConversationController, projectConversationVoiceState, VoiceConversationHost } from "@jskit-ai/assistant-voice/client";
|
|
4
4
|
import { ShellErrorHost } from "@jskit-ai/shell-web/client";
|
|
5
|
+
import { useAssistantConversationFactory } from "@jskit-ai/assistant-runtime/client";
|
|
5
6
|
import Conversation from "./Conversation.vue";
|
|
6
7
|
|
|
7
8
|
const example = inject("example.application");
|
|
8
9
|
const closed = ref([]);
|
|
9
10
|
const views = new Map();
|
|
10
11
|
const error = ref("");
|
|
12
|
+
const avatarSize = ref("compact");
|
|
13
|
+
const acquireConversation = useAssistantConversationFactory();
|
|
11
14
|
const controller = createVoiceConversationController({ connectSpeech: binding => binding.socketUrl });
|
|
12
15
|
async function openText(id) {
|
|
13
16
|
closed.value = closed.value.filter(value => value !== id);
|
|
@@ -18,12 +21,22 @@ function voiceBinding(runtime, label) {
|
|
|
18
21
|
const identity = runtime.identity;
|
|
19
22
|
let retained;
|
|
20
23
|
return {
|
|
21
|
-
id: JSON.stringify([identity.actorKey, identity.conversationId]), label,
|
|
24
|
+
id: JSON.stringify([identity.actorKey, identity.conversationId]), conversationId: identity.conversationId, label,
|
|
25
|
+
get adapter() { return retained?.adapter.value; },
|
|
22
26
|
socketUrl: `/api/conversations/${encodeURIComponent(identity.conversationId)}/voice`,
|
|
23
27
|
get state() { return projectConversationVoiceState({ turns: runtime.turns.value, status: runtime.snapshot.value?.status }); },
|
|
24
28
|
get available() { return runtime.available.value; },
|
|
25
29
|
captureContext: () => ({ ...identity }),
|
|
26
|
-
retain() {
|
|
30
|
+
retain() {
|
|
31
|
+
retained = acquireConversation({ conversationId: identity.conversationId, endpoint: identity.endpoint,
|
|
32
|
+
surfaceId: identity.targetSurfaceId, hostSurfaceId: identity.hostSurfaceId,
|
|
33
|
+
actorKey: identity.actorKey, workspaceSlug: identity.workspaceSlug,
|
|
34
|
+
presentation: { assistantLabel: label, layout: "compact" } });
|
|
35
|
+
if (retained.runtime.value !== runtime) {
|
|
36
|
+
retained.release(); retained = null;
|
|
37
|
+
throw new Error("Voice must retain the original conversation runtime.");
|
|
38
|
+
}
|
|
39
|
+
},
|
|
27
40
|
release() { retained?.release(); retained = null; },
|
|
28
41
|
submitText(text, { messageId, context } = {}) {
|
|
29
42
|
if (context?.actorKey !== identity.actorKey || context?.conversationId !== identity.conversationId) {
|
|
@@ -78,7 +91,7 @@ onScopeDispose(() => { void controller.dispose(); });
|
|
|
78
91
|
</section>
|
|
79
92
|
</div>
|
|
80
93
|
</main>
|
|
81
|
-
<VoiceConversationHost :controller="controller" />
|
|
94
|
+
<VoiceConversationHost v-model:avatar-size="avatarSize" :controller="controller" />
|
|
82
95
|
<ShellErrorHost />
|
|
83
96
|
</v-app>
|
|
84
97
|
</template>
|
|
@@ -9,14 +9,14 @@
|
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@fastify/websocket": "^11.2.0",
|
|
12
|
-
"@jskit-ai/assistant-core": "0.1.
|
|
13
|
-
"@jskit-ai/assistant-runtime": "0.1.
|
|
14
|
-
"@jskit-ai/assistant-voice": "0.1.
|
|
15
|
-
"@jskit-ai/connectors-catalog": "0.1.
|
|
16
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
17
|
-
"@jskit-ai/kernel": "0.1.
|
|
18
|
-
"@jskit-ai/realtime": "0.1.
|
|
19
|
-
"@jskit-ai/shell-web": "0.1.
|
|
12
|
+
"@jskit-ai/assistant-core": "0.1.204",
|
|
13
|
+
"@jskit-ai/assistant-runtime": "0.1.197",
|
|
14
|
+
"@jskit-ai/assistant-voice": "0.1.4",
|
|
15
|
+
"@jskit-ai/connectors-catalog": "0.1.43",
|
|
16
|
+
"@jskit-ai/http-runtime": "0.1.224",
|
|
17
|
+
"@jskit-ai/kernel": "0.1.226",
|
|
18
|
+
"@jskit-ai/realtime": "0.1.223",
|
|
19
|
+
"@jskit-ai/shell-web": "0.1.230",
|
|
20
20
|
"@mdi/js": "^7.4.47",
|
|
21
21
|
"@tanstack/vue-query": "^5.90.5",
|
|
22
22
|
"fastify": "^5.8.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/assistant-voice",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Duplex voice conversations with application-owned targets, authorization and speech services.",
|
|
6
6
|
"scripts": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"examples"
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
|
+
"@jskit-ai/assistant-core": "0.1.204",
|
|
21
22
|
"@mdi/js": "^7.4.47",
|
|
22
23
|
"ws": "^8.20.1"
|
|
23
24
|
},
|
|
@@ -1,28 +1,23 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { computed, ref,
|
|
3
|
-
import {
|
|
2
|
+
import { computed, ref, watch } from "vue";
|
|
3
|
+
import { AssistantConversationElement } from "@jskit-ai/assistant-core/client/conversation";
|
|
4
4
|
import VoiceAvatar from "./VoiceAvatar.vue";
|
|
5
|
-
import
|
|
5
|
+
import VoiceConversationControls from "./VoiceConversationControls.vue";
|
|
6
6
|
|
|
7
|
-
const props = defineProps({
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
stopSpeech, startPushToTalk, finishPushToTalk, cancelPushToTalk, toggleHandsFree, readAloud, toggleReadAloud,
|
|
13
|
-
deliverTranscript, discardHeldRecording, editTranscript
|
|
14
|
-
} = props.session;
|
|
15
|
-
const handsFreeListening = computed(() => live.value && callMode.value === "hands-free" && !microphoneMuted.value);
|
|
16
|
-
const canTalk = computed(() => !props.disabled && !sending.value && !pendingTranscript.value &&
|
|
17
|
-
(!capturing.value || voice.listening.value || pushHolding.value || starting.value));
|
|
18
|
-
const talkLabel = computed(() => starting.value ? "Connecting…" : pushHolding.value ? "Release to send" : handsFreeListening.value ? "Pause" : "Talk");
|
|
19
|
-
const gestureHint = useId();
|
|
20
|
-
const gesture = useVoiceLauncher({
|
|
21
|
-
enabled: canTalk,
|
|
22
|
-
open: () => ({ startHeldRecording: startPushToTalk, finishHeldRecording: finishPushToTalk, discardHeldRecording: cancelPushToTalk }),
|
|
23
|
-
onTap: toggleHandsFree,
|
|
24
|
-
onError: cause => { error.value = cause.message; }
|
|
7
|
+
const props = defineProps({
|
|
8
|
+
session: { type: Object, required: true }, disabled: { type: Boolean, default: false },
|
|
9
|
+
adapter: { type: Object, default: null },
|
|
10
|
+
showAvatar: { type: Boolean, default: true },
|
|
11
|
+
avatarSize: { type: String, default: "compact" }
|
|
25
12
|
});
|
|
13
|
+
defineEmits(["update:avatarSize"]);
|
|
14
|
+
const { targetLabel, voice, microphoneMuted, heldTranscript, pendingTranscript, sending,
|
|
15
|
+
avatarVisual, callAudioLevel, voiceWords, voiceAnswer } = props.session;
|
|
16
|
+
const presentationAdapter = computed(() => props.adapter && ({
|
|
17
|
+
...props.adapter,
|
|
18
|
+
composer: { ...props.adapter.composer, canSend: props.adapter.composer?.canSend && !props.session.hasUnsentSpeech.value }
|
|
19
|
+
}));
|
|
20
|
+
const toolsTarget = ref(null);
|
|
26
21
|
const voiceWordsTarget = ref(null);
|
|
27
22
|
const voiceAnswerTarget = ref(null);
|
|
28
23
|
const followWords = ref(true);
|
|
@@ -36,18 +31,30 @@ for (const [text, target, following] of [[voiceWords, voiceWordsTarget, followWo
|
|
|
36
31
|
</script>
|
|
37
32
|
|
|
38
33
|
<template>
|
|
39
|
-
<fieldset class="assistant-voice" :disabled="disabled">
|
|
40
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
34
|
+
<fieldset v-if="adapter" class="assistant-voice-conversation" :disabled="disabled">
|
|
35
|
+
<AssistantConversationElement :adapter="presentationAdapter" :avatar-size="avatarSize" @update:avatar-size="$emit('update:avatarSize', $event)">
|
|
36
|
+
<template v-if="showAvatar" #avatar="size">
|
|
37
|
+
<slot name="avatar" v-bind="avatarVisual" :size="size.size" :height="size.height">
|
|
38
|
+
<VoiceAvatar v-bind="avatarVisual" />
|
|
39
|
+
</slot>
|
|
40
|
+
</template>
|
|
41
|
+
<template #composer-tools><div ref="toolsTarget" class="assistant-voice-conversation__tools" /></template>
|
|
42
|
+
<template #composer-feedback>
|
|
43
|
+
<VoiceConversationControls :session="session" :disabled="disabled" compact :tools-target="toolsTarget">
|
|
44
|
+
<template #work-control><slot name="work-control" /></template>
|
|
45
|
+
</VoiceConversationControls>
|
|
46
|
+
</template>
|
|
47
|
+
</AssistantConversationElement>
|
|
48
|
+
</fieldset>
|
|
49
|
+
<fieldset v-else class="assistant-voice" :disabled="disabled">
|
|
50
|
+
<section
|
|
51
|
+
class="assistant-voice__call" :class="{ 'assistant-voice__call--without-avatar': !showAvatar }"
|
|
52
|
+
:aria-label="`${targetLabel} voice chat controls`"
|
|
53
|
+
>
|
|
54
|
+
<div v-if="showAvatar" class="assistant-voice__call-body">
|
|
43
55
|
<div class="assistant-voice__call-portrait" :style="{ '--call-audio-scale': 1 + callAudioLevel * .16 }" aria-hidden="true">
|
|
44
56
|
<slot name="avatar" v-bind="avatarVisual"><VoiceAvatar v-bind="avatarVisual" /></slot>
|
|
45
57
|
</div>
|
|
46
|
-
<div class="assistant-voice__call-status-row">
|
|
47
|
-
<p class="text-title-medium" role="status">{{ callStatus }}</p>
|
|
48
|
-
<v-btn v-if="speechActive" :icon="mdiStop" variant="text" min-width="48" min-height="48" :aria-label="`Stop ${targetLabel} speaking`" title="Stop speaking" @click="stopSpeech" />
|
|
49
|
-
<slot name="work-control" />
|
|
50
|
-
</div>
|
|
51
58
|
</div>
|
|
52
59
|
<div class="assistant-voice__captions" aria-label="Voice conversation text">
|
|
53
60
|
<section ref="voiceWordsTarget" class="assistant-voice__caption" aria-label="Recognized words" tabindex="0" @scroll="followWords = isAtBottom($event.currentTarget)">
|
|
@@ -59,59 +66,29 @@ for (const [text, target, following] of [[voiceWords, voiceWordsTarget, followWo
|
|
|
59
66
|
<p class="assistant-voice__caption-text text-body-medium">{{ voiceAnswer }}</p>
|
|
60
67
|
</section>
|
|
61
68
|
</div>
|
|
62
|
-
<
|
|
63
|
-
<v-textarea :model-value="pendingTranscript.text" label="Review your message" :disabled="sending" rows="2" max-rows="3" auto-grow hide-details @update:model-value="editTranscript" />
|
|
64
|
-
<div class="assistant-voice__review-actions">
|
|
65
|
-
<v-btn :disabled="sending" min-height="48" @click="discardHeldRecording">Discard</v-btn>
|
|
66
|
-
<v-btn :disabled="sending || !pendingTranscript.text.trim()" min-height="48" color="primary" @click="deliverTranscript">{{ sending ? 'Sending…' : 'Send' }}</v-btn>
|
|
67
|
-
</div>
|
|
68
|
-
</section>
|
|
69
|
-
<div class="assistant-voice__talk-action">
|
|
70
|
-
<v-btn
|
|
71
|
-
class="assistant-voice__talk" color="primary" :variant="handsFreeListening ? 'tonal' : 'flat'" min-height="64" rounded="xl"
|
|
72
|
-
:prepend-icon="voice.listening.value && !microphoneMuted ? mdiMicrophone : mdiMicrophoneOff" :aria-pressed="handsFreeListening || pushHolding" :aria-describedby="gestureHint"
|
|
73
|
-
:disabled="!canTalk"
|
|
74
|
-
@pointerdown="gesture.pointerDown" @pointerup="gesture.pointerUp" @pointercancel="gesture.cancel" @lostpointercapture="gesture.cancel"
|
|
75
|
-
@keydown.space.prevent="gesture.keyDown" @keyup.space.prevent="gesture.keyUp"
|
|
76
|
-
@keydown.enter.prevent="gesture.keyDown" @keyup.enter.prevent="gesture.keyUp" @blur="gesture.cancel" @contextmenu.prevent @click="gesture.click"
|
|
77
|
-
>
|
|
78
|
-
{{ talkLabel }}
|
|
79
|
-
</v-btn>
|
|
80
|
-
<v-btn
|
|
81
|
-
:icon="readAloud ? mdiVolumeHigh : mdiVolumeOff" :aria-pressed="readAloud" variant="tonal" min-height="64" min-width="64" rounded="xl"
|
|
82
|
-
:aria-label="readAloud ? `Turn ${targetLabel} read-aloud off` : `Read ${targetLabel} answers aloud`"
|
|
83
|
-
:title="readAloud ? 'Spoken replies on' : 'Spoken replies off'" @click="toggleReadAloud"
|
|
84
|
-
/>
|
|
85
|
-
<p :id="gestureHint" class="text-body-small text-medium-emphasis">{{ handsFreeListening ? 'Tap to pause · hold to speak' : 'Tap for hands-free · hold to speak' }}</p>
|
|
86
|
-
</div>
|
|
69
|
+
<VoiceConversationControls :session="session" :disabled="disabled"><template #work-control><slot name="work-control" /></template></VoiceConversationControls>
|
|
87
70
|
</section>
|
|
88
71
|
</fieldset>
|
|
89
72
|
</template>
|
|
90
73
|
|
|
91
74
|
<style scoped>
|
|
75
|
+
.assistant-voice-conversation { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; min-width: 0; height: 100%; border: 0; padding: 0; margin: 0; }
|
|
76
|
+
.assistant-voice-conversation__tools { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
92
77
|
.assistant-voice { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; border: 0; padding: 0; margin: 0; min-width: 0; }
|
|
93
78
|
.assistant-voice__call { width: 100%; flex: 1; min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr) minmax(144px, 30%) auto; }
|
|
79
|
+
.assistant-voice__call--without-avatar { grid-template-rows: minmax(144px, 1fr) auto; }
|
|
94
80
|
.assistant-voice__call-body { min-height: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 16px 16px 0; text-align: center; }
|
|
95
81
|
.assistant-voice__call-portrait { position: relative; height: min(240px, calc(100% - 64px)); aspect-ratio: 1; max-width: 100%; margin: 8px auto; isolation: isolate; }
|
|
96
82
|
.assistant-voice__call-portrait::before { content: ''; position: absolute; inset: -12px; border-radius: 50%; background: rgba(var(--v-theme-primary), .12); transform: scale(var(--call-audio-scale)); transition: transform 80ms linear; z-index: -1; }
|
|
97
83
|
.assistant-voice__call-portrait :deep(svg) { width: 100%; height: 100%; }
|
|
98
|
-
.assistant-voice__call-status-row { display: flex; flex: 0 0 48px; justify-content: center; align-items: center; gap: 8px; height: 48px; }
|
|
99
|
-
.assistant-voice__call-status-row p { margin: 0; max-height: 48px; overflow: hidden; }
|
|
100
84
|
.assistant-voice__captions { min-height: 0; display: grid; grid-template-rows: repeat(2, minmax(0, 1fr)); gap: 8px; padding: 0 16px; }
|
|
101
85
|
.assistant-voice__caption { min-height: 0; padding: 8px 12px; border-radius: 12px; background: rgba(var(--v-theme-on-surface), .04); overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; touch-action: pan-y; }
|
|
102
86
|
.assistant-voice__caption > strong { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
103
87
|
.assistant-voice__caption-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
104
|
-
.assistant-voice__talk-action { justify-self: center; width: min(288px, 100%); display: grid; grid-template-columns: minmax(0, 1fr) 64px; gap: 6px 8px; padding: 16px; }
|
|
105
|
-
.assistant-voice__talk { touch-action: none; user-select: none; -webkit-touch-callout: none; }
|
|
106
|
-
.assistant-voice__talk-action p { grid-column: 1 / -1; margin: 0; text-align: center; white-space: nowrap; }
|
|
107
|
-
.assistant-voice__review { flex: 0 0 auto; max-height: 160px; overflow-y: auto; padding: 8px 16px; }
|
|
108
|
-
.assistant-voice__review-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
|
109
|
-
.assistant-voice__error { padding: 8px 16px; color: rgb(var(--v-theme-error)); }
|
|
110
88
|
@media (max-height: 650px) {
|
|
111
89
|
.assistant-voice__call-body { padding-top: 0; }
|
|
112
90
|
.assistant-voice__captions { gap: 4px; }
|
|
113
91
|
.assistant-voice__caption { padding-block: 4px; }
|
|
114
|
-
.assistant-voice__talk-action { padding-top: 8px; }
|
|
115
92
|
}
|
|
116
93
|
@media (prefers-reduced-motion: reduce) { .assistant-voice__call-portrait::before { transition: none; transform: none; } }
|
|
117
94
|
</style>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, useId } from "vue";
|
|
3
|
+
import { mdiMicrophone, mdiMicrophoneOff, mdiStop, mdiVolumeHigh, mdiVolumeOff } from "@mdi/js";
|
|
4
|
+
import { useVoiceLauncher } from "./voiceLauncher.js";
|
|
5
|
+
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
session: { type: Object, required: true },
|
|
8
|
+
disabled: { type: Boolean, default: false },
|
|
9
|
+
compact: { type: Boolean, default: false },
|
|
10
|
+
toolsTarget: { type: Object, default: null }
|
|
11
|
+
});
|
|
12
|
+
const {
|
|
13
|
+
targetLabel, voice, error, microphoneMuted, live, starting, callMode,
|
|
14
|
+
callStatus, speechActive, heldTranscript,
|
|
15
|
+
capturing, pendingTranscript, pushHolding, sending,
|
|
16
|
+
stopSpeech, startPushToTalk, finishPushToTalk, cancelPushToTalk, toggleHandsFree, readAloud, toggleReadAloud, enableSound,
|
|
17
|
+
deliverTranscript, discardHeldRecording, editTranscript
|
|
18
|
+
} = props.session;
|
|
19
|
+
const controlStatus = computed(() => {
|
|
20
|
+
if (!props.compact || !props.session.hasUnsentSpeech.value) return callStatus.value;
|
|
21
|
+
if (pendingTranscript.value) return "Send or discard speech to send text.";
|
|
22
|
+
return voice.listening.value ? "Recording · finish speech to send text." : "Finish speech to send text.";
|
|
23
|
+
});
|
|
24
|
+
const handsFreeListening = computed(() => live.value && callMode.value === "hands-free" && !microphoneMuted.value);
|
|
25
|
+
const canTalk = computed(() => !props.disabled && !sending.value && !pendingTranscript.value &&
|
|
26
|
+
(!capturing.value || voice.listening.value || pushHolding.value || starting.value));
|
|
27
|
+
const talkLabel = computed(() => starting.value ? "Connecting…" : pushHolding.value ? "Release to send" : handsFreeListening.value ? "Pause" : "Talk");
|
|
28
|
+
const gestureHint = useId();
|
|
29
|
+
const gesture = useVoiceLauncher({
|
|
30
|
+
enabled: canTalk,
|
|
31
|
+
open: () => ({ startHeldRecording: startPushToTalk, finishHeldRecording: finishPushToTalk, discardHeldRecording: cancelPushToTalk }),
|
|
32
|
+
onTap: toggleHandsFree,
|
|
33
|
+
onError: cause => { error.value = cause.message; }
|
|
34
|
+
});
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<template>
|
|
38
|
+
<fieldset class="assistant-voice-controls" :class="{ 'assistant-voice-controls--compact': compact }" :disabled="disabled">
|
|
39
|
+
<p v-if="error || voice.error.value" class="assistant-voice__error" role="alert">{{ error || voice.error.value }}</p>
|
|
40
|
+
<p v-if="controlStatus" class="assistant-voice-controls__status text-body-small" role="status" :title="callStatus">{{ controlStatus }}</p>
|
|
41
|
+
<p v-if="compact && heldTranscript && !pendingTranscript" class="assistant-voice-controls__provisional" role="status" aria-label="Recognized words">Not sent · {{ heldTranscript }}</p>
|
|
42
|
+
<p v-if="readAloud && voice.playbackBlocked?.value" class="assistant-voice-controls__status" role="status">Sound is blocked. Enable sound to hear future replies.</p>
|
|
43
|
+
<v-btn v-if="readAloud && voice.playbackBlocked?.value" min-height="48" @click="enableSound">Enable sound</v-btn>
|
|
44
|
+
<section v-if="pendingTranscript && (pendingTranscript.reviewBeforeSend || !sending)" class="assistant-voice__review" aria-label="Review voice message">
|
|
45
|
+
<v-textarea
|
|
46
|
+
:model-value="pendingTranscript.text" label="Review your message" :disabled="sending"
|
|
47
|
+
:density="compact ? 'compact' : 'default'" :rows="compact ? 1 : 2" :max-rows="compact ? 2 : 3"
|
|
48
|
+
auto-grow hide-details @update:model-value="editTranscript"
|
|
49
|
+
/>
|
|
50
|
+
<div class="assistant-voice__review-actions">
|
|
51
|
+
<v-btn :disabled="sending" min-height="48" @click="discardHeldRecording">Discard</v-btn>
|
|
52
|
+
<v-btn :disabled="sending || !pendingTranscript.text.trim()" min-height="48" color="primary" @click="deliverTranscript">{{ sending ? 'Sending…' : 'Send' }}</v-btn>
|
|
53
|
+
</div>
|
|
54
|
+
</section>
|
|
55
|
+
<Teleport :to="toolsTarget" :disabled="!toolsTarget">
|
|
56
|
+
<fieldset class="assistant-voice-controls__tools" :class="{ 'assistant-voice-controls__tools--compact': compact }" :disabled="disabled" :aria-label="`${targetLabel} voice chat controls`">
|
|
57
|
+
<v-btn v-if="speechActive" :icon="mdiStop" variant="text" min-width="48" min-height="48" :aria-label="`Stop ${targetLabel} speaking`" title="Stop speaking" @click="stopSpeech" />
|
|
58
|
+
<slot name="work-control" />
|
|
59
|
+
<div class="assistant-voice__talk-action">
|
|
60
|
+
<v-btn
|
|
61
|
+
class="assistant-voice__talk" color="primary" :variant="handsFreeListening ? 'tonal' : 'flat'" :min-height="compact ? 48 : 64" rounded="xl"
|
|
62
|
+
:prepend-icon="voice.listening.value && !microphoneMuted ? mdiMicrophone : mdiMicrophoneOff" :aria-pressed="handsFreeListening || pushHolding" :aria-describedby="gestureHint"
|
|
63
|
+
:disabled="!canTalk"
|
|
64
|
+
@pointerdown="gesture.pointerDown" @pointerup="gesture.pointerUp" @pointercancel="gesture.cancel" @lostpointercapture="gesture.cancel"
|
|
65
|
+
@keydown.space.prevent="gesture.keyDown" @keyup.space.prevent="gesture.keyUp"
|
|
66
|
+
@keydown.enter.prevent="gesture.keyDown" @keyup.enter.prevent="gesture.keyUp" @blur="gesture.cancel" @contextmenu.prevent @click="gesture.click"
|
|
67
|
+
>
|
|
68
|
+
{{ talkLabel }}
|
|
69
|
+
</v-btn>
|
|
70
|
+
<v-btn
|
|
71
|
+
:icon="readAloud ? mdiVolumeHigh : mdiVolumeOff" :aria-pressed="readAloud" variant="tonal" :min-height="compact ? 48 : 64" :min-width="compact ? 48 : 64" rounded="xl"
|
|
72
|
+
:aria-label="readAloud ? `Turn ${targetLabel} read-aloud off` : `Read ${targetLabel} answers aloud`"
|
|
73
|
+
:title="readAloud ? 'Spoken replies on' : 'Spoken replies off'" @click="toggleReadAloud"
|
|
74
|
+
/>
|
|
75
|
+
<p :id="gestureHint" class="text-body-small text-medium-emphasis">{{ handsFreeListening ? 'Tap to pause · hold to speak' : 'Tap for hands-free · hold to speak' }}</p>
|
|
76
|
+
</div>
|
|
77
|
+
</fieldset>
|
|
78
|
+
</Teleport>
|
|
79
|
+
</fieldset>
|
|
80
|
+
</template>
|
|
81
|
+
|
|
82
|
+
<style scoped>
|
|
83
|
+
.assistant-voice-controls, .assistant-voice-controls__tools { border: 0; padding: 0; margin: 0; min-width: 0; }
|
|
84
|
+
.assistant-voice-controls { flex: 0 0 auto; }
|
|
85
|
+
.assistant-voice-controls--compact { flex: 1 1 100%; width: 100%; }
|
|
86
|
+
.assistant-voice-controls__tools { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; }
|
|
87
|
+
.assistant-voice-controls__status { text-align: center; margin: 0; }
|
|
88
|
+
.assistant-voice-controls__provisional { margin: 0; max-height: 3rem; overflow-y: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
89
|
+
.assistant-voice__talk-action { width: min(288px, 100%); display: grid; grid-template-columns: minmax(0, 1fr) 64px; gap: 6px 8px; padding: 16px; }
|
|
90
|
+
.assistant-voice__talk { touch-action: none; user-select: none; -webkit-touch-callout: none; }
|
|
91
|
+
.assistant-voice__talk-action p { grid-column: 1 / -1; margin: 0; text-align: center; white-space: nowrap; }
|
|
92
|
+
.assistant-voice__review { flex: 0 0 auto; max-height: 160px; overflow-y: auto; padding: 8px 16px; }
|
|
93
|
+
.assistant-voice__review-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
|
94
|
+
.assistant-voice__error { padding: 8px 16px; color: rgb(var(--v-theme-error)); }
|
|
95
|
+
.assistant-voice-controls__tools--compact { justify-content: flex-start; gap: 4px; }
|
|
96
|
+
.assistant-voice-controls__tools--compact .assistant-voice__talk-action { display: flex; padding: 0; width: auto; gap: 4px; }
|
|
97
|
+
.assistant-voice-controls__tools--compact .assistant-voice__talk-action p { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
98
|
+
.assistant-voice-controls--compact .assistant-voice__review { max-height: 128px; padding: 0; }
|
|
99
|
+
.assistant-voice-controls--compact .assistant-voice__error { margin: 0; padding: 0; }
|
|
100
|
+
@media (max-height: 650px) { .assistant-voice__talk-action { padding-top: 8px; } }
|
|
101
|
+
</style>
|
|
@@ -3,7 +3,8 @@ import { mdiCog, mdiHeadset, mdiStop } from "@mdi/js";
|
|
|
3
3
|
import ConversationDialog from "./ConversationDialog.vue";
|
|
4
4
|
import VoiceConversation from "./VoiceConversation.vue";
|
|
5
5
|
|
|
6
|
-
const props = defineProps({ controller: { type: Object, required: true }, activator: { type: Object, default: null } });
|
|
6
|
+
const props = defineProps({ controller: { type: Object, required: true }, activator: { type: Object, default: null }, avatarSize: { type: String, default: "compact" } });
|
|
7
|
+
defineEmits(["update:avatarSize"]);
|
|
7
8
|
const state = props.controller.state;
|
|
8
9
|
async function invoke(operation) {
|
|
9
10
|
try { await operation(); }
|
|
@@ -19,7 +20,9 @@ async function openText() {
|
|
|
19
20
|
|
|
20
21
|
<template>
|
|
21
22
|
<ConversationDialog
|
|
22
|
-
v-if="state.session" :model-value="state.visible" :activator="activator"
|
|
23
|
+
v-if="state.session" :model-value="state.visible" :activator="activator"
|
|
24
|
+
:title="state.binding.label || 'Assistant'" mode="talk"
|
|
25
|
+
:show-modes="Boolean(state.binding.openText && !state.binding.adapter)"
|
|
23
26
|
minimizable close-label="Close voice chat" @minimize="controller.minimize()" @update:model-value="invoke(() => controller.end({ discard: true }))"
|
|
24
27
|
@update:mode="value => value === 'text' && invoke(openText)"
|
|
25
28
|
>
|
|
@@ -53,12 +56,18 @@ async function openText() {
|
|
|
53
56
|
</div>
|
|
54
57
|
</v-alert>
|
|
55
58
|
<div class="voice-host__body">
|
|
56
|
-
<
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
<slot name="conversation" :binding="state.binding" :session="state.session" :disabled="state.busy">
|
|
60
|
+
<VoiceConversation
|
|
61
|
+
:key="state.binding.id" :session="state.session" :disabled="state.busy"
|
|
62
|
+
:adapter="state.binding.adapter || null" :show-avatar="state.binding.showAvatar !== false"
|
|
63
|
+
:avatar-size="avatarSize" @update:avatar-size="$emit('update:avatarSize', $event)"
|
|
64
|
+
>
|
|
65
|
+
<template v-if="$slots.avatar" #avatar="visual"><slot name="avatar" v-bind="visual" :binding="state.binding" /></template>
|
|
66
|
+
<template #work-control>
|
|
67
|
+
<v-btn v-if="state.binding.cancelWork && state.binding.state.status === 'working'" :disabled="state.busy" :icon="mdiStop" variant="text" aria-label="Stop agent work" title="Stop agent work" @click="invoke(state.binding.cancelWork)" />
|
|
68
|
+
</template>
|
|
69
|
+
</VoiceConversation>
|
|
70
|
+
</slot>
|
|
62
71
|
</div>
|
|
63
72
|
</ConversationDialog>
|
|
64
73
|
<template v-if="state.session && !state.visible">
|
package/src/client/index.js
CHANGED
|
@@ -3,6 +3,7 @@ export { createVoiceConversationController } from "./voiceController.js";
|
|
|
3
3
|
export { default as VoiceConversationHost } from "./VoiceConversationHost.vue";
|
|
4
4
|
export { default as ConversationDialog } from "./ConversationDialog.vue";
|
|
5
5
|
export { default as VoiceConversation } from "./VoiceConversation.vue";
|
|
6
|
+
export { default as VoiceConversationControls } from "./VoiceConversationControls.vue";
|
|
6
7
|
|
|
7
8
|
export { useVoiceLauncher } from "./voiceLauncher.js";
|
|
8
9
|
export { useVoiceAvatar } from "./voiceAvatar.js";
|
|
@@ -6,7 +6,10 @@ import { takeStreamingSpeech } from "../shared/protocol.js";
|
|
|
6
6
|
export function useVoiceConversation(binding, { socketUrl, createTransport = useVoiceTransport } = {}) {
|
|
7
7
|
const oneOffTalkMode = computed(() => binding.defaults?.talkMode === "hold" ? "hold" : "tap");
|
|
8
8
|
const targetLabel = computed(() => binding.label || "Assistant");
|
|
9
|
-
const readAloud = ref(
|
|
9
|
+
const readAloud = ref(binding.defaults?.readAloud === true);
|
|
10
|
+
const soundPreparing = ref(false);
|
|
11
|
+
let soundRevision = 0;
|
|
12
|
+
const conversationId = binding.conversationId || binding.id;
|
|
10
13
|
const error = ref("");
|
|
11
14
|
const pendingTranscript = ref(null);
|
|
12
15
|
const heldReview = ref(false);
|
|
@@ -27,7 +30,8 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
27
30
|
voiceId: binding.defaults?.voiceId || "",
|
|
28
31
|
speechEnabled: readAloud,
|
|
29
32
|
interruptSpeechOnListen: false,
|
|
30
|
-
autoReconnect: true
|
|
33
|
+
autoReconnect: true,
|
|
34
|
+
onPlayback: receivePlayback
|
|
31
35
|
});
|
|
32
36
|
watch([() => binding.defaults?.voiceId, voice.availableVoices], ([voiceId, voices]) => {
|
|
33
37
|
if (voiceId === undefined) return;
|
|
@@ -106,12 +110,42 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
106
110
|
let speechInvitation = "";
|
|
107
111
|
let lastStreamingId = "";
|
|
108
112
|
|
|
109
|
-
function
|
|
110
|
-
|
|
113
|
+
function observe(callback, value) {
|
|
114
|
+
try {
|
|
115
|
+
Promise.resolve(callback?.(value)).catch(() => {});
|
|
116
|
+
} catch {
|
|
117
|
+
// Observers cannot take ownership of scheduling or cleanup.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function emitReply(reply, phase, reason) {
|
|
121
|
+
if (reply.terminal || (phase === "started" && reply.audible)) return;
|
|
122
|
+
if (phase === "started") reply.audible = true;
|
|
123
|
+
else reply.terminal = true;
|
|
124
|
+
observe(binding.onPlayback, {
|
|
125
|
+
conversationId, outputId: reply.outputId, phase, ...(reason ? { reason } : {})
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
function receivePlayback(event) {
|
|
129
|
+
const reply = [...replies.values()].find(reply => reply.turnId === event.turnId);
|
|
130
|
+
if (!reply) return;
|
|
131
|
+
if (event.phase === "completed") {
|
|
132
|
+
reply.drained = true;
|
|
133
|
+
if (reply.canonicalFinal) emitReply(reply, "completed");
|
|
134
|
+
} else emitReply(reply, event.phase, event.reason);
|
|
135
|
+
}
|
|
136
|
+
function cancelReply(reply, reason = "cancelled", phase = "interrupted") {
|
|
137
|
+
reply.cancelled = true;
|
|
138
|
+
emitReply(reply, phase, reason);
|
|
139
|
+
}
|
|
140
|
+
function stopSpeech() { cancelSpeech(); }
|
|
141
|
+
function cancelSpeech(reason = "cancelled", phase = "interrupted") {
|
|
142
|
+
for (const reply of replies.values()) {
|
|
143
|
+
if (!reply.terminal) cancelReply(reply, reason, phase);
|
|
144
|
+
}
|
|
111
145
|
speechQueue.length = 0;
|
|
112
146
|
speechSuppressed = true;
|
|
113
147
|
speechInvitation = "";
|
|
114
|
-
voice.stopSpeaking();
|
|
148
|
+
voice.stopSpeaking(reason);
|
|
115
149
|
}
|
|
116
150
|
function inviteSpeech(messageId) {
|
|
117
151
|
// Only a new request from this page can lift silence. A delayed admission,
|
|
@@ -121,7 +155,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
121
155
|
speechInvitation = messageId;
|
|
122
156
|
}
|
|
123
157
|
async function drainSpeech() {
|
|
124
|
-
if (draining || disposed || !readAloud.value || speechSuppressed) return;
|
|
158
|
+
if (draining || disposed || !readAloud.value || speechSuppressed || soundPreparing.value || voice.playbackBlocked?.value) return;
|
|
125
159
|
draining = true;
|
|
126
160
|
try {
|
|
127
161
|
while (speechQueue.length) {
|
|
@@ -133,15 +167,24 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
133
167
|
const chunk = takeStreamingSpeech(reply.raw.slice(reply.consumed), reply.final, reply.started ? 140 : 64);
|
|
134
168
|
if (!chunk) {
|
|
135
169
|
if (reply.final && reply.started) voice.endSpeech();
|
|
136
|
-
else if (reply.final) {
|
|
170
|
+
else if (reply.final) {
|
|
171
|
+
cancelReply(reply, "no-audio");
|
|
172
|
+
speechQueue.shift();
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
137
175
|
return;
|
|
138
176
|
}
|
|
139
177
|
reply.consumed += chunk.consumed;
|
|
140
178
|
if (!chunk.text) continue;
|
|
141
179
|
if (!reply.started) {
|
|
142
180
|
reply.started = true;
|
|
143
|
-
|
|
181
|
+
reply.turnId = crypto.randomUUID();
|
|
182
|
+
const accepted = await voice.speak(chunk.text, reply.turnId, { stream: true });
|
|
144
183
|
if (reply.cancelled || disposed) return;
|
|
184
|
+
if (accepted === false) {
|
|
185
|
+
cancelReply(reply, "no-audio");
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
145
188
|
} else if (!voice.appendSpeech(chunk.text)) return;
|
|
146
189
|
if (reply.final && reply.consumed >= reply.raw.length) { voice.endSpeech(); return; }
|
|
147
190
|
}
|
|
@@ -152,7 +195,10 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
152
195
|
let reply = replies.get(message.id);
|
|
153
196
|
if (!reply) {
|
|
154
197
|
if (!primed || !readAloud.value || speechSuppressed || seen.has(message.id) || (!final && message.autonomous)) return;
|
|
155
|
-
reply = {
|
|
198
|
+
reply = {
|
|
199
|
+
id: message.id, outputId: message.outputId || message.id, streamId: message.streamId,
|
|
200
|
+
raw: "", consumed: 0, final: false, canonicalFinal: false, started: false
|
|
201
|
+
};
|
|
156
202
|
replies.set(message.id, reply);
|
|
157
203
|
speechQueue.push(reply);
|
|
158
204
|
}
|
|
@@ -162,25 +208,43 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
162
208
|
if (!text.startsWith(spokenPrefix) && !(final && text.trimEnd() === spokenPrefix.trimEnd())) {
|
|
163
209
|
// A model revision cannot unsay words. Retire that projection; never splice
|
|
164
210
|
// its old prefix onto a different answer or replay it as a second answer.
|
|
165
|
-
reply
|
|
211
|
+
cancelReply(reply);
|
|
166
212
|
if (speechQueue[0] === reply) voice.stopSpeaking();
|
|
167
213
|
return;
|
|
168
214
|
}
|
|
169
215
|
reply.consumed = Math.min(reply.consumed, text.length);
|
|
170
216
|
reply.raw = text;
|
|
217
|
+
reply.canonicalFinal ||= final;
|
|
171
218
|
reply.final = final || text.length >= 4000;
|
|
219
|
+
if (reply.canonicalFinal && reply.drained) emitReply(reply, "completed");
|
|
172
220
|
}
|
|
173
|
-
async function
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
221
|
+
async function enableSound() {
|
|
222
|
+
const revision = ++soundRevision;
|
|
223
|
+
soundPreparing.value = true;
|
|
224
|
+
try {
|
|
225
|
+
await voice.preparePlayback();
|
|
226
|
+
} catch {
|
|
227
|
+
// Requested sound stays on; its recovery control remains available.
|
|
228
|
+
} finally {
|
|
229
|
+
if (revision === soundRevision && !disposed) {
|
|
230
|
+
soundPreparing.value = false;
|
|
231
|
+
void drainSpeech();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
178
234
|
}
|
|
179
235
|
async function toggleReadAloud() {
|
|
180
236
|
error.value = "";
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
237
|
+
readAloud.value = !readAloud.value;
|
|
238
|
+
observe(binding.onReadAloudChange, readAloud.value);
|
|
239
|
+
if (!readAloud.value) {
|
|
240
|
+
++soundRevision;
|
|
241
|
+
soundPreparing.value = false;
|
|
242
|
+
cancelSpeech("muted");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
speechSuppressed = false;
|
|
246
|
+
speechInvitation = "";
|
|
247
|
+
await enableSound();
|
|
184
248
|
}
|
|
185
249
|
async function deliverTranscript() {
|
|
186
250
|
const pending = pendingTranscript.value;
|
|
@@ -232,10 +296,6 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
232
296
|
// Keep the starting target even if navigation changes during this utterance.
|
|
233
297
|
const next = { focus: { ...(binding.captureContext?.() || {}) }, messageId: crypto.randomUUID(), started: false, reviewBeforeSend, continuous };
|
|
234
298
|
recording = next;
|
|
235
|
-
if (!reviewBeforeSend && !live.value) {
|
|
236
|
-
readAloud.value = true;
|
|
237
|
-
void voice.preparePlayback().catch((cause) => { error.value = cause.message; });
|
|
238
|
-
}
|
|
239
299
|
try {
|
|
240
300
|
const started = await voice.startListening({ continuous });
|
|
241
301
|
if (recording === next) { next.started = started; if (!started) recording = null; }
|
|
@@ -264,7 +324,6 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
264
324
|
pushHolding.value = true;
|
|
265
325
|
callMode.value = "push-to-talk";
|
|
266
326
|
voice.setMicrophoneMuted(false);
|
|
267
|
-
retireAnswer(true);
|
|
268
327
|
if (!live.value) await toggleLive();
|
|
269
328
|
if (!pushHolding.value || !live.value || disposed) return;
|
|
270
329
|
// A hold can take over an open hands-free microphone without throwing away
|
|
@@ -318,9 +377,6 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
318
377
|
committing = null;
|
|
319
378
|
clearTimeout(stopTimer);
|
|
320
379
|
await cancelRecording();
|
|
321
|
-
stopSpeech();
|
|
322
|
-
readAloud.value = false;
|
|
323
|
-
void voice.close().catch(cause => { if (!live.value) error.value = cause.message; });
|
|
324
380
|
return;
|
|
325
381
|
}
|
|
326
382
|
if (disposed || microphoneMuted.value || capturing.value || pendingTranscript.value || sending.value) return;
|
|
@@ -329,16 +385,14 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
329
385
|
starting.value = true;
|
|
330
386
|
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(10000)]);
|
|
331
387
|
try {
|
|
332
|
-
// Unlock
|
|
333
|
-
|
|
334
|
-
await voice.preparePlayback();
|
|
388
|
+
// Unlock requested sound in the gesture without delaying microphone startup.
|
|
389
|
+
if (readAloud.value) void enableSound();
|
|
335
390
|
signal.throwIfAborted();
|
|
336
391
|
await voice.connect();
|
|
337
392
|
signal.throwIfAborted();
|
|
338
393
|
if (disposed) return;
|
|
339
394
|
starting.value = false;
|
|
340
395
|
live.value = true;
|
|
341
|
-
readAloud.value = true;
|
|
342
396
|
if (callMode.value === "hands-free") {
|
|
343
397
|
await startRecording(false, true);
|
|
344
398
|
if (startupAbort === controller && !recording?.started) live.value = false;
|
|
@@ -352,12 +406,6 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
352
406
|
if (startupAbort === controller) { startupAbort = null; starting.value = false; }
|
|
353
407
|
}
|
|
354
408
|
}
|
|
355
|
-
function retireAnswer(smooth = false) {
|
|
356
|
-
for (const reply of speechQueue) reply.cancelled = true;
|
|
357
|
-
speechQueue.length = 0;
|
|
358
|
-
if (smooth) voice.finishCurrentPhrase();
|
|
359
|
-
else voice.stopSpeaking();
|
|
360
|
-
}
|
|
361
409
|
function currentSpokenText() {
|
|
362
410
|
const reply = speechQueue[0];
|
|
363
411
|
return reply ? reply.raw.slice(0, reply.consumed).slice(-2000) : "";
|
|
@@ -394,8 +442,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
394
442
|
committing = { ...captured, turnId: candidate.turnId, revision: candidate.revision };
|
|
395
443
|
if (!voice.finishUtterance(candidate)) { committing = null; return; }
|
|
396
444
|
// The daemon rechecks the endpoint revision before returning final text.
|
|
397
|
-
//
|
|
398
|
-
retireAnswer(true);
|
|
445
|
+
// Ordinary steering leaves the current answer and queued audio intact.
|
|
399
446
|
} catch (cause) {
|
|
400
447
|
if (live.value && candidate === voice.endpoint.value) {
|
|
401
448
|
live.value = false;
|
|
@@ -491,8 +538,8 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
491
538
|
}
|
|
492
539
|
if (lastStreamingId && lastStreamingId !== reply?.id && !messages.some(message => message.id === lastStreamingId)) {
|
|
493
540
|
const retired = replies.get(lastStreamingId);
|
|
494
|
-
if (retired && !retired.
|
|
495
|
-
retired
|
|
541
|
+
if (retired && !retired.canonicalFinal && !retired.cancelled) {
|
|
542
|
+
cancelReply(retired);
|
|
496
543
|
if (speechQueue[0] === retired) voice.finishCurrentPhrase();
|
|
497
544
|
}
|
|
498
545
|
}
|
|
@@ -502,13 +549,14 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
502
549
|
if (!readAloud.value || !primed || reply.autonomous) seen.add(reply.id);
|
|
503
550
|
}
|
|
504
551
|
// Keep only the visible history plus active playback receipts in memory.
|
|
505
|
-
const retained = new Set([...messages.map((message) => message.id), reply?.id, ...speechQueue.map((item) => item.id)
|
|
552
|
+
const retained = new Set([...messages.map((message) => message.id), reply?.id, ...speechQueue.map((item) => item.id),
|
|
553
|
+
...[...replies.values()].filter(item => !item.terminal).map(item => item.id)]);
|
|
506
554
|
for (const id of seen) if (!retained.has(id)) seen.delete(id);
|
|
507
555
|
for (const id of replies.keys()) if (!retained.has(id)) replies.delete(id);
|
|
508
556
|
primed = true;
|
|
509
557
|
void drainSpeech();
|
|
510
558
|
}, { immediate: true });
|
|
511
|
-
watch(voice.error, (message) => { if (message)
|
|
559
|
+
watch(voice.error, (message) => { if (message) cancelSpeech("playback-error", "failed"); });
|
|
512
560
|
watch(() => error.value || voice.error.value, (message) => { if (message) binding.onError?.(message); });
|
|
513
561
|
watch([voice.activeSpeechTurnId, voice.canAppendSpeech], () => { void drainSpeech(); });
|
|
514
562
|
watch([pendingTranscript, voice.captureState, voice.partialTranscript], () => {
|
|
@@ -527,6 +575,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
527
575
|
page.removeEventListener?.("pagehide", cancelStartup);
|
|
528
576
|
page.removeEventListener?.("blur", cancelPushToTalk);
|
|
529
577
|
startupAbort?.abort(); startupAbort = null;
|
|
578
|
+
stopSpeech();
|
|
530
579
|
binding.onTranscript?.(null); binding.onVisual?.(null); disposed = true; live.value = false; clearTimeout(stopTimer); recording = null; speechQueue.length = 0;
|
|
531
580
|
});
|
|
532
581
|
|
|
@@ -549,7 +598,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
549
598
|
live, starting, callMode, changingCallMode, pushHolding, microphoneMuted, capturing,
|
|
550
599
|
speechActive, talkLabel, liveLabel, callConnection, callStatus, callDetail, callModeBusy,
|
|
551
600
|
microphoneStatus, avatarVisual, callAudioLevel, status, heldTranscript, voiceWords, voiceAnswer,
|
|
552
|
-
stopSpeech, inviteSpeech, toggleReadAloud, deliverTranscript, cancelRecording,
|
|
601
|
+
stopSpeech, inviteSpeech, toggleReadAloud, enableSound, soundPreparing, deliverTranscript, cancelRecording,
|
|
553
602
|
discardRecording, dismissTranscript, talk, toggleMicrophoneMuted, changeCallMode,
|
|
554
603
|
startPushToTalk, finishPushToTalk, cancelPushToTalk, toggleLive, toggleHandsFree, startHeldRecording,
|
|
555
604
|
finishHeldRecording, discardHeldRecording, close, editTranscript, hasUnsentSpeech
|
|
@@ -29,13 +29,14 @@ function resolveWebSocketUrl(value) {
|
|
|
29
29
|
return url.toString();
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnListen = false, autoReconnect = false, voiceId = "" } = {}) {
|
|
32
|
+
function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnListen = false, autoReconnect = false, voiceId = "", onPlayback } = {}) {
|
|
33
33
|
const availableVoices = ref([]);
|
|
34
34
|
const selectedVoice = ref(voiceId);
|
|
35
35
|
const connectionState = ref("idle");
|
|
36
36
|
const captureState = ref("idle");
|
|
37
37
|
const speechState = ref("idle");
|
|
38
38
|
const error = ref("");
|
|
39
|
+
const playbackBlocked = ref(false);
|
|
39
40
|
const transcript = ref("");
|
|
40
41
|
const endpoint = ref(null);
|
|
41
42
|
const completedUtterance = ref(null);
|
|
@@ -71,8 +72,10 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
|
|
|
71
72
|
let playbackGain = null;
|
|
72
73
|
let playbackAnalyser = null;
|
|
73
74
|
let playbackFrame = 0;
|
|
75
|
+
let playbackPreparation = 0;
|
|
74
76
|
let nextPlaybackTime = 0;
|
|
75
77
|
let playbackEpoch = 0;
|
|
78
|
+
let playbackReceipt = null;
|
|
76
79
|
let playbackQueue = Promise.resolve();
|
|
77
80
|
let queuedPlaybackFrames = 0;
|
|
78
81
|
let speechStreamEnded = false;
|
|
@@ -102,12 +105,31 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
|
|
|
102
105
|
const canAppendSpeech = computed(() => Boolean(activeSpeechTurnId.value) && !speechInputEnded.value &&
|
|
103
106
|
synthesisPending.value < 2 && bufferedSeconds.value < 4);
|
|
104
107
|
|
|
105
|
-
function
|
|
108
|
+
function emitPlayback(phase, reason) {
|
|
109
|
+
const receipt = playbackReceipt;
|
|
110
|
+
if (!receipt || receipt.terminal || (phase === "started" && receipt.started)) return;
|
|
111
|
+
if (phase === "started") receipt.started = true;
|
|
112
|
+
else receipt.terminal = true;
|
|
113
|
+
try {
|
|
114
|
+
Promise.resolve(onPlayback?.({
|
|
115
|
+
turnId: receipt.turnId, phase, ...(reason ? { reason } : {})
|
|
116
|
+
})).catch(() => {});
|
|
117
|
+
} catch {
|
|
118
|
+
// Observers cannot take ownership of playback or cleanup.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function observePlaybackStart(source) {
|
|
123
|
+
if (!source.voiceCancelled && playbackContext.state === "running" &&
|
|
124
|
+
playbackContext.currentTime >= source.voiceStartsAt) emitPlayback("started");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function setError(message = "", reason = "playback-error") {
|
|
106
128
|
pendingListenTurnId = "";
|
|
107
129
|
endpoint.value = null;
|
|
108
130
|
activeListenTurnId.value = "";
|
|
109
131
|
void closeMicrophone().catch(() => null);
|
|
110
|
-
stopPlayback();
|
|
132
|
+
stopPlayback("failed", reason);
|
|
111
133
|
error.value = String(message || "Voice could not continue.");
|
|
112
134
|
captureState.value = "idle";
|
|
113
135
|
connectionState.value = "idle";
|
|
@@ -129,7 +151,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
|
|
|
129
151
|
? "Voice connection was interrupted. Your unfinished recording needs review; use Talk to resume."
|
|
130
152
|
: "Voice connection was interrupted. The answer remains in chat; use Talk to resume.";
|
|
131
153
|
const wasActive = Boolean(activeListenTurnId.value || pendingListenTurnId || activeSpeechTurnId.value || reconnecting.value);
|
|
132
|
-
setError(message);
|
|
154
|
+
setError(message, "disconnected");
|
|
133
155
|
disconnect({ preserveError: true });
|
|
134
156
|
recoveryWanted = autoReconnect && wasActive;
|
|
135
157
|
scheduleRecovery();
|
|
@@ -190,6 +212,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
|
|
|
190
212
|
return total / Math.max(1, end - start);
|
|
191
213
|
};
|
|
192
214
|
const update = () => {
|
|
215
|
+
for (const source of playbackSources) observePlaybackStart(source);
|
|
193
216
|
bufferedSeconds.value = Math.max(0, nextPlaybackTime - playbackContext.currentTime);
|
|
194
217
|
playbackAnalyser.getFloatTimeDomainData(samples);
|
|
195
218
|
playbackAnalyser.getByteFrequencyData(spectrum);
|
|
@@ -235,6 +258,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
|
|
|
235
258
|
async function preparePlayback() {
|
|
236
259
|
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
|
237
260
|
if (!AudioContextCtor) {
|
|
261
|
+
playbackBlocked.value = true;
|
|
238
262
|
throw new Error("This browser does not support streamed audio playback.");
|
|
239
263
|
}
|
|
240
264
|
if (!playbackContext || playbackContext.state === "closed") {
|
|
@@ -247,13 +271,29 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
|
|
|
247
271
|
playbackAnalyser.connect(playbackContext.destination);
|
|
248
272
|
}
|
|
249
273
|
playbackGain.gain.value = speechOutputEnabled.value ? 1 : 0;
|
|
250
|
-
|
|
274
|
+
const context = playbackContext;
|
|
275
|
+
const preparation = ++playbackPreparation;
|
|
276
|
+
try {
|
|
277
|
+
const resumed = context.resume();
|
|
278
|
+
if (preparation === playbackPreparation && context.state !== "running") playbackBlocked.value = true;
|
|
279
|
+
await resumed;
|
|
280
|
+
if (context.state !== "running") throw new Error("Sound is blocked by this browser.");
|
|
281
|
+
if (preparation === playbackPreparation && !unmounted) playbackBlocked.value = false;
|
|
282
|
+
} catch (cause) {
|
|
283
|
+
// A newer unlock of this same context supersedes an older resume failure.
|
|
284
|
+
if (preparation !== playbackPreparation && context === playbackContext &&
|
|
285
|
+
context.state === "running" && !unmounted) return;
|
|
286
|
+
if (preparation === playbackPreparation && !unmounted) playbackBlocked.value = true;
|
|
287
|
+
throw cause;
|
|
288
|
+
}
|
|
251
289
|
}
|
|
252
290
|
|
|
253
291
|
function finishPlaybackWhenDrained() {
|
|
254
292
|
if (!activeSpeechTurnId.value || !speechStreamEnded || playbackSources.size || queuedPlaybackFrames) {
|
|
255
293
|
return;
|
|
256
294
|
}
|
|
295
|
+
if (playbackReceipt?.started) emitPlayback("completed");
|
|
296
|
+
else emitPlayback("failed", "playback-error");
|
|
257
297
|
activeSpeechTurnId.value = "";
|
|
258
298
|
speechState.value = "idle";
|
|
259
299
|
if (speechOwner.value === speechClaim) speechOwner.value = null;
|
|
@@ -312,6 +352,8 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
|
|
|
312
352
|
source.onended = () => {
|
|
313
353
|
source.disconnect();
|
|
314
354
|
if (epoch !== playbackEpoch) return;
|
|
355
|
+
// A natural end also observes very short sounds between animation frames.
|
|
356
|
+
if (!source.voiceCancelled && playbackContext.state === "running") emitPlayback("started");
|
|
315
357
|
playbackSources.delete(source);
|
|
316
358
|
bufferedSeconds.value = playbackSources.size ? Math.max(0, nextPlaybackTime - playbackContext.currentTime) : 0;
|
|
317
359
|
if (!playbackSources.size) speechState.value = "thinking";
|
|
@@ -337,7 +379,8 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
|
|
|
337
379
|
});
|
|
338
380
|
}
|
|
339
381
|
|
|
340
|
-
function stopPlayback() {
|
|
382
|
+
function stopPlayback(phase = "interrupted", reason = "cancelled") {
|
|
383
|
+
emitPlayback(phase, reason);
|
|
341
384
|
playbackEpoch += 1;
|
|
342
385
|
activeSpeechTurnId.value = "";
|
|
343
386
|
speechState.value = "idle";
|
|
@@ -347,6 +390,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
|
|
|
347
390
|
queuedPlaybackFrames = 0;
|
|
348
391
|
for (const source of playbackSources) {
|
|
349
392
|
try {
|
|
393
|
+
source.voiceCancelled = true;
|
|
350
394
|
source.stop();
|
|
351
395
|
} catch {
|
|
352
396
|
// A source that already ended needs no further cleanup.
|
|
@@ -681,6 +725,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
|
|
|
681
725
|
}
|
|
682
726
|
stopPlayback();
|
|
683
727
|
const epoch = playbackEpoch;
|
|
728
|
+
playbackReceipt = { turnId, started: false, terminal: false };
|
|
684
729
|
activeSpeechTurnId.value = turnId;
|
|
685
730
|
speechState.value = "thinking";
|
|
686
731
|
try {
|
|
@@ -708,7 +753,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
|
|
|
708
753
|
return true;
|
|
709
754
|
} catch (speechError) {
|
|
710
755
|
if (epoch !== playbackEpoch) return false;
|
|
711
|
-
stopPlayback();
|
|
756
|
+
stopPlayback("failed", "playback-error");
|
|
712
757
|
speechState.value = "idle";
|
|
713
758
|
throw speechError;
|
|
714
759
|
}
|
|
@@ -732,6 +777,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
|
|
|
732
777
|
|
|
733
778
|
function finishCurrentPhrase() {
|
|
734
779
|
if (!activeSpeechTurnId.value) return;
|
|
780
|
+
emitPlayback("interrupted", "cancelled");
|
|
735
781
|
const now = playbackContext?.currentTime || 0;
|
|
736
782
|
const current = [...playbackSources].find(source => source.voiceEndsAt > now);
|
|
737
783
|
if (!current?.voiceSegment) { stopSpeaking(); return; }
|
|
@@ -739,6 +785,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
|
|
|
739
785
|
speechInputEnded.value = true;
|
|
740
786
|
for (const source of [...playbackSources]) {
|
|
741
787
|
if (source.voiceSegment?.index <= stopAfterSegment) continue;
|
|
788
|
+
source.voiceCancelled = true;
|
|
742
789
|
source.stop();
|
|
743
790
|
playbackSources.delete(source);
|
|
744
791
|
}
|
|
@@ -747,10 +794,10 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
|
|
|
747
794
|
sendControl({ type: "speak.stop-after", turnId: activeSpeechTurnId.value, segmentIndex: stopAfterSegment });
|
|
748
795
|
}
|
|
749
796
|
|
|
750
|
-
function stopSpeaking() {
|
|
797
|
+
function stopSpeaking(reason = "cancelled") {
|
|
751
798
|
const turnId = activeSpeechTurnId.value;
|
|
752
799
|
const pending = turnId && !ready.value && !activeListenTurnId.value && !pendingListenTurnId;
|
|
753
|
-
stopPlayback();
|
|
800
|
+
stopPlayback("interrupted", reason);
|
|
754
801
|
if (turnId && socket?.readyState === WEBSOCKET_OPEN) sendControl({ turnId, type: "cancel" });
|
|
755
802
|
if (pending) disconnect();
|
|
756
803
|
}
|
|
@@ -807,7 +854,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
|
|
|
807
854
|
playbackGain.gain.value = enabled ? 1 : 0;
|
|
808
855
|
}
|
|
809
856
|
if (!enabled && activeSpeechTurnId.value) {
|
|
810
|
-
stopSpeaking();
|
|
857
|
+
stopSpeaking("muted");
|
|
811
858
|
}
|
|
812
859
|
}, { flush: "sync" });
|
|
813
860
|
watch(selectedSocketPath, () => {
|
|
@@ -869,6 +916,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
|
|
|
869
916
|
microphoneMuted,
|
|
870
917
|
muted,
|
|
871
918
|
partialTranscript,
|
|
919
|
+
playbackBlocked,
|
|
872
920
|
preparePlayback,
|
|
873
921
|
ready,
|
|
874
922
|
reconnecting,
|