@livekit/agents-plugin-azure 1.5.0
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/LICENSE +201 -0
- package/README.md +18 -0
- package/dist/index.cjs +34 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/stt.cjs +465 -0
- package/dist/stt.cjs.map +1 -0
- package/dist/stt.d.cts +106 -0
- package/dist/stt.d.ts +106 -0
- package/dist/stt.d.ts.map +1 -0
- package/dist/stt.js +435 -0
- package/dist/stt.js.map +1 -0
- package/dist/stt.test.cjs +209 -0
- package/dist/stt.test.cjs.map +1 -0
- package/dist/stt.test.d.cts +2 -0
- package/dist/stt.test.d.ts +2 -0
- package/dist/stt.test.d.ts.map +1 -0
- package/dist/stt.test.js +208 -0
- package/dist/stt.test.js.map +1 -0
- package/package.json +50 -0
- package/src/index.ts +18 -0
- package/src/stt.test.ts +245 -0
- package/src/stt.ts +585 -0
package/src/stt.test.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
import { stt } from '@livekit/agents';
|
|
5
|
+
import { AudioFrame } from '@livekit/rtc-node';
|
|
6
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { STT, SpeechStream, speechsdk } from './stt.js';
|
|
8
|
+
|
|
9
|
+
const azureHarness = vi.hoisted(() => ({
|
|
10
|
+
activeReaders: 0,
|
|
11
|
+
cancellationErrors: 0,
|
|
12
|
+
deadStreamWrites: 0,
|
|
13
|
+
maxActiveReaders: 0,
|
|
14
|
+
recognizers: [] as Array<{
|
|
15
|
+
canceled?: (_sender: unknown, event: unknown) => void;
|
|
16
|
+
sessionStarted?: (_sender: unknown, event: unknown) => void;
|
|
17
|
+
sessionStopped?: (_sender: unknown, event: unknown) => void;
|
|
18
|
+
speechStartDetected?: (_sender: unknown, event: unknown) => void;
|
|
19
|
+
}>,
|
|
20
|
+
streams: [] as Array<{
|
|
21
|
+
closed: boolean;
|
|
22
|
+
frames: number[];
|
|
23
|
+
}>,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock('microsoft-cognitiveservices-speech-sdk', async (importOriginal) => {
|
|
27
|
+
const actual = await importOriginal<typeof import('microsoft-cognitiveservices-speech-sdk')>();
|
|
28
|
+
|
|
29
|
+
class FakePushStream {
|
|
30
|
+
closed = false;
|
|
31
|
+
frames: number[] = [];
|
|
32
|
+
|
|
33
|
+
write(buffer: ArrayBuffer): void {
|
|
34
|
+
if (this.closed) {
|
|
35
|
+
azureHarness.deadStreamWrites += 1;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
this.frames.push(new Int16Array(buffer)[0] ?? 0);
|
|
39
|
+
if (azureHarness.streams[0] === this && this.frames.length === 1) {
|
|
40
|
+
queueMicrotask(() => {
|
|
41
|
+
azureHarness.cancellationErrors += 1;
|
|
42
|
+
azureHarness.recognizers[0]?.canceled?.(undefined, {
|
|
43
|
+
reason: actual.CancellationReason.Error,
|
|
44
|
+
errorCode: actual.CancellationErrorCode.ServiceTimeout,
|
|
45
|
+
errorDetails: 'timeout',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
close(): void {
|
|
52
|
+
this.closed = true;
|
|
53
|
+
const index = azureHarness.streams.indexOf(this);
|
|
54
|
+
queueMicrotask(() => {
|
|
55
|
+
azureHarness.recognizers[index]?.sessionStopped?.(undefined, {});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
class FakeRecognizer {
|
|
61
|
+
recognizing?: (_sender: unknown, event: unknown) => void;
|
|
62
|
+
recognized?: (_sender: unknown, event: unknown) => void;
|
|
63
|
+
speechStartDetected?: (_sender: unknown, event: unknown) => void;
|
|
64
|
+
speechEndDetected?: (_sender: unknown, event: unknown) => void;
|
|
65
|
+
sessionStarted?: (_sender: unknown, event: unknown) => void;
|
|
66
|
+
sessionStopped?: (_sender: unknown, event: unknown) => void;
|
|
67
|
+
canceled?: (_sender: unknown, event: unknown) => void;
|
|
68
|
+
|
|
69
|
+
constructor() {
|
|
70
|
+
azureHarness.recognizers.push(this);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
startContinuousRecognitionAsync(resolve: () => void): void {
|
|
74
|
+
resolve();
|
|
75
|
+
queueMicrotask(() => {
|
|
76
|
+
this.sessionStarted?.(undefined, {});
|
|
77
|
+
this.speechStartDetected?.(undefined, {});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
stopContinuousRecognitionAsync(resolve: () => void): void {
|
|
82
|
+
resolve();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
close(): void {}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
...actual,
|
|
90
|
+
AudioConfig: {
|
|
91
|
+
...actual.AudioConfig,
|
|
92
|
+
fromStreamInput: () => ({}),
|
|
93
|
+
},
|
|
94
|
+
AudioInputStream: {
|
|
95
|
+
...actual.AudioInputStream,
|
|
96
|
+
createPushStream: () => {
|
|
97
|
+
const stream = new FakePushStream();
|
|
98
|
+
azureHarness.streams.push(stream);
|
|
99
|
+
return stream;
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
SpeechRecognizer: FakeRecognizer,
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
function canceledEvent(
|
|
107
|
+
reason: speechsdk.CancellationReason,
|
|
108
|
+
errorCode?: speechsdk.CancellationErrorCode,
|
|
109
|
+
errorDetails = '',
|
|
110
|
+
) {
|
|
111
|
+
return { reason, errorCode, errorDetails };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
describe('Azure STT cancellation handling', () => {
|
|
115
|
+
beforeEach(() => {
|
|
116
|
+
azureHarness.activeReaders = 0;
|
|
117
|
+
azureHarness.cancellationErrors = 0;
|
|
118
|
+
azureHarness.deadStreamWrites = 0;
|
|
119
|
+
azureHarness.maxActiveReaders = 0;
|
|
120
|
+
azureHarness.recognizers.length = 0;
|
|
121
|
+
azureHarness.streams.length = 0;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('unblocks run on canceled error', () => {
|
|
125
|
+
const stream = SpeechStream.prototype as SpeechStream;
|
|
126
|
+
const testStream = Object.create(stream) as SpeechStream;
|
|
127
|
+
testStream._sessionStoppedEvent = {
|
|
128
|
+
isSet: false,
|
|
129
|
+
set() {
|
|
130
|
+
this.isSet = true;
|
|
131
|
+
},
|
|
132
|
+
clear() {
|
|
133
|
+
this.isSet = false;
|
|
134
|
+
},
|
|
135
|
+
wait: () => Promise.resolve(),
|
|
136
|
+
} as SpeechStream['_sessionStoppedEvent'];
|
|
137
|
+
testStream._cancellationError = null;
|
|
138
|
+
|
|
139
|
+
const event = canceledEvent(
|
|
140
|
+
speechsdk.CancellationReason.Error,
|
|
141
|
+
speechsdk.CancellationErrorCode.ServiceTimeout,
|
|
142
|
+
'timeout',
|
|
143
|
+
);
|
|
144
|
+
testStream._onCanceled(event);
|
|
145
|
+
|
|
146
|
+
expect(testStream._sessionStoppedEvent.isSet).toBe(true);
|
|
147
|
+
expect(testStream._cancellationError).toBe(event);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('ignores cancellations without error', () => {
|
|
151
|
+
const stream = SpeechStream.prototype as SpeechStream;
|
|
152
|
+
const testStream = Object.create(stream) as SpeechStream;
|
|
153
|
+
testStream._sessionStoppedEvent = {
|
|
154
|
+
isSet: false,
|
|
155
|
+
set() {
|
|
156
|
+
this.isSet = true;
|
|
157
|
+
},
|
|
158
|
+
clear() {
|
|
159
|
+
this.isSet = false;
|
|
160
|
+
},
|
|
161
|
+
wait: () => Promise.resolve(),
|
|
162
|
+
} as SpeechStream['_sessionStoppedEvent'];
|
|
163
|
+
testStream._cancellationError = null;
|
|
164
|
+
|
|
165
|
+
testStream._onCanceled(canceledEvent(speechsdk.CancellationReason.EndOfStream));
|
|
166
|
+
|
|
167
|
+
expect(testStream._sessionStoppedEvent.isSet).toBe(false);
|
|
168
|
+
expect(testStream._cancellationError).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('replaces a canceled recognizer without leaving its input consumer alive', async () => {
|
|
172
|
+
const stt = new STT({ speechHost: 'wss://azure.test' });
|
|
173
|
+
const stream = stt.stream({
|
|
174
|
+
connOptions: { maxRetry: 1, retryIntervalMs: 0, timeoutMs: 1000 },
|
|
175
|
+
});
|
|
176
|
+
const internal = stream as unknown as {
|
|
177
|
+
input: {
|
|
178
|
+
next(options?: { signal?: AbortSignal }): Promise<IteratorResult<AudioFrame | symbol>>;
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
const originalNext = internal.input.next.bind(internal.input);
|
|
182
|
+
internal.input.next = async (options = {}) => {
|
|
183
|
+
azureHarness.activeReaders += 1;
|
|
184
|
+
azureHarness.maxActiveReaders = Math.max(
|
|
185
|
+
azureHarness.maxActiveReaders,
|
|
186
|
+
azureHarness.activeReaders,
|
|
187
|
+
);
|
|
188
|
+
try {
|
|
189
|
+
return await originalNext(options);
|
|
190
|
+
} finally {
|
|
191
|
+
azureHarness.activeReaders -= 1;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
stream.pushFrame(frame(1));
|
|
196
|
+
await vi.waitFor(() => expect(azureHarness.recognizers).toHaveLength(2));
|
|
197
|
+
stream.pushFrame(frame(2));
|
|
198
|
+
stream.pushFrame(frame(3));
|
|
199
|
+
await vi.waitFor(() => expect(azureHarness.streams[1]?.frames).toEqual([2, 3]));
|
|
200
|
+
|
|
201
|
+
expect(azureHarness.cancellationErrors).toBe(1);
|
|
202
|
+
expect(azureHarness.maxActiveReaders).toBe(1);
|
|
203
|
+
expect(azureHarness.streams[0]?.closed).toBe(true);
|
|
204
|
+
expect(azureHarness.deadStreamWrites).toBe(0);
|
|
205
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
206
|
+
expect(azureHarness.recognizers).toHaveLength(2);
|
|
207
|
+
|
|
208
|
+
stream.close();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('emits speech start after replacing a canceled recognizer mid-speech', async () => {
|
|
212
|
+
const azureStt = new STT({ speechHost: 'wss://azure.test' });
|
|
213
|
+
const stream = azureStt.stream({
|
|
214
|
+
connOptions: { maxRetry: 1, retryIntervalMs: 0, timeoutMs: 1000 },
|
|
215
|
+
});
|
|
216
|
+
const startEvents: stt.SpeechEventType[] = [];
|
|
217
|
+
const collectEvents = (async () => {
|
|
218
|
+
for await (const event of stream) {
|
|
219
|
+
if (event.type === stt.SpeechEventType.START_OF_SPEECH) {
|
|
220
|
+
startEvents.push(event.type);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
})();
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
await vi.waitFor(() => expect(startEvents).toEqual([stt.SpeechEventType.START_OF_SPEECH]));
|
|
227
|
+
stream.pushFrame(frame(1));
|
|
228
|
+
await vi.waitFor(() => expect(azureHarness.recognizers).toHaveLength(2));
|
|
229
|
+
|
|
230
|
+
await vi.waitFor(() =>
|
|
231
|
+
expect(startEvents).toEqual([
|
|
232
|
+
stt.SpeechEventType.START_OF_SPEECH,
|
|
233
|
+
stt.SpeechEventType.START_OF_SPEECH,
|
|
234
|
+
]),
|
|
235
|
+
);
|
|
236
|
+
} finally {
|
|
237
|
+
stream.close();
|
|
238
|
+
await collectEvents;
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
function frame(value: number): AudioFrame {
|
|
244
|
+
return new AudioFrame(new Int16Array([value]), 16000, 1, 1);
|
|
245
|
+
}
|