@datalayer/agent-runtimes 1.5.43 → 1.5.44
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/chunks/1f30a-DxwAt2Ba.js +1 -0
- package/dist-embed/chunks/1f310-AqUM9a8q.js +1 -0
- package/dist-embed/chunks/1f324-8MHLOhTj.js +1 -0
- package/dist-embed/chunks/1f326-YSBr3aXI.js +1 -0
- package/dist-embed/chunks/1f327-Ie-nQBNO.js +1 -0
- package/dist-embed/chunks/1f33e-Cfl2WM63.js +1 -0
- package/dist-embed/chunks/1f372-3XLDlXkE.js +1 -0
- package/dist-embed/chunks/1f373-BPUJt2bp.js +1 -0
- package/dist-embed/chunks/1f39b-CSNtbAXr.js +1 -0
- package/dist-embed/chunks/1f3ab-BUCNQHz1.js +1 -0
- package/dist-embed/chunks/1f41b-Dre1z0gP.js +1 -0
- package/dist-embed/chunks/1f441-D8yah4C4.js +1 -0
- package/dist-embed/chunks/1f465-CPle6Hjq.js +1 -0
- package/dist-embed/chunks/1f4b9-aacFyckt.js +1 -0
- package/dist-embed/chunks/1f4be-DPAcAU4R.js +1 -0
- package/dist-embed/chunks/1f4c1-Ch6aJ0cj.js +1 -0
- package/dist-embed/chunks/1f4c2-Dx7BKmLO.js +1 -0
- package/dist-embed/chunks/1f4c4-CNvjvC5k.js +1 -0
- package/dist-embed/chunks/1f4d3-GTxRgjv7.js +1 -0
- package/dist-embed/chunks/1f4d6-COOZY-Q_.js +1 -0
- package/dist-embed/chunks/1f4dc-BhoW4cjD.js +1 -0
- package/dist-embed/chunks/1f4e1-DezU8ow-.js +1 -0
- package/dist-embed/chunks/1f4e2-DsStZsI-.js +1 -0
- package/dist-embed/chunks/1f4e7-BBi7c_zx.js +1 -0
- package/dist-embed/chunks/1f500-D9xRo-IT.js +1 -0
- package/dist-embed/chunks/1f501-CzKeDPcw.js +1 -0
- package/dist-embed/chunks/1f504-BYIHMoDP.js +1 -0
- package/dist-embed/chunks/1f52d-2QxCLKhU.js +1 -0
- package/dist-embed/chunks/1f552-4d0Y0kn7.js +1 -0
- package/dist-embed/chunks/1f58b-Cp8F_HBg.js +1 -0
- package/dist-embed/chunks/1f5b2-D6BUULuh.js +1 -0
- package/dist-embed/chunks/1f5c3-DjkgJLaz.js +1 -0
- package/dist-embed/chunks/1f5d1-C4Kpe4HL.js +1 -0
- package/dist-embed/chunks/1f5dc-DyLO7aB9.js +1 -0
- package/dist-embed/chunks/1f604-CEekcaYE.js +1 -0
- package/dist-embed/chunks/1f917-CyyS9fgf.js +1 -0
- package/dist-embed/chunks/1f9d1-200d-2696-D9eSB-0q.js +1 -0
- package/dist-embed/chunks/1fa79-DHhIZbWB.js +1 -0
- package/dist-embed/chunks/1fa9d-DUI9e77m.js +1 -0
- package/dist-embed/chunks/1fa9f-10ALsMaD.js +1 -0
- package/dist-embed/chunks/23f3-ASjqFsbT.js +1 -0
- package/dist-embed/chunks/2611-BnnmLm-K.js +1 -0
- package/dist-embed/chunks/2702-CfLyp35r.js +1 -0
- package/dist-embed/chunks/270f-CLwo30mk.js +1 -0
- package/dist-embed/chunks/2728-Qg6be-6p.js +1 -0
- package/dist-embed/chunks/2b1c-Uc1aRwAf.js +1 -0
- package/dist-embed/datalayer-app-main.js +66 -66
- package/lib/chat/ChatInline.js +43 -22
- package/lib/chat/base/ChatBase.js +43 -2
- package/lib/components/agents/AgentSummary.d.ts +2 -24
- package/lib/components/agents/AgentSummary.js +3 -19
- package/lib/hooks/useChat.js +110 -144
- package/lib/portable/activity.d.ts +78 -0
- package/lib/portable/activity.js +129 -0
- package/lib/portable/fixtures.d.ts +7 -0
- package/lib/portable/fixtures.js +50 -0
- package/lib/portable/index.d.ts +4 -1
- package/lib/portable/index.js +7 -1
- package/lib/portable/lifecycle.d.ts +77 -0
- package/lib/portable/lifecycle.js +135 -0
- package/lib/portable/runtimes.d.ts +68 -0
- package/lib/portable/runtimes.js +69 -0
- package/lib/voice/VoiceMenu.d.ts +2 -1
- package/lib/voice/VoiceMenu.js +2 -2
- package/lib/voice/chatVoice.d.ts +5 -2
- package/lib/voice/chatVoice.js +10 -4
- package/lib/voice/choices.d.ts +2 -0
- package/lib/voice/choices.js +6 -0
- package/lib/voice/index.d.ts +1 -0
- package/lib/voice/index.js +1 -0
- package/lib/voice/sayAloud.d.ts +25 -0
- package/lib/voice/sayAloud.js +34 -0
- package/lib/voice/serverHearing.d.ts +34 -2
- package/lib/voice/serverHearing.js +234 -6
- package/lib/voice/settings.d.ts +12 -0
- package/lib/voice/settings.js +9 -0
- package/package.json +1 -1
package/lib/voice/VoiceMenu.d.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* The composer's voice menu (VOICE.md VO-14, VO-21, VO-25, VO-30): beside
|
|
3
3
|
* the microphone, what a person chooses for themselves — the language they
|
|
4
4
|
* speak and hear, when answers are heard, the voice, its speed and loudness,
|
|
5
|
-
*
|
|
5
|
+
* how long they pause before they are done (VO-69), *Send what I say*, the
|
|
6
|
+
* microphone, and *Keep speech on this device*. Each
|
|
6
7
|
* choice is kept in the person's settings and heard at once by every chat
|
|
7
8
|
* on the page; *Use the application's* forgets them all.
|
|
8
9
|
*
|
package/lib/voice/VoiceMenu.js
CHANGED
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { useState } from 'react';
|
|
3
3
|
import { ActionList, ActionMenu, IconButton } from '@primer/react';
|
|
4
4
|
import { GearIcon } from '@primer/octicons-react';
|
|
5
|
-
import { OUTPUT_CHOICES, RATE_CHOICES, VOLUME_CHOICES, catalogueVoiceSpeaks, voiceLanguages, voicesSpeaking, } from './choices';
|
|
5
|
+
import { OUTPUT_CHOICES, PAUSE_CHOICES, RATE_CHOICES, VOLUME_CHOICES, catalogueVoiceSpeaks, voiceLanguages, voicesSpeaking, } from './choices';
|
|
6
6
|
import { withVoiceSetting } from './settings';
|
|
7
7
|
import { useVoiceSettings } from './useVoiceSettings';
|
|
8
8
|
/** The microphones the browser lists; named only once one was allowed. */
|
|
@@ -28,6 +28,6 @@ export function VoiceMenu({ voice, answers, disabled = false, }) {
|
|
|
28
28
|
void microphones().then(setDevices);
|
|
29
29
|
}
|
|
30
30
|
}, children: [_jsx(ActionMenu.Anchor, { children: _jsx(IconButton, { icon: GearIcon, size: "small", variant: "invisible", "aria-label": "Voice settings", "data-voice-menu": "", disabled: disabled }) }), _jsx(ActionMenu.Overlay, { width: "medium", children: _jsxs(ActionList, { children: [_jsxs(ActionList.Group, { selectionVariant: "single", children: [_jsx(ActionList.GroupHeading, { children: "Language" }), voiceLanguages().map(language => (_jsx(ActionList.Item, { selected: language.value === voice.language, onSelect: () => choose('language', language.value), "data-voice-language": language.value, children: language.label }, language.value)))] }), answers && !onDevice ? (_jsxs(_Fragment, { children: [_jsx(ActionList.Divider, {}), _jsxs(ActionList.Group, { selectionVariant: "single", children: [_jsx(ActionList.GroupHeading, { children: "Answers heard" }), OUTPUT_CHOICES.map(output => (_jsx(ActionList.Item, { selected: output.value === voice.output, onSelect: () => choose('output', output.value), "data-voice-output": output.value, children: output.label }, output.value)))] }), voices.length > 0 ? (_jsxs(ActionList.Group, { selectionVariant: "single", children: [_jsx(ActionList.GroupHeading, { children: "Voice" }), voices.map(spoken => (_jsx(ActionList.Item, { selected: spoken.id === voice.voice, onSelect: () => choose('voice', spoken.id), "data-voice-voice": spoken.id, children: spoken.name }, spoken.id)))] })) : null, _jsxs(ActionList.Group, { selectionVariant: "single", children: [_jsx(ActionList.GroupHeading, { children: "Speed" }), RATE_CHOICES.map(rate => (_jsx(ActionList.Item, { selected: rate.value === (voice.rate ?? 1), onSelect: () => choose('rate', rate.value), "data-voice-rate": rate.value, children: rate.label }, rate.value)))] }), _jsxs(ActionList.Group, { selectionVariant: "single", children: [_jsx(ActionList.GroupHeading, { children: "Loudness" }), VOLUME_CHOICES.map(volume => (_jsx(ActionList.Item, { selected: volume.value === (voice.volume ?? 1), onSelect: () => choose('volume', volume.value), "data-voice-volume": volume.value, children: volume.label }, volume.value)))] })] })) : null, named.length > 1 ? (_jsxs(_Fragment, { children: [_jsx(ActionList.Divider, {}), _jsxs(ActionList.Group, { selectionVariant: "single", children: [_jsx(ActionList.GroupHeading, { children: "Microphone" }), named.map(device => (_jsx(ActionList.Item, { selected: device.deviceId ===
|
|
31
|
-
(voice.microphoneId ?? named[0].deviceId), onSelect: () => choose('microphoneId', device.deviceId), "data-voice-microphone": device.deviceId, children: device.label }, device.deviceId)))] })] })) : null, _jsx(ActionList.Divider, {}), _jsxs(ActionList.Group, { selectionVariant: "multiple", children: [_jsxs(ActionList.Item, { selected: Boolean(voice.sendWhatISay), onSelect: () => choose('sendWhatISay', !voice.sendWhatISay), "data-voice-send-what-i-say": "", children: ["Send what I say", _jsx(ActionList.Description, { variant: "block", children: "Sent as soon as it is heard, not left in the box." })] }), voice.speechUrl && !onDevice ? (_jsxs(ActionList.Item, { selected: detect, onSelect: () => choose('detectLanguage', !detect), "data-voice-detect": "", children: ["Detect the language I speak", _jsx(ActionList.Description, { variant: "block", children: "Heard on Datalayer\u2019s servers by Whisper, which finds the language; nothing is kept there." })] })) : null, _jsxs(ActionList.Item, { selected: onDevice, onSelect: () => choose('onDeviceOnly', !onDevice), "data-voice-on-device": "", children: ["Keep speech on this device", _jsx(ActionList.Description, { variant: "block", children: "Nothing is sent to Datalayer\u2019s speech service: you are heard in the browser, answers are not spoken." })] })] }), Object.keys(settings).length > 0 ? (_jsxs(_Fragment, { children: [_jsx(ActionList.Divider, {}), _jsx(ActionList.Item, { onSelect: () => keep({}), "data-voice-settings-reset": "", children: "Use the application\u2019s" })] })) : null] }) })] }));
|
|
31
|
+
(voice.microphoneId ?? named[0].deviceId), onSelect: () => choose('microphoneId', device.deviceId), "data-voice-microphone": device.deviceId, children: device.label }, device.deviceId)))] })] })) : null, _jsx(ActionList.Divider, {}), _jsxs(ActionList.Group, { selectionVariant: "single", children: [_jsx(ActionList.GroupHeading, { children: "Pauses before I am done" }), PAUSE_CHOICES.map(pause => (_jsx(ActionList.Item, { selected: pause.value === (settings.pause ?? 1), onSelect: () => choose('pause', pause.value === 1 ? undefined : pause.value), "data-voice-pause": pause.value, children: pause.label }, pause.value)))] }), _jsx(ActionList.Divider, {}), _jsxs(ActionList.Group, { selectionVariant: "multiple", children: [_jsxs(ActionList.Item, { selected: Boolean(voice.sendWhatISay), onSelect: () => choose('sendWhatISay', !voice.sendWhatISay), "data-voice-send-what-i-say": "", children: ["Send what I say", _jsx(ActionList.Description, { variant: "block", children: "Sent as soon as it is heard, not left in the box." })] }), voice.speechUrl && !onDevice ? (_jsxs(ActionList.Item, { selected: detect, onSelect: () => choose('detectLanguage', !detect), "data-voice-detect": "", children: ["Detect the language I speak", _jsx(ActionList.Description, { variant: "block", children: "Heard on Datalayer\u2019s servers by Whisper, which finds the language; nothing is kept there." })] })) : null, _jsxs(ActionList.Item, { selected: onDevice, onSelect: () => choose('onDeviceOnly', !onDevice), "data-voice-on-device": "", children: ["Keep speech on this device", _jsx(ActionList.Description, { variant: "block", children: "Nothing is sent to Datalayer\u2019s speech service: you are heard in the browser, answers are not spoken." })] })] }), Object.keys(settings).length > 0 ? (_jsxs(_Fragment, { children: [_jsx(ActionList.Divider, {}), _jsx(ActionList.Item, { onSelect: () => keep({}), "data-voice-settings-reset": "", children: "Use the application\u2019s" })] })) : null] }) })] }));
|
|
32
32
|
}
|
|
33
33
|
export default VoiceMenu;
|
package/lib/voice/chatVoice.d.ts
CHANGED
|
@@ -59,7 +59,10 @@ export declare const SILENCE_MS = 1500;
|
|
|
59
59
|
* in: the person's own settings first where they made some (`voiceForPerson`:
|
|
60
60
|
* their language, voice, rate, volume, microphone; VO-14, VO-25, VO-30),
|
|
61
61
|
* else the host's; answers not heard, the language's first voice,
|
|
62
|
-
* Datalayer's models and packages, a silence of 1.5 s.
|
|
63
|
-
* the
|
|
62
|
+
* Datalayer's models and packages, a silence of 1.5 s. A person who pauses
|
|
63
|
+
* longer before they are done has both silences — the one that stops a
|
|
64
|
+
* microphone clicked on, and the one that ends a hands-free turn — made
|
|
65
|
+
* longer by their choice (`pause`, VO-69). Answers heard need the speech
|
|
66
|
+
* service's address: refused without.
|
|
64
67
|
*/
|
|
65
68
|
export declare function settledVoice(given: ChatVoice, person?: VoiceSettings): SettledVoice;
|
package/lib/voice/chatVoice.js
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* @module voice/chatVoice
|
|
12
12
|
*/
|
|
13
13
|
import { voiceFor } from '../specs/voices';
|
|
14
|
-
import {
|
|
14
|
+
import { TURN_SILENCE_MS } from './handsFree';
|
|
15
|
+
import { heldVoiceSettings, saneVoiceSettings, voiceForPerson, } from './settings';
|
|
15
16
|
/**
|
|
16
17
|
* Datalayer's origin for the browser's speech models, as pinned
|
|
17
18
|
* (`s3://datalayer-models-speech` behind its CDN; VO-48, VO-49): each model
|
|
@@ -84,11 +85,15 @@ export const SILENCE_MS = 1500;
|
|
|
84
85
|
* in: the person's own settings first where they made some (`voiceForPerson`:
|
|
85
86
|
* their language, voice, rate, volume, microphone; VO-14, VO-25, VO-30),
|
|
86
87
|
* else the host's; answers not heard, the language's first voice,
|
|
87
|
-
* Datalayer's models and packages, a silence of 1.5 s.
|
|
88
|
-
* the
|
|
88
|
+
* Datalayer's models and packages, a silence of 1.5 s. A person who pauses
|
|
89
|
+
* longer before they are done has both silences — the one that stops a
|
|
90
|
+
* microphone clicked on, and the one that ends a hands-free turn — made
|
|
91
|
+
* longer by their choice (`pause`, VO-69). Answers heard need the speech
|
|
92
|
+
* service's address: refused without.
|
|
89
93
|
*/
|
|
90
94
|
export function settledVoice(given, person = heldVoiceSettings()) {
|
|
91
95
|
const voice = voiceForPerson(given, person);
|
|
96
|
+
const pause = saneVoiceSettings(person).pause ?? 1;
|
|
92
97
|
const output = voice.output ?? 'off';
|
|
93
98
|
if (output !== 'off' && !voice.speechUrl) {
|
|
94
99
|
throw new Error(`A chat whose answers are heard (${output}) names the speech service: its speechUrl is missing.`);
|
|
@@ -99,6 +104,7 @@ export function settledVoice(given, person = heldVoiceSettings()) {
|
|
|
99
104
|
voice: voice.voice || voiceFor(voice.language)?.id || '',
|
|
100
105
|
modelsUrl: voice.modelsUrl || DATALAYER_SPEECH_MODELS_URL,
|
|
101
106
|
engines: voice.engines ?? DATALAYER_VOICE_ENGINES,
|
|
102
|
-
silenceMs: voice.silenceMs ?? SILENCE_MS,
|
|
107
|
+
silenceMs: Math.round((voice.silenceMs ?? SILENCE_MS) * pause),
|
|
108
|
+
turnSilenceMs: Math.round((voice.turnSilenceMs ?? TURN_SILENCE_MS) * pause),
|
|
103
109
|
};
|
|
104
110
|
}
|
package/lib/voice/choices.d.ts
CHANGED
|
@@ -28,6 +28,8 @@ export declare function catalogueVoiceSpeaks(voice: string, language: string): b
|
|
|
28
28
|
/** The speeds offered, as multipliers of the voice's own. */
|
|
29
29
|
export declare const RATE_CHOICES: VoiceChoice<number>[];
|
|
30
30
|
/** The loudnesses offered, 0 to 1. */
|
|
31
|
+
/** How long the person pauses before they are done (VO-69): the silences, times this. */
|
|
32
|
+
export declare const PAUSE_CHOICES: VoiceChoice<number>[];
|
|
31
33
|
export declare const VOLUME_CHOICES: VoiceChoice<number>[];
|
|
32
34
|
/** When answers are heard (VO-21): what a person may choose where the application speaks. */
|
|
33
35
|
export declare const OUTPUT_CHOICES: VoiceChoice<NonNullable<ChatVoice['output']>>[];
|
package/lib/voice/choices.js
CHANGED
|
@@ -56,6 +56,12 @@ export const RATE_CHOICES = [
|
|
|
56
56
|
{ value: 1.5, label: 'Fastest' },
|
|
57
57
|
];
|
|
58
58
|
/** The loudnesses offered, 0 to 1. */
|
|
59
|
+
/** How long the person pauses before they are done (VO-69): the silences, times this. */
|
|
60
|
+
export const PAUSE_CHOICES = [
|
|
61
|
+
{ value: 1, label: 'Usual' },
|
|
62
|
+
{ value: 2, label: 'Longer' },
|
|
63
|
+
{ value: 3, label: 'Much longer' },
|
|
64
|
+
];
|
|
59
65
|
export const VOLUME_CHOICES = [
|
|
60
66
|
{ value: 0.25, label: 'Quiet' },
|
|
61
67
|
{ value: 0.5, label: 'Half' },
|
package/lib/voice/index.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export * from './pinned';
|
|
|
14
14
|
export * from './capture';
|
|
15
15
|
export * from './hearing';
|
|
16
16
|
export * from './serverHearing';
|
|
17
|
+
export * from './sayAloud';
|
|
17
18
|
export * from './handsFree';
|
|
18
19
|
export { HandsFree, type HandsFreeProps } from './HandsFree';
|
|
19
20
|
export * from './speaker';
|
package/lib/voice/index.js
CHANGED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What only the runtime knows of voice, as it says it over AG-UI (VOICE.md
|
|
3
|
+
* VO-35) and Vercel AI (VO-36): `voice.settings` — the running application's
|
|
4
|
+
* voice and language, at a run's start — and `voice.say` — a short line the
|
|
5
|
+
* agent wants heard and not written (*One moment*), shown as a caption while
|
|
6
|
+
* it is said. The chat reads `voice.say` from a run's custom events
|
|
7
|
+
* (`CUSTOM`, the chat's `activity`) or data parts (`data-voice.say`); it
|
|
8
|
+
* already knows its application's voice from `/apps/configure` (VO-45), and
|
|
9
|
+
* `voice.settings` is for clients that do not ask it.
|
|
10
|
+
*
|
|
11
|
+
* @module voice/sayAloud
|
|
12
|
+
*/
|
|
13
|
+
/** A line the agent wants heard, not written. */
|
|
14
|
+
export declare const VOICE_SAY_EVENT = "voice.say";
|
|
15
|
+
/** The running application's voice, at a run's start. */
|
|
16
|
+
export declare const VOICE_SETTINGS_EVENT = "voice.settings";
|
|
17
|
+
/** The longest line said aloud: the runtime refuses longer ones. */
|
|
18
|
+
export declare const SAY_ALOUD_MAX_CHARACTERS = 200;
|
|
19
|
+
/** A custom event or data part, as the chat's adapters give it. */
|
|
20
|
+
export interface VoiceActivity {
|
|
21
|
+
type: string;
|
|
22
|
+
data: unknown;
|
|
23
|
+
}
|
|
24
|
+
/** The line a `voice.say` says, or `undefined` for anything else. */
|
|
25
|
+
export declare function lineToSay(activity: VoiceActivity): string | undefined;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* What only the runtime knows of voice, as it says it over AG-UI (VOICE.md
|
|
7
|
+
* VO-35) and Vercel AI (VO-36): `voice.settings` — the running application's
|
|
8
|
+
* voice and language, at a run's start — and `voice.say` — a short line the
|
|
9
|
+
* agent wants heard and not written (*One moment*), shown as a caption while
|
|
10
|
+
* it is said. The chat reads `voice.say` from a run's custom events
|
|
11
|
+
* (`CUSTOM`, the chat's `activity`) or data parts (`data-voice.say`); it
|
|
12
|
+
* already knows its application's voice from `/apps/configure` (VO-45), and
|
|
13
|
+
* `voice.settings` is for clients that do not ask it.
|
|
14
|
+
*
|
|
15
|
+
* @module voice/sayAloud
|
|
16
|
+
*/
|
|
17
|
+
/** A line the agent wants heard, not written. */
|
|
18
|
+
export const VOICE_SAY_EVENT = 'voice.say';
|
|
19
|
+
/** The running application's voice, at a run's start. */
|
|
20
|
+
export const VOICE_SETTINGS_EVENT = 'voice.settings';
|
|
21
|
+
/** The longest line said aloud: the runtime refuses longer ones. */
|
|
22
|
+
export const SAY_ALOUD_MAX_CHARACTERS = 200;
|
|
23
|
+
const nameOf = (type) => type.replace(/^data-/, '');
|
|
24
|
+
/** The line a `voice.say` says, or `undefined` for anything else. */
|
|
25
|
+
export function lineToSay(activity) {
|
|
26
|
+
if (nameOf(activity.type) !== VOICE_SAY_EVENT) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
const data = activity.data;
|
|
30
|
+
const text = data && typeof data.text === 'string'
|
|
31
|
+
? data.text.split(/\s+/).join(' ').trim()
|
|
32
|
+
: '';
|
|
33
|
+
return text ? text.slice(0, SAY_ALOUD_MAX_CHARACTERS) : undefined;
|
|
34
|
+
}
|
|
@@ -2,6 +2,12 @@ import type { Hearing, HearingProgress, HeardSoFar } from './hearing';
|
|
|
2
2
|
import type { Transcript } from './types';
|
|
3
3
|
/** Where the speech service hears a recording, on ai-agents' host. */
|
|
4
4
|
export declare const TRANSCRIPTIONS_PATH = "/api/ai-agents/v1/speech/transcriptions";
|
|
5
|
+
/** Where the speech service hears speech as it is said. */
|
|
6
|
+
export declare const TRANSCRIPTION_STREAM_PATH = "/api/ai-agents/v1/speech/transcriptions/stream";
|
|
7
|
+
/** A stream no look or hearing has fed for this long is closed: its press was cancelled. */
|
|
8
|
+
export declare const STREAM_IDLE_MS = 3000;
|
|
9
|
+
/** How long the final words of a stream are waited for. */
|
|
10
|
+
export declare const STREAM_FINAL_MS = 60000;
|
|
5
11
|
/** The model the speech service hears with (agentspecs' catalogue). */
|
|
6
12
|
export declare const SERVER_HEARING_MODEL = "whisper-large-v3-turbo";
|
|
7
13
|
/** Where a chat's voice hears: in the browser, or on Datalayer's servers. */
|
|
@@ -40,7 +46,26 @@ export interface ServerHearingOptions {
|
|
|
40
46
|
/** Let Whisper find the language (VO-14), rather than tell it. */
|
|
41
47
|
detect?: boolean;
|
|
42
48
|
fetcher?: typeof fetch;
|
|
49
|
+
/** Opens a WebSocket; the browser's own unless a test gives one. */
|
|
50
|
+
socket?: (url: string) => SpeechSocket;
|
|
43
51
|
}
|
|
52
|
+
/** What `ServerHearing` needs of a WebSocket. */
|
|
53
|
+
export interface SpeechSocket {
|
|
54
|
+
readonly readyState: number;
|
|
55
|
+
binaryType: string;
|
|
56
|
+
onopen: ((event: unknown) => void) | null;
|
|
57
|
+
onmessage: ((event: {
|
|
58
|
+
data: unknown;
|
|
59
|
+
}) => void) | null;
|
|
60
|
+
onclose: ((event: unknown) => void) | null;
|
|
61
|
+
onerror: ((event: unknown) => void) | null;
|
|
62
|
+
send(data: string | ArrayBuffer): void;
|
|
63
|
+
close(code?: number): void;
|
|
64
|
+
}
|
|
65
|
+
/** 16 kHz samples in [-1, 1] as little-endian 16-bit PCM: what a `pcm16` stream takes. */
|
|
66
|
+
export declare function pcm16Of(samples: Float32Array): ArrayBuffer;
|
|
67
|
+
/** The ws(s) address of the stream on ai-agents' origin. */
|
|
68
|
+
export declare function streamUrlOf(speechUrl: string): string;
|
|
44
69
|
/**
|
|
45
70
|
* Hears on Datalayer's servers: the browser's VAD finds the speech and when
|
|
46
71
|
* it stopped; Whisper, on the speech service, writes it.
|
|
@@ -52,7 +77,14 @@ export declare class ServerHearing implements Hearing {
|
|
|
52
77
|
constructor(finder: SpeechFinder, options: ServerHearingOptions);
|
|
53
78
|
/** Only the VAD loads in the page: the words are written on the server. */
|
|
54
79
|
ready(_language: string, progress?: HearingProgress): Promise<void>;
|
|
55
|
-
/**
|
|
56
|
-
|
|
80
|
+
/** The press being heard as it is said, while one is. */
|
|
81
|
+
private press?;
|
|
82
|
+
/** The press's stream, opened anew when a new press began (its sound is shorter). */
|
|
83
|
+
private streamFor;
|
|
84
|
+
/**
|
|
85
|
+
* Whether speech was found and how long it has been silent, and the words
|
|
86
|
+
* the speech service heard so far: the sound goes to its stream as it comes.
|
|
87
|
+
*/
|
|
88
|
+
hearSoFar(audio: Float32Array, language?: string): Promise<HeardSoFar>;
|
|
57
89
|
hear(audio: Float32Array, language: string): Promise<Transcript | undefined>;
|
|
58
90
|
}
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* Hearing on Datalayer's servers (VOICE.md VO-34, VO-14, §5): what was said
|
|
7
7
|
* in one press, kept to its speech by Silero VAD in the page, then sent as a
|
|
8
8
|
* WAV recording to the speech service's `POST /speech/transcriptions`, where
|
|
9
|
-
* Whisper
|
|
9
|
+
* Whisper hears it — small for a spoken turn, large-v3-turbo past one
|
|
10
|
+
* (decision 17) — in any language it knows, and finding
|
|
10
11
|
* the language itself when the person asks for that (detection, VO-14). The
|
|
11
12
|
* sound is held in the request only: the service keeps neither it nor the
|
|
12
13
|
* words (VO-60).
|
|
@@ -14,8 +15,15 @@
|
|
|
14
15
|
* Used where the application's voice says `where: server`, where it says
|
|
15
16
|
* `auto` and no model in the browser hears the language, and where the
|
|
16
17
|
* person asked for their language to be detected — never when they chose to
|
|
17
|
-
* keep speech on the device.
|
|
18
|
-
*
|
|
18
|
+
* keep speech on the device.
|
|
19
|
+
*
|
|
20
|
+
* While the person speaks, the words come too (VO-11): the first look at
|
|
21
|
+
* what was heard so far opens `WS /speech/transcriptions/stream` (the token
|
|
22
|
+
* in its first message, VO-37), the sound is sent as it is captured, 16-bit
|
|
23
|
+
* PCM at 16 kHz, and the service's partial words are what was heard so far,
|
|
24
|
+
* greyed in the composer until final. At the end of the press the stream is
|
|
25
|
+
* told so and its final words are the transcript. A press heard without a
|
|
26
|
+
* look before it (a short one) is sent whole as a recording instead.
|
|
19
27
|
*
|
|
20
28
|
* @module voice/serverHearing
|
|
21
29
|
*/
|
|
@@ -23,6 +31,12 @@ import { transcriberFor } from '../specs/voices';
|
|
|
23
31
|
import { HEARING_RATE } from './capture';
|
|
24
32
|
/** Where the speech service hears a recording, on ai-agents' host. */
|
|
25
33
|
export const TRANSCRIPTIONS_PATH = '/api/ai-agents/v1/speech/transcriptions';
|
|
34
|
+
/** Where the speech service hears speech as it is said. */
|
|
35
|
+
export const TRANSCRIPTION_STREAM_PATH = '/api/ai-agents/v1/speech/transcriptions/stream';
|
|
36
|
+
/** A stream no look or hearing has fed for this long is closed: its press was cancelled. */
|
|
37
|
+
export const STREAM_IDLE_MS = 3000;
|
|
38
|
+
/** How long the final words of a stream are waited for. */
|
|
39
|
+
export const STREAM_FINAL_MS = 60_000;
|
|
26
40
|
/** The model the speech service hears with (agentspecs' catalogue). */
|
|
27
41
|
export const SERVER_HEARING_MODEL = 'whisper-large-v3-turbo';
|
|
28
42
|
/** The shortest speech worth hearing, in samples: a word said alone. */
|
|
@@ -72,6 +86,162 @@ export function wavOf(samples, rate = HEARING_RATE) {
|
|
|
72
86
|
}
|
|
73
87
|
return bytes;
|
|
74
88
|
}
|
|
89
|
+
/** 16 kHz samples in [-1, 1] as little-endian 16-bit PCM: what a `pcm16` stream takes. */
|
|
90
|
+
export function pcm16Of(samples) {
|
|
91
|
+
const bytes = new ArrayBuffer(samples.length * 2);
|
|
92
|
+
const view = new DataView(bytes);
|
|
93
|
+
for (let i = 0; i < samples.length; i++) {
|
|
94
|
+
const sample = Math.max(-1, Math.min(1, samples[i]));
|
|
95
|
+
view.setInt16(i * 2, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
|
|
96
|
+
}
|
|
97
|
+
return bytes;
|
|
98
|
+
}
|
|
99
|
+
/** The ws(s) address of the stream on ai-agents' origin. */
|
|
100
|
+
export function streamUrlOf(speechUrl) {
|
|
101
|
+
return `${speechUrl.replace(/\/+$/, '').replace(/^http/, 'ws')}${TRANSCRIPTION_STREAM_PATH}`;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* One press heard as it is said: the sound sent as it comes, the service's
|
|
105
|
+
* partial words kept, its final words awaited at the end.
|
|
106
|
+
*/
|
|
107
|
+
class PressStream {
|
|
108
|
+
/** Samples of the press sent so far. */
|
|
109
|
+
sent = 0;
|
|
110
|
+
/** The newest partial words. */
|
|
111
|
+
partial = '';
|
|
112
|
+
/** Why the service refused, in its sentence. */
|
|
113
|
+
refused;
|
|
114
|
+
socket;
|
|
115
|
+
ready = false;
|
|
116
|
+
queued = [];
|
|
117
|
+
final;
|
|
118
|
+
waiting;
|
|
119
|
+
closed = false;
|
|
120
|
+
idle;
|
|
121
|
+
constructor(open, speechUrl, first) {
|
|
122
|
+
this.socket = open(streamUrlOf(speechUrl));
|
|
123
|
+
this.socket.binaryType = 'arraybuffer';
|
|
124
|
+
this.socket.onopen = () => {
|
|
125
|
+
this.socket.send(JSON.stringify({
|
|
126
|
+
token: first.token ?? '',
|
|
127
|
+
language: first.language ?? '',
|
|
128
|
+
format: 'pcm16',
|
|
129
|
+
}));
|
|
130
|
+
};
|
|
131
|
+
this.socket.onmessage = event => {
|
|
132
|
+
let said;
|
|
133
|
+
try {
|
|
134
|
+
said = JSON.parse(String(event.data));
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (said.type === 'ready') {
|
|
140
|
+
this.ready = true;
|
|
141
|
+
for (const chunk of this.queued.splice(0)) {
|
|
142
|
+
this.socket.send(chunk);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
else if (said.type === 'partial' && typeof said.text === 'string') {
|
|
146
|
+
this.partial = said.text.trim();
|
|
147
|
+
}
|
|
148
|
+
else if (said.type === 'final') {
|
|
149
|
+
this.final = {
|
|
150
|
+
text: typeof said.text === 'string' ? said.text.trim() : '',
|
|
151
|
+
language: typeof said.language === 'string' ? said.language : '',
|
|
152
|
+
};
|
|
153
|
+
this.settle();
|
|
154
|
+
}
|
|
155
|
+
else if (said.type === 'refused') {
|
|
156
|
+
this.refused =
|
|
157
|
+
typeof said.detail === 'string' && said.detail
|
|
158
|
+
? said.detail
|
|
159
|
+
: "What you said could not be heard on Datalayer's servers.";
|
|
160
|
+
this.settle();
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
this.socket.onclose = () => {
|
|
164
|
+
this.closed = true;
|
|
165
|
+
this.settle();
|
|
166
|
+
};
|
|
167
|
+
this.socket.onerror = () => undefined;
|
|
168
|
+
this.touch();
|
|
169
|
+
}
|
|
170
|
+
/** Send what the press added since the last call. */
|
|
171
|
+
feed(audio) {
|
|
172
|
+
this.touch();
|
|
173
|
+
if (audio.length <= this.sent || this.closed) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const chunk = pcm16Of(audio.subarray(this.sent));
|
|
177
|
+
this.sent = audio.length;
|
|
178
|
+
if (this.ready) {
|
|
179
|
+
this.socket.send(chunk);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
this.queued.push(chunk);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** The end of the press: the final words, or `undefined` when the stream ended without them. */
|
|
186
|
+
finish(audio) {
|
|
187
|
+
this.feed(audio);
|
|
188
|
+
if (this.final || this.closed || this.refused) {
|
|
189
|
+
return Promise.resolve(this.final);
|
|
190
|
+
}
|
|
191
|
+
return new Promise(resolve => {
|
|
192
|
+
const timer = setTimeout(() => {
|
|
193
|
+
this.waiting = undefined;
|
|
194
|
+
this.close();
|
|
195
|
+
resolve(undefined);
|
|
196
|
+
}, STREAM_FINAL_MS);
|
|
197
|
+
this.waiting = final => {
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
resolve(final);
|
|
200
|
+
};
|
|
201
|
+
const end = JSON.stringify({ type: 'end' });
|
|
202
|
+
if (this.ready) {
|
|
203
|
+
this.socket.send(end);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
// Said once the service is ready, after the sound queued before it.
|
|
207
|
+
this.queued.push(end);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
close() {
|
|
212
|
+
if (this.idle) {
|
|
213
|
+
clearTimeout(this.idle);
|
|
214
|
+
}
|
|
215
|
+
this.closed = true;
|
|
216
|
+
try {
|
|
217
|
+
this.socket.close(1000);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// Already closed.
|
|
221
|
+
}
|
|
222
|
+
this.settle();
|
|
223
|
+
}
|
|
224
|
+
touch() {
|
|
225
|
+
if (this.idle) {
|
|
226
|
+
clearTimeout(this.idle);
|
|
227
|
+
}
|
|
228
|
+
this.idle = setTimeout(() => {
|
|
229
|
+
if (!this.waiting) {
|
|
230
|
+
this.close();
|
|
231
|
+
}
|
|
232
|
+
}, STREAM_IDLE_MS);
|
|
233
|
+
}
|
|
234
|
+
settle() {
|
|
235
|
+
const waiting = this.waiting;
|
|
236
|
+
this.waiting = undefined;
|
|
237
|
+
waiting?.(this.final);
|
|
238
|
+
if (this.final || this.refused) {
|
|
239
|
+
if (this.idle) {
|
|
240
|
+
clearTimeout(this.idle);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
75
245
|
/**
|
|
76
246
|
* Hears on Datalayer's servers: the browser's VAD finds the speech and when
|
|
77
247
|
* it stopped; Whisper, on the speech service, writes it.
|
|
@@ -88,17 +258,75 @@ export class ServerHearing {
|
|
|
88
258
|
ready(_language, progress) {
|
|
89
259
|
return this.finder.readyToFindSpeech(progress);
|
|
90
260
|
}
|
|
91
|
-
/**
|
|
92
|
-
|
|
261
|
+
/** The press being heard as it is said, while one is. */
|
|
262
|
+
press;
|
|
263
|
+
/** The press's stream, opened anew when a new press began (its sound is shorter). */
|
|
264
|
+
streamFor(audio, language) {
|
|
265
|
+
const open = this.options.socket ??
|
|
266
|
+
(typeof WebSocket === 'undefined'
|
|
267
|
+
? undefined
|
|
268
|
+
: (url) => new WebSocket(url));
|
|
269
|
+
if (!open) {
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
if (this.press && audio.length < this.press.sent) {
|
|
273
|
+
this.press.close();
|
|
274
|
+
this.press = undefined;
|
|
275
|
+
}
|
|
276
|
+
if (!this.press) {
|
|
277
|
+
this.press = new PressStream(open, this.options.speechUrl, {
|
|
278
|
+
token: this.options.token,
|
|
279
|
+
language: this.options.detect ? '' : language,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return this.press;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Whether speech was found and how long it has been silent, and the words
|
|
286
|
+
* the speech service heard so far: the sound goes to its stream as it comes.
|
|
287
|
+
*/
|
|
288
|
+
async hearSoFar(audio, language = '') {
|
|
93
289
|
const { speech, silentMs } = await this.finder.findSpeech(audio);
|
|
94
|
-
|
|
290
|
+
const spoke = speech.length >= SHORTEST;
|
|
291
|
+
const press = this.streamFor(audio, language);
|
|
292
|
+
press?.feed(audio);
|
|
293
|
+
return { text: spoke ? (press?.partial ?? '') : '', spoke, silentMs };
|
|
95
294
|
}
|
|
96
295
|
async hear(audio, language) {
|
|
97
296
|
const started = performance.now();
|
|
297
|
+
const press = this.press && audio.length >= this.press.sent ? this.press : undefined;
|
|
298
|
+
if (this.press && !press) {
|
|
299
|
+
this.press.close();
|
|
300
|
+
}
|
|
301
|
+
this.press = undefined;
|
|
98
302
|
const { speech } = await this.finder.findSpeech(audio);
|
|
99
303
|
if (speech.length < SHORTEST) {
|
|
304
|
+
press?.close();
|
|
100
305
|
return undefined;
|
|
101
306
|
}
|
|
307
|
+
if (press) {
|
|
308
|
+
const final = await press.finish(audio);
|
|
309
|
+
press.close();
|
|
310
|
+
if (press.refused) {
|
|
311
|
+
throw new Error(press.refused);
|
|
312
|
+
}
|
|
313
|
+
if (!final) {
|
|
314
|
+
throw new Error("What you said could not be heard on Datalayer's servers: the stream ended before its words.");
|
|
315
|
+
}
|
|
316
|
+
return final.text
|
|
317
|
+
? {
|
|
318
|
+
text: final.text,
|
|
319
|
+
metadata: {
|
|
320
|
+
input: 'voice',
|
|
321
|
+
language: final.language || language,
|
|
322
|
+
engine: SERVER_HEARING_MODEL,
|
|
323
|
+
where: 'server',
|
|
324
|
+
},
|
|
325
|
+
seconds: audio.length / HEARING_RATE,
|
|
326
|
+
ms: Math.round(performance.now() - started),
|
|
327
|
+
}
|
|
328
|
+
: undefined;
|
|
329
|
+
}
|
|
102
330
|
const { speechUrl, token, detect, fetcher = fetch } = this.options;
|
|
103
331
|
const query = detect ? '' : `?language=${encodeURIComponent(language)}`;
|
|
104
332
|
const answered = await fetcher(`${speechUrl.replace(/\/+$/, '')}${TRANSCRIPTIONS_PATH}${query}`, {
|
package/lib/voice/settings.d.ts
CHANGED
|
@@ -33,6 +33,16 @@ export declare const VOLUME_RANGE: {
|
|
|
33
33
|
readonly max: 1;
|
|
34
34
|
readonly default: 1;
|
|
35
35
|
};
|
|
36
|
+
/**
|
|
37
|
+
* How much longer than usual the person pauses before they are done
|
|
38
|
+
* (VOICE.md VO-69): the silence that stops a microphone clicked on, and the
|
|
39
|
+
* one that ends a hands-free turn, times this.
|
|
40
|
+
*/
|
|
41
|
+
export declare const PAUSE_RANGE: {
|
|
42
|
+
readonly min: 1;
|
|
43
|
+
readonly max: 4;
|
|
44
|
+
readonly default: 1;
|
|
45
|
+
};
|
|
36
46
|
/**
|
|
37
47
|
* What a person chose for themselves. Every field is optional: an absent one
|
|
38
48
|
* means "whatever the application says".
|
|
@@ -62,6 +72,8 @@ export interface VoiceSettings {
|
|
|
62
72
|
* heard on Datalayer's servers, which detect it, rather than in the browser.
|
|
63
73
|
*/
|
|
64
74
|
detectLanguage?: boolean;
|
|
75
|
+
/** How much longer they pause before they are done (VO-69): 1 is usual. */
|
|
76
|
+
pause?: number;
|
|
65
77
|
}
|
|
66
78
|
/** The settings as they can be used: numbers in range, nothing else trusted. */
|
|
67
79
|
export declare function saneVoiceSettings(settings: VoiceSettings | null | undefined): VoiceSettings;
|
package/lib/voice/settings.js
CHANGED
|
@@ -7,6 +7,12 @@ export const VOICE_SETTINGS_KEY = 'datalayer-voice-settings';
|
|
|
7
7
|
/** How fast and how loud, as multipliers of the voice's own. */
|
|
8
8
|
export const RATE_RANGE = { min: 0.5, max: 2, default: 1 };
|
|
9
9
|
export const VOLUME_RANGE = { min: 0, max: 1, default: 1 };
|
|
10
|
+
/**
|
|
11
|
+
* How much longer than usual the person pauses before they are done
|
|
12
|
+
* (VOICE.md VO-69): the silence that stops a microphone clicked on, and the
|
|
13
|
+
* one that ends a hands-free turn, times this.
|
|
14
|
+
*/
|
|
15
|
+
export const PAUSE_RANGE = { min: 1, max: 4, default: 1 };
|
|
10
16
|
function clamp(value, { min, max }) {
|
|
11
17
|
return Math.min(max, Math.max(min, value));
|
|
12
18
|
}
|
|
@@ -43,6 +49,9 @@ export function saneVoiceSettings(settings) {
|
|
|
43
49
|
if (typeof kept.detectLanguage === 'boolean') {
|
|
44
50
|
sane.detectLanguage = kept.detectLanguage;
|
|
45
51
|
}
|
|
52
|
+
if (typeof kept.pause === 'number' && Number.isFinite(kept.pause)) {
|
|
53
|
+
sane.pause = clamp(kept.pause, PAUSE_RANGE);
|
|
54
|
+
}
|
|
46
55
|
return sane;
|
|
47
56
|
}
|
|
48
57
|
/** What was kept, or nothing. Storage may be refused, or hold nonsense. */
|