@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/README.md ADDED
@@ -0,0 +1,378 @@
1
+ # @qvac/bci-whispercpp
2
+
3
+ Brain-Computer Interface (BCI) neural signal transcription addon for qvac, powered by the [tetherto/qvac-ext-lib-whisper.cpp](https://github.com/tetherto/qvac-ext-lib-whisper.cpp) fork of whisper.cpp.
4
+
5
+ Transcribes multi-channel neural signals (e.g., 512-channel microelectrode array recordings) into text using a BCI-trained whisper model running natively via GGML. Output matches the Python BrainWhisperer reference model exactly.
6
+
7
+ ## Table of Contents
8
+
9
+ - [Architecture](#architecture)
10
+ - [Results](#results)
11
+ - [Neural Signal Format](#neural-signal-format)
12
+ - [Installation](#installation)
13
+ - [Model Conversion](#model-conversion)
14
+ - [Usage](#usage)
15
+ - [Configuration](#configuration)
16
+ - [Tests](#tests)
17
+ - [Error Range](#error-range)
18
+ - [whisper.cpp Patches](#whispercpp-patches)
19
+ - [Resources](#resources)
20
+ - [Glossary](#glossary)
21
+ - [License](#license)
22
+
23
+ ## Architecture
24
+
25
+ ```
26
+ Neural Signal (512ch, 20ms bins)
27
+
28
+
29
+ ┌──────────────────────────────┐
30
+ │ NeuralProcessor (C++) │
31
+ │ - Gaussian smoothing │ std=2, kernel=100
32
+ │ - Day-specific projection │ low-rank (A·B) + month + softsign
33
+ │ - Pad to 3000 frames │ mel-major layout for whisper.cpp
34
+ └──────────────┬───────────────┘
35
+ │ mel features (512 × 3000)
36
+
37
+ ┌──────────────────────────────┐
38
+ │ whisper.cpp (patched) │
39
+ │ - conv1 (k=7, 512→384) │ BCI-trained embedder weights
40
+ │ - conv2 (k=3, stride=2) │
41
+ │ - Positional encoding │ learned time PE + sinusoidal day PE
42
+ │ - 6-layer encoder │ windowed attention (w=57) on layers 0–3
43
+ │ - 4-layer decoder (LoRA) │ beam search, length_penalty=0.14
44
+ └──────────────┬───────────────┘
45
+
46
+
47
+ Text output
48
+ ```
49
+
50
+ ## Results
51
+
52
+ Native GGML inference matches the Python BrainWhisperer reference on all test samples:
53
+
54
+ | Sample | Ground Truth | GGML Native Output | WER |
55
+ |--------|-------------|-------------------|-----|
56
+ | 0 | "You can see the code at this point as well." | "You can see the good at this point as well." | 10.0% |
57
+ | 1 | "How does it keep the cost down?" | "How does it keep the cost down?" | 0.0% |
58
+ | 2 | "Not too controversial." | "Not too controversial." | 0.0% |
59
+ | 3 | "The jury and a judge work together on it." | "The jury and a judge work together on it." | 0.0% |
60
+ | 4 | "Were quite vocal about it." | "We're quite vocal about it." | 20.0% |
61
+ | **Average** | | | **6.0%** |
62
+
63
+ ## Neural Signal Format
64
+
65
+ Binary files with the following layout:
66
+
67
+ | Offset | Type | Description |
68
+ |--------|-----------|------------------------------------------------------|
69
+ | 0 | uint32 | Number of timesteps |
70
+ | 4 | uint32 | Number of channels |
71
+ | 8 | float32[] | Feature data (row-major: `features[t * channels + c]`) |
72
+
73
+ Each timestep represents a 20ms bin of neural activity. Channels correspond to individual electrodes in a microelectrode array (typically 512 channels).
74
+
75
+ ## Installation
76
+
77
+ ```bash
78
+ cd packages/bci-whispercpp
79
+ npm install
80
+ VCPKG_ROOT=/path/to/vcpkg npm run build
81
+ ```
82
+
83
+ ### Prerequisites
84
+
85
+ - **Bare runtime** >= 1.24.0
86
+ - **CMake** >= 3.25
87
+ - **vcpkg** with `VCPKG_ROOT` environment variable set
88
+
89
+ ### Model Conversion Prerequisites
90
+
91
+ - **Python 3** with `numpy`, `torch`, and `transformers` (`pip install numpy torch transformers`)
92
+
93
+ ### Model Conversion
94
+
95
+ Convert a trained BrainWhisperer checkpoint. This produces **two files**, both required for inference:
96
+
97
+ | File | Size | Description |
98
+ |------|------|-------------|
99
+ | `ggml-bci-windowed.bin` | ~84 MB | GGML model: whisper encoder/decoder (LoRA-merged), tokenizer, positional embedding, windowed attention header |
100
+ | `bci-embedder.bin` | ~24 MB | Day projection weights: low-rank A·B matrices per recording day, month projections, session-to-day mapping |
101
+
102
+ ```bash
103
+ python3 scripts/convert-model.py \
104
+ --checkpoint /path/to/epoch=93-val_wer=0.0910.ckpt
105
+ ```
106
+
107
+ Both files are written to `models/` by default. All flags are optional:
108
+
109
+ | Flag | Default | Description |
110
+ |------|---------|-------------|
111
+ | `--output` | `models/ggml-bci-windowed.bin` | GGML model output path |
112
+ | `--embedder-output` | `models/bci-embedder.bin` | Embedder weights output path |
113
+ | `--day-idx` | `1` | Day index for baked positional embedding |
114
+ | `--window-size` | `57` | Windowed attention size (0 to disable) |
115
+ | `--last-window-layer` | `3` | Last encoder layer with windowed attention |
116
+ | `--f32` | off | Use f32 for all tensors (avoids f16 precision loss, ~2x larger) |
117
+
118
+ **Important:** Both files must be in the same directory at runtime. The C++ addon looks for `bci-embedder.bin` next to the GGML model file and will fail if it is missing.
119
+
120
+ ## Usage
121
+
122
+ The package's default export is the high-level `BCIWhispercpp` class. It owns model lifecycle, an inference queue, and a sliding-window streaming driver on top of the native addon.
123
+
124
+ ```js
125
+ const BCIWhispercpp = require('@qvac/bci-whispercpp')
126
+ ```
127
+
128
+ ### 1. Construct an instance
129
+
130
+ ```js
131
+ const bci = new BCIWhispercpp({
132
+ files: { model: './models/ggml-bci-windowed.bin' },
133
+ opts: { stats: true } // optional — surfaces runtime stats on response.stats
134
+ }, {
135
+ whisperConfig: { language: 'en', temperature: 0.0 },
136
+ bciConfig: { day_idx: 1 }, // session day index for day-specific projection
137
+ miscConfig: { caption_enabled: false }
138
+ })
139
+ ```
140
+
141
+ > The companion `bci-embedder.bin` must sit next to `files.model`. The native addon resolves it by path and will fail to load otherwise.
142
+
143
+ ### 2. Load the model
144
+
145
+ ```js
146
+ await bci.load()
147
+ ```
148
+
149
+ `load()` is idempotent — calling it again unloads the existing model and re-initialises with the current config. There is no progress callback today.
150
+
151
+ ### 3. Transcribe (batch mode)
152
+
153
+ Use this when you have the full neural signal up-front. `transcribe()` accepts the raw bytes (header + body); `transcribeFile()` is a convenience wrapper that reads the file for you.
154
+
155
+ ```js
156
+ const fs = require('bare-fs')
157
+
158
+ const response = await bci.transcribeFile('./signal.bin')
159
+ // or: const response = await bci.transcribe(new Uint8Array(fs.readFileSync('./signal.bin')))
160
+
161
+ const segments = await response.await()
162
+ const text = segments.map(s => s.text).join('').trim()
163
+ console.log(text)
164
+
165
+ if (response.stats) console.log(response.stats) // when opts.stats: true
166
+ ```
167
+
168
+ Concurrent calls are serialised — a second `transcribe()` waits for the first to settle.
169
+
170
+ ### 4. Transcribe (streaming mode)
171
+
172
+ `transcribeStream()` consumes a stream of bytes (async iterable, sync iterable, `Uint8Array`, or array of chunks) and decodes a sliding window over the body as data arrives. The first 8 bytes of the stream must be the standard `[T u32 LE, C u32 LE]` header (`T` is ignored in stream mode; `C` must be non-zero).
173
+
174
+ ```js
175
+ const response = await bci.transcribeStream(chunkIterable, {
176
+ windowTimesteps: 1500, // default
177
+ hopTimesteps: 500, // default — must be < windowTimesteps
178
+ emit: 'delta' // 'delta' (default) | 'full'
179
+ })
180
+
181
+ response.onUpdate(segments => {
182
+ // emit: 'delta' — newly-discovered tail segments since the last window.
183
+ // Each segment carries native fields (text, t0, t1, ...) plus
184
+ // windowStartTimestep so you can map back to the stream timeline.
185
+ // emit: 'full' — single { text } entry with the full running transcript.
186
+ for (const s of segments) process.stdout.write(s.text)
187
+ })
188
+
189
+ await response.await() // resolves when the stream ends and the final window decodes
190
+ ```
191
+
192
+ Streaming constraints:
193
+
194
+ | Option | Constraint |
195
+ |--------|------------|
196
+ | `windowTimesteps` | positive integer, ≤ `2900` (`MAX_WINDOW_TIMESTEPS`) |
197
+ | `hopTimesteps` | positive integer, `< windowTimesteps` |
198
+ | `emit` | `'delta'` or `'full'` |
199
+
200
+ Only one stream may be active at a time. `response.stats` is **not** populated for streams.
201
+
202
+ ### 5. Cancel / unload / destroy
203
+
204
+ ```js
205
+ await bci.cancel() // abort an in-flight job or stream; instance remains usable
206
+ await bci.unload() // release native resources; bci.load() can be called again
207
+ await bci.destroy() // permanent — instance cannot be reused
208
+ ```
209
+
210
+ ### 6. Word Error Rate helper
211
+
212
+ The package re-exports `computeWER(hypothesis, reference)` for evaluation:
213
+
214
+ ```js
215
+ const { computeWER } = require('@qvac/bci-whispercpp')
216
+ const wer = computeWER('how does it keep the cost down', 'how does it keep the cost down?')
217
+ ```
218
+
219
+ ### Output shape
220
+
221
+ `response.await()` resolves to an array of segments; `response.onUpdate(cb)` receives the same shape per emission:
222
+
223
+ ```js
224
+ [
225
+ { text: ' How does it keep the cost down?', t0: 0, t1: 280, /* ... */ }
226
+ ]
227
+ ```
228
+
229
+ In streaming `delta` mode each segment is annotated with `windowStartTimestep`. In `full` mode the array contains a single `{ text }` entry.
230
+
231
+ ## Tests
232
+
233
+ | Script | Purpose |
234
+ |--------|---------|
235
+ | `npm run test:unit` | JS unit tests (`brittle-bare test/unit/*.test.js`) — no model required |
236
+ | `npm run test:integration` | JS integration tests against the native addon — requires `WHISPER_MODEL_PATH` |
237
+ | `npm run test:cpp` | C++ unit tests (GoogleTest); `bare-make` rebuilds the addon with `BUILD_TESTING=ON` |
238
+ | `npm run test:dts` | Type-checks the published `index.d.ts` |
239
+ | `npm test` | Runs `test:unit` + `test:integration` |
240
+
241
+ ```bash
242
+ # JS unit tests
243
+ npm run test:unit
244
+
245
+ # JS integration tests
246
+ WHISPER_MODEL_PATH=./models/ggml-bci-windowed.bin npm run test:integration
247
+
248
+ # C++ unit tests
249
+ VCPKG_ROOT=/path/to/vcpkg npm run test:cpp
250
+
251
+ # .d.ts typecheck
252
+ npm run test:dts
253
+ ```
254
+
255
+ Integration tests require both `ggml-bci-windowed.bin` and `bci-embedder.bin` to be present in the same directory. See [Model Conversion](#model-conversion).
256
+
257
+ ## Configuration
258
+
259
+ `BCIWhispercpp` accepts two arguments:
260
+
261
+ ```js
262
+ new BCIWhispercpp(args, config)
263
+ ```
264
+
265
+ ### args
266
+
267
+ | Field | Type | Description |
268
+ |-------|------|-------------|
269
+ | `files.model` | string | **Required.** Path to BCI GGML model file (`bci-embedder.bin` must sit alongside it). |
270
+ | `logger` | object | Optional logger; wrapped in `@qvac/logging`. Defaults to a noop logger. |
271
+ | `opts.stats` | boolean | When `true`, runtime stats are surfaced on `response.stats` for batch jobs. Default `false`. |
272
+
273
+ ### config.whisperConfig
274
+
275
+ The convenience defaults below are surfaced explicitly. **Any other `whisper_full_params` key is forwarded untouched** to whisper.cpp — see [Advanced configuration](#advanced-configuration).
276
+
277
+ | Parameter | Type | Default | Description |
278
+ |-----------|------|---------|-------------|
279
+ | `language` | string | `"en"` | Language code |
280
+ | `temperature` | number | `0.0` | Sampling temperature |
281
+ | `n_threads` | number | `0` (auto) | Number of threads |
282
+
283
+ ### config.bciConfig
284
+
285
+ | Parameter | Type | Default | Description |
286
+ |-----------|------|---------|-------------|
287
+ | `day_idx` | number | `0` | Session day index for the day-specific low-rank projection at runtime. Distinct from the conversion-time `--day-idx` flag, which bakes a positional embedding into `ggml-bci-windowed.bin`. |
288
+
289
+ ### config.contextParams
290
+
291
+ These keys back the `whisper_context`. Changing any of them between jobs forces a full model reload (unload → re-init → warmup), which can take several seconds.
292
+
293
+ | Parameter | Type | Description |
294
+ |-----------|------|-------------|
295
+ | `model` | string | Optional override; usually set via `args.files.model`. |
296
+ | `use_gpu` | boolean | Enable GPU acceleration (Metal on macOS by default). |
297
+ | `flash_attn` | boolean | Enable flash attention. |
298
+ | `gpu_device` | number | Select a non-default GPU device. |
299
+
300
+ ### config.miscConfig
301
+
302
+ | Parameter | Type | Default | Description |
303
+ |-----------|------|---------|-------------|
304
+ | `caption_enabled` | boolean | `false` | Format segments with `<\|start\|>..<\|end\|>` markers. |
305
+
306
+ ### streamOpts (passed to `transcribeStream()`)
307
+
308
+ | Parameter | Type | Default | Constraint | Description |
309
+ |-----------|------|---------|------------|-------------|
310
+ | `windowTimesteps` | number | `1500` | positive integer, ≤ `2900` (`MAX_WINDOW_TIMESTEPS`) | Decode window size in 20 ms timesteps. |
311
+ | `hopTimesteps` | number | `500` | positive integer, `< windowTimesteps` | How far the window advances between decodes (~33% overlap by default). |
312
+ | `emit` | string | `'delta'` | `'delta'` or `'full'` | `'delta'` emits the newly-discovered tail per window with native segment fields plus `windowStartTimestep`. `'full'` emits a single `{ text }` entry with the running transcript. |
313
+
314
+ The encoder accepts up to ~3000 timesteps per forward pass; `MAX_WINDOW_TIMESTEPS = 2900` keeps a safety margin so partial flush windows always fit.
315
+
316
+ ### Advanced configuration
317
+
318
+ `whisperConfig` is a thin pass-through to whisper.cpp's `whisper_full_params`. For the full surface (decoding strategy, beam search, VAD, suppression, callbacks, etc.) refer to the upstream reference:
319
+
320
+ - [`whisper_full_params` in whisper.cpp](https://github.com/ggerganov/whisper.cpp/blob/master/include/whisper.h)
321
+ - Concrete shapes used in production: see the [examples](examples) directory and [`@qvac/transcription-whispercpp`](https://github.com/tetherto/qvac/tree/main/packages/transcription-whispercpp) for richer usage patterns (VAD, chunking, live streaming).
322
+
323
+ ## whisper.cpp Patches
324
+
325
+ The BCI patches live in the `tetherto/qvac-ext-lib-whisper.cpp` fork (v1.8.4.2) and are consumed via the `qvac-registry-vcpkg` port:
326
+
327
+ | Feature | Description |
328
+ |---------|-------------|
329
+ | Variable conv1 kernel | Read `n_audio_conv1_kernel` from model header (k=7 for 512ch BCI vs k=3 for audio) |
330
+ | Windowed attention | Attention mask with configurable window size/layer params in header |
331
+ | BCI SOS tokens | BCI-specific start-of-sequence token handling |
332
+ | Graph placement fix | Correct encoder-graph mask population for the encoder graph refactor |
333
+
334
+ ## Error Range
335
+
336
+ All errors thrown by this package are `QvacErrorAddonBCI` instances (extending `QvacErrorBase` from `@qvac/error`) and use codes in the range **26001–27000**.
337
+
338
+ | Code | Name | When |
339
+ |------|------|------|
340
+ | `26001` | `FAILED_TO_LOAD_WEIGHTS` | Native addon failed to load the GGML model |
341
+ | `26002` | `FAILED_TO_CANCEL` | `cancel()` failed at the addon layer |
342
+ | `26003` | `FAILED_TO_APPEND` | Append to processing queue failed |
343
+ | `26004` | `FAILED_TO_DESTROY` | `destroy()` failed at the addon layer |
344
+ | `26005` | `FAILED_TO_ACTIVATE` | `addon.activate()` failed during `load()` |
345
+ | `26006` | `INVALID_NEURAL_INPUT` | Batch input rejected by the addon |
346
+ | `26007` | `JOB_ALREADY_RUNNING` | `transcribe()` called while a job is in flight |
347
+ | `26008` | `MODEL_NOT_LOADED` | Inference called before `load()` or after `destroy()` |
348
+ | `26009` | `MODEL_FILE_NOT_FOUND` | `files.model` missing or unreadable |
349
+ | `26010` | `BUFFER_LIMIT_EXCEEDED` | Neural signal buffer exceeded the addon limit |
350
+ | `26011` | `FAILED_TO_START_JOB` | Addon refused to start the job |
351
+ | `26012` | `INVALID_CONFIG` | Constructor / context configuration rejected |
352
+ | `26013` | `EMBEDDER_WEIGHTS_INVALID` | `bci-embedder.bin` failed validation |
353
+ | `26014` | `STREAM_ALREADY_ACTIVE` | `transcribeStream()` called while one is already active |
354
+ | `26015` | `INVALID_STREAM_INPUT` | Bad stream input type or `streamOpts` |
355
+ | `26016` | `INVALID_STREAM_HEADER` | Stream `[T u32, C u32]` header malformed (e.g. `C == 0`) |
356
+ | `26017` | `WINDOW_TOO_LARGE` | `windowTimesteps` exceeds `MAX_WINDOW_TIMESTEPS` (2900) |
357
+
358
+ Codes are also re-exported via `require('@qvac/bci-whispercpp/lib/error').ERR_CODES` for programmatic matching.
359
+
360
+ ## Resources
361
+
362
+ - whisper.cpp fork (Tether): [`tetherto/qvac-ext-lib-whisper.cpp`](https://github.com/tetherto/qvac-ext-lib-whisper.cpp)
363
+ - Sibling package — audio transcription: [`@qvac/transcription-whispercpp`](https://github.com/tetherto/qvac/tree/main/packages/transcription-whispercpp)
364
+ - vcpkg registry: [`qvac-registry-vcpkg`](https://github.com/tetherto/qvac-registry-vcpkg)
365
+ - BrainWhisperer reference (Python): the model checkpoints converted by `scripts/convert-model.py`
366
+
367
+ ## Glossary
368
+
369
+ - **Bare** — small modular JavaScript runtime for desktop and mobile. [Learn more](https://docs.pears.com/bare-reference/overview).
370
+ - **QVAC** — Tether's open-source SDK for building decentralized, local-first AI applications.
371
+ - **GGML** — tensor library / file format used by whisper.cpp for native inference.
372
+ - **BCI** — Brain-Computer Interface; here, microelectrode-array recordings of neural activity decoded into text.
373
+ - **Day index (`day_idx`)** — selects the day-specific low-rank projection (A·B) baked into `bci-embedder.bin`. Sessions recorded on different days use different projections.
374
+ - **Windowed attention** — encoder attention mask restricted to a local window (`w=57` over layers 0–3 by default), configured at model conversion time.
375
+
376
+ ## License
377
+
378
+ Apache-2.0
@@ -0,0 +1,7 @@
1
+ export interface AddonLogging {
2
+ setLogger(callback: (priority: number, message: string) => void): void
3
+ releaseLogger(): void
4
+ }
5
+
6
+ declare const addonLogging: AddonLogging
7
+ export default addonLogging
@@ -0,0 +1,6 @@
1
+ const binding = require('./binding')
2
+
3
+ module.exports = {
4
+ setLogger: binding.setLogger,
5
+ releaseLogger: binding.releaseLogger
6
+ }