@datalayer/agent-runtimes 1.5.60 → 1.5.62
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/dist-embed/datalayer-app-main.js +90 -90
- package/lib/apps/apps/AppRenderer.js +8 -1
- package/lib/chat/assistant/AssistantStage.js +51 -3
- package/lib/chat/assistant/balloonHover.d.ts +29 -0
- package/lib/chat/assistant/balloonHover.js +48 -0
- package/lib/chat/base/ChatBase.js +8 -1
- package/lib/voice/ComposerVoice.js +2 -2
- package/lib/voice/HandsFree.d.ts +14 -3
- package/lib/voice/HandsFree.js +52 -8
- package/lib/voice/VoiceInput.d.ts +6 -1
- package/lib/voice/VoiceInput.js +21 -6
- package/lib/voice/chatVoice.d.ts +1 -1
- package/lib/voice/chatVoice.js +3 -1
- package/lib/voice/consent.d.ts +6 -2
- package/lib/voice/consent.js +8 -3
- package/lib/voice/handsFree.d.ts +2 -2
- package/lib/voice/handsFree.js +1 -1
- package/lib/voice/hearing.d.ts +13 -0
- package/lib/voice/hearing.js +8 -0
- package/lib/voice/index.d.ts +8 -1
- package/lib/voice/index.js +1 -0
- package/lib/voice/keptSound.d.ts +15 -0
- package/lib/voice/keptSound.js +69 -0
- package/lib/voice/serverHearing.d.ts +4 -0
- package/lib/voice/serverHearing.js +20 -0
- package/lib/voice/speaker.d.ts +30 -0
- package/lib/voice/speaker.js +152 -9
- package/lib/voice/types.d.ts +12 -0
- package/lib/voice/useVoiceSpeaker.js +4 -0
- package/package.json +1 -1
|
@@ -71,6 +71,7 @@ import { keepsFeedback } from './feedback';
|
|
|
71
71
|
import { keptBeforeFirstMessage } from './kept';
|
|
72
72
|
import { ChatLanguage } from '../../chat/ChatLanguage';
|
|
73
73
|
import { chatVoiceOf } from '../../voice/chatVoice';
|
|
74
|
+
import { keptAudioDays } from '../../voice/keptSound';
|
|
74
75
|
import { defineAppRuntimePlugin } from './AppRuntimePlugins';
|
|
75
76
|
import { hasFolderModules } from './customComponents';
|
|
76
77
|
import { appPluginPair } from './pluginPair';
|
|
@@ -471,7 +472,11 @@ export function AppRenderer({ app, plugins = NO_PLUGINS, instance, onPresence, o
|
|
|
471
472
|
...speech,
|
|
472
473
|
consentKey: app.id,
|
|
473
474
|
language,
|
|
474
|
-
}
|
|
475
|
+
},
|
|
476
|
+
// What is said kept as sound, as its record says (VO-63): only for
|
|
477
|
+
// a person signed in (the host gave their token), never a visitor
|
|
478
|
+
// nor an embed's.
|
|
479
|
+
visitor || !speech.token ? undefined : keptAudioDays(app.record)),
|
|
475
480
|
};
|
|
476
481
|
}
|
|
477
482
|
catch (error) {
|
|
@@ -482,6 +487,8 @@ export function AppRenderer({ app, plugins = NO_PLUGINS, instance, onPresence, o
|
|
|
482
487
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
483
488
|
}, [
|
|
484
489
|
JSON.stringify(app.interface?.voice ?? null),
|
|
490
|
+
JSON.stringify(app.record?.include ?? null),
|
|
491
|
+
visitor,
|
|
485
492
|
app.id,
|
|
486
493
|
language,
|
|
487
494
|
speech?.speechUrl,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Suspense, lazy, useCallback, useEffect, useId, useRef, useState, } from 'react';
|
|
2
|
+
import { Suspense, lazy, useCallback, useEffect, useId, useReducer, useRef, useState, } from 'react';
|
|
3
3
|
import { ActionList, ActionMenu, IconButton, useTheme } from '@primer/react';
|
|
4
4
|
import { Box } from '@datalayer/primer-addons';
|
|
5
5
|
import { CodeIcon, CommentDiscussionIcon, DependabotIcon, InfoIcon, MuteIcon, PencilIcon, PlusIcon, PulseIcon, ScreenNormalIcon, SquareFillIcon, TrashIcon, UnmuteIcon, XIcon, } from '@primer/octicons-react';
|
|
@@ -11,6 +11,7 @@ import { ASSISTANT_CLEARANCE_VAR } from './clearance';
|
|
|
11
11
|
import { AssistantContextMenu, LONG_PRESS_MS, opensContextMenu, } from './AssistantContextMenu';
|
|
12
12
|
import { BalloonExpandContext, ExpandedVisual, focusExpanded, } from './BalloonVisual';
|
|
13
13
|
import { SpriteCharacter } from './SpriteCharacter';
|
|
14
|
+
import { BALLOON_GRACE_MS, BALLOON_HOVER_START, balloonHover, graceRuns, } from './balloonHover';
|
|
14
15
|
import { isAssistantModel3D } from './avatar3d';
|
|
15
16
|
import { conversationCount, } from './toolLine';
|
|
16
17
|
import { ASSISTANT_OBSTACLES, POINTER_CALM_MS, mouthOpening, boxesMeet, pointerNear, } from './state';
|
|
@@ -393,7 +394,33 @@ export function AssistantStage({ character, state, size = 88, place, stageRef, o
|
|
|
393
394
|
// Drawn for the chat's colour mode: the dark drawing on a dark page (T-25).
|
|
394
395
|
const { colorScheme } = useTheme();
|
|
395
396
|
const colorMode = colorScheme?.startsWith('dark') ? 'dark' : 'light';
|
|
396
|
-
|
|
397
|
+
// The figure and its balloon are one hover region: the balloon stays
|
|
398
|
+
// while the pointer crosses to it, and closes once the pointer has been
|
|
399
|
+
// on neither for a moment, on Escape or on a press outside.
|
|
400
|
+
const [hover, dispatchHover] = useReducer(balloonHover, BALLOON_HOVER_START);
|
|
401
|
+
const hovered = hover.shown;
|
|
402
|
+
const hoverGrace = graceRuns(hover);
|
|
403
|
+
useEffect(() => {
|
|
404
|
+
if (!hoverGrace) {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const grace = setTimeout(() => dispatchHover('grace'), BALLOON_GRACE_MS);
|
|
408
|
+
return () => clearTimeout(grace);
|
|
409
|
+
}, [hoverGrace]);
|
|
410
|
+
useEffect(() => {
|
|
411
|
+
if (!hovered) {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const onPress = (event) => {
|
|
415
|
+
const target = event.target;
|
|
416
|
+
if (target && stageRef.current?.contains(target)) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
dispatchHover('outside');
|
|
420
|
+
};
|
|
421
|
+
document.addEventListener('pointerdown', onPress, true);
|
|
422
|
+
return () => document.removeEventListener('pointerdown', onPress, true);
|
|
423
|
+
}, [hovered, stageRef]);
|
|
397
424
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
398
425
|
// Its own menu, where it was opened; the inspector and *About* it opens.
|
|
399
426
|
const [menuAt, setMenuAt] = useState(null);
|
|
@@ -596,7 +623,28 @@ export function AssistantStage({ character, state, size = 88, place, stageRef, o
|
|
|
596
623
|
'@media (prefers-reduced-motion: reduce)': {
|
|
597
624
|
'& *': { animation: 'none !important' },
|
|
598
625
|
},
|
|
599
|
-
}, onMouseEnter: () =>
|
|
626
|
+
}, onMouseEnter: () => dispatchHover('enter'), onMouseLeave: () => dispatchHover('leave'), onFocus: (event) => {
|
|
627
|
+
// Keyboard focus keeps the balloon; a click's focus does not.
|
|
628
|
+
let keyboard = true;
|
|
629
|
+
try {
|
|
630
|
+
keyboard = event.target.matches(':focus-visible');
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
// No :focus-visible here: count it.
|
|
634
|
+
}
|
|
635
|
+
if (keyboard) {
|
|
636
|
+
dispatchHover('focus');
|
|
637
|
+
}
|
|
638
|
+
}, onBlur: (event) => {
|
|
639
|
+
const next = event.relatedTarget;
|
|
640
|
+
if (!next || !event.currentTarget.contains(next)) {
|
|
641
|
+
dispatchHover('blur');
|
|
642
|
+
}
|
|
643
|
+
}, onKeyDown: (event) => {
|
|
644
|
+
if (event.key === 'Escape' && hovered) {
|
|
645
|
+
dispatchHover('escape');
|
|
646
|
+
}
|
|
647
|
+
}, children: [shown && (_jsx(ExpandedVisual, { visual: shown, target: expandTarget, onClose: () => setExpanded(null) })), showBalloon && balloon && (_jsx(BalloonExpandContext.Provider, { value: expand, children: _jsx(SpeechBalloon, { text: balloon.text, more: balloon.more, approval: balloon.approval, onDismissPeek: balloon.onDismiss, display: balloonDisplay, tool: balloon.tool, speaking: balloon.speaking, busy: balloon.busy, attachment: balloon.attachment, history: balloon.history, onExpand: expand ?? undefined, expandTitle: visual?.title, onExpandHistory: historyVisual
|
|
600
648
|
? () => {
|
|
601
649
|
setExpanded(historyVisual);
|
|
602
650
|
if (expandTarget) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When the assistant's balloon shows for a hover — the hover-intent rule.
|
|
3
|
+
*
|
|
4
|
+
* The figure and its balloon are one region: the balloon opens when the
|
|
5
|
+
* pointer enters either, and stays while it is over either. Leaving them
|
|
6
|
+
* does not close it at once — the pointer crosses the gap between the figure
|
|
7
|
+
* and the balloon, or a 3D figure's canvas redraws under it — it closes once
|
|
8
|
+
* the pointer has been on neither for `BALLOON_GRACE_MS`. Keyboard focus in
|
|
9
|
+
* the region keeps it open too. Escape, or a press outside, closes it at
|
|
10
|
+
* once; it opens again on the next enter or focus.
|
|
11
|
+
*
|
|
12
|
+
* Pure: the stage dispatches what happened and runs the grace timer while
|
|
13
|
+
* `graceRuns` says so.
|
|
14
|
+
*/
|
|
15
|
+
/** How long the balloon stays once the pointer is on neither, in ms. */
|
|
16
|
+
export declare const BALLOON_GRACE_MS = 700;
|
|
17
|
+
export interface BalloonHover {
|
|
18
|
+
/** The balloon shows for the hover. */
|
|
19
|
+
shown: boolean;
|
|
20
|
+
/** The pointer is over the figure or its balloon. */
|
|
21
|
+
over: boolean;
|
|
22
|
+
/** Keyboard focus is in the figure or its balloon. */
|
|
23
|
+
focused: boolean;
|
|
24
|
+
}
|
|
25
|
+
export type BalloonHoverEvent = 'enter' | 'leave' | 'focus' | 'blur' | 'grace' | 'escape' | 'outside';
|
|
26
|
+
export declare const BALLOON_HOVER_START: BalloonHover;
|
|
27
|
+
export declare function balloonHover(state: BalloonHover, event: BalloonHoverEvent): BalloonHover;
|
|
28
|
+
/** Whether the grace timer runs: shown, the pointer on neither, no focus. */
|
|
29
|
+
export declare function graceRuns(state: BalloonHover): boolean;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* When the assistant's balloon shows for a hover — the hover-intent rule.
|
|
7
|
+
*
|
|
8
|
+
* The figure and its balloon are one region: the balloon opens when the
|
|
9
|
+
* pointer enters either, and stays while it is over either. Leaving them
|
|
10
|
+
* does not close it at once — the pointer crosses the gap between the figure
|
|
11
|
+
* and the balloon, or a 3D figure's canvas redraws under it — it closes once
|
|
12
|
+
* the pointer has been on neither for `BALLOON_GRACE_MS`. Keyboard focus in
|
|
13
|
+
* the region keeps it open too. Escape, or a press outside, closes it at
|
|
14
|
+
* once; it opens again on the next enter or focus.
|
|
15
|
+
*
|
|
16
|
+
* Pure: the stage dispatches what happened and runs the grace timer while
|
|
17
|
+
* `graceRuns` says so.
|
|
18
|
+
*/
|
|
19
|
+
/** How long the balloon stays once the pointer is on neither, in ms. */
|
|
20
|
+
export const BALLOON_GRACE_MS = 700;
|
|
21
|
+
export const BALLOON_HOVER_START = {
|
|
22
|
+
shown: false,
|
|
23
|
+
over: false,
|
|
24
|
+
focused: false,
|
|
25
|
+
};
|
|
26
|
+
export function balloonHover(state, event) {
|
|
27
|
+
switch (event) {
|
|
28
|
+
case 'enter':
|
|
29
|
+
return { ...state, over: true, shown: true };
|
|
30
|
+
case 'leave':
|
|
31
|
+
// Still shown: the grace runs.
|
|
32
|
+
return { ...state, over: false };
|
|
33
|
+
case 'focus':
|
|
34
|
+
return { ...state, focused: true, shown: true };
|
|
35
|
+
case 'blur':
|
|
36
|
+
return { ...state, focused: false };
|
|
37
|
+
case 'grace':
|
|
38
|
+
return state.over || state.focused ? state : { ...state, shown: false };
|
|
39
|
+
case 'escape':
|
|
40
|
+
return { ...state, shown: false };
|
|
41
|
+
case 'outside':
|
|
42
|
+
return { ...state, shown: false, over: false, focused: false };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Whether the grace timer runs: shown, the pointer on neither, no focus. */
|
|
46
|
+
export function graceRuns(state) {
|
|
47
|
+
return state.shown && !state.over && !state.focused;
|
|
48
|
+
}
|
|
@@ -3087,7 +3087,14 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
|
|
|
3087
3087
|
const forwardedProps = spoken
|
|
3088
3088
|
? { ...(forwardedPropsGiven ?? {}), voice: spoken }
|
|
3089
3089
|
: forwardedPropsGiven;
|
|
3090
|
-
|
|
3090
|
+
// The message says how it was heard; its sound, sent to be kept
|
|
3091
|
+
// (VO-63), rides on the run only, never on the conversation.
|
|
3092
|
+
let heardAs;
|
|
3093
|
+
if (spoken) {
|
|
3094
|
+
const { sound: _sound, ...rest } = spoken;
|
|
3095
|
+
heardAs = rest;
|
|
3096
|
+
}
|
|
3097
|
+
const userMessage = createUserMessage(messageContent, heardAs ? { ...heardAs } : undefined);
|
|
3091
3098
|
const currentMessages = displayItems.filter((item) => !isToolCallMessage(item));
|
|
3092
3099
|
const allMessages = [...currentMessages, userMessage];
|
|
3093
3100
|
{
|
|
@@ -55,14 +55,14 @@ export function ComposerVoice({ voice, input, setInput, onSpoken, send, disabled
|
|
|
55
55
|
if (!hearing) {
|
|
56
56
|
return null;
|
|
57
57
|
}
|
|
58
|
-
return (_jsxs("span", { "data-composer-voice": "", style: { display: 'inline-flex', alignItems: 'center', gap: 2 }, children: [_jsx(VoiceMenu, { voice: voice, answers: answers, disabled: disabled }), voice.input === 'hands_free' ? (_jsx(HandsFree, { hearing: hearing, language: voice.language, consentKey: voice.consentKey ?? 'chat', microphoneId: voice.microphoneId, turnSilenceMs: voice.turnSilenceMs, disabled: disabled, running: running, speaking: speaking, playbackLevel: playbackLevel, onStopSpeaking: onStopSpeaking, onStopRun: onStopRun, onInterrupted: onInterrupted, onInterim: (words) => {
|
|
58
|
+
return (_jsxs("span", { "data-composer-voice": "", style: { display: 'inline-flex', alignItems: 'center', gap: 2 }, children: [_jsx(VoiceMenu, { voice: voice, answers: answers, disabled: disabled }), voice.input === 'hands_free' ? (_jsx(HandsFree, { hearing: hearing, language: voice.language, consentKey: voice.consentKey ?? 'chat', keepsAudio: voice.keepsAudio, microphoneId: voice.microphoneId, turnSilenceMs: voice.turnSilenceMs, disabled: disabled, running: running, speaking: speaking, playbackLevel: playbackLevel, onStopSpeaking: onStopSpeaking, onStopRun: onStopRun, onInterrupted: onInterrupted, onInterim: (words) => {
|
|
59
59
|
setInput(words ? afterBase(words) : base.current);
|
|
60
60
|
onInterimChange?.(words !== '');
|
|
61
61
|
}, send: (text, metadata) => {
|
|
62
62
|
onInterimChange?.(false);
|
|
63
63
|
setInput(base.current);
|
|
64
64
|
send(text, metadata);
|
|
65
|
-
} })) : null, _jsx(VoiceInput, { hearing: hearing, language: voice.language, consentKey: voice.consentKey ?? 'chat', silenceMs: voice.silenceMs, microphoneId: voice.microphoneId, disabled: disabled, speaking: speaking, onStopSpeaking: onStopSpeaking, onListen: () => {
|
|
65
|
+
} })) : null, _jsx(VoiceInput, { hearing: hearing, language: voice.language, consentKey: voice.consentKey ?? 'chat', keepsAudio: voice.keepsAudio, silenceMs: voice.silenceMs, microphoneId: voice.microphoneId, disabled: disabled, speaking: speaking, onStopSpeaking: onStopSpeaking, onListen: () => {
|
|
66
66
|
base.current = input.trim();
|
|
67
67
|
onStopSpeaking?.();
|
|
68
68
|
},
|
package/lib/voice/HandsFree.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* On, the microphone stays open. The words are written in the composer as
|
|
7
7
|
* they are heard (VO-11); once the person has spoken and been silent for the
|
|
8
|
-
* turn's silence (`turnSilenceMs`,
|
|
8
|
+
* turn's silence (`turnSilenceMs`, 500 ms unless said), what was said is
|
|
9
9
|
* heard whole and **sent** — nothing waits for a click. The answer is said as
|
|
10
10
|
* it is written (hands-free implies `output: always`), and listening goes
|
|
11
11
|
* on through it: speech heard while the agent thinks or speaks is
|
|
@@ -24,6 +24,12 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import type { JSX } from 'react';
|
|
26
26
|
import type { Hearing } from './hearing';
|
|
27
|
+
/**
|
|
28
|
+
* While the agent answers, how often the microphone is looked at for the
|
|
29
|
+
* person speaking over it, by the VAD alone (VO-52): looked at every
|
|
30
|
+
* `INTERIM_MS` with the words transcribed, barge-in waited up to 0.7 s.
|
|
31
|
+
*/
|
|
32
|
+
export declare const BARGE_IN_POLL_MS = 150;
|
|
27
33
|
import type { SpokenMetadata } from './types';
|
|
28
34
|
export interface HandsFreeProps {
|
|
29
35
|
hearing: Hearing;
|
|
@@ -31,9 +37,14 @@ export interface HandsFreeProps {
|
|
|
31
37
|
language: string;
|
|
32
38
|
/** Whose consent to the microphone is remembered: the application's id. */
|
|
33
39
|
consentKey: string;
|
|
40
|
+
/**
|
|
41
|
+
* The days the application keeps what is said as sound (VO-63): asked in
|
|
42
|
+
* the first sentence, and each turn's recording sent with its words.
|
|
43
|
+
*/
|
|
44
|
+
keepsAudio?: number;
|
|
34
45
|
/** The microphone the person chose (VO-30); the browser's own when unsaid. */
|
|
35
46
|
microphoneId?: string;
|
|
36
|
-
/** The silence after speech that ends a turn, in ms;
|
|
47
|
+
/** The silence after speech that ends a turn, in ms; 500 unless said (VO-12). */
|
|
37
48
|
turnSilenceMs?: number;
|
|
38
49
|
/** A turn, heard whole: sent at once. */
|
|
39
50
|
send: (text: string, metadata: SpokenMetadata) => void;
|
|
@@ -57,5 +68,5 @@ export interface HandsFreeProps {
|
|
|
57
68
|
onInterrupted?: () => void;
|
|
58
69
|
disabled?: boolean;
|
|
59
70
|
}
|
|
60
|
-
export declare function HandsFree({ hearing, language, consentKey, microphoneId, turnSilenceMs, send, onInterim, running, speaking, playbackLevel, onStopSpeaking, onStopRun, onInterrupted, disabled, }: HandsFreeProps): JSX.Element;
|
|
71
|
+
export declare function HandsFree({ hearing, language, consentKey, keepsAudio, microphoneId, turnSilenceMs, send, onInterim, running, speaking, playbackLevel, onStopSpeaking, onStopRun, onInterrupted, disabled, }: HandsFreeProps): JSX.Element;
|
|
61
72
|
export default HandsFree;
|
package/lib/voice/HandsFree.js
CHANGED
|
@@ -2,13 +2,24 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { Button, IconButton, Text, Tooltip } from '@primer/react';
|
|
4
4
|
import { Box } from '@datalayer/primer-addons';
|
|
5
|
-
import { openMicrophone } from './capture';
|
|
5
|
+
import { HEARING_RATE, openMicrophone } from './capture';
|
|
6
6
|
import { CONSENT_SENTENCE, SERVER_CONSENT_SENTENCE, consent, consentKeyFor, consented, } from './consent';
|
|
7
7
|
import { HANDS_FREE_WORDS, IDLE_PAUSE_MS, TURN_SILENCE_MS, bargedIn, handsFreeState, turnEnded, } from './handsFree';
|
|
8
|
+
import { keptAudioSentence, soundOf } from './keptSound';
|
|
8
9
|
import { INTERIM_MS } from './VoiceInput';
|
|
10
|
+
/**
|
|
11
|
+
* While the agent answers, how often the microphone is looked at for the
|
|
12
|
+
* person speaking over it, by the VAD alone (VO-52): looked at every
|
|
13
|
+
* `INTERIM_MS` with the words transcribed, barge-in waited up to 0.7 s.
|
|
14
|
+
*/
|
|
15
|
+
export const BARGE_IN_POLL_MS = 150;
|
|
16
|
+
/** The last of the sound looked at for barge-in: 1.5 s, so each look is cheap. */
|
|
17
|
+
const BARGE_IN_WINDOW = Math.round(HEARING_RATE * 1.5);
|
|
18
|
+
/** The least speech that is barge-in, in seconds: a short word (VO-52). */
|
|
19
|
+
const BARGE_IN_SHORTEST = 0.12;
|
|
9
20
|
/** Waits. */
|
|
10
21
|
const pause = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
11
|
-
export function HandsFree({ hearing, language, consentKey, microphoneId, turnSilenceMs = TURN_SILENCE_MS, send, onInterim, running = false, speaking = false, playbackLevel, onStopSpeaking, onStopRun, onInterrupted, disabled = false, }) {
|
|
22
|
+
export function HandsFree({ hearing, language, consentKey, keepsAudio, microphoneId, turnSilenceMs = TURN_SILENCE_MS, send, onInterim, running = false, speaking = false, playbackLevel, onStopSpeaking, onStopRun, onInterrupted, disabled = false, }) {
|
|
12
23
|
const [on, setOn] = useState(false);
|
|
13
24
|
const [paused, setPaused] = useState(false);
|
|
14
25
|
const [asking, setAsking] = useState(false);
|
|
@@ -35,7 +46,7 @@ export function HandsFree({ hearing, language, consentKey, microphoneId, turnSil
|
|
|
35
46
|
onStopRun,
|
|
36
47
|
onInterrupted,
|
|
37
48
|
};
|
|
38
|
-
const key = consentKeyFor(consentKey, hearing.where);
|
|
49
|
+
const key = consentKeyFor(consentKey, hearing.where, keepsAudio);
|
|
39
50
|
const turnOn = useCallback(() => {
|
|
40
51
|
if (!consented(key)) {
|
|
41
52
|
setAsking(true);
|
|
@@ -82,8 +93,18 @@ export function HandsFree({ hearing, language, consentKey, microphoneId, turnSil
|
|
|
82
93
|
}
|
|
83
94
|
heardWhile.current =
|
|
84
95
|
stateRef.current === 'listening' ? 'listening' : 'answering';
|
|
96
|
+
// Once the person spoke in this turn, its end is looked for often,
|
|
97
|
+
// by the VAD alone, the words between (VO-52).
|
|
98
|
+
let spokeThisTurn = false;
|
|
99
|
+
let wordsAt = 0;
|
|
85
100
|
while (alive) {
|
|
86
|
-
|
|
101
|
+
const quick = (Boolean(hearing.spokeSoFar) &&
|
|
102
|
+
(stateRef.current === 'thinking' ||
|
|
103
|
+
stateRef.current === 'speaking')) ||
|
|
104
|
+
(Boolean(hearing.quietSoFar) &&
|
|
105
|
+
spokeThisTurn &&
|
|
106
|
+
stateRef.current === 'listening');
|
|
107
|
+
await pause(quick ? BARGE_IN_POLL_MS : INTERIM_MS);
|
|
87
108
|
const open = capture.current;
|
|
88
109
|
if (!alive || !open) {
|
|
89
110
|
return;
|
|
@@ -102,11 +123,27 @@ export function HandsFree({ hearing, language, consentKey, microphoneId, turnSil
|
|
|
102
123
|
}
|
|
103
124
|
heardWhile.current = answering ? 'answering' : 'listening';
|
|
104
125
|
quietSince = Date.now();
|
|
126
|
+
spokeThisTurn = false;
|
|
105
127
|
continue;
|
|
106
128
|
}
|
|
107
129
|
let so;
|
|
108
130
|
try {
|
|
109
|
-
|
|
131
|
+
if (answering && hearing.spokeSoFar) {
|
|
132
|
+
// Barge-in only: the VAD over the last moment, nothing transcribed.
|
|
133
|
+
const sound = open.peek();
|
|
134
|
+
const spoke = await hearing.spokeSoFar(sound.subarray(Math.max(0, sound.length - BARGE_IN_WINDOW)), BARGE_IN_SHORTEST);
|
|
135
|
+
so = { text: '', spoke, silentMs: 0 };
|
|
136
|
+
}
|
|
137
|
+
else if (spokeThisTurn &&
|
|
138
|
+
hearing.quietSoFar &&
|
|
139
|
+
Date.now() - wordsAt < INTERIM_MS) {
|
|
140
|
+
// The turn's end looked for: the VAD alone, no words.
|
|
141
|
+
so = await hearing.quietSoFar(open.peek());
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
so = await hearing.hearSoFar(open.peek(), language);
|
|
145
|
+
wordsAt = Date.now();
|
|
146
|
+
}
|
|
110
147
|
}
|
|
111
148
|
catch {
|
|
112
149
|
so = undefined;
|
|
@@ -119,6 +156,9 @@ export function HandsFree({ hearing, language, consentKey, microphoneId, turnSil
|
|
|
119
156
|
}
|
|
120
157
|
if (so.spoke) {
|
|
121
158
|
quietSince = Date.now();
|
|
159
|
+
if (now === 'listening') {
|
|
160
|
+
spokeThisTurn = true;
|
|
161
|
+
}
|
|
122
162
|
}
|
|
123
163
|
const playing = latest.current.playbackLevel;
|
|
124
164
|
const levels = playing
|
|
@@ -155,7 +195,10 @@ export function HandsFree({ hearing, language, consentKey, microphoneId, turnSil
|
|
|
155
195
|
}
|
|
156
196
|
latest.current.onInterim?.('');
|
|
157
197
|
if (heard) {
|
|
158
|
-
|
|
198
|
+
// Kept as sound too, as the person agreed (VO-63).
|
|
199
|
+
latest.current.send(heard.text, keepsAudio
|
|
200
|
+
? { ...heard.metadata, sound: soundOf(audio) }
|
|
201
|
+
: heard.metadata);
|
|
159
202
|
}
|
|
160
203
|
try {
|
|
161
204
|
capture.current = await openMicrophone(microphoneId);
|
|
@@ -165,6 +208,7 @@ export function HandsFree({ hearing, language, consentKey, microphoneId, turnSil
|
|
|
165
208
|
}
|
|
166
209
|
heardWhile.current = 'listening';
|
|
167
210
|
quietSince = Date.now();
|
|
211
|
+
spokeThisTurn = false;
|
|
168
212
|
continue;
|
|
169
213
|
}
|
|
170
214
|
if (now === 'listening' && Date.now() - quietSince >= IDLE_PAUSE_MS) {
|
|
@@ -179,7 +223,7 @@ export function HandsFree({ hearing, language, consentKey, microphoneId, turnSil
|
|
|
179
223
|
capture.current = undefined;
|
|
180
224
|
};
|
|
181
225
|
// The loop reads the state through a ref: it is not restarted on it.
|
|
182
|
-
}, [on, paused, hearing, language, microphoneId, turnSilenceMs]);
|
|
226
|
+
}, [on, paused, hearing, language, microphoneId, turnSilenceMs, keepsAudio]);
|
|
183
227
|
// Esc pauses it.
|
|
184
228
|
useEffect(() => {
|
|
185
229
|
if (!on) {
|
|
@@ -215,7 +259,7 @@ export function HandsFree({ hearing, language, consentKey, microphoneId, turnSil
|
|
|
215
259
|
latest.current.onInterim?.('');
|
|
216
260
|
} }))] })) : null, problem ? (_jsx(Text, { sx: { fontSize: 0, color: 'danger.fg' }, "data-hands-free-problem": "", children: problem })) : null, asking ? (_jsxs(Box, { role: "dialog", "aria-label": "The microphone", "data-hands-free-consent": "", position: "absolute", bottom: "100%", left: 0, width: 280, p: 3, bg: "canvas.overlay", border: "1px solid", borderColor: "border.default", borderRadius: 2, boxShadow: "shadow.large", zIndex: 10, children: [_jsx(Text, { as: "p", sx: { fontSize: 1, mb: 2 }, children: hearing.where === 'server'
|
|
217
261
|
? SERVER_CONSENT_SENTENCE
|
|
218
|
-
: CONSENT_SENTENCE }), _jsxs(Box, { display: "flex", gap: 2, justifyContent: "flex-end", children: [_jsx(Button, { size: "small", onClick: () => setAsking(false), children: "Not now" }), _jsx(Button, { size: "small", variant: "primary", "data-hands-free-consent-yes": "", onClick: () => {
|
|
262
|
+
: CONSENT_SENTENCE }), keepsAudio ? (_jsx(Text, { as: "p", sx: { fontSize: 1, mb: 2 }, "data-voice-consent-kept": "", children: keptAudioSentence(keepsAudio) })) : null, _jsxs(Box, { display: "flex", gap: 2, justifyContent: "flex-end", children: [_jsx(Button, { size: "small", onClick: () => setAsking(false), children: "Not now" }), _jsx(Button, { size: "small", variant: "primary", "data-hands-free-consent-yes": "", onClick: () => {
|
|
219
263
|
consent(key);
|
|
220
264
|
setAsking(false);
|
|
221
265
|
setProblem(undefined);
|
|
@@ -48,10 +48,15 @@ export interface VoiceInputProps {
|
|
|
48
48
|
onListen?: () => void;
|
|
49
49
|
/** Whose consent is remembered: the application's id. */
|
|
50
50
|
consentKey: string;
|
|
51
|
+
/**
|
|
52
|
+
* The days the application keeps what is said as sound (VO-63): asked in
|
|
53
|
+
* the first sentence, and each turn's recording sent with its words.
|
|
54
|
+
*/
|
|
55
|
+
keepsAudio?: number;
|
|
51
56
|
disabled?: boolean;
|
|
52
57
|
/** The agent is speaking: offer to stop it. */
|
|
53
58
|
speaking?: boolean;
|
|
54
59
|
onStopSpeaking?: () => void;
|
|
55
60
|
}
|
|
56
|
-
export declare function VoiceInput({ hearing, language, onTranscript, onInterim, silenceMs, microphoneId, onListen, consentKey, disabled, speaking, onStopSpeaking, }: VoiceInputProps): JSX.Element;
|
|
61
|
+
export declare function VoiceInput({ hearing, language, onTranscript, onInterim, silenceMs, microphoneId, onListen, consentKey, keepsAudio, disabled, speaking, onStopSpeaking, }: VoiceInputProps): JSX.Element;
|
|
57
62
|
export default VoiceInput;
|
package/lib/voice/VoiceInput.js
CHANGED
|
@@ -6,6 +6,7 @@ import { MicrophoneIcon } from '@datalayer/icons-react';
|
|
|
6
6
|
import { MuteIcon } from '@primer/octicons-react';
|
|
7
7
|
import { MicrophoneRefused, openMicrophone } from './capture';
|
|
8
8
|
import { CONSENT_SENTENCE, SERVER_CONSENT_SENTENCE, consent, consentKeyFor, consented, } from './consent';
|
|
9
|
+
import { keptAudioSentence, soundOf } from './keptSound';
|
|
9
10
|
const WORDS = {
|
|
10
11
|
idle: '',
|
|
11
12
|
asking: '',
|
|
@@ -27,7 +28,7 @@ function prefersStill() {
|
|
|
27
28
|
return (typeof window !== 'undefined' &&
|
|
28
29
|
!!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches);
|
|
29
30
|
}
|
|
30
|
-
export function VoiceInput({ hearing, language, onTranscript, onInterim, silenceMs = 1500, microphoneId, onListen, consentKey, disabled = false, speaking = false, onStopSpeaking, }) {
|
|
31
|
+
export function VoiceInput({ hearing, language, onTranscript, onInterim, silenceMs = 1500, microphoneId, onListen, consentKey, keepsAudio, disabled = false, speaking = false, onStopSpeaking, }) {
|
|
31
32
|
const [state, setState] = useState('idle');
|
|
32
33
|
const [said, setSaid] = useState('');
|
|
33
34
|
const [problem, setProblem] = useState();
|
|
@@ -49,7 +50,7 @@ export function VoiceInput({ hearing, language, onTranscript, onInterim, silence
|
|
|
49
50
|
if (disabled || stateRef.current !== 'idle') {
|
|
50
51
|
return;
|
|
51
52
|
}
|
|
52
|
-
if (!consented(consentKeyFor(consentKey, hearing.where))) {
|
|
53
|
+
if (!consented(consentKeyFor(consentKey, hearing.where, keepsAudio))) {
|
|
53
54
|
setState('asking');
|
|
54
55
|
return;
|
|
55
56
|
}
|
|
@@ -75,7 +76,15 @@ export function VoiceInput({ hearing, language, onTranscript, onInterim, silence
|
|
|
75
76
|
? error.message
|
|
76
77
|
: 'The microphone could not be opened.');
|
|
77
78
|
}
|
|
78
|
-
}, [
|
|
79
|
+
}, [
|
|
80
|
+
consentKey,
|
|
81
|
+
keepsAudio,
|
|
82
|
+
disabled,
|
|
83
|
+
hearing,
|
|
84
|
+
language,
|
|
85
|
+
microphoneId,
|
|
86
|
+
onListen,
|
|
87
|
+
]);
|
|
79
88
|
const stop = useCallback(async () => {
|
|
80
89
|
const open = capture.current;
|
|
81
90
|
capture.current = undefined;
|
|
@@ -99,7 +108,13 @@ export function VoiceInput({ hearing, language, onTranscript, onInterim, silence
|
|
|
99
108
|
return;
|
|
100
109
|
}
|
|
101
110
|
setSaid(`Heard: ${transcript.text}`);
|
|
102
|
-
|
|
111
|
+
// Kept as sound too, as the person agreed (VO-63).
|
|
112
|
+
onTranscript(keepsAudio
|
|
113
|
+
? {
|
|
114
|
+
...transcript,
|
|
115
|
+
metadata: { ...transcript.metadata, sound: soundOf(audio) },
|
|
116
|
+
}
|
|
117
|
+
: transcript);
|
|
103
118
|
}
|
|
104
119
|
catch (error) {
|
|
105
120
|
setState('idle');
|
|
@@ -287,8 +302,8 @@ export function VoiceInput({ hearing, language, onTranscript, onInterim, silence
|
|
|
287
302
|
}
|
|
288
303
|
} }) }), state === 'asking' && (_jsxs(Box, { role: "dialog", "aria-label": "Talk instead of typing", "data-voice-consent": "", position: "absolute", bottom: "100%", right: 0, mb: 2, width: 280, p: 3, bg: "canvas.overlay", border: "1px solid", borderColor: "border.default", borderRadius: 2, boxShadow: "shadow.large", zIndex: 10, children: [_jsx(Text, { as: "p", sx: { fontSize: 1, mb: 2 }, children: hearing.where === 'server'
|
|
289
304
|
? SERVER_CONSENT_SENTENCE
|
|
290
|
-
: CONSENT_SENTENCE }), _jsxs(Box, { display: "flex", gap: 2, justifyContent: "flex-end", children: [_jsx(Button, { size: "small", onClick: () => setState('idle'), children: "Not now" }), _jsx(Button, { size: "small", variant: "primary", "data-voice-consent-yes": "", onClick: () => {
|
|
291
|
-
consent(consentKeyFor(consentKey, hearing.where));
|
|
305
|
+
: CONSENT_SENTENCE }), keepsAudio ? (_jsx(Text, { as: "p", sx: { fontSize: 1, mb: 2 }, "data-voice-consent-kept": "", children: keptAudioSentence(keepsAudio) })) : null, _jsxs(Box, { display: "flex", gap: 2, justifyContent: "flex-end", children: [_jsx(Button, { size: "small", onClick: () => setState('idle'), children: "Not now" }), _jsx(Button, { size: "small", variant: "primary", "data-voice-consent-yes": "", onClick: () => {
|
|
306
|
+
consent(consentKeyFor(consentKey, hearing.where, keepsAudio));
|
|
292
307
|
// Asked, and said yes: listen now, without another press.
|
|
293
308
|
stateRef.current = 'idle';
|
|
294
309
|
setState('idle');
|
package/lib/voice/chatVoice.d.ts
CHANGED
|
@@ -40,7 +40,7 @@ export interface ChatVoicePlace {
|
|
|
40
40
|
* - **Speaking**: its answers said by Datalayer's speech service, on request
|
|
41
41
|
* or always (VO-20, VO-21); refused here when its host says no service.
|
|
42
42
|
*/
|
|
43
|
-
export declare function chatVoiceOf(spec: AppVoiceSpec | undefined, place: ChatVoicePlace): ChatVoice | undefined;
|
|
43
|
+
export declare function chatVoiceOf(spec: AppVoiceSpec | undefined, place: ChatVoicePlace, keepsAudio?: number): ChatVoice | undefined;
|
|
44
44
|
/**
|
|
45
45
|
* A chat's voice asked of the speech service as the chat itself speaks to
|
|
46
46
|
* its runtime (VO-37, VO-19): the token its host gave, else the chat's own —
|
package/lib/voice/chatVoice.js
CHANGED
|
@@ -41,7 +41,7 @@ export const DATALAYER_VOICE_ENGINES = {
|
|
|
41
41
|
* - **Speaking**: its answers said by Datalayer's speech service, on request
|
|
42
42
|
* or always (VO-20, VO-21); refused here when its host says no service.
|
|
43
43
|
*/
|
|
44
|
-
export function chatVoiceOf(spec, place) {
|
|
44
|
+
export function chatVoiceOf(spec, place, keepsAudio) {
|
|
45
45
|
if (!spec?.enabled || (spec.input === 'off' && spec.output === 'off')) {
|
|
46
46
|
return undefined;
|
|
47
47
|
}
|
|
@@ -61,6 +61,8 @@ export function chatVoiceOf(spec, place) {
|
|
|
61
61
|
token: place.token,
|
|
62
62
|
consentKey: place.consentKey,
|
|
63
63
|
where: spec.where ?? 'auto',
|
|
64
|
+
// Kept as sound only when it listens, as its record says (VO-63).
|
|
65
|
+
...(keepsAudio && spec.input !== 'off' ? { keepsAudio } : {}),
|
|
64
66
|
};
|
|
65
67
|
}
|
|
66
68
|
/**
|
package/lib/voice/consent.d.ts
CHANGED
|
@@ -10,5 +10,9 @@ export declare const CONSENT_SENTENCE = "What you say is transcribed on this dev
|
|
|
10
10
|
* a yes to one is not a yes to the other.
|
|
11
11
|
*/
|
|
12
12
|
export declare const SERVER_CONSENT_SENTENCE = "What you say is sent to Datalayer\u2019s servers to be transcribed: the sound and the words are not kept there. The words go in the message box, for you to send.";
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Whose consent is remembered for hearing in a place: the application's,
|
|
15
|
+
* apart for the server, and apart again when what is said is kept as sound
|
|
16
|
+
* (VO-63) — a yes to being heard is not a yes to being kept.
|
|
17
|
+
*/
|
|
18
|
+
export declare function consentKeyFor(app: string, where: 'device' | 'server', keepsAudio?: number): string;
|
package/lib/voice/consent.js
CHANGED
|
@@ -38,7 +38,12 @@ export const CONSENT_SENTENCE = 'What you say is transcribed on this device: the
|
|
|
38
38
|
* a yes to one is not a yes to the other.
|
|
39
39
|
*/
|
|
40
40
|
export const SERVER_CONSENT_SENTENCE = 'What you say is sent to Datalayer’s servers to be transcribed: the sound and the words are not kept there. The words go in the message box, for you to send.';
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Whose consent is remembered for hearing in a place: the application's,
|
|
43
|
+
* apart for the server, and apart again when what is said is kept as sound
|
|
44
|
+
* (VO-63) — a yes to being heard is not a yes to being kept.
|
|
45
|
+
*/
|
|
46
|
+
export function consentKeyFor(app, where, keepsAudio) {
|
|
47
|
+
const heard = where === 'server' ? `${app}:server` : app;
|
|
48
|
+
return keepsAudio ? `${heard}:kept` : heard;
|
|
44
49
|
}
|
package/lib/voice/handsFree.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* microphone and the clock, this decides.
|
|
6
6
|
*
|
|
7
7
|
* - **Listening**: the microphone is open; the turn ends after
|
|
8
|
-
* `turnSilenceMs` of silence once the person has spoken (
|
|
8
|
+
* `turnSilenceMs` of silence once the person has spoken (500 ms unless
|
|
9
9
|
* the application or the person says otherwise), and is sent.
|
|
10
10
|
* - **Thinking**: the turn was sent and the agent is answering, nothing
|
|
11
11
|
* said yet.
|
|
@@ -23,7 +23,7 @@ export type HandsFreeState = 'off' | 'listening' | 'thinking' | 'speaking' | 'pa
|
|
|
23
23
|
/** The word for each state, as the composer shows it. */
|
|
24
24
|
export declare const HANDS_FREE_WORDS: Record<HandsFreeState, string>;
|
|
25
25
|
/** The silence after speech that ends a turn, in ms, unless said otherwise (VO-12). */
|
|
26
|
-
export declare const TURN_SILENCE_MS =
|
|
26
|
+
export declare const TURN_SILENCE_MS = 500;
|
|
27
27
|
/** Listening with nothing said for this long pauses it: a microphone is not left open on an empty room. */
|
|
28
28
|
export declare const IDLE_PAUSE_MS: number;
|
|
29
29
|
/** What decides the state. */
|
package/lib/voice/handsFree.js
CHANGED
|
@@ -11,7 +11,7 @@ export const HANDS_FREE_WORDS = {
|
|
|
11
11
|
paused: 'Paused',
|
|
12
12
|
};
|
|
13
13
|
/** The silence after speech that ends a turn, in ms, unless said otherwise (VO-12). */
|
|
14
|
-
export const TURN_SILENCE_MS =
|
|
14
|
+
export const TURN_SILENCE_MS = 500;
|
|
15
15
|
/** Listening with nothing said for this long pauses it: a microphone is not left open on an empty room. */
|
|
16
16
|
export const IDLE_PAUSE_MS = 5 * 60_000;
|
|
17
17
|
/** The state for what is known: speaking wins over thinking, pausing over both. */
|
package/lib/voice/hearing.d.ts
CHANGED
|
@@ -35,6 +35,17 @@ export interface Hearing {
|
|
|
35
35
|
hearSoFar(audio: Float32Array, language: string): Promise<HeardSoFar>;
|
|
36
36
|
/** What was said in a press, or `undefined` when nothing was. */
|
|
37
37
|
hear(audio: Float32Array, language: string): Promise<Transcript | undefined>;
|
|
38
|
+
/**
|
|
39
|
+
* Whether there is speech in some sound, found by the VAD alone — nothing
|
|
40
|
+
* transcribed, nothing sent (VO-52): hands-free's quick look for barge-in.
|
|
41
|
+
*/
|
|
42
|
+
spokeSoFar?(audio: Float32Array, shortestSeconds?: number): Promise<boolean>;
|
|
43
|
+
/**
|
|
44
|
+
* Whether there is speech in some sound and how long it has been quiet
|
|
45
|
+
* since, by the VAD alone — no words (VO-52): hands-free's quick look for
|
|
46
|
+
* the end of a turn once the person has spoken.
|
|
47
|
+
*/
|
|
48
|
+
quietSoFar?(audio: Float32Array): Promise<HeardSoFar>;
|
|
38
49
|
}
|
|
39
50
|
/** Joins the speech a VAD found, in order. */
|
|
40
51
|
export declare function joinSegments(segments: Float32Array[]): Float32Array;
|
|
@@ -73,6 +84,8 @@ export declare class DeviceHearing implements Hearing {
|
|
|
73
84
|
* words, for the composer as they come, and how long it has been silent
|
|
74
85
|
* since the speech ended — what stops a click to talk (VO-10).
|
|
75
86
|
*/
|
|
87
|
+
spokeSoFar(audio: Float32Array, shortestSeconds?: number): Promise<boolean>;
|
|
88
|
+
quietSoFar(audio: Float32Array): Promise<HeardSoFar>;
|
|
76
89
|
hearSoFar(audio: Float32Array, language: string): Promise<HeardSoFar>;
|
|
77
90
|
/**
|
|
78
91
|
* What was said in a press, or `undefined` when nothing was: its speech
|
package/lib/voice/hearing.js
CHANGED
|
@@ -179,6 +179,14 @@ export class DeviceHearing {
|
|
|
179
179
|
* words, for the composer as they come, and how long it has been silent
|
|
180
180
|
* since the speech ended — what stops a click to talk (VO-10).
|
|
181
181
|
*/
|
|
182
|
+
async spokeSoFar(audio, shortestSeconds = 0.2) {
|
|
183
|
+
const { speech } = await this.findSpeech(audio);
|
|
184
|
+
return speech.length >= HEARING_RATE * shortestSeconds;
|
|
185
|
+
}
|
|
186
|
+
async quietSoFar(audio) {
|
|
187
|
+
const { speech, silentMs } = await this.findSpeech(audio);
|
|
188
|
+
return { text: '', spoke: speech.length >= HEARING_RATE * 0.2, silentMs };
|
|
189
|
+
}
|
|
182
190
|
async hearSoFar(audio, language) {
|
|
183
191
|
const { speech, silentMs } = await this.findSpeech(audio);
|
|
184
192
|
if (speech.length < HEARING_RATE * 0.2) {
|