arcane-os 0.94.0 → 0.95.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.
@@ -0,0 +1,304 @@
1
+ import Is from 'strong-type';
2
+ import {arcaneLogging} from 'arcane-os/logging';
3
+
4
+ const is = new Is(false);
5
+
6
+ /** Prepare exact native speech for the existing utterance queue, without starting synthesis. */
7
+ export default function prepareNativeSpeechPlayback({speech, request, getAudioContext, signal = null}) {
8
+ if (!is.function(speech?.synthesizeStream) || !is.function(getAudioContext)) {
9
+ throw new TypeError('Native speech playback requires streaming speech and an AudioContext owner.');
10
+ }
11
+ return {
12
+ kind: 'native-speech',
13
+ input: request.input,
14
+ voice: request.voice,
15
+ language: request.languageCode ?? request.language_code ?? request.language,
16
+ speed: request.speed ?? 1,
17
+ play(options = {}) {
18
+ return startPlayback(speech, request, getAudioContext, signal, options);
19
+ }
20
+ };
21
+ }
22
+
23
+ function startPlayback(speech, request, getAudioContext, preparationSignal, {signal = null, onState = null} = {}) {
24
+ const controller = new AbortController();
25
+ const signals = new Set([preparationSignal, signal].filter(Boolean));
26
+ const segments = [];
27
+ const failures = [];
28
+ const completion = deferred();
29
+ const release = deferred();
30
+ let context = null;
31
+ let playbackState = 'queued';
32
+ let playbackError = null;
33
+ let paused = false;
34
+ let settled = false;
35
+ let synthesisSettled = true;
36
+ let releaseSettled = false;
37
+ let nextStart = 0;
38
+ let playbackReady = null;
39
+
40
+ function publish(nextState, error = playbackError) {
41
+ playbackState = nextState;
42
+ playbackError = error;
43
+ if (!is.function(onState)) return;
44
+ function observerFailed(cause) {
45
+ arcaneLogging.error('Native speech playback observer failed.', cause);
46
+ }
47
+ try { Promise.resolve(onState({state: nextState, error})).catch(observerFailed); }
48
+ catch (cause) { observerFailed(cause); }
49
+ }
50
+
51
+ function retainFailure(error) {
52
+ if (!failures.includes(error)) failures.push(error);
53
+ playbackError = failures.length === 1 ? failures[0]
54
+ : new AggregateError(failures, 'Native speech playback and cleanup failed.');
55
+ }
56
+
57
+ function releaseIfFinished() {
58
+ if (!settled || !synthesisSettled || releaseSettled) return;
59
+ releaseSettled = true;
60
+ if (failures.length) release.reject(playbackError);
61
+ else release.resolve();
62
+ }
63
+
64
+ function removeSegment(segment) {
65
+ const position = segments.indexOf(segment);
66
+ if (position >= 0) segments.splice(position, 1);
67
+ segment.buffer = null;
68
+ segment.ended.resolve();
69
+ }
70
+
71
+ function detachSource(segment, stop) {
72
+ const node = segment.node;
73
+ if (!node) return;
74
+ segment.node = null;
75
+ node.onended = null;
76
+ if (stop && segment.started) {
77
+ try { node.stop(); }
78
+ catch (error) { retainFailure(error); }
79
+ }
80
+ segment.started = false;
81
+ try { node.disconnect(); }
82
+ catch (error) { retainFailure(error); }
83
+ }
84
+
85
+ function wakePlayback() {
86
+ if (!settled && (paused || context?.state !== 'running')) return;
87
+ playbackReady?.resolve();
88
+ playbackReady = null;
89
+ }
90
+
91
+ function finish(completed, error = null) {
92
+ if (error) retainFailure(error);
93
+ if (settled) {
94
+ if (error) publish('error');
95
+ releaseIfFinished();
96
+ return;
97
+ }
98
+ settled = true;
99
+ for (const requestSignal of signals) requestSignal.removeEventListener('abort', stop);
100
+ context?.removeEventListener('statechange', contextChanged);
101
+ if (!completed && !controller.signal.aborted) {
102
+ controller.abort(error ?? cancellation());
103
+ }
104
+ for (const segment of [...segments]) {
105
+ detachSource(segment, true);
106
+ removeSegment(segment);
107
+ }
108
+ wakePlayback();
109
+ publish(playbackError ? 'error' : completed ? 'complete' : 'stopped');
110
+ if (playbackError) completion.reject(playbackError);
111
+ else completion.resolve(completed);
112
+ releaseIfFinished();
113
+ }
114
+
115
+ function updatePlaybackState() {
116
+ if (settled) return;
117
+ publish(paused ? 'paused'
118
+ : context?.state === 'running' && segments.length ? 'playing' : 'waiting-for-audio');
119
+ }
120
+
121
+ function contextChanged() {
122
+ if (settled) return;
123
+ if (context.state === 'closed') {
124
+ finish(false, playbackFailure('The shared audio output closed during native speech.'));
125
+ return;
126
+ }
127
+ wakePlayback();
128
+ updatePlaybackState();
129
+ }
130
+
131
+ function schedule(segment) {
132
+ if (settled || paused || segment.node) return;
133
+ const node = context.createBufferSource();
134
+ segment.node = node;
135
+ node.buffer = segment.buffer;
136
+ node.connect(context.destination);
137
+ segment.startAt = Math.max(context.currentTime, nextStart);
138
+ nextStart = segment.startAt + segment.buffer.duration - segment.offset;
139
+ node.onended = function segmentEnded() {
140
+ if (segment.node !== node || settled) return;
141
+ detachSource(segment, false);
142
+ removeSegment(segment);
143
+ if (failures.length) finish(false);
144
+ else if (synthesisSettled && !segments.length) finish(true);
145
+ else updatePlaybackState();
146
+ };
147
+ node.start(segment.startAt, segment.offset);
148
+ segment.started = true;
149
+ }
150
+
151
+ async function receiveAudio({pcm, format, sampleRate, channels}) {
152
+ try {
153
+ await playAudio(pcm, format, sampleRate, channels);
154
+ } catch (error) {
155
+ if (error !== controller.signal.reason) finish(false, error);
156
+ throw error;
157
+ }
158
+ }
159
+
160
+ async function playAudio(pcm, format, sampleRate, channels) {
161
+ controller.signal.throwIfAborted();
162
+ if (format !== 's16le' || !(pcm instanceof Uint8Array)) {
163
+ throw playbackFailure('Native speech requires signed 16-bit little-endian PCM.');
164
+ }
165
+ // PCM sample framing belongs to this audio transport, not request admission.
166
+ const frames = pcm.byteLength / (2 * channels);
167
+ if (!Number.isInteger(frames)) {
168
+ throw playbackFailure('Native speech returned an incomplete PCM frame.');
169
+ }
170
+ if (!frames) return;
171
+ const buffer = context.createBuffer(channels, frames, sampleRate);
172
+ const samples = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength);
173
+ for (let channel = 0; channel < channels; channel += 1) {
174
+ const output = buffer.getChannelData(channel);
175
+ for (let frame = 0; frame < frames; frame += 1) {
176
+ output[frame] = samples.getInt16((frame * channels + channel) * 2, true) / 32768;
177
+ }
178
+ }
179
+ const preceding = segments[segments.length - 1];
180
+ const segment = {buffer, offset: 0, startAt: 0, node: null, started: false, ended: deferred()};
181
+ segments.push(segment);
182
+ schedule(segment);
183
+ updatePlaybackState();
184
+ controller.signal.throwIfAborted();
185
+ if (paused || context.state !== 'running') {
186
+ playbackReady ??= deferred();
187
+ await playbackReady.promise;
188
+ }
189
+ // Keep the current and next chunk scheduled. The awaited callback applies
190
+ // playback backpressure without another utterance queue or a complete clip.
191
+ if (preceding) await preceding.ended.promise;
192
+ controller.signal.throwIfAborted();
193
+ }
194
+
195
+ function stop() {
196
+ if (settled) return false;
197
+ finish(false);
198
+ return playbackError === null;
199
+ }
200
+
201
+ function pause() {
202
+ if (settled || paused) return false;
203
+ paused = true;
204
+ const now = context?.currentTime ?? 0;
205
+ for (const segment of [...segments]) {
206
+ if (segment.node) {
207
+ segment.offset = Math.min(segment.buffer.duration,
208
+ segment.offset + Math.max(0, now - segment.startAt));
209
+ detachSource(segment, true);
210
+ }
211
+ if (segment.offset >= segment.buffer.duration) removeSegment(segment);
212
+ }
213
+ nextStart = now;
214
+ if (failures.length) { finish(false); return false; }
215
+ if (synthesisSettled && !segments.length) { finish(true); return false; }
216
+ publish('paused');
217
+ return true;
218
+ }
219
+
220
+ function resume() {
221
+ if (settled || !paused) return false;
222
+ paused = false;
223
+ try {
224
+ nextStart = context?.currentTime ?? 0;
225
+ for (const segment of segments) schedule(segment);
226
+ wakePlayback();
227
+ updatePlaybackState();
228
+ return true;
229
+ } catch (error) {
230
+ finish(false, error);
231
+ return false;
232
+ }
233
+ }
234
+
235
+ const control = {
236
+ finished: completion.promise,
237
+ released: release.promise,
238
+ get state() { return playbackState; },
239
+ get error() { return playbackError; },
240
+ pause,
241
+ resume,
242
+ stop
243
+ };
244
+ for (const requestSignal of signals) requestSignal.addEventListener('abort', stop, {once: true});
245
+ publish('queued');
246
+ if ([...signals].some(function alreadyAborted(requestSignal) { return requestSignal.aborted; })) stop();
247
+ if (!settled) {
248
+ try {
249
+ context = getAudioContext();
250
+ if (context.state === 'closed') throw playbackFailure('The shared audio output is closed.');
251
+ context.addEventListener('statechange', contextChanged);
252
+ synthesisSettled = false;
253
+ updatePlaybackState();
254
+ const synthesis = Promise.resolve().then(function synthesizeOwnedUtterance() {
255
+ controller.signal.throwIfAborted();
256
+ return speech.synthesizeStream(request, {signal: controller.signal, onAudio: receiveAudio});
257
+ });
258
+ synthesis.then(function synthesisCompleted() {
259
+ synthesisSettled = true;
260
+ if (!settled && !segments.length) finish(true);
261
+ releaseIfFinished();
262
+ }, function synthesisFailed(error) {
263
+ synthesisSettled = true;
264
+ if (cancelledByOwner(error, controller.signal)) finish(false);
265
+ else finish(false, error);
266
+ releaseIfFinished();
267
+ });
268
+ } catch (error) {
269
+ finish(false, error);
270
+ }
271
+ }
272
+ return control;
273
+ }
274
+
275
+ function deferred() {
276
+ const result = {};
277
+ result.promise = new Promise(function retainCompletion(resolve, reject) {
278
+ result.resolve = resolve;
279
+ result.reject = reject;
280
+ });
281
+ result.promise.catch(function observeRetainedFailure() {});
282
+ return result;
283
+ }
284
+
285
+ function cancellation() {
286
+ const error = new Error('The owning caller stopped this native utterance.');
287
+ error.name = 'AbortError';
288
+ error.code = 'ARCANE_AI_REQUEST_ABORTED';
289
+ return error;
290
+ }
291
+
292
+ function cancelledByOwner(error, signal) {
293
+ if (!signal.aborted) return false;
294
+ if (error === signal.reason) return true;
295
+ // Core represents the matching request cancellation with its transport error.
296
+ // A separate failure or cleanup aggregate remains observable.
297
+ return error?.code === 'ARCANE_REQUEST_ABORTED' && !error.cause && !error.errors;
298
+ }
299
+
300
+ function playbackFailure(message) {
301
+ const error = new Error(message);
302
+ error.code = 'ARCANE_AI_SPEECH_PLAYBACK_FAILED';
303
+ return error;
304
+ }
@@ -18,10 +18,21 @@ class ArcaneVoiceCaptureProcessor extends AudioWorkletProcessor {
18
18
  #buffer = null;
19
19
  #view = null;
20
20
  #stopped = false;
21
+ #pcmMode = false;
22
+ #pcmBuffer = null;
23
+ #pcmOffset = 0;
24
+ #pcmFrames = 0;
25
+ #observedActivity = false;
26
+ #observedFrames = 0;
21
27
 
22
28
  constructor(options) {
23
29
  super();
24
30
  const configuration = options.processorOptions;
31
+ this.#pcmMode = configuration.mode === 'pcm';
32
+ if (this.#pcmMode) {
33
+ this.#pcmFrames = Math.max(1, Math.round(configuration.pcmChunkMs * sampleRate / 1000));
34
+ this.#pcmBuffer = new Float32Array(this.#pcmFrames);
35
+ }
25
36
  this.#preRoll = new Float32Array(
26
37
  Math.round(configuration.preRollMs * sampleRate / 1000)
27
38
  );
@@ -35,11 +46,23 @@ class ArcaneVoiceCaptureProcessor extends AudioWorkletProcessor {
35
46
  }
36
47
 
37
48
  #receiveControl(event) {
38
- if (this.#stopped || event.data.type !== 'stop') return;
49
+ if (this.#stopped) return;
50
+ if (event.data.type === 'flush') {
51
+ try {
52
+ this.#flushAnalysis();
53
+ if (this.#pcmMode) this.#emitPCM();
54
+ this.port.postMessage({type: 'flushed', id: event.data.id});
55
+ } catch (error) {
56
+ this.#fail(error);
57
+ }
58
+ return;
59
+ }
60
+ if (event.data.type !== 'stop') return;
39
61
  this.#stopped = true;
40
62
  try {
41
63
  this.#flushAnalysis();
42
- this.#emit('stop');
64
+ if (this.#pcmMode) this.#emitPCM();
65
+ else this.#emit('stop');
43
66
  this.#release();
44
67
  this.port.postMessage(
45
68
  {type: 'stopped'}
@@ -74,6 +97,25 @@ class ArcaneVoiceCaptureProcessor extends AudioWorkletProcessor {
74
97
  #flushAnalysis() {
75
98
  if (!this.#analysisCount) return;
76
99
  const activity = Math.sqrt(this.#analysisEnergy / this.#analysisCount) > this.#activityThreshold;
100
+ if (this.#pcmMode) {
101
+ if (activity !== this.#observedActivity) {
102
+ this.#observedActivity = activity;
103
+ this.port.postMessage({
104
+ type: 'activity',
105
+ active: activity,
106
+ audioTime: this.#observedFrames / sampleRate
107
+ });
108
+ }
109
+ for (let index = 0; index < this.#analysisCount; index += 1) {
110
+ this.#pcmBuffer[this.#pcmOffset] = this.#analysisSamples[index];
111
+ this.#pcmOffset += 1;
112
+ if (this.#pcmOffset === this.#pcmFrames) this.#emitPCM();
113
+ }
114
+ this.#observedFrames += this.#analysisCount;
115
+ this.#analysisCount = 0;
116
+ this.#analysisEnergy = 0;
117
+ return;
118
+ }
77
119
  // Classification selects boundaries only. Every original sample still
78
120
  // enters capture in order, including a partial final analysis window.
79
121
  for (let index = 0; index < this.#analysisCount; index += 1) {
@@ -123,6 +165,22 @@ class ArcaneVoiceCaptureProcessor extends AudioWorkletProcessor {
123
165
  this.#preRollLength = Math.min(this.#preRollLength + 1, this.#preRoll.length);
124
166
  }
125
167
 
168
+ #emitPCM() {
169
+ if (!this.#pcmOffset) return;
170
+ const samples = this.#pcmOffset === this.#pcmFrames
171
+ ? this.#pcmBuffer
172
+ : this.#pcmBuffer.slice(0, this.#pcmOffset);
173
+ this.#sequence += 1;
174
+ this.port.postMessage({
175
+ type: 'pcm',
176
+ audio: samples.buffer,
177
+ sampleRate,
178
+ sequence: this.#sequence
179
+ }, [samples.buffer]);
180
+ this.#pcmBuffer = new Float32Array(this.#pcmFrames);
181
+ this.#pcmOffset = 0;
182
+ }
183
+
126
184
  #beginSegment() {
127
185
  // Preserve all requested pre-roll even when it exceeds chunkMs. After a
128
186
  // periodic boundary, retain quiet audio until activity or the pause so
@@ -206,6 +264,8 @@ class ArcaneVoiceCaptureProcessor extends AudioWorkletProcessor {
206
264
  }
207
265
 
208
266
  #release() {
267
+ this.#pcmBuffer = null;
268
+ this.#pcmOffset = 0;
209
269
  this.#buffer = null;
210
270
  this.#view = null;
211
271
  this.#preRoll = new Float32Array(0);
@@ -68,6 +68,8 @@ export function createSpeechService({stt, tts, signal} = {}) {
68
68
  {source: 'core-speech', eventTypes: ['speech.state']}
69
69
  );
70
70
  const jobs = new Set();
71
+ const streams = new Map();
72
+ const syntheses = new Map();
71
73
  const publications = [];
72
74
  let context;
73
75
  let started = false;
@@ -91,8 +93,10 @@ export function createSpeechService({stt, tts, signal} = {}) {
91
93
  ttsEngine: roles.tts.state.providerId ?? '',
92
94
  sttEngine: roles.stt.state.providerId ?? '',
93
95
  roles: {
94
- stt: {...roles.stt.state, available: transcriptionAvailable},
95
- tts: {...roles.tts.state, available: synthesisAvailable}
96
+ stt: {...roles.stt.state, available: transcriptionAvailable,
97
+ supportsStreaming: is.function(roles.stt.engine?.openStream)},
98
+ tts: {...roles.tts.state, available: synthesisAvailable,
99
+ supportsStreaming: is.function(roles.tts.engine?.synthesizeStream)}
96
100
  },
97
101
  closed
98
102
  };
@@ -285,6 +289,201 @@ export function createSpeechService({stt, tts, signal} = {}) {
285
289
  }
286
290
  }
287
291
 
292
+ function streamRole(name, operation, request, streamId) {
293
+ if (closing || closed) throw failure('CORE_CLOSING', 'The speech service is closing.');
294
+ const role = roles[name];
295
+ if (!is.function(role.engine?.[operation])) {
296
+ throw failure('SPEECH_OPERATION_UNAVAILABLE', `The ${name} speech engine does not support ${operation}().`);
297
+ }
298
+ const operationSignal = request.signal
299
+ ? AbortSignal.any([lifetimeSignal, request.signal]) : lifetimeSignal;
300
+ operationSignal.throwIfAborted();
301
+ request.emit?.('speech.progress', {
302
+ streamId, requestId: request.requestId, role: name, status: 'Thinking', progress: {phase: 'accepted'}
303
+ });
304
+ return {role, operationSignal};
305
+ }
306
+
307
+ function retainStreamTask(task) {
308
+ jobs.add(task);
309
+ function releaseSpeechStreamTask() { jobs.delete(task); }
310
+ task.then(releaseSpeechStreamTask, releaseSpeechStreamTask);
311
+ return task;
312
+ }
313
+
314
+ function openStream({streamId, options = {}} = {}, request) {
315
+ const activation = streamRole('stt', 'openStream', request, streamId);
316
+ const role = activation.role;
317
+ const controller = new AbortController();
318
+ const operationSignal = AbortSignal.any([activation.operationSignal, controller.signal]);
319
+ if (!is.string(streamId) || !streamId) throw new TypeError('A speech stream requires a streamId.');
320
+ if (streams.has(streamId)) throw failure('SPEECH_STREAM_ACTIVE', 'The speech stream is already open.');
321
+ const record = {stream: null, controller, queue: Promise.resolve(), ending: false, cancellation: null, task: null};
322
+ streams.set(streamId, record);
323
+
324
+ function cancelNativeStream() {
325
+ record.ending = true;
326
+ if (record.stream && !record.cancellation) {
327
+ record.cancellation = Promise.resolve().then(function cancelOwnedNativeStream() {
328
+ return record.stream.cancel();
329
+ });
330
+ }
331
+ return record.cancellation;
332
+ }
333
+ function abortedStream() {
334
+ cancelNativeStream()?.catch(function reportNativeStreamCancellation(error) {
335
+ console.error('Native speech stream cancellation failed.', error);
336
+ });
337
+ }
338
+ record.cancel = cancelNativeStream;
339
+ operationSignal.addEventListener('abort', abortedStream, {once: true});
340
+ const task = (async function ownTranscriptionStream() {
341
+ try {
342
+ if (role.loading) await waitForLoad(role.loading, operationSignal);
343
+ operationSignal.throwIfAborted();
344
+ record.stream = await role.engine.openStream({
345
+ ...options,
346
+ signal: operationSignal,
347
+ onUpdate: function reportTranscriptionUpdate(result) {
348
+ if (!operationSignal.aborted) request.emit?.('speech.transcription', {streamId, result});
349
+ }
350
+ });
351
+ if (operationSignal.aborted) {
352
+ await cancelNativeStream();
353
+ operationSignal.throwIfAborted();
354
+ }
355
+ request.emit?.('speech.opened', {
356
+ streamId,
357
+ ...(options.sampleRate === undefined ? {} : {sampleRate: options.sampleRate})
358
+ });
359
+ const result = await record.stream.completion;
360
+ operationSignal.throwIfAborted();
361
+ return result;
362
+ } catch (error) {
363
+ try { await cancelNativeStream(); } catch (cleanupError) {
364
+ throw new AggregateError([error, cleanupError], 'Speech stream and cleanup failed.');
365
+ }
366
+ throw error;
367
+ } finally {
368
+ operationSignal.removeEventListener('abort', abortedStream);
369
+ if (streams.get(streamId) === record) streams.delete(streamId);
370
+ }
371
+ })();
372
+ record.task = task;
373
+ return retainStreamTask(task);
374
+ }
375
+
376
+ function streamOperation(operation, {streamId, audio} = {}, request) {
377
+ request.signal?.throwIfAborted();
378
+ const record = streams.get(streamId);
379
+ if (operation === 'cancel') {
380
+ if (!record) return {streamId, cancelled: true};
381
+ record.controller.abort();
382
+ return record.task.then(
383
+ function streamFinishedBeforeCancellation() { return {streamId, cancelled: true}; },
384
+ function streamCancellationSettled(error) {
385
+ if (error?.name !== 'AbortError') throw error;
386
+ return {streamId, cancelled: true};
387
+ }
388
+ );
389
+ }
390
+ if (!record?.stream) throw failure('SPEECH_STREAM_UNAVAILABLE', 'The speech stream is not open.');
391
+ if (record.ending) throw failure('SPEECH_STREAM_ENDING', 'The speech stream is finishing.');
392
+ if (operation === 'finish') record.ending = true;
393
+ const task = record.queue.then(function performSpeechStreamOperation() {
394
+ request.signal?.throwIfAborted();
395
+ if (operation === 'push') return record.stream.push(decodeFloatAudio(audio));
396
+ return record.stream[operation]();
397
+ });
398
+ record.queue = task;
399
+ task.catch(function cancelFailedStreamOperation() {
400
+ record.cancel()?.catch(function reportStreamOperationCleanup(error) {
401
+ console.error('Failed speech stream cleanup failed.', error);
402
+ });
403
+ });
404
+ return retainStreamTask(task);
405
+ }
406
+
407
+ function synthesizeStream({streamId, request: payload} = {}, request) {
408
+ const activation = streamRole('tts', 'synthesizeStream', request, streamId);
409
+ const role = activation.role;
410
+ const controller = new AbortController();
411
+ const operationSignal = AbortSignal.any([activation.operationSignal, controller.signal]);
412
+ if (!is.string(streamId) || !streamId) throw new TypeError('Speech synthesis requires a streamId.');
413
+ if (syntheses.has(streamId)) throw failure('SPEECH_STREAM_ACTIVE', 'The speech synthesis is already active.');
414
+ const record = {controller, task: null, sequence: 0, acknowledgements: new Map()};
415
+ syntheses.set(streamId, record);
416
+ function cancelAudioDelivery() {
417
+ for (const acknowledgement of record.acknowledgements.values()) acknowledgement.reject(operationSignal.reason);
418
+ record.acknowledgements.clear();
419
+ }
420
+ operationSignal.addEventListener('abort', cancelAudioDelivery, {once: true});
421
+ const task = (async function ownSynthesisStream() {
422
+ try {
423
+ if (role.loading) await waitForLoad(role.loading, operationSignal);
424
+ operationSignal.throwIfAborted();
425
+ const result = await role.engine.synthesizeStream(payload, {
426
+ signal: operationSignal,
427
+ requestId: request.requestId,
428
+ onProgress: function reportSynthesisProgress(progress) {
429
+ if (!operationSignal.aborted) request.emit?.('speech.progress', {
430
+ streamId, requestId: request.requestId, role: 'tts', status: 'Thinking', progress
431
+ });
432
+ },
433
+ onAudio: async function reportSynthesisAudio(audio) {
434
+ operationSignal.throwIfAborted();
435
+ const sequence = ++record.sequence;
436
+ const consumed = new Promise(function waitForAudioConsumer(resolve, reject) {
437
+ record.acknowledgements.set(sequence, {resolve, reject});
438
+ });
439
+ // The native producer waits here, never the event parser.
440
+ // Cancellation releases this wait before joining native work.
441
+ try {
442
+ const pcm = audio.pcm;
443
+ const encoded = Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength);
444
+ request.emit?.('speech.audio', {
445
+ streamId, sequence,
446
+ audio: {...audio, pcm: {encoding: 'base64', data: encoded.toString('base64')}}
447
+ });
448
+ await consumed;
449
+ operationSignal.throwIfAborted();
450
+ } finally {
451
+ record.acknowledgements.delete(sequence);
452
+ }
453
+ }
454
+ });
455
+ operationSignal.throwIfAborted();
456
+ return result;
457
+ } finally {
458
+ operationSignal.removeEventListener('abort', cancelAudioDelivery);
459
+ if (syntheses.get(streamId) === record) syntheses.delete(streamId);
460
+ }
461
+ })();
462
+ record.task = task;
463
+ return retainStreamTask(task);
464
+ }
465
+
466
+ function synthesisControl(operation, {streamId, sequence} = {}, request) {
467
+ request.signal?.throwIfAborted();
468
+ const record = syntheses.get(streamId);
469
+ if (operation === 'cancel') {
470
+ if (!record) return {streamId, cancelled: true};
471
+ record.controller.abort();
472
+ return record.task.then(
473
+ function synthesisFinishedBeforeCancellation() { return {streamId, cancelled: true}; },
474
+ function synthesisCancellationSettled(error) {
475
+ if (error !== record.controller.signal.reason) throw error;
476
+ return {streamId, cancelled: true};
477
+ }
478
+ );
479
+ }
480
+ const acknowledgement = record?.acknowledgements.get(sequence);
481
+ if (!acknowledgement) throw failure('SPEECH_AUDIO_UNAVAILABLE', 'That speech audio contribution is not awaiting consumption.');
482
+ record.acknowledgements.delete(sequence);
483
+ acknowledgement.resolve();
484
+ return {streamId, sequence};
485
+ }
486
+
288
487
  function subscribe(listener, {replay = true, signal: subscriptionSignal} = {}) {
289
488
  if (!is.function(listener)) {
290
489
  throw new TypeError('A speech state listener must be a function.');
@@ -407,6 +606,28 @@ export function createSpeechService({stt, tts, signal} = {}) {
407
606
  },
408
607
  'speech.synthesize': function synthesize(parameters, request = {}) {
409
608
  return invoke('tts', parameters, request);
609
+ },
610
+ // The open call stays pending so normal connection cancellation owns
611
+ // the native stream; controls never extend it to service lifetime.
612
+ 'speech.open': openStream,
613
+ 'speech.push': function push(parameters, request) {
614
+ return streamOperation('push', parameters, request);
615
+ },
616
+ 'speech.endpoint': function endpoint(parameters, request) {
617
+ return streamOperation('forceEndpoint', parameters, request);
618
+ },
619
+ 'speech.finish': function finish(parameters, request) {
620
+ return streamOperation('finish', parameters, request);
621
+ },
622
+ 'speech.cancel': function cancel(parameters, request) {
623
+ return streamOperation('cancel', parameters, request);
624
+ },
625
+ 'speech.synthesizeStream': synthesizeStream,
626
+ 'speech.audioConsumed': function audioConsumed(parameters, request) {
627
+ return synthesisControl('consumed', parameters, request);
628
+ },
629
+ 'speech.cancelSynthesis': function cancelSynthesis(parameters, request) {
630
+ return synthesisControl('cancel', parameters, request);
410
631
  }
411
632
  },
412
633
  drain: close,
@@ -420,4 +641,16 @@ export function createSpeechService({stt, tts, signal} = {}) {
420
641
  };
421
642
  }
422
643
 
644
+ function decodeFloatAudio(audio) {
645
+ if (audio?.encoding !== 'f32le' || !is.string(audio.data)) {
646
+ throw new TypeError('Core streaming audio requires {encoding: "f32le", data: BASE64}.');
647
+ }
648
+ const encoded = Buffer.from(audio.data, 'base64');
649
+ if (encoded.length % 4 !== 0) throw new TypeError('The f32le transport ends during a sample.');
650
+ const samples = new Float32Array(encoded.length / 4);
651
+ const bits = new Uint32Array(samples.buffer);
652
+ for (let index = 0; index < samples.length; index += 1) bits[index] = encoded.readUInt32LE(index * 4);
653
+ return samples;
654
+ }
655
+
423
656
  export default createSpeechService;