@qvac/bci-whispercpp 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bci.js ADDED
@@ -0,0 +1,370 @@
1
+ 'use strict'
2
+
3
+ const { QvacErrorAddonBCI, ERR_CODES } = require('./lib/error')
4
+ const { checkConfig } = require('./configChecker')
5
+
6
+ const state = Object.freeze({
7
+ LOADING: 'loading',
8
+ LISTENING: 'listening',
9
+ PROCESSING: 'processing',
10
+ IDLE: 'idle'
11
+ })
12
+
13
+ const END_OF_INPUT = 'end of job'
14
+
15
+ // Upper bound on buffered neural-signal bytes between append() calls.
16
+ // Neural data is ~1 MB/s at 512ch * 50 Hz * 4 B, so 500 MB ~= 8 minutes of
17
+ // signal. The bound matches qvac-lib-infer-whispercpp and protects against
18
+ // runaway producers.
19
+ const MAX_BUFFERED_BYTES = 500 * 1024 * 1024
20
+
21
+ function nextSafeId (current) {
22
+ return current >= Number.MAX_SAFE_INTEGER ? 1 : current + 1
23
+ }
24
+
25
+ /**
26
+ * Low-level interface between the Bare C++ BCI addon and the JS runtime.
27
+ * Accepts neural signal data (Uint8Array) instead of audio.
28
+ */
29
+ class BCIInterface {
30
+ /**
31
+ * @param {Object} binding - the native binding object
32
+ * @param {Object} configurationParams - configuration for the BCI model
33
+ * @param {Function} outputCb - callback for inference events (Output, JobEnded, Error)
34
+ * @param {Function} [transitionCb] - callback for state changes
35
+ */
36
+ constructor (binding, configurationParams, outputCb, transitionCb = null) {
37
+ this._binding = binding
38
+ this._outputCb = outputCb
39
+ this._transitionCb = transitionCb
40
+ this._nextJobId = 1
41
+ this._activeJobId = null
42
+ this._bufferedSignal = []
43
+ this._bufferedBytes = 0
44
+ this._state = state.LOADING
45
+
46
+ checkConfig(configurationParams)
47
+ this._handle = this._binding.createInstance(
48
+ this,
49
+ configurationParams,
50
+ this._addonOutputCallback.bind(this),
51
+ transitionCb
52
+ )
53
+ }
54
+
55
+ _setState (newState) {
56
+ this._state = newState
57
+ if (this._transitionCb) {
58
+ this._transitionCb(this, newState)
59
+ }
60
+ }
61
+
62
+ _addonOutputCallback (addon, event, data, error) {
63
+ const isError = typeof error === 'string' && error.length > 0
64
+ const isStats = data && typeof data === 'object' && (
65
+ 'totalTime' in data ||
66
+ 'tokensPerSecond' in data ||
67
+ 'totalWallMs' in data
68
+ )
69
+ const isTranscriptOutput = (
70
+ (Array.isArray(data) && data.length > 0) ||
71
+ (data && typeof data === 'object' && typeof data.text === 'string')
72
+ )
73
+
74
+ let mappedEvent = event
75
+ if (event === 'Error' || isError || String(event).includes('Error')) {
76
+ mappedEvent = 'Error'
77
+ } else if (event === 'JobEnded' || isStats || String(event).includes('RuntimeStats')) {
78
+ mappedEvent = 'JobEnded'
79
+ } else if (event === 'Output' || isTranscriptOutput) {
80
+ mappedEvent = 'Output'
81
+ } else if (Array.isArray(data) && data.length === 0) {
82
+ return
83
+ }
84
+
85
+ const jobId = this._activeJobId
86
+ if (jobId === null || jobId === undefined) {
87
+ return
88
+ }
89
+
90
+ if (mappedEvent === 'Output') {
91
+ this._setState(state.PROCESSING)
92
+
93
+ if (this._outputCb != null) {
94
+ const isTranscriptArray = Array.isArray(data) && data.length > 0 &&
95
+ typeof data[0]?.text === 'string'
96
+ const isSingleTranscript = !Array.isArray(data) &&
97
+ data && typeof data === 'object' && typeof data.text === 'string'
98
+ if (isTranscriptArray) {
99
+ for (const segment of data) {
100
+ this._outputCb(addon, 'Output', jobId, [segment], null)
101
+ }
102
+ } else if (isSingleTranscript) {
103
+ this._outputCb(addon, 'Output', jobId, [data], null)
104
+ } else {
105
+ this._outputCb(addon, 'Output', jobId, data, null)
106
+ }
107
+ }
108
+ return
109
+ }
110
+
111
+ if (this._outputCb != null) {
112
+ this._outputCb(addon, mappedEvent, jobId, data, isError ? error : null)
113
+ }
114
+
115
+ if (mappedEvent === 'Error' || mappedEvent === 'JobEnded') {
116
+ this._activeJobId = null
117
+ this._setState(state.LISTENING)
118
+ }
119
+ }
120
+
121
+ async unload () {
122
+ await this.destroyInstance()
123
+ }
124
+
125
+ async load (configurationParams) {
126
+ checkConfig(configurationParams)
127
+ await this.destroyInstance()
128
+ this._handle = this._binding.createInstance(
129
+ this,
130
+ configurationParams,
131
+ this._addonOutputCallback.bind(this),
132
+ this._transitionCb
133
+ )
134
+ this._setState(state.LOADING)
135
+ }
136
+
137
+ async reload (configurationParams) {
138
+ checkConfig(configurationParams)
139
+ await this.cancel()
140
+
141
+ if (typeof this._binding.reload === 'function') {
142
+ await this._binding.reload(this._handle, configurationParams)
143
+ this._setState(state.LOADING)
144
+ return
145
+ }
146
+
147
+ await this.load(configurationParams)
148
+ }
149
+
150
+ async loadWeights (weightsData) {
151
+ try {
152
+ this._binding.loadWeights(this._handle, weightsData)
153
+ } catch (err) {
154
+ throw new QvacErrorAddonBCI({
155
+ code: ERR_CODES.FAILED_TO_LOAD_WEIGHTS,
156
+ adds: err.message,
157
+ cause: err
158
+ })
159
+ }
160
+ }
161
+
162
+ async unloadWeights () {
163
+ return true
164
+ }
165
+
166
+ async activate () {
167
+ try {
168
+ this._binding.activate(this._handle)
169
+ this._setState(state.LISTENING)
170
+ } catch (err) {
171
+ throw new QvacErrorAddonBCI({
172
+ code: ERR_CODES.FAILED_TO_ACTIVATE,
173
+ adds: err.message,
174
+ cause: err
175
+ })
176
+ }
177
+ }
178
+
179
+ async cancel (jobId) {
180
+ try {
181
+ await this._binding.cancel(this._handle, jobId)
182
+ this._bufferedSignal = []
183
+ this._bufferedBytes = 0
184
+ this._activeJobId = null
185
+ this._setState(state.LISTENING)
186
+ } catch (err) {
187
+ throw new QvacErrorAddonBCI({
188
+ code: ERR_CODES.FAILED_TO_CANCEL,
189
+ adds: err.message,
190
+ cause: err
191
+ })
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Appends neural signal data to the processing buffer.
197
+ * Send { type: 'end of job' } to trigger processing.
198
+ * @param {Object} data
199
+ * @param {string} data.type - 'neural' or 'end of job'
200
+ * @param {Uint8Array} [data.input] - binary neural signal data
201
+ * @returns {number} job ID
202
+ */
203
+ async append (data) {
204
+ try {
205
+ if (data?.type === END_OF_INPUT) {
206
+ if (this._bufferedSignal.length === 0) {
207
+ throw new QvacErrorAddonBCI({
208
+ code: ERR_CODES.INVALID_NEURAL_INPUT,
209
+ adds: 'no neural signal data was appended before end-of-job'
210
+ })
211
+ }
212
+ const currentJobId = this._nextJobId
213
+ const input = this._concatBufferedSignal()
214
+ const previousState = this._state
215
+ const previousJobId = this._activeJobId
216
+
217
+ let accepted = false
218
+ try {
219
+ accepted = this._binding.runJob(this._handle, {
220
+ type: 'neural',
221
+ input
222
+ })
223
+ } catch (err) {
224
+ this._activeJobId = previousJobId
225
+ this._setState(previousState)
226
+ throw err
227
+ }
228
+ if (!accepted) {
229
+ this._activeJobId = previousJobId
230
+ this._setState(previousState)
231
+ throw new QvacErrorAddonBCI({ code: ERR_CODES.JOB_ALREADY_RUNNING })
232
+ }
233
+
234
+ this._activeJobId = currentJobId
235
+ this._nextJobId = nextSafeId(this._nextJobId)
236
+ this._bufferedSignal = []
237
+ this._bufferedBytes = 0
238
+ this._setState(state.PROCESSING)
239
+ return currentJobId
240
+ }
241
+
242
+ if (data?.type === 'neural') {
243
+ if (!(data.input instanceof Uint8Array)) {
244
+ throw new QvacErrorAddonBCI({
245
+ code: ERR_CODES.INVALID_NEURAL_INPUT,
246
+ adds: 'input must be Uint8Array'
247
+ })
248
+ }
249
+ if (this._bufferedBytes + data.input.byteLength > MAX_BUFFERED_BYTES) {
250
+ throw new QvacErrorAddonBCI({
251
+ code: ERR_CODES.BUFFER_LIMIT_EXCEEDED,
252
+ adds: MAX_BUFFERED_BYTES + ' bytes'
253
+ })
254
+ }
255
+ this._bufferedSignal.push(data.input)
256
+ this._bufferedBytes += data.input.byteLength
257
+ return this._nextJobId
258
+ }
259
+
260
+ throw new Error(`Unknown append input type: ${data?.type}`)
261
+ } catch (err) {
262
+ if (err instanceof QvacErrorAddonBCI) throw err
263
+ throw new QvacErrorAddonBCI({
264
+ code: ERR_CODES.FAILED_TO_APPEND,
265
+ adds: err.message,
266
+ cause: err
267
+ })
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Run a single batch job directly with neural signal data.
273
+ * @param {Object} data
274
+ * @param {Uint8Array} data.input - binary neural signal data
275
+ */
276
+ async runJob (data) {
277
+ if (!data || !(data.input instanceof Uint8Array)) {
278
+ throw new QvacErrorAddonBCI({
279
+ code: ERR_CODES.INVALID_NEURAL_INPUT,
280
+ adds: 'runJob input must be a Uint8Array'
281
+ })
282
+ }
283
+ if (data.input.byteLength === 0) {
284
+ throw new QvacErrorAddonBCI({
285
+ code: ERR_CODES.INVALID_NEURAL_INPUT,
286
+ adds: 'runJob input must not be empty'
287
+ })
288
+ }
289
+
290
+ const candidateJobId = this._nextJobId
291
+ const previousState = this._state
292
+ const previousJobId = this._activeJobId
293
+ let accepted = false
294
+ try {
295
+ accepted = this._binding.runJob(this._handle, {
296
+ type: 'neural',
297
+ input: data.input
298
+ })
299
+ } catch (err) {
300
+ this._activeJobId = previousJobId
301
+ this._setState(previousState)
302
+ throw new QvacErrorAddonBCI({
303
+ code: ERR_CODES.FAILED_TO_START_JOB,
304
+ adds: err.message,
305
+ cause: err
306
+ })
307
+ }
308
+
309
+ if (!accepted) {
310
+ this._activeJobId = previousJobId
311
+ this._setState(previousState)
312
+ return false
313
+ }
314
+
315
+ this._activeJobId = candidateJobId
316
+ this._nextJobId = nextSafeId(this._nextJobId)
317
+ this._setState(state.PROCESSING)
318
+ return accepted
319
+ }
320
+
321
+ async status () {
322
+ return this._state
323
+ }
324
+
325
+ async destroyInstance () {
326
+ if (this._handle === null) {
327
+ return
328
+ }
329
+ try {
330
+ try {
331
+ await this._binding.cancel(this._handle)
332
+ } catch {}
333
+ this._binding.destroyInstance(this._handle)
334
+ this._handle = null
335
+ this._bufferedSignal = []
336
+ this._bufferedBytes = 0
337
+ this._activeJobId = null
338
+ this._setState(state.IDLE)
339
+ } catch (err) {
340
+ throw new QvacErrorAddonBCI({
341
+ code: ERR_CODES.FAILED_TO_DESTROY,
342
+ adds: err.message,
343
+ cause: err
344
+ })
345
+ }
346
+ }
347
+
348
+ _concatBufferedSignal () {
349
+ if (this._bufferedSignal.length === 0) {
350
+ return new Uint8Array()
351
+ }
352
+ if (this._bufferedSignal.length === 1) {
353
+ return this._bufferedSignal[0]
354
+ }
355
+ const totalLength = this._bufferedSignal.reduce(
356
+ (sum, chunk) => sum + chunk.byteLength, 0
357
+ )
358
+ const merged = new Uint8Array(totalLength)
359
+ let offset = 0
360
+ for (const chunk of this._bufferedSignal) {
361
+ merged.set(chunk, offset)
362
+ offset += chunk.byteLength
363
+ }
364
+ return merged
365
+ }
366
+ }
367
+
368
+ BCIInterface.END_OF_INPUT = END_OF_INPUT
369
+
370
+ module.exports = { BCIInterface, END_OF_INPUT, MAX_BUFFERED_BYTES, nextSafeId }
package/binding.js ADDED
@@ -0,0 +1 @@
1
+ module.exports = require.addon()
@@ -0,0 +1,91 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Validates BCI addon configuration.
5
+ * @param {Object} configObject
6
+ * @returns {void} or throws if invalid
7
+ */
8
+ function checkConfig (configObject) {
9
+ const requiredSections = ['whisperConfig', 'contextParams', 'miscConfig']
10
+
11
+ for (const section of requiredSections) {
12
+ if (!configObject[section]) {
13
+ throw new Error(`${section} object is required`)
14
+ }
15
+ }
16
+
17
+ const validWhisperParams = [
18
+ 'n_threads',
19
+ 'duration_ms',
20
+ 'translate',
21
+ 'no_timestamps',
22
+ 'single_segment',
23
+ 'print_special',
24
+ 'print_progress',
25
+ 'print_realtime',
26
+ 'print_timestamps',
27
+ 'language',
28
+ 'detect_language',
29
+ 'suppress_blank',
30
+ 'suppress_nst',
31
+ 'temperature',
32
+ 'greedy_best_of',
33
+ 'beam_search_beam_size'
34
+ ]
35
+
36
+ const validContextParams = [
37
+ 'model',
38
+ 'use_gpu',
39
+ 'flash_attn',
40
+ 'gpu_device'
41
+ ]
42
+
43
+ const validMiscParams = [
44
+ 'caption_enabled'
45
+ ]
46
+
47
+ const validBCIParams = [
48
+ 'day_idx'
49
+ ]
50
+
51
+ for (const userParam of Object.keys(configObject.whisperConfig)) {
52
+ if (!validWhisperParams.includes(userParam)) {
53
+ throw new Error(`${userParam} is not a valid parameter for whisperConfig`)
54
+ }
55
+ }
56
+
57
+ for (const userParam of Object.keys(configObject.contextParams)) {
58
+ if (!validContextParams.includes(userParam)) {
59
+ throw new Error(`${userParam} is not a valid parameter for contextParams`)
60
+ }
61
+ }
62
+
63
+ for (const userParam of Object.keys(configObject.miscConfig)) {
64
+ if (!validMiscParams.includes(userParam)) {
65
+ throw new Error(`${userParam} is not a valid parameter for miscConfig`)
66
+ }
67
+ }
68
+ if (configObject.miscConfig.caption_enabled !== undefined &&
69
+ typeof configObject.miscConfig.caption_enabled !== 'boolean') {
70
+ throw new Error('miscConfig.caption_enabled must be a boolean')
71
+ }
72
+
73
+ if (configObject.bciConfig) {
74
+ for (const userParam of Object.keys(configObject.bciConfig)) {
75
+ if (!validBCIParams.includes(userParam)) {
76
+ throw new Error(`${userParam} is not a valid parameter for bciConfig`)
77
+ }
78
+ }
79
+ const dayIdx = configObject.bciConfig.day_idx
80
+ if (dayIdx !== undefined) {
81
+ if (typeof dayIdx !== 'number' || !Number.isFinite(dayIdx) || !Number.isInteger(dayIdx)) {
82
+ throw new Error('bciConfig.day_idx must be a finite integer')
83
+ }
84
+ if (dayIdx < -1) {
85
+ throw new Error('bciConfig.day_idx must be >= -1 (use -1 to enable mel-passthrough mode)')
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ module.exports = { checkConfig }
package/index.d.ts ADDED
@@ -0,0 +1,134 @@
1
+ import QvacResponse from '@qvac/infer-base/src/QvacResponse'
2
+ import type { LoggerInterface } from '@qvac/logging'
3
+
4
+ declare interface BCIConfig {
5
+ /**
6
+ * Session day index used to select day-specific projection matrices in
7
+ * bci-embedder.bin.
8
+ *
9
+ * - `day_idx >= 0` (default `0`): apply the day projection; values beyond
10
+ * the available range are clamped at the native layer.
11
+ * - `day_idx === -1`: mel passthrough — skip preprocessing and treat
12
+ * the input buffer as pre-computed 512-bin mel features in
13
+ * frame-major layout. Intended for parity testing against the Python
14
+ * reference, not production use.
15
+ */
16
+ day_idx?: number
17
+ }
18
+
19
+ declare interface WhisperConfig {
20
+ language?: string
21
+ n_threads?: number
22
+ temperature?: number
23
+ suppress_nst?: boolean
24
+ suppress_blank?: boolean
25
+ duration_ms?: number
26
+ translate?: boolean
27
+ no_timestamps?: boolean
28
+ single_segment?: boolean
29
+ print_special?: boolean
30
+ print_progress?: boolean
31
+ print_realtime?: boolean
32
+ print_timestamps?: boolean
33
+ detect_language?: boolean
34
+ greedy_best_of?: number
35
+ beam_search_beam_size?: number
36
+ }
37
+
38
+ declare interface BCIWhispercppFiles {
39
+ /** Absolute path to the BCI GGML model file. */
40
+ model: string
41
+ }
42
+
43
+ declare interface BCIWhispercppArgs {
44
+ files: BCIWhispercppFiles
45
+ logger?: LoggerInterface
46
+ opts?: {
47
+ stats?: boolean
48
+ }
49
+ }
50
+
51
+ declare interface BCIWhispercppConfig {
52
+ whisperConfig?: WhisperConfig
53
+ bciConfig?: BCIConfig
54
+ contextParams?: {
55
+ model?: string
56
+ use_gpu?: boolean
57
+ flash_attn?: boolean
58
+ gpu_device?: number
59
+ }
60
+ miscConfig?: {
61
+ caption_enabled?: boolean
62
+ }
63
+ }
64
+
65
+ declare interface TranscriptSegment {
66
+ text: string
67
+ toAppend: boolean
68
+ start: number
69
+ end: number
70
+ id: number
71
+ }
72
+
73
+ declare interface BCIWhispercppState {
74
+ configLoaded: boolean
75
+ destroyed: boolean
76
+ }
77
+
78
+ /**
79
+ * BCI neural signal transcription client powered by whisper.cpp.
80
+ *
81
+ * Uses `createJobHandler` + `exclusiveRunQueue` from `@qvac/infer-base` and
82
+ * follows the same lifecycle contract as `TranscriptionWhispercpp` /
83
+ * `LlmLlamacpp`: construct with local file paths, call `load()`, issue
84
+ * `transcribe()` / `transcribeFile()` calls, then `destroy()`.
85
+ */
86
+ declare class BCIWhispercpp {
87
+ constructor(args: BCIWhispercppArgs, config?: BCIWhispercppConfig)
88
+
89
+ /** Load and activate the model. Must be awaited before `transcribe()`. */
90
+ load(): Promise<void>
91
+
92
+ /** Transcribe a neural signal binary file (convenience wrapper). */
93
+ transcribeFile(filePath: string): Promise<QvacResponse>
94
+
95
+ /** Transcribe a neural signal buffer (batch mode). */
96
+ transcribe(neuralData: Uint8Array): Promise<QvacResponse>
97
+
98
+ /** Cancel the in-flight inference, if any. */
99
+ cancel(): Promise<void>
100
+
101
+ /** Unload the model and release native resources. Instance is reusable. */
102
+ unload(): Promise<void>
103
+
104
+ /**
105
+ * Destroy the instance, unload, and mark as permanently destroyed.
106
+ * Subsequent `load()` calls will throw `MODEL_NOT_LOADED`.
107
+ */
108
+ destroy(): Promise<void>
109
+
110
+ /** Current lifecycle state. */
111
+ getState(): BCIWhispercppState
112
+ }
113
+
114
+ declare namespace BCIWhispercpp {
115
+ /**
116
+ * Compute Word Error Rate between hypothesis and reference strings.
117
+ * @returns WER as a ratio (0.0 = perfect).
118
+ */
119
+ function computeWER(hypothesis: string, reference: string): number
120
+
121
+ export {
122
+ BCIWhispercpp as default,
123
+ BCIWhispercpp,
124
+ BCIConfig,
125
+ WhisperConfig,
126
+ BCIWhispercppFiles,
127
+ BCIWhispercppArgs,
128
+ BCIWhispercppConfig,
129
+ BCIWhispercppState,
130
+ TranscriptSegment
131
+ }
132
+ }
133
+
134
+ export = BCIWhispercpp