@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/CHANGELOG.md +82 -0
- package/LICENSE +179 -0
- package/NOTICE +108 -0
- package/README.md +378 -0
- package/addonLogging.d.ts +7 -0
- package/addonLogging.js +6 -0
- package/bci.js +370 -0
- package/binding.js +1 -0
- package/configChecker.js +91 -0
- package/index.d.ts +134 -0
- package/index.js +676 -0
- package/lib/error.js +112 -0
- package/lib/stream.js +207 -0
- package/lib/util.js +15 -0
- package/lib/wer.js +40 -0
- package/package.json +96 -1
- package/prebuilds/android-arm64/qvac__bci-whispercpp.bare +0 -0
- package/prebuilds/darwin-arm64/qvac__bci-whispercpp.bare +0 -0
- package/prebuilds/darwin-arm64/qvac__bci-whispercpp.bare.exports +1439 -0
- package/prebuilds/darwin-x64/qvac__bci-whispercpp.bare +0 -0
- package/prebuilds/darwin-x64/qvac__bci-whispercpp.bare.exports +1654 -0
- package/prebuilds/ios-arm64/qvac__bci-whispercpp.bare +0 -0
- package/prebuilds/ios-arm64/qvac__bci-whispercpp.bare.exports +1439 -0
- package/prebuilds/ios-arm64-simulator/qvac__bci-whispercpp.bare +0 -0
- package/prebuilds/ios-arm64-simulator/qvac__bci-whispercpp.bare.exports +1439 -0
- package/prebuilds/ios-x64-simulator/qvac__bci-whispercpp.bare +0 -0
- package/prebuilds/ios-x64-simulator/qvac__bci-whispercpp.bare.exports +1654 -0
- package/prebuilds/linux-arm64/qvac__bci-whispercpp.bare +0 -0
- package/prebuilds/linux-x64/qvac__bci-whispercpp.bare +0 -0
- package/prebuilds/win32-x64/qvac__bci-whispercpp.bare +0 -0
- package/prebuilds/win32-x64/qvac__bci-whispercpp.bare.exports +0 -0
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
|