@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/src/stt.ts ADDED
@@ -0,0 +1,585 @@
1
+ // SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ import {
5
+ type APIConnectOptions,
6
+ APIConnectionError,
7
+ type AudioBuffer,
8
+ DEFAULT_API_CONNECT_OPTIONS,
9
+ asLanguageCode,
10
+ log,
11
+ stt,
12
+ } from '@livekit/agents';
13
+ import type { AudioFrame } from '@livekit/rtc-node';
14
+ import * as speechsdk from 'microsoft-cognitiveservices-speech-sdk';
15
+
16
+ export { speechsdk };
17
+
18
+ /** @public */
19
+ export interface STTOptions {
20
+ /** Azure Speech subscription key. Defaults to `AZURE_SPEECH_KEY`. */
21
+ speechKey?: string;
22
+ /** Azure Speech region. Defaults to `AZURE_SPEECH_REGION`. */
23
+ speechRegion?: string;
24
+ /** Azure Speech container host. Defaults to `AZURE_SPEECH_HOST`. */
25
+ speechHost?: string;
26
+ /** Ephemeral Microsoft Entra auth token. */
27
+ speechAuthToken?: string;
28
+ /** Azure Speech endpoint URL. */
29
+ speechEndpoint?: string;
30
+ sampleRate: number;
31
+ numChannels: number;
32
+ segmentationSilenceTimeoutMs?: number;
33
+ segmentationMaxTimeMs?: number;
34
+ segmentationStrategy?: string;
35
+ language: string[];
36
+ profanity?: speechsdk.ProfanityOption;
37
+ phraseList?: string[] | null;
38
+ explicitPunctuation: boolean;
39
+ trueTextPostProcessing: boolean;
40
+ }
41
+
42
+ /** @public */
43
+ export type STTUpdateOptions = Partial<
44
+ Pick<
45
+ STTOptions,
46
+ 'segmentationSilenceTimeoutMs' | 'segmentationMaxTimeMs' | 'segmentationStrategy'
47
+ >
48
+ > & {
49
+ language?: string | string[];
50
+ };
51
+
52
+ /** @public */
53
+ export interface STTConstructorOptions extends STTUpdateOptions {
54
+ speechKey?: string;
55
+ speechRegion?: string;
56
+ speechHost?: string;
57
+ speechAuthToken?: string;
58
+ speechEndpoint?: string;
59
+ sampleRate?: number;
60
+ numChannels?: number;
61
+ profanity?: speechsdk.ProfanityOption;
62
+ phraseList?: string[] | null;
63
+ explicitPunctuation?: boolean;
64
+ trueTextPostProcessing?: boolean;
65
+ }
66
+
67
+ /** @internal */
68
+ export interface _CanceledEvent {
69
+ errorDetails?: string;
70
+ reason: speechsdk.CancellationReason;
71
+ errorCode?: speechsdk.CancellationErrorCode;
72
+ }
73
+
74
+ /** @internal */
75
+ export interface _WaitableEvent {
76
+ isSet: boolean;
77
+ wait(): Promise<void>;
78
+ set(): void;
79
+ clear(): void;
80
+ }
81
+
82
+ class DeferredEvent implements _WaitableEvent {
83
+ #resolve?: () => void;
84
+ #promise: Promise<void>;
85
+ isSet = false;
86
+
87
+ constructor() {
88
+ this.#promise = new Promise<void>((resolve) => {
89
+ this.#resolve = resolve;
90
+ });
91
+ }
92
+
93
+ wait(): Promise<void> {
94
+ return this.#promise;
95
+ }
96
+
97
+ set(): void {
98
+ if (this.isSet) return;
99
+ this.isSet = true;
100
+ this.#resolve?.();
101
+ }
102
+
103
+ clear(): void {
104
+ if (!this.isSet) return;
105
+ this.isSet = false;
106
+ this.#promise = new Promise<void>((resolve) => {
107
+ this.#resolve = resolve;
108
+ });
109
+ }
110
+ }
111
+
112
+ const defaultSTTOptions = {
113
+ sampleRate: 16000,
114
+ numChannels: 1,
115
+ language: ['en-US'],
116
+ explicitPunctuation: false,
117
+ trueTextPostProcessing: false,
118
+ } satisfies Pick<
119
+ STTOptions,
120
+ 'sampleRate' | 'numChannels' | 'language' | 'explicitPunctuation' | 'trueTextPostProcessing'
121
+ >;
122
+
123
+ /** @public */
124
+ export class STT extends stt.STT {
125
+ #opts: STTOptions;
126
+ #streams = new Set<WeakRef<SpeechStream>>();
127
+ label = 'azure.STT';
128
+
129
+ get model(): string {
130
+ return 'unknown';
131
+ }
132
+
133
+ get provider(): string {
134
+ return 'Azure STT';
135
+ }
136
+
137
+ constructor(opts: STTConstructorOptions = {}) {
138
+ super({ streaming: true, interimResults: true, alignedTranscript: 'chunk' });
139
+
140
+ const speechHost = opts.speechHost ?? process.env.AZURE_SPEECH_HOST;
141
+ const speechKey = opts.speechKey ?? process.env.AZURE_SPEECH_KEY;
142
+ const speechRegion = opts.speechRegion ?? process.env.AZURE_SPEECH_REGION;
143
+ const speechAuthToken = opts.speechAuthToken;
144
+ const speechEndpoint = opts.speechEndpoint;
145
+
146
+ if (
147
+ !speechHost &&
148
+ !(speechKey && speechRegion) &&
149
+ !(speechAuthToken && speechRegion) &&
150
+ !(speechKey && speechEndpoint)
151
+ ) {
152
+ throw new Error(
153
+ 'AZURE_SPEECH_HOST or AZURE_SPEECH_KEY and AZURE_SPEECH_REGION or speechAuthToken and AZURE_SPEECH_REGION or AZURE_SPEECH_KEY and speechEndpoint must be set',
154
+ );
155
+ }
156
+
157
+ if (speechRegion && speechEndpoint) {
158
+ log().warn('speechRegion and speechEndpoint both are set, using speechEndpoint');
159
+ }
160
+
161
+ this.#opts = {
162
+ ...defaultSTTOptions,
163
+ ...opts,
164
+ speechHost,
165
+ speechKey,
166
+ speechRegion: speechEndpoint ? undefined : speechRegion,
167
+ speechAuthToken,
168
+ speechEndpoint,
169
+ language: normalizeLanguages(opts.language),
170
+ };
171
+ }
172
+
173
+ async _recognize(_frame: AudioBuffer): Promise<stt.SpeechEvent> {
174
+ throw new Error('Azure STT does not support single frame recognition');
175
+ }
176
+
177
+ updateOptions(opts: STTUpdateOptions): void {
178
+ this.#opts = {
179
+ ...this.#opts,
180
+ ...opts,
181
+ language:
182
+ opts.language === undefined ? this.#opts.language : normalizeLanguages(opts.language),
183
+ };
184
+
185
+ for (const ref of this.#streams) {
186
+ const stream = ref.deref();
187
+ if (stream) {
188
+ stream.updateOptions(opts);
189
+ } else {
190
+ this.#streams.delete(ref);
191
+ }
192
+ }
193
+ }
194
+
195
+ stream(options: { language?: string; connOptions?: APIConnectOptions } = {}): SpeechStream {
196
+ const opts = {
197
+ ...this.#opts,
198
+ language: options.language ? [options.language] : [...this.#opts.language],
199
+ };
200
+ const stream = new SpeechStream(this, opts, options.connOptions ?? DEFAULT_API_CONNECT_OPTIONS);
201
+ this.#streams.add(new WeakRef(stream));
202
+ return stream;
203
+ }
204
+ }
205
+
206
+ /** @public */
207
+ export class SpeechStream extends stt.SpeechStream {
208
+ /** @internal */
209
+ _opts: STTOptions;
210
+ /** @internal */
211
+ _speaking = false;
212
+ /** @internal */
213
+ _sessionStoppedEvent: _WaitableEvent = new DeferredEvent();
214
+ /** @internal */
215
+ _sessionStartedEvent: _WaitableEvent = new DeferredEvent();
216
+ /** @internal */
217
+ _reconnectEvent: _WaitableEvent = new DeferredEvent();
218
+ /** @internal */
219
+ _cancellationError: _CanceledEvent | null = null;
220
+ #connOptions: APIConnectOptions;
221
+ #audioDuration = 0;
222
+ #lastAudioDurationReportTime = performance.now();
223
+ label = 'azure.SpeechStream';
224
+
225
+ constructor(stt: STT, opts: STTOptions, connOptions: APIConnectOptions) {
226
+ super(stt, opts.sampleRate, connOptions);
227
+ this._opts = opts;
228
+ this.#connOptions = connOptions;
229
+ }
230
+
231
+ updateOptions(opts: STTUpdateOptions): void {
232
+ this._opts = {
233
+ ...this._opts,
234
+ ...opts,
235
+ language:
236
+ opts.language === undefined ? this._opts.language : normalizeLanguages(opts.language),
237
+ };
238
+ this._reconnectEvent.set();
239
+ }
240
+
241
+ protected async run(): Promise<void> {
242
+ while (!this.input.closed && !this.closed) {
243
+ this._sessionStoppedEvent.clear();
244
+ this._sessionStartedEvent.clear();
245
+ this._cancellationError = null;
246
+ this._speaking = false;
247
+
248
+ const pushStream = speechsdk.AudioInputStream.createPushStream(
249
+ speechsdk.AudioStreamFormat.getWaveFormatPCM(
250
+ this._opts.sampleRate,
251
+ 16,
252
+ this._opts.numChannels,
253
+ ),
254
+ );
255
+ const recognizer = createSpeechRecognizer(this._opts, pushStream);
256
+ this.#connectRecognizerEvents(recognizer);
257
+
258
+ await new Promise<void>((resolve, reject) => {
259
+ recognizer.startContinuousRecognitionAsync(resolve, (error) => reject(new Error(error)));
260
+ });
261
+
262
+ try {
263
+ await withTimeout(this._sessionStartedEvent.wait(), this.#connOptions.timeoutMs);
264
+
265
+ const inputAbortController = new AbortController();
266
+ const inputTask = this.#processInput(pushStream, inputAbortController.signal);
267
+ let inputEnded = false;
268
+ try {
269
+ const completed = await Promise.race([
270
+ inputTask.then(() => 'input' as const),
271
+ this._reconnectEvent.wait().then(() => 'reconnect' as const),
272
+ this._sessionStoppedEvent.wait().then(() => 'stopped' as const),
273
+ ]);
274
+
275
+ if (completed === 'stopped') {
276
+ const details = this._cancellationError as _CanceledEvent | null;
277
+ if (details !== null) {
278
+ throw new APIConnectionError({
279
+ message:
280
+ `Azure STT canceled: ${details.errorDetails || details.reason} ` +
281
+ `(${details.errorCode})`,
282
+ });
283
+ }
284
+ throw new APIConnectionError({ message: 'SpeechRecognition session stopped' });
285
+ }
286
+
287
+ if (completed === 'reconnect') {
288
+ this._reconnectEvent.clear();
289
+ }
290
+
291
+ inputEnded = completed === 'input';
292
+ } finally {
293
+ inputAbortController.abort();
294
+ await inputTask;
295
+ pushStream.close();
296
+ }
297
+
298
+ if (inputEnded) {
299
+ await this._sessionStoppedEvent.wait();
300
+ break;
301
+ }
302
+ } finally {
303
+ await new Promise<void>((resolve) => {
304
+ recognizer.stopContinuousRecognitionAsync(resolve, () => resolve());
305
+ });
306
+ recognizer.close();
307
+ }
308
+ }
309
+ }
310
+
311
+ async #processInput(
312
+ pushStream: speechsdk.PushAudioInputStream,
313
+ abortSignal: AbortSignal,
314
+ ): Promise<void> {
315
+ try {
316
+ while (!this.closed) {
317
+ let result: IteratorResult<AudioFrame | typeof SpeechStream.FLUSH_SENTINEL>;
318
+ try {
319
+ result = await this.input.next({ signal: abortSignal });
320
+ } catch (error) {
321
+ if (abortSignal.aborted) break;
322
+ throw error;
323
+ }
324
+ if (result.done) break;
325
+
326
+ const input = result.value;
327
+ if (input === SpeechStream.FLUSH_SENTINEL) {
328
+ this.#emitRecognitionUsage();
329
+ continue;
330
+ }
331
+
332
+ this.#audioDuration += input.samplesPerChannel / input.sampleRate;
333
+ this.#maybeEmitRecognitionUsage();
334
+ pushStream.write(toArrayBuffer(input));
335
+ }
336
+ } finally {
337
+ this.#emitRecognitionUsage();
338
+ }
339
+ }
340
+
341
+ #connectRecognizerEvents(recognizer: speechsdk.SpeechRecognizer): void {
342
+ recognizer.recognizing = (_sender, evt) => this._onRecognizing(evt);
343
+ recognizer.recognized = (_sender, evt) => this._onRecognized(evt);
344
+ recognizer.speechStartDetected = (_sender, evt) => this._onSpeechStart(evt);
345
+ recognizer.speechEndDetected = (_sender, evt) => this._onSpeechEnd(evt);
346
+ recognizer.sessionStarted = (_sender, evt) => this._onSessionStarted(evt);
347
+ recognizer.sessionStopped = (_sender, evt) => this._onSessionStopped(evt);
348
+ recognizer.canceled = (_sender, evt) => this._onCanceled(evt);
349
+ }
350
+
351
+ /** @internal */
352
+ _onRecognized(evt: speechsdk.SpeechRecognitionEventArgs): void {
353
+ const text = evt.result.text.trim();
354
+ if (!text) return;
355
+
356
+ this.queue.put({
357
+ type: stt.SpeechEventType.FINAL_TRANSCRIPT,
358
+ alternatives: [this.#speechData(evt, 1)],
359
+ });
360
+ }
361
+
362
+ /** @internal */
363
+ _onRecognizing(evt: speechsdk.SpeechRecognitionEventArgs): void {
364
+ const text = evt.result.text.trim();
365
+ if (!text) return;
366
+
367
+ this.queue.put({
368
+ type: stt.SpeechEventType.INTERIM_TRANSCRIPT,
369
+ alternatives: [this.#speechData(evt, 0)],
370
+ });
371
+ }
372
+
373
+ /** @internal */
374
+ _onSpeechStart(_evt: speechsdk.RecognitionEventArgs): void {
375
+ if (this._speaking) return;
376
+ this._speaking = true;
377
+ this.queue.put({ type: stt.SpeechEventType.START_OF_SPEECH });
378
+ }
379
+
380
+ /** @internal */
381
+ _onSpeechEnd(_evt: speechsdk.RecognitionEventArgs): void {
382
+ if (!this._speaking) return;
383
+ this._speaking = false;
384
+ this.queue.put({ type: stt.SpeechEventType.END_OF_SPEECH });
385
+ }
386
+
387
+ /** @internal */
388
+ _onSessionStarted(_evt: speechsdk.SessionEventArgs): void {
389
+ this._sessionStartedEvent.set();
390
+ }
391
+
392
+ /** @internal */
393
+ _onSessionStopped(_evt: speechsdk.SessionEventArgs): void {
394
+ this._sessionStoppedEvent.set();
395
+ }
396
+
397
+ /** @internal */
398
+ _onCanceled(evt: _CanceledEvent): void {
399
+ if (evt.reason === speechsdk.CancellationReason.Error) {
400
+ log().warn(
401
+ {
402
+ code: evt.errorCode,
403
+ reason: evt.reason,
404
+ errorDetails: evt.errorDetails,
405
+ },
406
+ `Speech recognition canceled: ${evt.errorDetails || evt.reason}`,
407
+ );
408
+ this._cancellationError = evt;
409
+ this._sessionStoppedEvent.set();
410
+ }
411
+ }
412
+
413
+ #speechData(evt: speechsdk.SpeechRecognitionEventArgs, confidence: number): stt.SpeechData {
414
+ const result = speechsdk.AutoDetectSourceLanguageResult.fromResult(evt.result);
415
+ const language = result.language || this._opts.language[0] || '';
416
+ return {
417
+ language: asLanguageCode(language),
418
+ confidence,
419
+ text: evt.result.text,
420
+ startTime: evt.result.offset / 10 ** 7 + this.startTimeOffset,
421
+ endTime: (evt.result.offset + evt.result.duration) / 10 ** 7 + this.startTimeOffset,
422
+ };
423
+ }
424
+
425
+ #maybeEmitRecognitionUsage(): void {
426
+ if (performance.now() - this.#lastAudioDurationReportTime >= 5000) {
427
+ this.#emitRecognitionUsage();
428
+ }
429
+ }
430
+
431
+ #emitRecognitionUsage(): void {
432
+ if (this.#audioDuration <= 0) return;
433
+
434
+ const audioDuration = this.#audioDuration;
435
+ this.#audioDuration = 0;
436
+ this.#lastAudioDurationReportTime = performance.now();
437
+ this.queue.put({
438
+ type: stt.SpeechEventType.RECOGNITION_USAGE,
439
+ recognitionUsage: { audioDuration },
440
+ });
441
+ }
442
+ }
443
+
444
+ function normalizeLanguages(language?: string | string[]): string[] {
445
+ if (language === undefined) return [...defaultSTTOptions.language];
446
+ return Array.isArray(language) ? [...language] : [language];
447
+ }
448
+
449
+ function createSpeechRecognizer(
450
+ config: STTOptions,
451
+ stream: speechsdk.PushAudioInputStream,
452
+ ): speechsdk.SpeechRecognizer {
453
+ const speechConfig = createSpeechConfig(config);
454
+
455
+ if (config.segmentationSilenceTimeoutMs !== undefined) {
456
+ speechConfig.setProperty(
457
+ speechsdk.PropertyId.Speech_SegmentationSilenceTimeoutMs,
458
+ String(config.segmentationSilenceTimeoutMs),
459
+ );
460
+ }
461
+ if (config.segmentationMaxTimeMs !== undefined) {
462
+ speechConfig.setProperty(
463
+ speechsdk.PropertyId.Speech_SegmentationMaximumTimeMs,
464
+ String(config.segmentationMaxTimeMs),
465
+ );
466
+ }
467
+ if (config.segmentationStrategy !== undefined) {
468
+ speechConfig.setProperty(
469
+ speechsdk.PropertyId.Speech_SegmentationStrategy,
470
+ config.segmentationStrategy,
471
+ );
472
+ }
473
+ if (config.profanity !== undefined) {
474
+ speechConfig.setProfanity(config.profanity);
475
+ }
476
+ if (config.explicitPunctuation) {
477
+ speechConfig.setServiceProperty(
478
+ 'punctuation',
479
+ 'explicit',
480
+ speechsdk.ServicePropertyChannel.UriQueryParameter,
481
+ );
482
+ }
483
+ if (config.trueTextPostProcessing) {
484
+ speechConfig.setProperty(
485
+ speechsdk.PropertyId.SpeechServiceResponse_PostProcessingOption,
486
+ 'TrueText',
487
+ );
488
+ }
489
+
490
+ const audioConfig = speechsdk.AudioConfig.fromStreamInput(stream);
491
+ const recognizer =
492
+ config.language.length > 1
493
+ ? createMultiLanguageRecognizer(speechConfig, audioConfig, config.language)
494
+ : new speechsdk.SpeechRecognizer(speechConfig, audioConfig);
495
+
496
+ if (config.phraseList?.length) {
497
+ const phraseListGrammar = speechsdk.PhraseListGrammar.fromRecognizer(recognizer);
498
+ for (const phrase of config.phraseList) {
499
+ phraseListGrammar.addPhrase(phrase);
500
+ }
501
+ }
502
+
503
+ return recognizer;
504
+ }
505
+
506
+ function createSpeechConfig(config: STTOptions): speechsdk.SpeechConfig {
507
+ if (config.speechEndpoint) {
508
+ const speechConfig = speechsdk.SpeechConfig.fromEndpoint(
509
+ new URL(config.speechEndpoint),
510
+ config.speechKey,
511
+ );
512
+ if (config.speechAuthToken) speechConfig.authorizationToken = config.speechAuthToken;
513
+ speechConfig.speechRecognitionLanguage = firstLanguage(config);
514
+ return speechConfig;
515
+ }
516
+ if (config.speechHost) {
517
+ const speechConfig = speechsdk.SpeechConfig.fromHost(
518
+ new URL(config.speechHost),
519
+ config.speechKey,
520
+ );
521
+ if (config.speechAuthToken) speechConfig.authorizationToken = config.speechAuthToken;
522
+ speechConfig.speechRecognitionLanguage = firstLanguage(config);
523
+ return speechConfig;
524
+ }
525
+ if (config.speechAuthToken) {
526
+ const speechConfig = speechsdk.SpeechConfig.fromAuthorizationToken(
527
+ config.speechAuthToken,
528
+ config.speechRegion!,
529
+ );
530
+ speechConfig.speechRecognitionLanguage = firstLanguage(config);
531
+ return speechConfig;
532
+ }
533
+ const speechConfig = speechsdk.SpeechConfig.fromSubscription(
534
+ config.speechKey!,
535
+ config.speechRegion!,
536
+ );
537
+ speechConfig.speechRecognitionLanguage = firstLanguage(config);
538
+ return speechConfig;
539
+ }
540
+
541
+ function firstLanguage(config: STTOptions): string {
542
+ return config.language[0] ?? 'en-US';
543
+ }
544
+
545
+ function createMultiLanguageRecognizer(
546
+ speechConfig: speechsdk.SpeechConfig,
547
+ audioConfig: speechsdk.AudioConfig,
548
+ languages: string[],
549
+ ): speechsdk.SpeechRecognizer {
550
+ speechConfig.setProperty(
551
+ speechsdk.PropertyId.SpeechServiceConnection_LanguageIdMode,
552
+ 'Continuous',
553
+ );
554
+ const autoDetectSourceLanguageConfig =
555
+ speechsdk.AutoDetectSourceLanguageConfig.fromLanguages(languages);
556
+ return speechsdk.SpeechRecognizer.FromConfig(
557
+ speechConfig,
558
+ autoDetectSourceLanguageConfig,
559
+ audioConfig,
560
+ );
561
+ }
562
+
563
+ function toArrayBuffer(frame: AudioFrame): ArrayBuffer {
564
+ const view = new Uint8Array(frame.data.buffer, frame.data.byteOffset, frame.data.byteLength);
565
+ const buffer = new ArrayBuffer(view.byteLength);
566
+ new Uint8Array(buffer).set(view);
567
+ return buffer;
568
+ }
569
+
570
+ async function withTimeout(promise: Promise<void>, timeoutMs: number): Promise<void> {
571
+ let timeout: NodeJS.Timeout | undefined;
572
+ try {
573
+ await Promise.race([
574
+ promise,
575
+ new Promise<void>((_, reject) => {
576
+ timeout = setTimeout(
577
+ () => reject(new APIConnectionError({ message: 'Request timed out.' })),
578
+ timeoutMs,
579
+ );
580
+ }),
581
+ ]);
582
+ } finally {
583
+ if (timeout) clearTimeout(timeout);
584
+ }
585
+ }