@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.
@@ -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);
@@ -50,6 +50,21 @@ export interface SpeakerOptions {
50
50
  onState?: (state: SpeakerState) => void;
51
51
  /** Called with how long a sentence took from asked to first sound, in ms. */
52
52
  onFirstSound?: (ms: number) => void;
53
+ /**
54
+ * Say a long first sentence's first clause on its own (VO-52): the service
55
+ * makes a sentence whole, in about the time its length takes, so the first
56
+ * sound comes sooner when the first thing asked is shorter. Off for a
57
+ * caller that follows its sentences one by one (a deck's narrator).
58
+ */
59
+ firstClauseFirst?: boolean;
60
+ /**
61
+ * Ask each sentence streamed (VO-52): the speech service says it piece by
62
+ * piece where it pauses and sends each piece's 16-bit PCM as soon as it is
63
+ * said, and the speaker plays the pieces as they come — the first sound
64
+ * after the first piece, not the whole sentence. A service that answers a
65
+ * whole sound instead is played as before.
66
+ */
67
+ streamed?: boolean;
53
68
  fetcher?: typeof fetch;
54
69
  }
55
70
  /**
@@ -60,6 +75,14 @@ export interface SpeakerOptions {
60
75
  * 14 October 2026).
61
76
  */
62
77
  export declare const SENTENCES_AHEAD = 2;
78
+ /** A first sentence longer than this is said clause first (`firstClauseFirst`). */
79
+ export declare const FIRST_CLAUSE_SPLIT_CHARS = 60;
80
+ /**
81
+ * A sentence cut after its first clause — a comma, semicolon, colon or dash
82
+ * followed by a space — when it is long and neither part is too short (VO-52);
83
+ * else the sentence alone. Never cut inside a number (`1,000`).
84
+ */
85
+ export declare function firstClause(sentence: string): string[];
63
86
  /** Plays an answer's sentences, said by the speech service. */
64
87
  export declare class ServerSpeaker {
65
88
  private options;
@@ -69,6 +92,8 @@ export declare class ServerSpeaker {
69
92
  private buffer?;
70
93
  private queue;
71
94
  private playing?;
95
+ /** The pieces of a streamed sentence scheduled to play, stopped together. */
96
+ private scheduled;
72
97
  private running;
73
98
  private generation;
74
99
  private state;
@@ -94,6 +119,11 @@ export declare class ServerSpeaker {
94
119
  private askAhead;
95
120
  private fetch;
96
121
  private run;
122
+ /**
123
+ * A sentence streamed as 16-bit PCM (VO-52): played in slices as they
124
+ * come, each scheduled right after the last, until the last has ended.
125
+ */
126
+ private playStreamed;
97
127
  /** Stop at once: what plays and what waits (barge-in, `Esc`; VO-13). */
98
128
  stop(): void;
99
129
  /** Stop, and let the audio context go. */
@@ -13,6 +13,8 @@
13
13
  */
14
14
  /** The path of the syntheses on ai-agents. */
15
15
  export const SYNTHESES_PATH = '/api/ai-agents/v1/speech/syntheses';
16
+ /** Played as it comes: at least this much at a time, in seconds. */
17
+ const STREAMED_SLICE_SECONDS = 0.25;
16
18
  /**
17
19
  * How many sentences are asked of the speech service ahead of the one
18
20
  * playing: enough that the next is ready when one ends, and under the calls
@@ -21,6 +23,34 @@ export const SYNTHESES_PATH = '/api/ai-agents/v1/speech/syntheses';
21
23
  * 14 October 2026).
22
24
  */
23
25
  export const SENTENCES_AHEAD = 2;
26
+ /** A first sentence longer than this is said clause first (`firstClauseFirst`). */
27
+ export const FIRST_CLAUSE_SPLIT_CHARS = 60;
28
+ /** Neither part of a sentence cut at its first clause is shorter than this. */
29
+ const SHORTEST_CLAUSE_CHARS = 15;
30
+ /**
31
+ * A sentence cut after its first clause — a comma, semicolon, colon or dash
32
+ * followed by a space — when it is long and neither part is too short (VO-52);
33
+ * else the sentence alone. Never cut inside a number (`1,000`).
34
+ */
35
+ export function firstClause(sentence) {
36
+ if (sentence.length <= FIRST_CLAUSE_SPLIT_CHARS) {
37
+ return [sentence];
38
+ }
39
+ const boundary = /[,;:\u2013\u2014]\s+/g;
40
+ let found;
41
+ while ((found = boundary.exec(sentence)) !== null) {
42
+ const end = found.index + 1;
43
+ if (end < SHORTEST_CLAUSE_CHARS) {
44
+ continue;
45
+ }
46
+ const rest = sentence.slice(found.index + found[0].length);
47
+ if (rest.length < SHORTEST_CLAUSE_CHARS) {
48
+ break;
49
+ }
50
+ return [sentence.slice(0, end), rest];
51
+ }
52
+ return [sentence];
53
+ }
24
54
  /** Plays an answer's sentences, said by the speech service. */
25
55
  export class ServerSpeaker {
26
56
  options;
@@ -30,6 +60,8 @@ export class ServerSpeaker {
30
60
  buffer;
31
61
  queue = [];
32
62
  playing;
63
+ /** The pieces of a streamed sentence scheduled to play, stopped together. */
64
+ scheduled = [];
33
65
  running = false;
34
66
  generation = 0;
35
67
  state = { speaking: false };
@@ -99,7 +131,15 @@ export class ServerSpeaker {
99
131
  if (!sentence) {
100
132
  return;
101
133
  }
102
- this.queue.push({ text: sentence, asked: performance.now() });
134
+ // Nothing playing or waiting: this is the first thing heard (VO-52).
135
+ const first = this.options.firstClauseFirst &&
136
+ !this.options.streamed &&
137
+ !this.queue.length &&
138
+ !this.playing &&
139
+ !this.running;
140
+ for (const part of first ? firstClause(sentence) : [sentence]) {
141
+ this.queue.push({ text: part, asked: performance.now() });
142
+ }
103
143
  if (!this.state.speaking) {
104
144
  // Something new to say: the last refusal is no longer the news.
105
145
  this.set({ speaking: true, refused: undefined });
@@ -130,7 +170,9 @@ export class ServerSpeaker {
130
170
  text,
131
171
  voice,
132
172
  language,
133
- format: 'opus',
173
+ ...(this.options.streamed
174
+ ? { format: 'pcm', stream: true }
175
+ : { format: 'opus' }),
134
176
  }),
135
177
  });
136
178
  if (!answered.ok) {
@@ -139,7 +181,18 @@ export class ServerSpeaker {
139
181
  ? body.detail
140
182
  : `The answer could not be spoken (${answered.status}).`;
141
183
  }
142
- return await answered.arrayBuffer();
184
+ const kind = answered.headers.get('content-type') ?? '';
185
+ if (kind.toLowerCase().startsWith('audio/l16')) {
186
+ const rate = Number(answered.headers.get('x-speech-rate')) ||
187
+ Number(/rate=(\d+)/i.exec(kind)?.[1]) ||
188
+ 24000;
189
+ return {
190
+ kind: 'pcm',
191
+ rate,
192
+ body: answered.body ?? (await answered.arrayBuffer()),
193
+ };
194
+ }
195
+ return { kind: 'whole', audio: await answered.arrayBuffer() };
143
196
  }
144
197
  catch {
145
198
  return 'Datalayer’s speech service could not be reached: the answer is still written here.';
@@ -165,7 +218,11 @@ export class ServerSpeaker {
165
218
  this.queue.shift();
166
219
  this.askAhead();
167
220
  const context = this.audio();
168
- const decoded = await context.decodeAudioData(audio);
221
+ if (audio.kind === 'pcm') {
222
+ await this.playStreamed(generation, next, audio);
223
+ continue;
224
+ }
225
+ const decoded = await context.decodeAudioData(audio.audio);
169
226
  if (generation !== this.generation) {
170
227
  return;
171
228
  }
@@ -195,17 +252,103 @@ export class ServerSpeaker {
195
252
  }
196
253
  }
197
254
  }
255
+ /**
256
+ * A sentence streamed as 16-bit PCM (VO-52): played in slices as they
257
+ * come, each scheduled right after the last, until the last has ended.
258
+ */
259
+ async playStreamed(generation, next, answer) {
260
+ const context = this.audio();
261
+ const speed = this.speed();
262
+ if (this.gain) {
263
+ this.gain.gain.value = this.loudness();
264
+ }
265
+ let at = 0;
266
+ let first = true;
267
+ let pending = new Uint8Array(0);
268
+ let last = Promise.resolve();
269
+ const slice = Math.round(answer.rate * STREAMED_SLICE_SECONDS) * 2;
270
+ const play = (bytes) => {
271
+ const samples = new Float32Array(bytes.length / 2);
272
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.length);
273
+ for (let k = 0; k < samples.length; k++) {
274
+ samples[k] = view.getInt16(k * 2, true) / 32768;
275
+ }
276
+ const buffer = context.createBuffer(1, samples.length, answer.rate);
277
+ buffer.getChannelData(0).set(samples);
278
+ const source = context.createBufferSource();
279
+ source.buffer = buffer;
280
+ source.playbackRate.value = speed;
281
+ source.connect(this.analyser);
282
+ at = Math.max(at, context.currentTime);
283
+ last = new Promise(resolve => {
284
+ source.onended = () => {
285
+ this.scheduled = this.scheduled.filter(one => one !== source);
286
+ resolve();
287
+ };
288
+ });
289
+ this.scheduled.push(source);
290
+ this.playing = source;
291
+ if (first) {
292
+ first = false;
293
+ this.set({ speaking: true, sentence: next.text, refused: undefined });
294
+ this.options.onFirstSound?.(Math.round(performance.now() - next.asked));
295
+ }
296
+ source.start(at);
297
+ at += buffer.duration / speed;
298
+ };
299
+ const take = (chunk, done) => {
300
+ const joined = new Uint8Array(pending.length + chunk.length);
301
+ joined.set(pending);
302
+ joined.set(chunk, pending.length);
303
+ pending = joined;
304
+ while (pending.length >= slice || (done && pending.length >= 2)) {
305
+ const size = Math.min(pending.length, Math.max(slice, 2));
306
+ const even = size - (size % 2);
307
+ play(pending.subarray(0, even));
308
+ pending = pending.slice(even);
309
+ if (done && pending.length < 2) {
310
+ break;
311
+ }
312
+ }
313
+ };
314
+ if (answer.body instanceof ArrayBuffer) {
315
+ take(new Uint8Array(answer.body), true);
316
+ }
317
+ else {
318
+ const reader = answer.body.getReader();
319
+ for (;;) {
320
+ const { done, value } = await reader.read();
321
+ if (generation !== this.generation) {
322
+ void reader.cancel().catch(() => undefined);
323
+ return;
324
+ }
325
+ if (done) {
326
+ take(new Uint8Array(0), true);
327
+ break;
328
+ }
329
+ take(value, false);
330
+ }
331
+ }
332
+ await last;
333
+ this.playing = undefined;
334
+ }
198
335
  /** Stop at once: what plays and what waits (barge-in, `Esc`; VO-13). */
199
336
  stop() {
200
337
  this.generation += 1;
201
338
  this.queue = [];
202
339
  this.running = false;
203
- try {
204
- this.playing?.stop();
205
- }
206
- catch {
207
- // Already ended.
340
+ for (const source of [
341
+ ...this.scheduled,
342
+ ...(this.playing ? [this.playing] : []),
343
+ ]) {
344
+ try {
345
+ source.stop();
346
+ }
347
+ catch {
348
+ // Already ended.
349
+ }
208
350
  }
351
+ this.scheduled = [];
209
352
  this.playing = undefined;
210
353
  this.set({ speaking: false, sentence: undefined });
211
354
  }
@@ -80,6 +80,18 @@ export interface SpokenMetadata {
80
80
  language: string;
81
81
  engine: string;
82
82
  where: 'device' | 'server';
83
+ /**
84
+ * The turn's sound, sent to be kept with its words when the application's
85
+ * record keeps `audio` and the person agreed (VO-63); never on the
86
+ * conversation's own message.
87
+ */
88
+ sound?: SpokenSound;
89
+ }
90
+ /** A turn's recording, to keep (VO-63): a WAV, base64. */
91
+ export interface SpokenSound {
92
+ data: string;
93
+ type: 'audio/wav';
94
+ seconds: number;
83
95
  }
84
96
  /** What the person said, once heard. */
85
97
  export interface Transcript {
@@ -30,6 +30,10 @@ export function useVoiceSpeaker(voice, on = true) {
30
30
  rate: voice.rate,
31
31
  volume: voice.volume,
32
32
  onState: setState,
33
+ // The chat's answers: streamed, played as they are said (VO-52);
34
+ // from a service that does not stream, the first clause first.
35
+ streamed: true,
36
+ firstClauseFirst: true,
33
37
  })
34
38
  : undefined,
35
39
  // eslint-disable-next-line react-hooks/exhaustive-deps
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datalayer/agent-runtimes",
3
- "version": "1.5.60",
3
+ "version": "1.5.62",
4
4
  "type": "module",
5
5
  "workspaces": [
6
6
  ".",