@datalayer/agent-runtimes 1.5.60 → 1.5.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-embed/datalayer-app-main.js +90 -90
- package/lib/apps/apps/AppRenderer.js +8 -1
- package/lib/chat/assistant/AssistantStage.js +51 -3
- package/lib/chat/assistant/balloonHover.d.ts +29 -0
- package/lib/chat/assistant/balloonHover.js +48 -0
- package/lib/chat/base/ChatBase.js +8 -1
- package/lib/voice/ComposerVoice.js +2 -2
- package/lib/voice/HandsFree.d.ts +14 -3
- package/lib/voice/HandsFree.js +52 -8
- package/lib/voice/VoiceInput.d.ts +6 -1
- package/lib/voice/VoiceInput.js +21 -6
- package/lib/voice/chatVoice.d.ts +1 -1
- package/lib/voice/chatVoice.js +3 -1
- package/lib/voice/consent.d.ts +6 -2
- package/lib/voice/consent.js +8 -3
- package/lib/voice/handsFree.d.ts +2 -2
- package/lib/voice/handsFree.js +1 -1
- package/lib/voice/hearing.d.ts +13 -0
- package/lib/voice/hearing.js +8 -0
- package/lib/voice/index.d.ts +8 -1
- package/lib/voice/index.js +1 -0
- package/lib/voice/keptSound.d.ts +15 -0
- package/lib/voice/keptSound.js +69 -0
- package/lib/voice/serverHearing.d.ts +4 -0
- package/lib/voice/serverHearing.js +20 -0
- package/lib/voice/speaker.d.ts +30 -0
- package/lib/voice/speaker.js +152 -9
- package/lib/voice/types.d.ts +12 -0
- package/lib/voice/useVoiceSpeaker.js +4 -0
- package/package.json +1 -1
package/lib/voice/index.d.ts
CHANGED
|
@@ -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):
|
|
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
|
/**
|
package/lib/voice/index.js
CHANGED
|
@@ -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);
|
package/lib/voice/speaker.d.ts
CHANGED
|
@@ -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. */
|
package/lib/voice/speaker.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
this.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
}
|
package/lib/voice/types.d.ts
CHANGED
|
@@ -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
|