@livekit/agents 1.0.11 → 1.0.13
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/audio.cjs +88 -2
- package/dist/audio.cjs.map +1 -1
- package/dist/audio.d.cts +35 -0
- package/dist/audio.d.ts +35 -0
- package/dist/audio.d.ts.map +1 -1
- package/dist/audio.js +75 -1
- package/dist/audio.js.map +1 -1
- package/dist/metrics/index.cjs.map +1 -1
- package/dist/metrics/index.d.cts +1 -1
- package/dist/metrics/index.d.ts +1 -1
- package/dist/metrics/index.d.ts.map +1 -1
- package/dist/metrics/index.js.map +1 -1
- package/dist/stream/stream_channel.test.cjs +27 -0
- package/dist/stream/stream_channel.test.cjs.map +1 -1
- package/dist/stream/stream_channel.test.js +27 -0
- package/dist/stream/stream_channel.test.js.map +1 -1
- package/dist/voice/background_audio.cjs +326 -0
- package/dist/voice/background_audio.cjs.map +1 -0
- package/dist/voice/background_audio.d.cts +114 -0
- package/dist/voice/background_audio.d.ts +114 -0
- package/dist/voice/background_audio.d.ts.map +1 -0
- package/dist/voice/background_audio.js +301 -0
- package/dist/voice/background_audio.js.map +1 -0
- package/dist/voice/index.cjs +2 -0
- package/dist/voice/index.cjs.map +1 -1
- package/dist/voice/index.d.cts +1 -0
- package/dist/voice/index.d.ts +1 -0
- package/dist/voice/index.d.ts.map +1 -1
- package/dist/voice/index.js +1 -0
- package/dist/voice/index.js.map +1 -1
- package/package.json +9 -5
- package/resources/NOTICE +2 -0
- package/resources/keyboard-typing.ogg +0 -0
- package/resources/keyboard-typing2.ogg +0 -0
- package/resources/office-ambience.ogg +0 -0
- package/src/audio.ts +131 -0
- package/src/metrics/index.ts +1 -0
- package/src/stream/stream_channel.test.ts +37 -0
- package/src/voice/background_audio.ts +451 -0
- package/src/voice/index.ts +1 -1
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AudioFrame,
|
|
3
|
+
AudioSource,
|
|
4
|
+
LocalAudioTrack,
|
|
5
|
+
TrackPublishOptions
|
|
6
|
+
} from "@livekit/rtc-node";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { audioFramesFromFile, loopAudioFramesFromFile } from "../audio.js";
|
|
10
|
+
import { log } from "../log.js";
|
|
11
|
+
import { Future, Task, cancelAndWait } from "../utils.js";
|
|
12
|
+
import { AgentSessionEventTypes } from "./events.js";
|
|
13
|
+
const TASK_TIMEOUT_MS = 500;
|
|
14
|
+
var BuiltinAudioClip = /* @__PURE__ */ ((BuiltinAudioClip2) => {
|
|
15
|
+
BuiltinAudioClip2["OFFICE_AMBIENCE"] = "office-ambience.ogg";
|
|
16
|
+
BuiltinAudioClip2["KEYBOARD_TYPING"] = "keyboard-typing.ogg";
|
|
17
|
+
BuiltinAudioClip2["KEYBOARD_TYPING2"] = "keyboard-typing2.ogg";
|
|
18
|
+
return BuiltinAudioClip2;
|
|
19
|
+
})(BuiltinAudioClip || {});
|
|
20
|
+
function isBuiltinAudioClip(source) {
|
|
21
|
+
return typeof source === "string" && Object.values(BuiltinAudioClip).includes(source);
|
|
22
|
+
}
|
|
23
|
+
function getBuiltinAudioPath(clip) {
|
|
24
|
+
const resourcesPath = join(dirname(fileURLToPath(import.meta.url)), "../../resources");
|
|
25
|
+
return join(resourcesPath, clip);
|
|
26
|
+
}
|
|
27
|
+
const AUDIO_SOURCE_BUFFER_MS = 400;
|
|
28
|
+
class PlayHandle {
|
|
29
|
+
doneFuture = new Future();
|
|
30
|
+
stopFuture = new Future();
|
|
31
|
+
done() {
|
|
32
|
+
return this.doneFuture.done;
|
|
33
|
+
}
|
|
34
|
+
stop() {
|
|
35
|
+
if (this.done()) return;
|
|
36
|
+
if (!this.stopFuture.done) {
|
|
37
|
+
this.stopFuture.resolve();
|
|
38
|
+
}
|
|
39
|
+
this._markPlayoutDone();
|
|
40
|
+
}
|
|
41
|
+
async waitForPlayout() {
|
|
42
|
+
return this.doneFuture.await;
|
|
43
|
+
}
|
|
44
|
+
_markPlayoutDone() {
|
|
45
|
+
if (!this.doneFuture.done) {
|
|
46
|
+
this.doneFuture.resolve();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
class BackgroundAudioPlayer {
|
|
51
|
+
ambientSound;
|
|
52
|
+
thinkingSound;
|
|
53
|
+
playTasks = [];
|
|
54
|
+
audioSource = new AudioSource(48e3, 1, AUDIO_SOURCE_BUFFER_MS);
|
|
55
|
+
room;
|
|
56
|
+
agentSession;
|
|
57
|
+
publication;
|
|
58
|
+
trackPublishOptions;
|
|
59
|
+
republishTask;
|
|
60
|
+
ambientHandle;
|
|
61
|
+
thinkingHandle;
|
|
62
|
+
// TODO (Brian): add lock
|
|
63
|
+
#logger = log();
|
|
64
|
+
constructor(options) {
|
|
65
|
+
const { ambientSound, thinkingSound } = options || {};
|
|
66
|
+
this.ambientSound = ambientSound;
|
|
67
|
+
this.thinkingSound = thinkingSound;
|
|
68
|
+
if (this.thinkingSound) {
|
|
69
|
+
this.#logger.warn("thinkingSound is not yet supported");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Select a sound from a list of background sound based on probability weights
|
|
74
|
+
* Return undefined if no sound is selected (when sum of probabilities < 1.0).
|
|
75
|
+
*/
|
|
76
|
+
selectSoundFromList(sounds) {
|
|
77
|
+
const totalProbability = sounds.reduce((sum, sound) => sum + (sound.probability ?? 1), 0);
|
|
78
|
+
if (totalProbability <= 0) {
|
|
79
|
+
return void 0;
|
|
80
|
+
}
|
|
81
|
+
if (totalProbability < 1 && Math.random() > totalProbability) {
|
|
82
|
+
return void 0;
|
|
83
|
+
}
|
|
84
|
+
const normalizeFactor = totalProbability <= 1 ? 1 : totalProbability;
|
|
85
|
+
const r = Math.random() * Math.min(totalProbability, 1);
|
|
86
|
+
let cumulative = 0;
|
|
87
|
+
for (const sound of sounds) {
|
|
88
|
+
const prob = sound.probability ?? 1;
|
|
89
|
+
if (prob <= 0) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const normProb = prob / normalizeFactor;
|
|
93
|
+
cumulative += normProb;
|
|
94
|
+
if (r <= cumulative) {
|
|
95
|
+
return sound;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return sounds[sounds.length - 1];
|
|
99
|
+
}
|
|
100
|
+
normalizeSoundSource(source) {
|
|
101
|
+
if (source === void 0) {
|
|
102
|
+
return void 0;
|
|
103
|
+
}
|
|
104
|
+
if (typeof source === "string") {
|
|
105
|
+
return {
|
|
106
|
+
source: this.normalizeBuiltinAudio(source),
|
|
107
|
+
volume: 1
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (Array.isArray(source)) {
|
|
111
|
+
const selected = this.selectSoundFromList(source);
|
|
112
|
+
if (selected === void 0) {
|
|
113
|
+
return void 0;
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
source: selected.source,
|
|
117
|
+
volume: selected.volume ?? 1
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (typeof source === "object" && "source" in source) {
|
|
121
|
+
return {
|
|
122
|
+
source: this.normalizeBuiltinAudio(source.source),
|
|
123
|
+
volume: source.volume ?? 1
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return { source, volume: 1 };
|
|
127
|
+
}
|
|
128
|
+
normalizeBuiltinAudio(source) {
|
|
129
|
+
if (isBuiltinAudioClip(source)) {
|
|
130
|
+
return getBuiltinAudioPath(source);
|
|
131
|
+
}
|
|
132
|
+
return source;
|
|
133
|
+
}
|
|
134
|
+
play(audio, loop = false) {
|
|
135
|
+
const normalized = this.normalizeSoundSource(audio);
|
|
136
|
+
if (normalized === void 0) {
|
|
137
|
+
const handle = new PlayHandle();
|
|
138
|
+
handle._markPlayoutDone();
|
|
139
|
+
return handle;
|
|
140
|
+
}
|
|
141
|
+
const { source, volume } = normalized;
|
|
142
|
+
const playHandle = new PlayHandle();
|
|
143
|
+
const task = Task.from(async ({ signal }) => {
|
|
144
|
+
await this.playTask({ playHandle, sound: source, volume, loop, signal });
|
|
145
|
+
});
|
|
146
|
+
task.addDoneCallback(() => {
|
|
147
|
+
playHandle._markPlayoutDone();
|
|
148
|
+
this.playTasks.splice(this.playTasks.indexOf(task), 1);
|
|
149
|
+
});
|
|
150
|
+
this.playTasks.push(task);
|
|
151
|
+
return playHandle;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Start the background audio system, publishing the audio track
|
|
155
|
+
* and beginning playback of any configured ambient sound.
|
|
156
|
+
*
|
|
157
|
+
* If `ambientSound` is provided (and contains file paths), they will loop
|
|
158
|
+
* automatically. If `ambientSound` contains AsyncIterators, they are assumed
|
|
159
|
+
* to be already infinite or looped.
|
|
160
|
+
*
|
|
161
|
+
* @param options - Options for starting background audio playback
|
|
162
|
+
*/
|
|
163
|
+
async start(options) {
|
|
164
|
+
var _a;
|
|
165
|
+
const { room, agentSession, trackPublishOptions } = options;
|
|
166
|
+
this.room = room;
|
|
167
|
+
this.agentSession = agentSession;
|
|
168
|
+
this.trackPublishOptions = trackPublishOptions;
|
|
169
|
+
await this.publishTrack();
|
|
170
|
+
this.room.on("reconnected", this.onReconnected);
|
|
171
|
+
(_a = this.agentSession) == null ? void 0 : _a.on(AgentSessionEventTypes.AgentStateChanged, this.onAgentStateChanged);
|
|
172
|
+
if (!this.ambientSound) return;
|
|
173
|
+
const normalized = this.normalizeSoundSource(this.ambientSound);
|
|
174
|
+
if (!normalized) return;
|
|
175
|
+
const { source, volume } = normalized;
|
|
176
|
+
const selectedSound = { source, volume, probability: 1 };
|
|
177
|
+
this.ambientHandle = this.play(selectedSound, typeof source === "string");
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Close and cleanup the background audio system
|
|
181
|
+
*/
|
|
182
|
+
async close() {
|
|
183
|
+
var _a, _b, _c, _d;
|
|
184
|
+
await cancelAndWait(this.playTasks, TASK_TIMEOUT_MS);
|
|
185
|
+
if (this.republishTask) {
|
|
186
|
+
await this.republishTask.cancelAndWait(TASK_TIMEOUT_MS);
|
|
187
|
+
}
|
|
188
|
+
await this.audioSource.close();
|
|
189
|
+
(_a = this.agentSession) == null ? void 0 : _a.off(AgentSessionEventTypes.AgentStateChanged, this.onAgentStateChanged);
|
|
190
|
+
(_b = this.room) == null ? void 0 : _b.off("reconnected", this.onReconnected);
|
|
191
|
+
if (this.publication && this.publication.sid) {
|
|
192
|
+
await ((_d = (_c = this.room) == null ? void 0 : _c.localParticipant) == null ? void 0 : _d.unpublishTrack(this.publication.sid));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Get the current track publication
|
|
197
|
+
*/
|
|
198
|
+
getPublication() {
|
|
199
|
+
return this.publication;
|
|
200
|
+
}
|
|
201
|
+
async publishTrack() {
|
|
202
|
+
var _a;
|
|
203
|
+
if (this.publication !== void 0) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const track = LocalAudioTrack.createAudioTrack("background_audio", this.audioSource);
|
|
207
|
+
if (((_a = this.room) == null ? void 0 : _a.localParticipant) === void 0) {
|
|
208
|
+
throw new Error("Local participant not available");
|
|
209
|
+
}
|
|
210
|
+
const publication = await this.room.localParticipant.publishTrack(
|
|
211
|
+
track,
|
|
212
|
+
this.trackPublishOptions ?? new TrackPublishOptions()
|
|
213
|
+
);
|
|
214
|
+
this.publication = publication;
|
|
215
|
+
this.#logger.debug(`Background audio track published: ${this.publication.sid}`);
|
|
216
|
+
}
|
|
217
|
+
onReconnected = () => {
|
|
218
|
+
if (this.republishTask) {
|
|
219
|
+
this.republishTask.cancel();
|
|
220
|
+
}
|
|
221
|
+
this.publication = void 0;
|
|
222
|
+
this.republishTask = Task.from(async () => {
|
|
223
|
+
await this.republishTrackTask();
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
async republishTrackTask() {
|
|
227
|
+
await this.publishTrack();
|
|
228
|
+
}
|
|
229
|
+
onAgentStateChanged = (ev) => {
|
|
230
|
+
var _a;
|
|
231
|
+
if (!this.thinkingSound) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (ev.newState === "thinking") {
|
|
235
|
+
if (this.thinkingHandle && !this.thinkingHandle.done()) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
(_a = this.thinkingHandle) == null ? void 0 : _a.stop();
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
async playTask({
|
|
243
|
+
playHandle,
|
|
244
|
+
sound,
|
|
245
|
+
volume,
|
|
246
|
+
loop,
|
|
247
|
+
signal
|
|
248
|
+
}) {
|
|
249
|
+
if (isBuiltinAudioClip(sound)) {
|
|
250
|
+
sound = getBuiltinAudioPath(sound);
|
|
251
|
+
}
|
|
252
|
+
if (typeof sound === "string") {
|
|
253
|
+
sound = loop ? loopAudioFramesFromFile(sound, { abortSignal: signal }) : audioFramesFromFile(sound, { abortSignal: signal });
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
for await (const frame of sound) {
|
|
257
|
+
if (signal.aborted || playHandle.done()) break;
|
|
258
|
+
let processedFrame;
|
|
259
|
+
if (volume !== 1) {
|
|
260
|
+
const int16Data = new Int16Array(
|
|
261
|
+
frame.data.buffer,
|
|
262
|
+
frame.data.byteOffset,
|
|
263
|
+
frame.data.byteLength / 2
|
|
264
|
+
);
|
|
265
|
+
const float32Data = new Float32Array(int16Data.length);
|
|
266
|
+
for (let i = 0; i < int16Data.length; i++) {
|
|
267
|
+
float32Data[i] = int16Data[i];
|
|
268
|
+
}
|
|
269
|
+
const volumeFactor = 10 ** Math.log10(volume);
|
|
270
|
+
for (let i = 0; i < float32Data.length; i++) {
|
|
271
|
+
float32Data[i] *= volumeFactor;
|
|
272
|
+
}
|
|
273
|
+
const outputData = new Int16Array(float32Data.length);
|
|
274
|
+
for (let i = 0; i < float32Data.length; i++) {
|
|
275
|
+
const clipped = Math.max(-32768, Math.min(32767, float32Data[i]));
|
|
276
|
+
outputData[i] = Math.round(clipped);
|
|
277
|
+
}
|
|
278
|
+
processedFrame = new AudioFrame(
|
|
279
|
+
outputData,
|
|
280
|
+
frame.sampleRate,
|
|
281
|
+
frame.channels,
|
|
282
|
+
frame.samplesPerChannel
|
|
283
|
+
);
|
|
284
|
+
} else {
|
|
285
|
+
processedFrame = frame;
|
|
286
|
+
}
|
|
287
|
+
await this.audioSource.captureFrame(processedFrame);
|
|
288
|
+
}
|
|
289
|
+
} finally {
|
|
290
|
+
playHandle._markPlayoutDone();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
export {
|
|
295
|
+
BackgroundAudioPlayer,
|
|
296
|
+
BuiltinAudioClip,
|
|
297
|
+
PlayHandle,
|
|
298
|
+
getBuiltinAudioPath,
|
|
299
|
+
isBuiltinAudioClip
|
|
300
|
+
};
|
|
301
|
+
//# sourceMappingURL=background_audio.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/voice/background_audio.ts"],"sourcesContent":["// SPDX-FileCopyrightText: 2025 LiveKit, Inc.\n//\n// SPDX-License-Identifier: Apache-2.0\nimport {\n AudioFrame,\n AudioSource,\n LocalAudioTrack,\n type LocalTrackPublication,\n type Room,\n TrackPublishOptions,\n} from '@livekit/rtc-node';\nimport { dirname, join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport { audioFramesFromFile, loopAudioFramesFromFile } from '../audio.js';\nimport { log } from '../log.js';\nimport { Future, Task, cancelAndWait } from '../utils.js';\nimport type { AgentSession } from './agent_session.js';\nimport { AgentSessionEventTypes, type AgentStateChangedEvent } from './events.js';\n\nconst TASK_TIMEOUT_MS = 500;\n\nexport enum BuiltinAudioClip {\n OFFICE_AMBIENCE = 'office-ambience.ogg',\n KEYBOARD_TYPING = 'keyboard-typing.ogg',\n KEYBOARD_TYPING2 = 'keyboard-typing2.ogg',\n}\n\nexport function isBuiltinAudioClip(\n source: AudioSourceType | AudioConfig | AudioConfig[],\n): source is BuiltinAudioClip {\n return (\n typeof source === 'string' &&\n Object.values(BuiltinAudioClip).includes(source as BuiltinAudioClip)\n );\n}\n\nexport function getBuiltinAudioPath(clip: BuiltinAudioClip): string {\n const resourcesPath = join(dirname(fileURLToPath(import.meta.url)), '../../resources');\n return join(resourcesPath, clip);\n}\n\nexport type AudioSourceType = string | BuiltinAudioClip | AsyncIterable<AudioFrame>;\n\nexport interface AudioConfig {\n source: AudioSourceType;\n volume?: number;\n probability?: number;\n}\n\nexport interface BackgroundAudioPlayerOptions {\n /**\n * Ambient sound to play continuously in the background.\n * Can be a file path, BuiltinAudioClip, or AudioConfig.\n * File paths will be looped automatically.\n */\n ambientSound?: AudioSourceType | AudioConfig | AudioConfig[];\n\n /**\n * Sound to play when the agent is thinking.\n * TODO (Brian): Implement thinking sound when AudioMixer becomes available\n */\n thinkingSound?: AudioSourceType | AudioConfig | AudioConfig[];\n\n /**\n * Stream timeout in milliseconds\n * @defaultValue 200\n */\n streamTimeoutMs?: number;\n}\n\nexport interface BackgroundAudioStartOptions {\n room: Room;\n agentSession?: AgentSession;\n trackPublishOptions?: TrackPublishOptions;\n}\n\n// Queue size for AudioSource buffer (400ms)\n// Kept small to avoid abrupt cutoffs when removing sounds\nconst AUDIO_SOURCE_BUFFER_MS = 400;\n\nexport class PlayHandle {\n private doneFuture = new Future<void>();\n private stopFuture = new Future<void>();\n\n done(): boolean {\n return this.doneFuture.done;\n }\n\n stop(): void {\n if (this.done()) return;\n\n if (!this.stopFuture.done) {\n this.stopFuture.resolve();\n }\n\n this._markPlayoutDone();\n }\n\n async waitForPlayout(): Promise<void> {\n return this.doneFuture.await;\n }\n\n _markPlayoutDone(): void {\n if (!this.doneFuture.done) {\n this.doneFuture.resolve();\n }\n }\n}\n\n/**\n * Manages background audio playback for LiveKit agent sessions\n *\n * This class handles playing ambient sounds and manages audio track publishing.\n * It supports:\n * - Continuous ambient sound playback with looping\n * - Volume control and probability-based sound selection\n * - Integration with LiveKit rooms and agent sessions\n *\n * Note: Thinking sound not yet supported\n *\n * @example\n * ```typescript\n * const player = new BackgroundAudioPlayer({\n * ambientSound: { source: BuiltinAudioClip.OFFICE_AMBIENCE, volume: 0.8 },\n * });\n *\n * await player.start({ room, agentSession });\n * ```\n */\nexport class BackgroundAudioPlayer {\n private ambientSound?: AudioSourceType | AudioConfig | AudioConfig[];\n private thinkingSound?: AudioSourceType | AudioConfig | AudioConfig[];\n\n private playTasks: Task<void>[] = [];\n private audioSource = new AudioSource(48000, 1, AUDIO_SOURCE_BUFFER_MS);\n\n private room?: Room;\n private agentSession?: AgentSession;\n private publication?: LocalTrackPublication;\n private trackPublishOptions?: TrackPublishOptions;\n private republishTask?: Task<void>;\n\n private ambientHandle?: PlayHandle;\n private thinkingHandle?: PlayHandle;\n\n // TODO (Brian): add lock\n\n #logger = log();\n\n constructor(options?: BackgroundAudioPlayerOptions) {\n const { ambientSound, thinkingSound } = options || {};\n\n this.ambientSound = ambientSound;\n this.thinkingSound = thinkingSound;\n\n if (this.thinkingSound) {\n this.#logger.warn('thinkingSound is not yet supported');\n // TODO: Implement thinking sound when AudioMixer becomes available\n }\n }\n\n /**\n * Select a sound from a list of background sound based on probability weights\n * Return undefined if no sound is selected (when sum of probabilities < 1.0).\n */\n private selectSoundFromList(sounds: AudioConfig[]): AudioConfig | undefined {\n const totalProbability = sounds.reduce((sum, sound) => sum + (sound.probability ?? 1.0), 0);\n\n if (totalProbability <= 0) {\n return undefined;\n }\n\n if (totalProbability < 1.0 && Math.random() > totalProbability) {\n return undefined;\n }\n\n const normalizeFactor = totalProbability <= 1.0 ? 1.0 : totalProbability;\n const r = Math.random() * Math.min(totalProbability, 1.0);\n let cumulative = 0.0;\n\n for (const sound of sounds) {\n const prob = sound.probability ?? 1.0;\n if (prob <= 0) {\n continue;\n }\n\n const normProb = prob / normalizeFactor;\n cumulative += normProb;\n\n if (r <= cumulative) {\n return sound;\n }\n }\n\n return sounds[sounds.length - 1];\n }\n\n private normalizeSoundSource(\n source?: AudioSourceType | AudioConfig | AudioConfig[],\n ): { source: AudioSourceType; volume: number } | undefined {\n if (source === undefined) {\n return undefined;\n }\n\n if (typeof source === 'string') {\n return {\n source: this.normalizeBuiltinAudio(source),\n volume: 1.0,\n };\n }\n\n if (Array.isArray(source)) {\n const selected = this.selectSoundFromList(source);\n if (selected === undefined) {\n return undefined;\n }\n\n return {\n source: selected.source,\n volume: selected.volume ?? 1.0,\n };\n }\n\n if (typeof source === 'object' && 'source' in source) {\n return {\n source: this.normalizeBuiltinAudio(source.source),\n volume: source.volume ?? 1.0,\n };\n }\n\n return { source, volume: 1.0 };\n }\n\n private normalizeBuiltinAudio(source: AudioSourceType): AudioSourceType {\n if (isBuiltinAudioClip(source)) {\n return getBuiltinAudioPath(source);\n }\n return source;\n }\n\n play(audio: AudioSourceType | AudioConfig | AudioConfig[], loop = false): PlayHandle {\n const normalized = this.normalizeSoundSource(audio);\n if (normalized === undefined) {\n const handle = new PlayHandle();\n handle._markPlayoutDone();\n return handle;\n }\n\n const { source, volume } = normalized;\n const playHandle = new PlayHandle();\n\n const task = Task.from(async ({ signal }) => {\n await this.playTask({ playHandle, sound: source, volume, loop, signal });\n });\n\n task.addDoneCallback(() => {\n playHandle._markPlayoutDone();\n this.playTasks.splice(this.playTasks.indexOf(task), 1);\n });\n\n this.playTasks.push(task);\n return playHandle;\n }\n\n /**\n * Start the background audio system, publishing the audio track\n * and beginning playback of any configured ambient sound.\n *\n * If `ambientSound` is provided (and contains file paths), they will loop\n * automatically. If `ambientSound` contains AsyncIterators, they are assumed\n * to be already infinite or looped.\n *\n * @param options - Options for starting background audio playback\n */\n async start(options: BackgroundAudioStartOptions): Promise<void> {\n const { room, agentSession, trackPublishOptions } = options;\n this.room = room;\n this.agentSession = agentSession;\n this.trackPublishOptions = trackPublishOptions;\n\n await this.publishTrack();\n\n // TODO (Brian): check job context is not fake\n\n // TODO (Brian): start audio mixer task\n this.room.on('reconnected', this.onReconnected);\n\n this.agentSession?.on(AgentSessionEventTypes.AgentStateChanged, this.onAgentStateChanged);\n\n if (!this.ambientSound) return;\n\n const normalized = this.normalizeSoundSource(this.ambientSound);\n if (!normalized) return;\n\n const { source, volume } = normalized;\n const selectedSound: AudioConfig = { source, volume, probability: 1.0 };\n this.ambientHandle = this.play(selectedSound, typeof source === 'string');\n }\n\n /**\n * Close and cleanup the background audio system\n */\n async close(): Promise<void> {\n await cancelAndWait(this.playTasks, TASK_TIMEOUT_MS);\n\n if (this.republishTask) {\n await this.republishTask.cancelAndWait(TASK_TIMEOUT_MS);\n }\n\n // TODO (Brian): cancel audio mixer task and close audio mixer\n\n await this.audioSource.close();\n\n this.agentSession?.off(AgentSessionEventTypes.AgentStateChanged, this.onAgentStateChanged);\n this.room?.off('reconnected', this.onReconnected);\n\n if (this.publication && this.publication.sid) {\n await this.room?.localParticipant?.unpublishTrack(this.publication.sid);\n }\n }\n\n /**\n * Get the current track publication\n */\n getPublication(): LocalTrackPublication | undefined {\n return this.publication;\n }\n\n private async publishTrack(): Promise<void> {\n if (this.publication !== undefined) {\n return;\n }\n\n const track = LocalAudioTrack.createAudioTrack('background_audio', this.audioSource);\n\n if (this.room?.localParticipant === undefined) {\n throw new Error('Local participant not available');\n }\n\n const publication = await this.room.localParticipant.publishTrack(\n track,\n this.trackPublishOptions ?? new TrackPublishOptions(),\n );\n\n this.publication = publication;\n this.#logger.debug(`Background audio track published: ${this.publication.sid}`);\n }\n\n private onReconnected = (): void => {\n if (this.republishTask) {\n this.republishTask.cancel();\n }\n\n this.publication = undefined;\n this.republishTask = Task.from(async () => {\n await this.republishTrackTask();\n });\n };\n\n private async republishTrackTask(): Promise<void> {\n // TODO (Brian): add lock protection when implementing lock\n await this.publishTrack();\n }\n\n private onAgentStateChanged = (ev: AgentStateChangedEvent): void => {\n if (!this.thinkingSound) {\n return;\n }\n\n if (ev.newState === 'thinking') {\n if (this.thinkingHandle && !this.thinkingHandle.done()) {\n return;\n }\n\n // TODO (Brian): play thinking sound and assign to thinkingHandle\n } else {\n this.thinkingHandle?.stop();\n }\n };\n\n private async playTask({\n playHandle,\n sound,\n volume,\n loop,\n signal,\n }: {\n playHandle: PlayHandle;\n sound: AudioSourceType;\n volume: number;\n loop: boolean;\n signal: AbortSignal;\n }): Promise<void> {\n if (isBuiltinAudioClip(sound)) {\n sound = getBuiltinAudioPath(sound);\n }\n\n if (typeof sound === 'string') {\n sound = loop\n ? loopAudioFramesFromFile(sound, { abortSignal: signal })\n : audioFramesFromFile(sound, { abortSignal: signal });\n }\n\n try {\n for await (const frame of sound) {\n if (signal.aborted || playHandle.done()) break;\n\n let processedFrame: AudioFrame;\n\n if (volume !== 1.0) {\n const int16Data = new Int16Array(\n frame.data.buffer,\n frame.data.byteOffset,\n frame.data.byteLength / 2,\n );\n const float32Data = new Float32Array(int16Data.length);\n\n for (let i = 0; i < int16Data.length; i++) {\n float32Data[i] = int16Data[i]!;\n }\n\n const volumeFactor = 10 ** Math.log10(volume);\n for (let i = 0; i < float32Data.length; i++) {\n float32Data[i]! *= volumeFactor;\n }\n\n const outputData = new Int16Array(float32Data.length);\n for (let i = 0; i < float32Data.length; i++) {\n const clipped = Math.max(-32768, Math.min(32767, float32Data[i]!));\n outputData[i] = Math.round(clipped);\n }\n\n processedFrame = new AudioFrame(\n outputData,\n frame.sampleRate,\n frame.channels,\n frame.samplesPerChannel,\n );\n } else {\n processedFrame = frame;\n }\n\n // TODO (Brian): use AudioMixer to add/remove frame streams\n await this.audioSource.captureFrame(processedFrame);\n }\n } finally {\n // TODO: the waitForPlayout() may be innaccurate by 400ms\n playHandle._markPlayoutDone();\n }\n }\n}\n"],"mappings":"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","names":["BuiltinAudioClip"]}
|
package/dist/voice/index.cjs
CHANGED
|
@@ -28,6 +28,7 @@ module.exports = __toCommonJS(voice_exports);
|
|
|
28
28
|
var import_agent = require("./agent.cjs");
|
|
29
29
|
var import_agent_session = require("./agent_session.cjs");
|
|
30
30
|
__reExport(voice_exports, require("./avatar/index.cjs"), module.exports);
|
|
31
|
+
__reExport(voice_exports, require("./background_audio.cjs"), module.exports);
|
|
31
32
|
__reExport(voice_exports, require("./events.cjs"), module.exports);
|
|
32
33
|
__reExport(voice_exports, require("./room_io/index.cjs"), module.exports);
|
|
33
34
|
var import_run_context = require("./run_context.cjs");
|
|
@@ -38,6 +39,7 @@ var import_run_context = require("./run_context.cjs");
|
|
|
38
39
|
RunContext,
|
|
39
40
|
StopResponse,
|
|
40
41
|
...require("./avatar/index.cjs"),
|
|
42
|
+
...require("./background_audio.cjs"),
|
|
41
43
|
...require("./events.cjs"),
|
|
42
44
|
...require("./room_io/index.cjs")
|
|
43
45
|
});
|
package/dist/voice/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/voice/index.ts"],"sourcesContent":["// SPDX-FileCopyrightText: 2025 LiveKit, Inc.\n//\n// SPDX-License-Identifier: Apache-2.0\nexport { Agent, StopResponse, type AgentOptions, type ModelSettings } from './agent.js';\nexport { AgentSession, type AgentSessionOptions } from './agent_session.js';\
|
|
1
|
+
{"version":3,"sources":["../../src/voice/index.ts"],"sourcesContent":["// SPDX-FileCopyrightText: 2025 LiveKit, Inc.\n//\n// SPDX-License-Identifier: Apache-2.0\nexport { Agent, StopResponse, type AgentOptions, type ModelSettings } from './agent.js';\nexport { AgentSession, type AgentSessionOptions } from './agent_session.js';\nexport * from './avatar/index.js';\nexport * from './background_audio.js';\nexport * from './events.js';\nexport * from './room_io/index.js';\nexport { RunContext } from './run_context.js';\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAGA,mBAA2E;AAC3E,2BAAuD;AACvD,0BAAc,8BALd;AAMA,0BAAc,kCANd;AAOA,0BAAc,wBAPd;AAQA,0BAAc,+BARd;AASA,yBAA2B;","names":[]}
|
package/dist/voice/index.d.cts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { Agent, StopResponse, type AgentOptions, type ModelSettings } from './agent.js';
|
|
2
2
|
export { AgentSession, type AgentSessionOptions } from './agent_session.js';
|
|
3
3
|
export * from './avatar/index.js';
|
|
4
|
+
export * from './background_audio.js';
|
|
4
5
|
export * from './events.js';
|
|
5
6
|
export * from './room_io/index.js';
|
|
6
7
|
export { RunContext } from './run_context.js';
|
package/dist/voice/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { Agent, StopResponse, type AgentOptions, type ModelSettings } from './agent.js';
|
|
2
2
|
export { AgentSession, type AgentSessionOptions } from './agent_session.js';
|
|
3
3
|
export * from './avatar/index.js';
|
|
4
|
+
export * from './background_audio.js';
|
|
4
5
|
export * from './events.js';
|
|
5
6
|
export * from './room_io/index.js';
|
|
6
7
|
export { RunContext } from './run_context.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/voice/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,EAAE,YAAY,EAAE,KAAK,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,YAAY,CAAC;AACxF,OAAO,EAAE,YAAY,EAAE,KAAK,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/voice/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,EAAE,YAAY,EAAE,KAAK,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,YAAY,CAAC;AACxF,OAAO,EAAE,YAAY,EAAE,KAAK,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAC5E,cAAc,mBAAmB,CAAC;AAClC,cAAc,uBAAuB,CAAC;AACtC,cAAc,aAAa,CAAC;AAC5B,cAAc,oBAAoB,CAAC;AACnC,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC"}
|
package/dist/voice/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Agent, StopResponse } from "./agent.js";
|
|
2
2
|
import { AgentSession } from "./agent_session.js";
|
|
3
3
|
export * from "./avatar/index.js";
|
|
4
|
+
export * from "./background_audio.js";
|
|
4
5
|
export * from "./events.js";
|
|
5
6
|
export * from "./room_io/index.js";
|
|
6
7
|
import { RunContext } from "./run_context.js";
|
package/dist/voice/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/voice/index.ts"],"sourcesContent":["// SPDX-FileCopyrightText: 2025 LiveKit, Inc.\n//\n// SPDX-License-Identifier: Apache-2.0\nexport { Agent, StopResponse, type AgentOptions, type ModelSettings } from './agent.js';\nexport { AgentSession, type AgentSessionOptions } from './agent_session.js';\
|
|
1
|
+
{"version":3,"sources":["../../src/voice/index.ts"],"sourcesContent":["// SPDX-FileCopyrightText: 2025 LiveKit, Inc.\n//\n// SPDX-License-Identifier: Apache-2.0\nexport { Agent, StopResponse, type AgentOptions, type ModelSettings } from './agent.js';\nexport { AgentSession, type AgentSessionOptions } from './agent_session.js';\nexport * from './avatar/index.js';\nexport * from './background_audio.js';\nexport * from './events.js';\nexport * from './room_io/index.js';\nexport { RunContext } from './run_context.js';\n"],"mappings":"AAGA,SAAS,OAAO,oBAA2D;AAC3E,SAAS,oBAA8C;AACvD,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAAS,kBAAkB;","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livekit/agents",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.13",
|
|
4
4
|
"description": "LiveKit Agents - Node.js",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"require": "dist/index.cjs",
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
"default": "./dist/index.js"
|
|
12
12
|
},
|
|
13
13
|
"require": {
|
|
14
|
-
"types": "./index.d.cts",
|
|
15
|
-
"default": "./index.cjs"
|
|
14
|
+
"types": "./dist/index.d.cts",
|
|
15
|
+
"default": "./dist/index.cjs"
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
"author": "LiveKit",
|
|
@@ -22,14 +22,18 @@
|
|
|
22
22
|
"files": [
|
|
23
23
|
"dist",
|
|
24
24
|
"src",
|
|
25
|
+
"resources",
|
|
25
26
|
"README.md"
|
|
26
27
|
],
|
|
27
28
|
"devDependencies": {
|
|
28
|
-
"@
|
|
29
|
+
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
|
30
|
+
"@livekit/rtc-node": "^0.13.13",
|
|
29
31
|
"@microsoft/api-extractor": "^7.35.0",
|
|
32
|
+
"@types/fluent-ffmpeg": "^2.1.28",
|
|
30
33
|
"@types/json-schema": "^7.0.15",
|
|
31
34
|
"@types/node": "^22.5.5",
|
|
32
35
|
"@types/ws": "^8.5.10",
|
|
36
|
+
"fluent-ffmpeg": "^2.1.3",
|
|
33
37
|
"tsup": "^8.4.0",
|
|
34
38
|
"typescript": "^5.0.0"
|
|
35
39
|
},
|
|
@@ -41,8 +45,8 @@
|
|
|
41
45
|
"commander": "^12.0.0",
|
|
42
46
|
"heap-js": "^2.6.0",
|
|
43
47
|
"json-schema": "^0.4.0",
|
|
44
|
-
"openai": "^4.91.1",
|
|
45
48
|
"livekit-server-sdk": "^2.13.3",
|
|
49
|
+
"openai": "^4.91.1",
|
|
46
50
|
"pidusage": "^4.0.1",
|
|
47
51
|
"pino": "^8.19.0",
|
|
48
52
|
"pino-pretty": "^11.0.0",
|
package/resources/NOTICE
ADDED
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/src/audio.ts
CHANGED
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2024 LiveKit, Inc.
|
|
2
2
|
//
|
|
3
3
|
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
import ffmpegInstaller from '@ffmpeg-installer/ffmpeg';
|
|
4
5
|
import { AudioFrame } from '@livekit/rtc-node';
|
|
6
|
+
import ffmpeg from 'fluent-ffmpeg';
|
|
7
|
+
import type { ReadableStream } from 'node:stream/web';
|
|
5
8
|
import { log } from './log.js';
|
|
9
|
+
import { createStreamChannel } from './stream/stream_channel.js';
|
|
6
10
|
import type { AudioBuffer } from './utils.js';
|
|
7
11
|
|
|
12
|
+
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
|
|
13
|
+
|
|
14
|
+
export interface AudioDecodeOptions {
|
|
15
|
+
sampleRate?: number;
|
|
16
|
+
numChannels?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Audio format hint (e.g., 'mp3', 'ogg', 'wav', 'opus')
|
|
19
|
+
* If not provided, FFmpeg will auto-detect
|
|
20
|
+
*/
|
|
21
|
+
format?: string;
|
|
22
|
+
abortSignal?: AbortSignal;
|
|
23
|
+
}
|
|
24
|
+
|
|
8
25
|
export function calculateAudioDurationSeconds(frame: AudioBuffer) {
|
|
9
26
|
// TODO(AJS-102): use frame.durationMs once available in rtc-node
|
|
10
27
|
return Array.isArray(frame)
|
|
@@ -72,3 +89,117 @@ export class AudioByteStream {
|
|
|
72
89
|
return frames;
|
|
73
90
|
}
|
|
74
91
|
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Decode an audio file into AudioFrame instances
|
|
95
|
+
*
|
|
96
|
+
* @param filePath - Path to the audio file
|
|
97
|
+
* @param options - Decoding options
|
|
98
|
+
* @returns AsyncGenerator that yields AudioFrame objects
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* ```typescript
|
|
102
|
+
* for await (const frame of audioFramesFromFile('audio.ogg', { sampleRate: 48000 })) {
|
|
103
|
+
* console.log('Frame:', frame.samplesPerChannel, 'samples');
|
|
104
|
+
* }
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
export function audioFramesFromFile(
|
|
108
|
+
filePath: string,
|
|
109
|
+
options: AudioDecodeOptions = {},
|
|
110
|
+
): ReadableStream<AudioFrame> {
|
|
111
|
+
const sampleRate = options.sampleRate ?? 48000;
|
|
112
|
+
const numChannels = options.numChannels ?? 1;
|
|
113
|
+
|
|
114
|
+
const audioStream = new AudioByteStream(sampleRate, numChannels);
|
|
115
|
+
const channel = createStreamChannel<AudioFrame>();
|
|
116
|
+
const logger = log();
|
|
117
|
+
|
|
118
|
+
// TODO (Brian): decode WAV using a custom decoder instead of FFmpeg
|
|
119
|
+
const command = ffmpeg(filePath)
|
|
120
|
+
.inputOptions([
|
|
121
|
+
'-probesize',
|
|
122
|
+
'32',
|
|
123
|
+
'-analyzeduration',
|
|
124
|
+
'0',
|
|
125
|
+
'-fflags',
|
|
126
|
+
'+nobuffer+flush_packets',
|
|
127
|
+
'-flags',
|
|
128
|
+
'low_delay',
|
|
129
|
+
])
|
|
130
|
+
.format('s16le') // signed 16-bit little-endian PCM to be consistent cross-platform
|
|
131
|
+
.audioChannels(numChannels)
|
|
132
|
+
.audioFrequency(sampleRate);
|
|
133
|
+
|
|
134
|
+
let commandRunning = true;
|
|
135
|
+
|
|
136
|
+
const onClose = () => {
|
|
137
|
+
logger.debug('Audio file playback aborted');
|
|
138
|
+
|
|
139
|
+
channel.close();
|
|
140
|
+
if (commandRunning) {
|
|
141
|
+
commandRunning = false;
|
|
142
|
+
command.kill('SIGKILL');
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const outputStream = command.pipe();
|
|
147
|
+
options.abortSignal?.addEventListener('abort', onClose, { once: true });
|
|
148
|
+
|
|
149
|
+
outputStream.on('data', (chunk: Buffer) => {
|
|
150
|
+
const arrayBuffer = chunk.buffer.slice(
|
|
151
|
+
chunk.byteOffset,
|
|
152
|
+
chunk.byteOffset + chunk.byteLength,
|
|
153
|
+
) as ArrayBuffer;
|
|
154
|
+
|
|
155
|
+
const frames = audioStream.write(arrayBuffer);
|
|
156
|
+
for (const frame of frames) {
|
|
157
|
+
channel.write(frame);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
outputStream.on('end', () => {
|
|
162
|
+
const frames = audioStream.flush();
|
|
163
|
+
for (const frame of frames) {
|
|
164
|
+
channel.write(frame);
|
|
165
|
+
}
|
|
166
|
+
commandRunning = false;
|
|
167
|
+
channel.close();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
outputStream.on('error', (err: Error) => {
|
|
171
|
+
logger.error(err);
|
|
172
|
+
commandRunning = false;
|
|
173
|
+
onClose();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return channel.stream();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Loop audio frames from a file indefinitely
|
|
181
|
+
*
|
|
182
|
+
* @param filePath - Path to the audio file
|
|
183
|
+
* @param options - Decoding options
|
|
184
|
+
* @returns AsyncGenerator that yields AudioFrame objects in an infinite loop
|
|
185
|
+
*/
|
|
186
|
+
export async function* loopAudioFramesFromFile(
|
|
187
|
+
filePath: string,
|
|
188
|
+
options: AudioDecodeOptions = {},
|
|
189
|
+
): AsyncGenerator<AudioFrame, void, unknown> {
|
|
190
|
+
const frames: AudioFrame[] = [];
|
|
191
|
+
const logger = log();
|
|
192
|
+
|
|
193
|
+
for await (const frame of audioFramesFromFile(filePath, options)) {
|
|
194
|
+
frames.push(frame);
|
|
195
|
+
yield frame;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
while (!options.abortSignal?.aborted) {
|
|
199
|
+
for (const frame of frames) {
|
|
200
|
+
yield frame;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
logger.debug('Audio file playback loop finished');
|
|
205
|
+
}
|
package/src/metrics/index.ts
CHANGED
|
@@ -126,4 +126,41 @@ describe('StreamChannel', () => {
|
|
|
126
126
|
const nextResult = await reader.read();
|
|
127
127
|
expect(nextResult.done).toBe(true);
|
|
128
128
|
});
|
|
129
|
+
|
|
130
|
+
it('should gracefully handle close while read is pending', async () => {
|
|
131
|
+
const channel = createStreamChannel<string>();
|
|
132
|
+
const reader = channel.stream().getReader();
|
|
133
|
+
|
|
134
|
+
const readPromise = reader.read();
|
|
135
|
+
|
|
136
|
+
await channel.close();
|
|
137
|
+
|
|
138
|
+
const result = await readPromise;
|
|
139
|
+
expect(result.done).toBe(true);
|
|
140
|
+
expect(result.value).toBeUndefined();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should complete all pending reads when closed', async () => {
|
|
144
|
+
const channel = createStreamChannel<number>();
|
|
145
|
+
const reader = channel.stream().getReader();
|
|
146
|
+
|
|
147
|
+
const read1 = reader.read();
|
|
148
|
+
const read2 = reader.read();
|
|
149
|
+
const read3 = reader.read();
|
|
150
|
+
|
|
151
|
+
await channel.write(42);
|
|
152
|
+
await channel.write(43);
|
|
153
|
+
await channel.close();
|
|
154
|
+
|
|
155
|
+
const result1 = await read1;
|
|
156
|
+
expect(result1.done).toBe(false);
|
|
157
|
+
expect(result1.value).toBe(42);
|
|
158
|
+
|
|
159
|
+
const result2 = await read2;
|
|
160
|
+
expect(result2.done).toBe(false);
|
|
161
|
+
expect(result2.value).toBe(43);
|
|
162
|
+
|
|
163
|
+
const result3 = await read3;
|
|
164
|
+
expect(result3.done).toBe(true);
|
|
165
|
+
});
|
|
129
166
|
});
|