@qvac/bci-whispercpp 0.0.0 → 0.1.1

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/index.js ADDED
@@ -0,0 +1,676 @@
1
+ 'use strict'
2
+
3
+ const fs = require('bare-fs')
4
+ const QvacLogger = require('@qvac/logging')
5
+ const { createJobHandler, exclusiveRunQueue, QvacResponse } = require('@qvac/infer-base')
6
+
7
+ const { BCIInterface } = require('./bci')
8
+ const { QvacErrorAddonBCI, ERR_CODES } = require('./lib/error')
9
+ const { computeWER } = require('./lib/wer')
10
+ const {
11
+ toUint8,
12
+ sliceBody,
13
+ buildWindowBuffer,
14
+ stitchSegments
15
+ } = require('./lib/stream')
16
+
17
+ // Sliding-window streaming constants.
18
+ //
19
+ // The underlying whisper encoder accepts up to ~3000 timesteps of input per
20
+ // forward pass. We keep MAX_WINDOW_TIMESTEPS slightly below that ceiling so
21
+ // that edge-case window sizes (e.g. final flush of a partial window) always
22
+ // fit without a native-side truncation. Requests above this surface as
23
+ // WINDOW_TOO_LARGE so callers can react explicitly.
24
+ //
25
+ // DEFAULT_WINDOW_TIMESTEPS / DEFAULT_HOP_TIMESTEPS are chosen as a balanced
26
+ // first-step trade-off: a 1500-step window decodes quickly on commodity
27
+ // hardware, and a 500-step hop (≈33% overlap) gives the word-stitcher
28
+ // enough overlap to deduplicate across boundaries without decoding the
29
+ // same audio ~2x. These numbers will be revisited when a segmentation
30
+ // model replaces the fixed-window heuristic.
31
+ //
32
+ // MAX_STITCH_WORDS bounds the suffix/prefix search in stitchSegments so
33
+ // the per-window merge stays O(maxWords^2) regardless of transcript length.
34
+ const DEFAULT_WINDOW_TIMESTEPS = 1500
35
+ const DEFAULT_HOP_TIMESTEPS = 500
36
+ const MAX_WINDOW_TIMESTEPS = 2900
37
+ const MAX_STITCH_WORDS = 40
38
+
39
+ /**
40
+ * BCI neural signal transcription client powered by whisper.cpp.
41
+ *
42
+ * Follows the same architecture as TranscriptionWhispercpp / LlmLlamacpp:
43
+ * standalone class using createJobHandler + exclusiveRunQueue from
44
+ * @qvac/infer-base.
45
+ */
46
+ class BCIWhispercpp {
47
+ /**
48
+ * @param {Object} args
49
+ * @param {Object} args.files - local model file paths
50
+ * @param {string} args.files.model - path to the BCI GGML model file
51
+ * @param {Object} [args.logger] - optional logger instance
52
+ * @param {Object} [args.opts] - optional options (e.g. { stats: true })
53
+ * @param {Object} config - inference configuration
54
+ * @param {Object} config.whisperConfig - whisper decoding params
55
+ * @param {Object} [config.bciConfig] - BCI-specific params (e.g. { day_idx: 1 })
56
+ * @param {Object} [config.contextParams] - whisper context params
57
+ * @param {Object} [config.miscConfig] - miscellaneous config
58
+ */
59
+ constructor ({ files, logger = null, opts = {} }, config = {}) {
60
+ if (!files || typeof files.model !== 'string' || files.model.length === 0) {
61
+ throw new QvacErrorAddonBCI({
62
+ code: ERR_CODES.MODEL_FILE_NOT_FOUND,
63
+ adds: 'files.model is required'
64
+ })
65
+ }
66
+
67
+ this._files = { model: files.model }
68
+ this._config = config
69
+ this.opts = opts
70
+ this.logger = new QvacLogger(logger)
71
+ this._withExclusiveRun = exclusiveRunQueue()
72
+ this._inferenceQueueWaiter = Promise.resolve()
73
+ this._job = createJobHandler({
74
+ cancel: () => this.addon?.cancel()
75
+ })
76
+
77
+ this.addon = null
78
+ this.state = {
79
+ configLoaded: false,
80
+ destroyed: false
81
+ }
82
+
83
+ // Stream lifecycle state. A stream is considered active iff
84
+ // `_streamResponse` is non-null; no separate boolean is needed. The
85
+ // handler/reject pair is the side-channel `_outputCallback` uses to
86
+ // divert per-window events to `_decodeWindow` while a stream is running.
87
+ this._streamResponse = null
88
+ this._streamWindowHandler = null
89
+ this._streamWindowReject = null
90
+ this._streamDriverPromise = null
91
+ this._streamAborted = false
92
+ }
93
+
94
+ /**
95
+ * Abort any active stream: reject the in-flight window decode (if any),
96
+ * clear the stream side-channel, and fail the outward-facing response.
97
+ * Idempotent. Does NOT await the driver - callers that need the driver
98
+ * to fully unwind (unload/destroy) should `await this._streamDriverPromise`
99
+ * after calling this.
100
+ */
101
+ _teardownActiveStream (reason) {
102
+ this._streamAborted = true
103
+ this._streamWindowHandler = null
104
+ if (this._streamWindowReject) {
105
+ const rej = this._streamWindowReject
106
+ this._streamWindowReject = null
107
+ rej(new Error(reason))
108
+ }
109
+ if (this._streamResponse) {
110
+ const r = this._streamResponse
111
+ this._streamResponse = null
112
+ r.failed(new Error(reason))
113
+ }
114
+ }
115
+
116
+ getState () {
117
+ return this.state
118
+ }
119
+
120
+ async load () {
121
+ if (this.state.destroyed) {
122
+ throw new QvacErrorAddonBCI({
123
+ code: ERR_CODES.MODEL_NOT_LOADED,
124
+ adds: 'instance was destroyed'
125
+ })
126
+ }
127
+ if (this.state.configLoaded) {
128
+ this.logger.info('Reload requested - unloading existing model first')
129
+ await this.unload()
130
+ }
131
+ await this._load()
132
+ this.state.configLoaded = true
133
+ }
134
+
135
+ async _load () {
136
+ if (!fs.existsSync(this._files.model)) {
137
+ throw new QvacErrorAddonBCI({
138
+ code: ERR_CODES.MODEL_FILE_NOT_FOUND,
139
+ adds: this._files.model
140
+ })
141
+ }
142
+
143
+ const whisperConfig = {
144
+ language: 'en',
145
+ n_threads: 0,
146
+ ...(this._config.whisperConfig || {})
147
+ }
148
+
149
+ const configurationParams = {
150
+ contextParams: {
151
+ model: this._files.model,
152
+ ...(this._config.contextParams || {})
153
+ },
154
+ whisperConfig,
155
+ miscConfig: {
156
+ caption_enabled: false,
157
+ ...(this._config.miscConfig || {})
158
+ }
159
+ }
160
+
161
+ if (this._config.bciConfig) {
162
+ configurationParams.bciConfig = this._config.bciConfig
163
+ }
164
+
165
+ if (this.state.destroyed) {
166
+ throw new QvacErrorAddonBCI({
167
+ code: ERR_CODES.MODEL_NOT_LOADED,
168
+ adds: 'instance was destroyed'
169
+ })
170
+ }
171
+
172
+ const binding = require('./binding')
173
+ try {
174
+ this.addon = new BCIInterface(
175
+ binding,
176
+ configurationParams,
177
+ this._outputCallback.bind(this),
178
+ this.logger.info.bind(this.logger)
179
+ )
180
+ } catch (err) {
181
+ this.addon = null
182
+ const configError = this._isConfigurationError(err)
183
+ throw new QvacErrorAddonBCI({
184
+ code: configError ? ERR_CODES.INVALID_CONFIG : ERR_CODES.FAILED_TO_LOAD_WEIGHTS,
185
+ adds: err.message,
186
+ cause: err
187
+ })
188
+ }
189
+
190
+ try {
191
+ await this.addon.activate()
192
+ } catch (err) {
193
+ this.addon = null
194
+ throw new QvacErrorAddonBCI({
195
+ code: ERR_CODES.FAILED_TO_ACTIVATE,
196
+ adds: err.message,
197
+ cause: err
198
+ })
199
+ }
200
+ this.logger.info('BCI addon activated')
201
+ }
202
+
203
+ /**
204
+ * Transcribe a neural signal from a binary file.
205
+ * Convenience wrapper around transcribe().
206
+ * @param {string} filePath - path to .bin neural signal file
207
+ * @returns {Promise<QvacResponse>}
208
+ */
209
+ async transcribeFile (filePath) {
210
+ const data = fs.readFileSync(filePath)
211
+ return this.transcribe(new Uint8Array(data))
212
+ }
213
+
214
+ /**
215
+ * Transcribe neural signal data (batch mode).
216
+ * Returns a QvacResponse; use response.await() for the final output array,
217
+ * response.onUpdate() for streaming updates, response.stats for runtime stats.
218
+ * @param {Uint8Array} neuralData - binary neural signal
219
+ * @returns {Promise<QvacResponse>}
220
+ */
221
+ async transcribe (neuralData) {
222
+ this._assertReadyForInference()
223
+ return await this._enqueueInference(async () => {
224
+ const response = this._job.start()
225
+
226
+ let accepted
227
+ try {
228
+ accepted = await this.addon.runJob({ input: neuralData })
229
+ } catch (err) {
230
+ this._job.fail(err)
231
+ throw err
232
+ }
233
+ if (!accepted) {
234
+ const error = new QvacErrorAddonBCI({ code: ERR_CODES.JOB_ALREADY_RUNNING })
235
+ this._job.fail(error)
236
+ throw error
237
+ }
238
+
239
+ const finalized = response.await()
240
+ finalized.catch(() => {})
241
+ response.await = () => finalized
242
+ return response
243
+ })
244
+ }
245
+
246
+ /**
247
+ * Incrementally transcribe a neural signal stream using a sliding window
248
+ * over the existing batch `runJob` pipeline. Purely JS-side; no native
249
+ * streaming hooks are used.
250
+ *
251
+ * Input shape (header semantics):
252
+ * [T (u32 LE), C (u32 LE), body bytes...]
253
+ * In streaming mode the T field is required to be present for format
254
+ * compatibility with batch inputs but is ignored; window sizing comes
255
+ * from `streamOpts.windowTimesteps`. C must be non-zero.
256
+ *
257
+ * Stream input types accepted: async iterable, sync iterable, Uint8Array,
258
+ * or chunk array. Each yielded chunk must be a Uint8Array / ArrayBuffer
259
+ * view / ArrayBuffer / plain byte array.
260
+ *
261
+ * Emission contract: `response.onUpdate(...)` fires per window that
262
+ * produced non-empty text.
263
+ * - emit:'delta' (default): update carries the trimmed native segments
264
+ * for the newly-discovered tail, preserving each segment's native
265
+ * fields (`text`, `t0`, `t1`, ...). Each segment is additionally
266
+ * annotated with `windowStartTimestep` (the absolute timestep at
267
+ * which its owning window began) so consumers can map window-local
268
+ * timestamps back to the stream timeline.
269
+ * - emit:'full': update carries a single `{ text }` entry with the
270
+ * full running transcript. Per-segment timestamps are NOT preserved
271
+ * in this mode because a cumulative segment timeline across windows
272
+ * cannot be reliably reconstructed from window-local timestamps.
273
+ *
274
+ * `response.await()` resolves once the input stream ends and the final
275
+ * flush window decodes. `response.stats` is not populated for streams.
276
+ *
277
+ * @param {AsyncIterable|Iterable|Uint8Array|Uint8Array[]} neuralStream
278
+ * @param {Object} [streamOpts]
279
+ * @param {number} [streamOpts.windowTimesteps=1500] - decode window size
280
+ * in timesteps. Must be > 0 and ≤ MAX_WINDOW_TIMESTEPS.
281
+ * @param {number} [streamOpts.hopTimesteps=500] - how far the window
282
+ * advances between decodes. Must be > 0 and < windowTimesteps.
283
+ * @param {'delta'|'full'} [streamOpts.emit='delta'] - whether each
284
+ * update carries only the newly-discovered tail ('delta') or the
285
+ * full running transcript ('full').
286
+ * @returns {Promise<QvacResponse>}
287
+ */
288
+ async transcribeStream (neuralStream, streamOpts = {}) {
289
+ this._assertReadyForInference()
290
+ if (this._streamResponse !== null) {
291
+ throw new QvacErrorAddonBCI({ code: ERR_CODES.STREAM_ALREADY_ACTIVE })
292
+ }
293
+
294
+ const opts = this._validateStreamOpts(streamOpts)
295
+ const iterable = this._normalizeNeuralStream(neuralStream)
296
+
297
+ return await this._enqueueInference(async () => {
298
+ this._streamAborted = false
299
+ const response = new QvacResponse({
300
+ cancelHandler: async () => { await this.cancel() }
301
+ })
302
+ this._streamResponse = response
303
+
304
+ const driver = this._runStreamDriver(iterable, opts, response).catch((err) => {
305
+ if (this._streamResponse === response) {
306
+ this._streamResponse = null
307
+ }
308
+ response.failed(err)
309
+ }).finally(() => {
310
+ if (this._streamDriverPromise === driver) {
311
+ this._streamDriverPromise = null
312
+ }
313
+ })
314
+ this._streamDriverPromise = driver
315
+
316
+ return response
317
+ })
318
+ }
319
+
320
+ async _runStreamDriver (iterable, opts, response) {
321
+ let channels = null
322
+ let headerCarry = new Uint8Array(0)
323
+ const body = []
324
+ let bodyBytes = 0
325
+ let bytesPerTimestep = 0
326
+ let windowStartTs = 0
327
+ let lastDecodedEndTs = 0
328
+ let mergedText = ''
329
+
330
+ const decodeRange = async (startTs, windowTs) => {
331
+ if (this._streamAborted) return
332
+ if (windowTs <= 0) return
333
+ const endTs = startTs + windowTs
334
+ if (endTs <= lastDecodedEndTs) return
335
+
336
+ const windowBody = sliceBody(body, bytesPerTimestep, startTs, endTs, bodyBytes)
337
+ const windowBuf = buildWindowBuffer(windowBody, channels, windowTs)
338
+
339
+ this.logger.debug('Decoding stream window', {
340
+ startTimestep: startTs,
341
+ endTimestep: endTs,
342
+ windowTimesteps: windowTs
343
+ })
344
+
345
+ const segments = await this._decodeWindow(windowBuf)
346
+ lastDecodedEndTs = endTs
347
+
348
+ const { deltaSegments, merged } = stitchSegments(
349
+ mergedText,
350
+ segments,
351
+ MAX_STITCH_WORDS,
352
+ startTs
353
+ )
354
+ mergedText = merged
355
+
356
+ if (opts.emit === 'full') {
357
+ if (merged.length > 0) {
358
+ response.updateOutput([{ text: merged }])
359
+ }
360
+ } else if (deltaSegments.length > 0) {
361
+ response.updateOutput(deltaSegments)
362
+ }
363
+ }
364
+
365
+ try {
366
+ for await (const rawChunk of iterable) {
367
+ if (this._streamAborted) return
368
+ let chunk = toUint8(rawChunk)
369
+ if (chunk.byteLength === 0) continue
370
+
371
+ if (channels === null) {
372
+ if (headerCarry.byteLength > 0) {
373
+ const combined = new Uint8Array(headerCarry.byteLength + chunk.byteLength)
374
+ combined.set(headerCarry, 0)
375
+ combined.set(chunk, headerCarry.byteLength)
376
+ chunk = combined
377
+ headerCarry = new Uint8Array(0)
378
+ }
379
+ if (chunk.byteLength < 8) {
380
+ headerCarry = chunk
381
+ continue
382
+ }
383
+ const view = new DataView(chunk.buffer, chunk.byteOffset, chunk.byteLength)
384
+ channels = view.getUint32(4, true)
385
+ if (channels === 0) {
386
+ throw new QvacErrorAddonBCI({
387
+ code: ERR_CODES.INVALID_STREAM_HEADER,
388
+ adds: 'channels is zero'
389
+ })
390
+ }
391
+ bytesPerTimestep = channels * 4
392
+ chunk = chunk.subarray(8)
393
+ if (chunk.byteLength === 0) continue
394
+ }
395
+
396
+ body.push(chunk)
397
+ bodyBytes += chunk.byteLength
398
+
399
+ while (!this._streamAborted &&
400
+ Math.floor(bodyBytes / bytesPerTimestep) >= (windowStartTs + opts.windowTimesteps)) {
401
+ await decodeRange(windowStartTs, opts.windowTimesteps)
402
+ if (this._streamAborted) return
403
+ windowStartTs += opts.hopTimesteps
404
+ }
405
+ }
406
+
407
+ if (this._streamAborted) return
408
+
409
+ if (channels === null && headerCarry.byteLength > 0) {
410
+ throw new QvacErrorAddonBCI({
411
+ code: ERR_CODES.INVALID_STREAM_HEADER,
412
+ adds: `stream ended with ${headerCarry.byteLength} header byte(s) buffered; need 8`
413
+ })
414
+ }
415
+
416
+ if (channels !== null) {
417
+ const bufferedTs = Math.floor(bodyBytes / bytesPerTimestep)
418
+ if (bufferedTs > lastDecodedEndTs && bufferedTs > windowStartTs) {
419
+ await decodeRange(windowStartTs, bufferedTs - windowStartTs)
420
+ }
421
+ }
422
+
423
+ if (!this._streamAborted) {
424
+ this._streamResponse = null
425
+ if (opts.emit === 'full') {
426
+ response.ended(mergedText.length > 0 ? [{ text: mergedText }] : [])
427
+ } else {
428
+ response.ended()
429
+ }
430
+ }
431
+ } catch (err) {
432
+ this._streamResponse = null
433
+ throw err
434
+ }
435
+ }
436
+
437
+ async _decodeWindow (windowBytes) {
438
+ return await new Promise((resolve, reject) => {
439
+ const collected = []
440
+ const cleanup = () => {
441
+ this._streamWindowHandler = null
442
+ this._streamWindowReject = null
443
+ }
444
+ this._streamWindowReject = (err) => {
445
+ cleanup()
446
+ reject(err)
447
+ }
448
+ this._streamWindowHandler = (event, data, error) => {
449
+ if (event === 'Error') {
450
+ cleanup()
451
+ const err = error instanceof Error
452
+ ? error
453
+ : new Error(typeof error === 'string' ? error : 'window decode failed')
454
+ reject(err)
455
+ return
456
+ }
457
+ if (event === 'Output') {
458
+ if (Array.isArray(data)) {
459
+ for (const seg of data) {
460
+ if (seg && typeof seg.text === 'string') collected.push(seg)
461
+ }
462
+ } else if (data && typeof data.text === 'string') {
463
+ collected.push(data)
464
+ }
465
+ return
466
+ }
467
+ if (event === 'JobEnded') {
468
+ cleanup()
469
+ resolve(collected)
470
+ }
471
+ }
472
+
473
+ this.addon.runJob({ input: windowBytes })
474
+ .then(accepted => {
475
+ if (!accepted) {
476
+ cleanup()
477
+ reject(new QvacErrorAddonBCI({ code: ERR_CODES.JOB_ALREADY_RUNNING }))
478
+ }
479
+ })
480
+ .catch(err => {
481
+ cleanup()
482
+ reject(err)
483
+ })
484
+ })
485
+ }
486
+
487
+ /**
488
+ * Apply defaults and validate `streamOpts` passed to transcribeStream().
489
+ * Centralised so the public method body stays focused on orchestration,
490
+ * mirroring whispercpp's `_checkParamsExists` pattern. Returns a new
491
+ * opts object; does not mutate the caller's input.
492
+ */
493
+ _validateStreamOpts (streamOpts) {
494
+ const opts = {
495
+ windowTimesteps: streamOpts.windowTimesteps ?? DEFAULT_WINDOW_TIMESTEPS,
496
+ hopTimesteps: streamOpts.hopTimesteps ?? DEFAULT_HOP_TIMESTEPS,
497
+ emit: streamOpts.emit ?? 'delta'
498
+ }
499
+
500
+ if (!Number.isInteger(opts.windowTimesteps) || opts.windowTimesteps <= 0) {
501
+ throw new QvacErrorAddonBCI({
502
+ code: ERR_CODES.INVALID_STREAM_INPUT,
503
+ adds: 'windowTimesteps must be a positive integer'
504
+ })
505
+ }
506
+ if (!Number.isInteger(opts.hopTimesteps) || opts.hopTimesteps <= 0) {
507
+ throw new QvacErrorAddonBCI({
508
+ code: ERR_CODES.INVALID_STREAM_INPUT,
509
+ adds: 'hopTimesteps must be a positive integer'
510
+ })
511
+ }
512
+ if (opts.hopTimesteps >= opts.windowTimesteps) {
513
+ throw new QvacErrorAddonBCI({
514
+ code: ERR_CODES.INVALID_STREAM_INPUT,
515
+ adds: 'hopTimesteps must be less than windowTimesteps'
516
+ })
517
+ }
518
+ if (opts.windowTimesteps > MAX_WINDOW_TIMESTEPS) {
519
+ throw new QvacErrorAddonBCI({
520
+ code: ERR_CODES.WINDOW_TOO_LARGE,
521
+ adds: MAX_WINDOW_TIMESTEPS
522
+ })
523
+ }
524
+ if (opts.emit !== 'delta' && opts.emit !== 'full') {
525
+ throw new QvacErrorAddonBCI({
526
+ code: ERR_CODES.INVALID_STREAM_INPUT,
527
+ adds: `unsupported emit mode: ${opts.emit}`
528
+ })
529
+ }
530
+
531
+ return opts
532
+ }
533
+
534
+ _normalizeNeuralStream (input) {
535
+ if (input == null) {
536
+ throw new QvacErrorAddonBCI({
537
+ code: ERR_CODES.INVALID_STREAM_INPUT,
538
+ adds: 'stream is required'
539
+ })
540
+ }
541
+ if (typeof input[Symbol.asyncIterator] === 'function') return input
542
+ if (input instanceof Uint8Array) return [input]
543
+ if (Array.isArray(input)) return input
544
+ if (typeof input[Symbol.iterator] === 'function') return input
545
+ throw new QvacErrorAddonBCI({
546
+ code: ERR_CODES.INVALID_STREAM_INPUT,
547
+ adds: 'unsupported input type; expected async iterable, Uint8Array, or chunk array'
548
+ })
549
+ }
550
+
551
+ /**
552
+ * Serialize inference runs so a second transcribe() waits until the first
553
+ * response settles. Separate from _withExclusiveRun (lifecycle ops) so
554
+ * destroy/unload can still preempt.
555
+ */
556
+ async _enqueueInference (runFn) {
557
+ const prev = this._inferenceQueueWaiter
558
+ let releaseSlot
559
+ this._inferenceQueueWaiter = new Promise(resolve => { releaseSlot = resolve })
560
+ await prev
561
+ let response
562
+ try {
563
+ response = await runFn()
564
+ } catch (err) {
565
+ releaseSlot()
566
+ throw err
567
+ }
568
+ response.await().finally(() => { releaseSlot() }).catch(() => {})
569
+ return response
570
+ }
571
+
572
+ _assertReadyForInference () {
573
+ if (this.state.destroyed || !this.state.configLoaded || !this.addon) {
574
+ throw new QvacErrorAddonBCI({
575
+ code: ERR_CODES.MODEL_NOT_LOADED,
576
+ adds: this.state.destroyed ? 'instance was destroyed' : 'call load() before transcribe()'
577
+ })
578
+ }
579
+ }
580
+
581
+ _isConfigurationError (err) {
582
+ if (err && err.code === 'ERR_ASSERTION') return true
583
+ if (err instanceof TypeError) return true
584
+ const msg = String(err?.message || '')
585
+ return msg.includes('is required') || msg.includes('is not a valid parameter') || msg.includes('must be')
586
+ }
587
+
588
+ /**
589
+ * Single sink for native addon events. During a stream, events are
590
+ * diverted to the active `_streamWindowHandler` (registered by
591
+ * `_decodeWindow`) instead of the batch `_job`. This side-channel
592
+ * exists because per-window `runJob` calls must resolve into the
593
+ * streaming driver rather than the `_job` state machine, which is
594
+ * reserved for batch `transcribe()` calls and not used while a stream
595
+ * is active. When `_streamWindowHandler` is null the batch path runs.
596
+ */
597
+ _outputCallback (addon, event, jobId, data, error) {
598
+ if (this._streamWindowHandler) {
599
+ this._streamWindowHandler(event, data, error)
600
+ return
601
+ }
602
+ if (event === 'Error') {
603
+ this.logger.error('Job ' + jobId + ' failed with error: ' + error)
604
+ this._job.fail(error)
605
+ return
606
+ }
607
+ if (event === 'Output') {
608
+ this._job.output(data)
609
+ return
610
+ }
611
+ if (event === 'JobEnded') {
612
+ this.logger.info('Job ' + jobId + ' completed')
613
+ if (this.opts.stats) {
614
+ this._job.end(data)
615
+ } else {
616
+ this._job.end()
617
+ }
618
+ return
619
+ }
620
+ this.logger.debug('Received event for job ' + jobId + ': ' + event)
621
+ }
622
+
623
+ async cancel () {
624
+ this._teardownActiveStream('Stream cancelled')
625
+ if (this.addon?.cancel) {
626
+ await this.addon.cancel()
627
+ }
628
+ if (this._streamDriverPromise) {
629
+ await this._streamDriverPromise
630
+ }
631
+ if (this._job.active) {
632
+ this._job.fail(new Error('Job cancelled'))
633
+ }
634
+ }
635
+
636
+ async unload () {
637
+ return await this._withExclusiveRun(async () => {
638
+ this._teardownActiveStream('Model was unloaded')
639
+ if (this._streamDriverPromise) {
640
+ await this._streamDriverPromise
641
+ }
642
+ await this._inferenceQueueWaiter
643
+ if (this.addon) {
644
+ await this.addon.destroyInstance()
645
+ this.addon = null
646
+ }
647
+ if (this._job.active) {
648
+ this._job.fail(new Error('Model was unloaded'))
649
+ }
650
+ this.state.configLoaded = false
651
+ })
652
+ }
653
+
654
+ async destroy () {
655
+ return await this._withExclusiveRun(async () => {
656
+ this._teardownActiveStream('Model was destroyed')
657
+ if (this._streamDriverPromise) {
658
+ await this._streamDriverPromise
659
+ }
660
+ await this._inferenceQueueWaiter
661
+ if (this.addon) {
662
+ await this.addon.destroyInstance()
663
+ this.addon = null
664
+ }
665
+ if (this._job.active) {
666
+ this._job.fail(new Error('Model was destroyed'))
667
+ }
668
+ this.state.configLoaded = false
669
+ this.state.destroyed = true
670
+ })
671
+ }
672
+ }
673
+
674
+ module.exports = BCIWhispercpp
675
+ module.exports.BCIWhispercpp = BCIWhispercpp
676
+ module.exports.computeWER = computeWER