dsh-live-voice 0.2.1 → 0.2.3

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.
@@ -46,6 +46,7 @@ export class VoiceCoordinator {
46
46
  conversation: false,
47
47
  listening: false,
48
48
  recognizing: false,
49
+ pendingTranscriptions: 0,
49
50
  muted: normalizeSettings(settings).microphoneEnabled === false,
50
51
  speaking: false,
51
52
  paused: false,
@@ -60,6 +61,7 @@ export class VoiceCoordinator {
60
61
  };
61
62
  this.autoSendTimer = null;
62
63
  this.autoSendDraft = null;
64
+ this.holdToTalkRelease = false;
63
65
  this.interruptionTimer = null;
64
66
  this.interruptionTranscriptConfirmed = false;
65
67
  this.interruptionPausedSpeech = false;
@@ -186,6 +188,27 @@ export class VoiceCoordinator {
186
188
  startDictation() {
187
189
  return this.startListening(false);
188
190
  }
191
+ startHoldToTalk() {
192
+ this.holdToTalkRelease = false;
193
+ return this.startListening(false);
194
+ }
195
+ async releaseHoldToTalk() {
196
+ if (this.disposed || (!this.snapshot.listening && !this.snapshot.starting)) return;
197
+ this.holdToTalkRelease = true;
198
+ if (this.snapshot.starting) return;
199
+ await this.recognition.finish?.();
200
+ if (this.snapshot.pendingTranscriptions === 0) this._finishHoldToTalk();
201
+ }
202
+ _finishHoldToTalk() {
203
+ if (!this.holdToTalkRelease) return;
204
+ this.holdToTalkRelease = false;
205
+ const draft = this.composer.getDraft();
206
+ if (!draft.trim()) {
207
+ void this.stopListening();
208
+ return;
209
+ }
210
+ this.scheduleAutoSend(draft, { force: true, stopAfter: true });
211
+ }
189
212
  startConversation() {
190
213
  return this.startListening(true);
191
214
  }
@@ -269,6 +292,14 @@ export class VoiceCoordinator {
269
292
  onResult: (result) => {
270
293
  if (valid()) this.onResult(result);
271
294
  },
295
+ onProcessingChange: ({ pending = 0 } = {}) => {
296
+ if (!valid()) return;
297
+ const count = Number.isSafeInteger(pending) && pending >= 0 ? pending : 0;
298
+ this.patch({ pendingTranscriptions: count });
299
+ if (count > 0) this.cancelAutoSend();
300
+ else if (this.holdToTalkRelease) this._finishHoldToTalk();
301
+ else this.maybeScheduleAutoSend();
302
+ },
272
303
  onActivity: (active) => {
273
304
  if (!valid()) return;
274
305
  if (active) {
@@ -294,6 +325,7 @@ export class VoiceCoordinator {
294
325
  return;
295
326
  }
296
327
  this.patch({ listening: true, starting: false });
328
+ if (this.holdToTalkRelease) void this.releaseHoldToTalk();
297
329
  this._drain();
298
330
  } catch (error) {
299
331
  try {
@@ -378,8 +410,10 @@ export class VoiceCoordinator {
378
410
  }
379
411
  // Pending structured questions own recognition while waiting for a hands-free
380
412
  // answer. Keep their interim and final text out of the normal chat composer.
381
- if (typeof this.composer.handleQuestionResult === 'function' &&
382
- this.composer.handleQuestionResult({ final, interim })) {
413
+ if (
414
+ typeof this.composer.handleQuestionResult === 'function' &&
415
+ this.composer.handleQuestionResult({ final, interim })
416
+ ) {
383
417
  this.cancelAutoSend();
384
418
  this.transcript.reset();
385
419
  this.patch({ recognizing: !!interim });
@@ -441,7 +475,7 @@ export class VoiceCoordinator {
441
475
  }
442
476
  const next = this.transcript.update(this.composer.getDraft(), final, true);
443
477
  this.composer.setDraft(next);
444
- if (this.snapshot.settings.sendingMode !== 'manual') this.scheduleAutoSend(next);
478
+ this.maybeScheduleAutoSend(next);
445
479
  }
446
480
  if (interim) {
447
481
  this.cancelAutoSend();
@@ -453,7 +487,16 @@ export class VoiceCoordinator {
453
487
  this.patch({ recognizing: !!interim });
454
488
  this._drain();
455
489
  }
456
- scheduleAutoSend(draft) {
490
+ maybeScheduleAutoSend(draft = this.composer.getDraft()) {
491
+ if (
492
+ this.snapshot.settings.sendingMode === 'manual' ||
493
+ this.snapshot.recognizing ||
494
+ this.snapshot.pendingTranscriptions > 0
495
+ )
496
+ return;
497
+ this.scheduleAutoSend(draft);
498
+ }
499
+ scheduleAutoSend(draft, { force = false, stopAfter = false } = {}) {
457
500
  this.cancelAutoSend();
458
501
  if (!draft.trim() || typeof this.composer.submit !== 'function') return;
459
502
  const delay = this.snapshot.settings.autoSendDelaySeconds * 1000;
@@ -466,16 +509,19 @@ export class VoiceCoordinator {
466
509
  this.patch({ autoSendAt: null });
467
510
  if (
468
511
  !this.disposed &&
469
- this.snapshot.settings.sendingMode !== 'manual' &&
512
+ (force || this.snapshot.settings.sendingMode !== 'manual') &&
470
513
  expected === this.composer.getDraft()
471
514
  ) {
472
515
  try {
473
- this.composer.submit(this.snapshot.settings.sendingMode === 'steer' ? 'steer' : 'queue');
516
+ this.composer.submit(
517
+ this.snapshot.settings.sendingMode === 'steer' && !force ? 'steer' : 'queue',
518
+ );
474
519
  // Web Speech keeps a cumulative native result list for the lifetime of
475
520
  // one recognition instance. Start a fresh instance at the turn boundary
476
521
  // so the sent utterance cannot prefix the next one.
477
522
  this.transcript.reset();
478
523
  this.recognition.reset?.();
524
+ if (stopAfter) void this.stopListening();
479
525
  } catch (error) {
480
526
  this.patch({ error: message(error) });
481
527
  }
@@ -492,10 +538,11 @@ export class VoiceCoordinator {
492
538
  if (this.autoSendDraft !== null && draft !== this.autoSendDraft) this.cancelAutoSend();
493
539
  }
494
540
  stopListening() {
541
+ this.holdToTalkRelease = false;
495
542
  this.cancelAutoSend();
496
543
  ++this.epoch;
497
544
  this.inputController?.abort();
498
- this.patch({ listening: false, recognizing: false, starting: false });
545
+ this.patch({ listening: false, recognizing: false, pendingTranscriptions: 0, starting: false });
499
546
  return this._input(async () => {
500
547
  try {
501
548
  await this._releaseInput();
@@ -36,7 +36,9 @@ export const defaultSettings = Object.freeze({
36
36
  recognitionProcessLocally: true,
37
37
  recognitionAutoInstall: true,
38
38
  voiceDetectionPreset: 'natural',
39
+ recognitionMaxUtteranceSeconds: 60,
39
40
  microphoneEnabled: true,
41
+ holdToTalkEnabled: true,
40
42
  announceAssistantMessages: true,
41
43
  interruptSpeechOnUserMessage: false,
42
44
  sendingMode: 'manual',
@@ -94,10 +96,20 @@ export function normalizeSettings(value) {
94
96
  voiceDetectionPreset: Object.hasOwn(voiceDetectionPresets, source.voiceDetectionPreset)
95
97
  ? source.voiceDetectionPreset
96
98
  : defaultSettings.voiceDetectionPreset,
99
+ recognitionMaxUtteranceSeconds:
100
+ Number.isInteger(source.recognitionMaxUtteranceSeconds) &&
101
+ source.recognitionMaxUtteranceSeconds >= 10 &&
102
+ source.recognitionMaxUtteranceSeconds <= 300
103
+ ? source.recognitionMaxUtteranceSeconds
104
+ : defaultSettings.recognitionMaxUtteranceSeconds,
97
105
  microphoneEnabled:
98
106
  typeof source.microphoneEnabled === 'boolean'
99
107
  ? source.microphoneEnabled
100
108
  : defaultSettings.microphoneEnabled,
109
+ holdToTalkEnabled:
110
+ typeof source.holdToTalkEnabled === 'boolean'
111
+ ? source.holdToTalkEnabled
112
+ : defaultSettings.holdToTalkEnabled,
101
113
  announceAssistantMessages:
102
114
  typeof source.announceAssistantMessages === 'boolean'
103
115
  ? source.announceAssistantMessages
@@ -141,6 +141,9 @@ export class BrowserRecognitionEngine {
141
141
  timer: null,
142
142
  recognition: null,
143
143
  speaking: false,
144
+ finishing: false,
145
+ finishPromise: null,
146
+ resolveFinish: null,
144
147
  };
145
148
  this.session = session;
146
149
  const cancelled = new Promise((resolve, reject) => {
@@ -244,6 +247,12 @@ export class BrowserRecognitionEngine {
244
247
  session.recognition = null;
245
248
  this._activity(session, false);
246
249
  if (this.session !== session) return;
250
+ if (session.finishing) {
251
+ this.session = null;
252
+ session.signal?.removeEventListener('abort', session.cancel);
253
+ session.resolveFinish?.();
254
+ return;
255
+ }
247
256
  if (session.restarts >= this.maxRestarts) {
248
257
  this._fail(
249
258
  session,
@@ -273,6 +282,34 @@ export class BrowserRecognitionEngine {
273
282
  recognition.start();
274
283
  }
275
284
 
285
+ finish() {
286
+ const session = this.session;
287
+ if (!session) return Promise.resolve();
288
+ if (session.finishPromise) return session.finishPromise;
289
+ session.finishing = true;
290
+ session.finishPromise = new Promise((resolve) => {
291
+ session.resolveFinish = resolve;
292
+ });
293
+ if (session.timer !== null) {
294
+ (this.globals.clearTimeout ?? globalThis.clearTimeout)(session.timer);
295
+ session.timer = null;
296
+ this.session = null;
297
+ session.resolveFinish();
298
+ return session.finishPromise;
299
+ }
300
+ try {
301
+ session.recognition?.stop();
302
+ if (!session.recognition) {
303
+ this.session = null;
304
+ session.resolveFinish();
305
+ }
306
+ } catch {
307
+ this.session = null;
308
+ session.resolveFinish();
309
+ }
310
+ return session.finishPromise;
311
+ }
312
+
276
313
  reset() {
277
314
  const session = this.session;
278
315
  const recognition = session?.recognition;
@@ -310,5 +347,6 @@ export class BrowserRecognitionEngine {
310
347
  /* Browser already ended capture. */
311
348
  }
312
349
  this._activity(session, false);
350
+ session.resolveFinish?.();
313
351
  }
314
352
  }
@@ -39,12 +39,18 @@ export function encodeMonoPcm16Wav(samples, inputRate) {
39
39
  return buffer;
40
40
  }
41
41
  export class WhisperHttpRecognitionEngine {
42
- constructor({ globals = globalThis, meter, voiceDetectionPreset = 'natural' } = {}) {
42
+ constructor({
43
+ globals = globalThis,
44
+ meter,
45
+ voiceDetectionPreset = 'natural',
46
+ maxUtteranceSeconds = 60,
47
+ } = {}) {
43
48
  this.g = globals;
44
49
  this.meter = meter;
45
50
  this.session = null;
46
51
  this.lang = 'pt-BR';
47
52
  this.voiceDetectionPreset = voiceDetectionPreset;
53
+ this.maxUtteranceSeconds = maxUtteranceSeconds;
48
54
  this.route = ROUTE;
49
55
  }
50
56
  get segmentation() {
@@ -73,7 +79,14 @@ export class WhisperHttpRecognitionEngine {
73
79
  };
74
80
  }
75
81
  }
76
- async start({ lang = this.lang, signal, onResult, onActivity, onError } = {}) {
82
+ async start({
83
+ lang = this.lang,
84
+ signal,
85
+ onResult,
86
+ onActivity,
87
+ onError,
88
+ onProcessingChange,
89
+ } = {}) {
77
90
  await this.stop();
78
91
  if (signal?.aborted) throw abortError();
79
92
  const context = this.meter?.context,
@@ -95,16 +108,25 @@ export class WhisperHttpRecognitionEngine {
95
108
  samples: 0,
96
109
  voiced: false,
97
110
  silence: 0,
98
- inflight: new Set(),
111
+ transcriptionQueue: [],
112
+ activeRequest: null,
113
+ draining: false,
99
114
  onResult,
100
115
  onActivity,
101
116
  onError,
117
+ onProcessingChange,
102
118
  lang,
103
119
  signal,
104
120
  };
105
121
  this.session = session;
106
122
  const valid = () => this.session === session && !signal?.aborted;
107
- const submit = async () => {
123
+ const notifyProcessing = () =>
124
+ session.onProcessingChange?.({
125
+ queued: session.transcriptionQueue.length,
126
+ active: !!session.activeRequest,
127
+ pending: session.transcriptionQueue.length + (session.activeRequest ? 1 : 0),
128
+ });
129
+ const enqueue = () => {
108
130
  if (!session.voiced || session.samples < context.sampleRate * 0.25) {
109
131
  session.chunks = [];
110
132
  session.samples = 0;
@@ -122,29 +144,46 @@ export class WhisperHttpRecognitionEngine {
122
144
  session.samples = 0;
123
145
  session.voiced = false;
124
146
  session.silence = 0;
125
- const request = new AbortController();
126
- session.inflight.add(request);
147
+ session.transcriptionQueue.push(samples);
148
+ notifyProcessing();
149
+ void drain();
150
+ };
151
+ const drain = async () => {
152
+ if (session.draining || !valid()) return;
153
+ session.draining = true;
127
154
  try {
128
- const response = await this.g.fetch(this.route + '/transcribe', {
129
- method: 'POST',
130
- credentials: 'same-origin',
131
- headers: {
132
- 'content-type': 'audio/wav',
133
- 'x-dlv-client-id': session.operation,
134
- 'x-dlv-operation-id': id(),
135
- 'x-dlv-language': lang,
136
- },
137
- body: encodeMonoPcm16Wav(samples, context.sampleRate),
138
- signal: request.signal,
139
- });
140
- const json = await response.json();
141
- if (!response.ok || !json?.ok)
142
- throw new Error(json?.error?.message || 'HTTP transcription failed.');
143
- if (valid() && json.value.text) onResult?.({ final: json.value.text, interim: '' });
144
- } catch (error) {
145
- if (error.name !== 'AbortError' && valid()) onError?.(error);
155
+ while (valid() && session.transcriptionQueue.length) {
156
+ const samples = session.transcriptionQueue.shift();
157
+ const request = new AbortController();
158
+ session.activeRequest = request;
159
+ notifyProcessing();
160
+ try {
161
+ const response = await this.g.fetch(this.route + '/transcribe', {
162
+ method: 'POST',
163
+ credentials: 'same-origin',
164
+ headers: {
165
+ 'content-type': 'audio/wav',
166
+ 'x-dlv-client-id': session.operation,
167
+ 'x-dlv-operation-id': id(),
168
+ 'x-dlv-language': lang,
169
+ },
170
+ body: encodeMonoPcm16Wav(samples, context.sampleRate),
171
+ signal: request.signal,
172
+ });
173
+ const json = await response.json();
174
+ if (!response.ok || !json?.ok)
175
+ throw new Error(json?.error?.message || 'HTTP transcription failed.');
176
+ if (valid() && json.value.text) onResult?.({ final: json.value.text, interim: '' });
177
+ } catch (error) {
178
+ if (error.name !== 'AbortError' && valid()) onError?.(error);
179
+ } finally {
180
+ if (session.activeRequest === request) session.activeRequest = null;
181
+ notifyProcessing();
182
+ }
183
+ }
146
184
  } finally {
147
- session.inflight.delete(request);
185
+ session.draining = false;
186
+ notifyProcessing();
148
187
  }
149
188
  };
150
189
  processor.onaudioprocess = (event) => {
@@ -164,14 +203,24 @@ export class WhisperHttpRecognitionEngine {
164
203
  if (
165
204
  (session.voiced &&
166
205
  session.silence > context.sampleRate * (this.segmentation.silenceMs / 1000)) ||
167
- session.samples > context.sampleRate * 20
206
+ session.samples > context.sampleRate * this.maxUtteranceSeconds
168
207
  )
169
- void submit();
208
+ enqueue();
170
209
  };
171
210
  source.connect(processor);
211
+ session.finish = enqueue;
172
212
  session.abort = () => this.stop();
173
213
  signal?.addEventListener('abort', session.abort, { once: true });
174
214
  }
215
+ finish() {
216
+ const session = this.session;
217
+ if (!session) return;
218
+ session.processor.onaudioprocess = null;
219
+ try {
220
+ this.meter?.source?.disconnect(session.processor);
221
+ } catch {}
222
+ session.finish?.();
223
+ }
175
224
  async stop() {
176
225
  const session = this.session;
177
226
  if (!session) return;
@@ -185,7 +234,9 @@ export class WhisperHttpRecognitionEngine {
185
234
  session.processor.disconnect();
186
235
  session.gain?.disconnect();
187
236
  } catch {}
188
- for (const request of session.inflight) request.abort();
189
- session.inflight.clear();
237
+ session.transcriptionQueue = [];
238
+ session.activeRequest?.abort();
239
+ session.activeRequest = null;
240
+ session.onProcessingChange?.({ queued: 0, active: false, pending: 0 });
190
241
  }
191
242
  }