@datalayer/agent-runtimes 1.5.60 → 1.5.61

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.
@@ -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,
@@ -3087,7 +3087,14 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3087
3087
  const forwardedProps = spoken
3088
3088
  ? { ...(forwardedPropsGiven ?? {}), voice: spoken }
3089
3089
  : forwardedPropsGiven;
3090
- const userMessage = createUserMessage(messageContent, spoken ? { ...spoken } : undefined);
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
  },
@@ -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`, 700 ms unless said), what was said is
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; 700 unless said (VO-12). */
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;
@@ -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
- await pause(INTERIM_MS);
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
- so = await hearing.hearSoFar(open.peek(), language);
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
- latest.current.send(heard.text, heard.metadata);
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;
@@ -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
- }, [consentKey, disabled, hearing, language, microphoneId, onListen]);
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
- onTranscript(transcript);
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');
@@ -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 —
@@ -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
  /**
@@ -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
- /** Whose consent is remembered for hearing in a place: the application's, apart for the server. */
14
- export declare function consentKeyFor(app: string, where: 'device' | 'server'): string;
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;
@@ -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
- /** Whose consent is remembered for hearing in a place: the application's, apart for the server. */
42
- export function consentKeyFor(app, where) {
43
- return where === 'server' ? `${app}:server` : app;
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
  }
@@ -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 (700 ms unless
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 = 700;
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. */
@@ -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 = 700;
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. */
@@ -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
@@ -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) {
@@ -10,6 +10,7 @@
10
10
  import type { VoiceEngines } from './types';
11
11
  export * from './types';
12
12
  export * from './sentences';
13
+ export * from './keptSound';
13
14
  export * from './pinned';
14
15
  export * from './capture';
15
16
  export * from './hearing';
@@ -56,7 +57,7 @@ export interface ChatVoice {
56
57
  * `off`: none.
57
58
  */
58
59
  input: 'off' | 'push_to_talk' | 'hands_free';
59
- /** The silence after speech that ends a hands-free turn, in ms (VO-12): 700 when unsaid. */
60
+ /** The silence after speech that ends a hands-free turn, in ms (VO-12): 500 when unsaid. */
60
61
  turnSilenceMs?: number;
61
62
  /**
62
63
  * When answers are heard: `always`, as they are written; `on_request`, a
@@ -87,6 +88,12 @@ export interface ChatVoice {
87
88
  detect?: boolean;
88
89
  /** The token the speech service is asked with. */
89
90
  token?: string;
91
+ /**
92
+ * The days the application keeps what is said as sound (VO-63), when its
93
+ * record keeps `audio`: asked in the microphone's first sentence, each
94
+ * turn's recording sent with its words. Never for a visitor.
95
+ */
96
+ keepsAudio?: number;
90
97
  /** Send what is said at once, rather than put it in the composer. */
91
98
  sendWhatISay?: boolean;
92
99
  /**
@@ -4,6 +4,7 @@
4
4
  */
5
5
  export * from './types';
6
6
  export * from './sentences';
7
+ export * from './keptSound';
7
8
  export * from './pinned';
8
9
  export * from './capture';
9
10
  export * from './hearing';
@@ -0,0 +1,15 @@
1
+ import type { SpokenSound } from './types';
2
+ /** The longest a sound is kept, whatever the record keeps (decision 10). */
3
+ export declare const AUDIO_KEPT_DAYS_AT_MOST = 30;
4
+ /**
5
+ * How many days an application keeps what is said as sound: `undefined`
6
+ * when its record does not keep `audio`, else its record's days, 30 at most.
7
+ */
8
+ export declare function keptAudioDays(record?: {
9
+ include?: readonly string[];
10
+ retentionDays?: number;
11
+ }): number | undefined;
12
+ /** The sentence added to the microphone's first one when the sound is kept (VO-61). */
13
+ export declare const keptAudioSentence: (days: number) => string;
14
+ /** A turn's samples (mono, 16 kHz, -1 to 1) as a WAV, base64, to keep. */
15
+ export declare function soundOf(audio: Float32Array): SpokenSound;
@@ -0,0 +1,69 @@
1
+ /*
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ /**
6
+ * What the person said, kept as sound (VO-63): an application whose record
7
+ * keeps `audio` (`record.include`), and a person who agreed in the
8
+ * microphone's first sentence, send each turn's recording with its words —
9
+ * `forwardedProps.voice.sound`, a 16-bit mono WAV at 16 kHz — and its
10
+ * runtime gives it to ai-agents to keep, 30 days at most, deleted with the
11
+ * conversation. Never for a visitor without an account.
12
+ *
13
+ * @module voice/keptSound
14
+ */
15
+ import { HEARING_RATE } from './capture';
16
+ /** The longest a sound is kept, whatever the record keeps (decision 10). */
17
+ export const AUDIO_KEPT_DAYS_AT_MOST = 30;
18
+ /** The most of a turn kept: a minute. */
19
+ const KEPT_SECONDS_AT_MOST = 60;
20
+ /**
21
+ * How many days an application keeps what is said as sound: `undefined`
22
+ * when its record does not keep `audio`, else its record's days, 30 at most.
23
+ */
24
+ export function keptAudioDays(record) {
25
+ if (!record?.include?.includes('audio')) {
26
+ return undefined;
27
+ }
28
+ const days = record.retentionDays || AUDIO_KEPT_DAYS_AT_MOST;
29
+ return Math.max(1, Math.min(days, AUDIO_KEPT_DAYS_AT_MOST));
30
+ }
31
+ /** The sentence added to the microphone's first one when the sound is kept (VO-61). */
32
+ export const keptAudioSentence = (days) => `This application also keeps what you say as sound, with this conversation, for ${days} ${days === 1 ? 'day' : 'days'} at most: its owner can listen to it, and it is deleted with the conversation. Not now, and you type instead.`;
33
+ /** A turn's samples (mono, 16 kHz, -1 to 1) as a WAV, base64, to keep. */
34
+ export function soundOf(audio) {
35
+ const samples = audio.subarray(0, HEARING_RATE * KEPT_SECONDS_AT_MOST);
36
+ const bytes = new Uint8Array(44 + samples.length * 2);
37
+ const view = new DataView(bytes.buffer);
38
+ const text = (at, value) => {
39
+ for (let k = 0; k < value.length; k++) {
40
+ view.setUint8(at + k, value.charCodeAt(k));
41
+ }
42
+ };
43
+ text(0, 'RIFF');
44
+ view.setUint32(4, 36 + samples.length * 2, true);
45
+ text(8, 'WAVE');
46
+ text(12, 'fmt ');
47
+ view.setUint32(16, 16, true);
48
+ view.setUint16(20, 1, true);
49
+ view.setUint16(22, 1, true);
50
+ view.setUint32(24, HEARING_RATE, true);
51
+ view.setUint32(28, HEARING_RATE * 2, true);
52
+ view.setUint16(32, 2, true);
53
+ view.setUint16(34, 16, true);
54
+ text(36, 'data');
55
+ view.setUint32(40, samples.length * 2, true);
56
+ for (let k = 0; k < samples.length; k++) {
57
+ const sample = Math.max(-1, Math.min(1, samples[k]));
58
+ view.setInt16(44 + k * 2, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
59
+ }
60
+ let binary = '';
61
+ for (let k = 0; k < bytes.length; k += 0x8000) {
62
+ binary += String.fromCharCode(...bytes.subarray(k, k + 0x8000));
63
+ }
64
+ return {
65
+ data: btoa(binary),
66
+ type: 'audio/wav',
67
+ seconds: Math.round((samples.length / HEARING_RATE) * 100) / 100,
68
+ };
69
+ }
@@ -94,6 +94,8 @@ export declare class ServerHearing implements Hearing {
94
94
  * Whether speech was found and how long it has been silent, and the words
95
95
  * the speech service heard so far: the sound goes to its stream as it comes.
96
96
  */
97
+ spokeSoFar(audio: Float32Array, shortestSeconds?: number): Promise<boolean>;
98
+ quietSoFar(audio: Float32Array): Promise<HeardSoFar>;
97
99
  hearSoFar(audio: Float32Array, language?: string): Promise<HeardSoFar>;
98
100
  hear(audio: Float32Array, language: string): Promise<Transcript | undefined>;
99
101
  }
@@ -121,5 +123,7 @@ export declare class AutoHearing implements Hearing {
121
123
  private get current();
122
124
  ready(language: string, progress?: HearingProgress): Promise<void>;
123
125
  hearSoFar(audio: Float32Array, language: string): Promise<HeardSoFar>;
126
+ spokeSoFar(audio: Float32Array, shortestSeconds?: number): Promise<boolean>;
127
+ quietSoFar(audio: Float32Array): Promise<HeardSoFar>;
124
128
  hear(audio: Float32Array, language: string): Promise<Transcript | undefined>;
125
129
  }
@@ -315,6 +315,14 @@ export class ServerHearing {
315
315
  * Whether speech was found and how long it has been silent, and the words
316
316
  * the speech service heard so far: the sound goes to its stream as it comes.
317
317
  */
318
+ async spokeSoFar(audio, shortestSeconds = 0.2) {
319
+ const { speech } = await this.finder.findSpeech(audio);
320
+ return speech.length >= HEARING_RATE * shortestSeconds;
321
+ }
322
+ async quietSoFar(audio) {
323
+ const { speech, silentMs } = await this.finder.findSpeech(audio);
324
+ return { text: '', spoke: speech.length >= SHORTEST, silentMs };
325
+ }
318
326
  async hearSoFar(audio, language = '') {
319
327
  const { speech, silentMs } = await this.finder.findSpeech(audio);
320
328
  const spoke = speech.length >= SHORTEST;
@@ -450,6 +458,18 @@ export class AutoHearing {
450
458
  hearSoFar(audio, language) {
451
459
  return this.current.hearSoFar(audio, language);
452
460
  }
461
+ async spokeSoFar(audio, shortestSeconds) {
462
+ const current = this.current;
463
+ return current.spokeSoFar
464
+ ? current.spokeSoFar(audio, shortestSeconds)
465
+ : (await current.hearSoFar(audio, '')).spoke;
466
+ }
467
+ quietSoFar(audio) {
468
+ const current = this.current;
469
+ return current.quietSoFar
470
+ ? current.quietSoFar(audio)
471
+ : current.hearSoFar(audio, '');
472
+ }
453
473
  async hear(audio, language) {
454
474
  if (this.place === 'device') {
455
475
  return this.device.hear(audio, language);