@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/lib/error.js ADDED
@@ -0,0 +1,112 @@
1
+ 'use strict'
2
+
3
+ const { QvacErrorBase, addCodes } = require('@qvac/error')
4
+
5
+ class QvacErrorAddonBCI extends QvacErrorBase { }
6
+
7
+ const { name, version } = require('../package.json')
8
+
9
+ // This library has error code range from 26001 to 27000.
10
+ // Ranges used elsewhere in the @qvac/error registry:
11
+ // 6001-6018 @qvac/transcription-whispercpp
12
+ // 7001-7011 @qvac/tts-onnx
13
+ // 8001-8008 @qvac/translation-nmtcpp
14
+ // 24001+ @qvac/transcription-parakeet
15
+ const ERR_CODES = Object.freeze({
16
+ FAILED_TO_LOAD_WEIGHTS: 26001,
17
+ FAILED_TO_CANCEL: 26002,
18
+ FAILED_TO_APPEND: 26003,
19
+ FAILED_TO_DESTROY: 26004,
20
+ FAILED_TO_ACTIVATE: 26005,
21
+ INVALID_NEURAL_INPUT: 26006,
22
+ JOB_ALREADY_RUNNING: 26007,
23
+ MODEL_NOT_LOADED: 26008,
24
+ MODEL_FILE_NOT_FOUND: 26009,
25
+ BUFFER_LIMIT_EXCEEDED: 26010,
26
+ FAILED_TO_START_JOB: 26011,
27
+ INVALID_CONFIG: 26012,
28
+ EMBEDDER_WEIGHTS_INVALID: 26013,
29
+ STREAM_ALREADY_ACTIVE: 26014,
30
+ INVALID_STREAM_INPUT: 26015,
31
+ INVALID_STREAM_HEADER: 26016,
32
+ WINDOW_TOO_LARGE: 26017
33
+ })
34
+
35
+ addCodes({
36
+ [ERR_CODES.FAILED_TO_LOAD_WEIGHTS]: {
37
+ name: 'FAILED_TO_LOAD_WEIGHTS',
38
+ message: (message) => `Failed to load weights, error: ${message}`
39
+ },
40
+ [ERR_CODES.FAILED_TO_CANCEL]: {
41
+ name: 'FAILED_TO_CANCEL',
42
+ message: (message) => `Failed to cancel inference, error: ${message}`
43
+ },
44
+ [ERR_CODES.FAILED_TO_APPEND]: {
45
+ name: 'FAILED_TO_APPEND',
46
+ message: (message) => `Failed to append data to processing queue, error: ${message}`
47
+ },
48
+ [ERR_CODES.FAILED_TO_DESTROY]: {
49
+ name: 'FAILED_TO_DESTROY',
50
+ message: (message) => `Failed to destroy instance, error: ${message}`
51
+ },
52
+ [ERR_CODES.FAILED_TO_ACTIVATE]: {
53
+ name: 'FAILED_TO_ACTIVATE',
54
+ message: (message) => `Failed to activate model, error: ${message}`
55
+ },
56
+ [ERR_CODES.INVALID_NEURAL_INPUT]: {
57
+ name: 'INVALID_NEURAL_INPUT',
58
+ message: (message) => `Invalid neural signal input: ${message}`
59
+ },
60
+ [ERR_CODES.JOB_ALREADY_RUNNING]: {
61
+ name: 'JOB_ALREADY_RUNNING',
62
+ message: () => 'Cannot set new job: a job is already set or being processed'
63
+ },
64
+ [ERR_CODES.MODEL_NOT_LOADED]: {
65
+ name: 'MODEL_NOT_LOADED',
66
+ message: () => 'Model is not loaded'
67
+ },
68
+ [ERR_CODES.MODEL_FILE_NOT_FOUND]: {
69
+ name: 'MODEL_FILE_NOT_FOUND',
70
+ message: (modelPath) => `Model file not found at: ${modelPath}`
71
+ },
72
+ [ERR_CODES.BUFFER_LIMIT_EXCEEDED]: {
73
+ name: 'BUFFER_LIMIT_EXCEEDED',
74
+ message: (limit) => `Neural signal buffer exceeded limit of ${limit}`
75
+ },
76
+ [ERR_CODES.FAILED_TO_START_JOB]: {
77
+ name: 'FAILED_TO_START_JOB',
78
+ message: (message) => `Failed to start inference job, error: ${message}`
79
+ },
80
+ [ERR_CODES.INVALID_CONFIG]: {
81
+ name: 'INVALID_CONFIG',
82
+ message: (message) => `Invalid BCI configuration: ${message}`
83
+ },
84
+ [ERR_CODES.EMBEDDER_WEIGHTS_INVALID]: {
85
+ name: 'EMBEDDER_WEIGHTS_INVALID',
86
+ message: (message) => `BCI embedder weights are invalid: ${message}`
87
+ },
88
+ [ERR_CODES.STREAM_ALREADY_ACTIVE]: {
89
+ name: 'STREAM_ALREADY_ACTIVE',
90
+ message: () => 'A streaming transcription is already in progress'
91
+ },
92
+ [ERR_CODES.INVALID_STREAM_INPUT]: {
93
+ name: 'INVALID_STREAM_INPUT',
94
+ message: (message) => `Invalid neural signal stream input: ${message}`
95
+ },
96
+ [ERR_CODES.INVALID_STREAM_HEADER]: {
97
+ name: 'INVALID_STREAM_HEADER',
98
+ message: (message) => `Invalid neural signal stream header: ${message}`
99
+ },
100
+ [ERR_CODES.WINDOW_TOO_LARGE]: {
101
+ name: 'WINDOW_TOO_LARGE',
102
+ message: (limit) => `Stream window size exceeds encoder capacity of ${limit} timesteps`
103
+ }
104
+ }, {
105
+ name,
106
+ version
107
+ })
108
+
109
+ module.exports = {
110
+ ERR_CODES,
111
+ QvacErrorAddonBCI
112
+ }
package/lib/stream.js ADDED
@@ -0,0 +1,207 @@
1
+ 'use strict'
2
+
3
+ const { QvacErrorAddonBCI, ERR_CODES } = require('./error')
4
+
5
+ /**
6
+ * Streaming helpers for the sliding-window transcription driver in index.js.
7
+ *
8
+ * These are pure functions kept separate from the class so they can be
9
+ * unit-tested in isolation (see test/unit/stitch.test.js) and so index.js
10
+ * stays focused on lifecycle orchestration.
11
+ */
12
+
13
+ /**
14
+ * Coerce a stream chunk into a Uint8Array without copying when possible.
15
+ * Throws INVALID_STREAM_INPUT if the chunk isn't a recognised binary form.
16
+ */
17
+ function toUint8 (chunk) {
18
+ if (chunk instanceof Uint8Array) return chunk
19
+ if (ArrayBuffer.isView(chunk)) {
20
+ return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)
21
+ }
22
+ if (chunk instanceof ArrayBuffer) return new Uint8Array(chunk)
23
+ if (Array.isArray(chunk)) return Uint8Array.from(chunk)
24
+ throw new QvacErrorAddonBCI({
25
+ code: ERR_CODES.INVALID_STREAM_INPUT,
26
+ adds: 'stream yielded non-binary chunk'
27
+ })
28
+ }
29
+
30
+ /**
31
+ * Copy out the byte range [startTs, endTs) from a list of body chunks
32
+ * (each element a Uint8Array) into a single contiguous Uint8Array.
33
+ * The caller owns timestep→byte translation via bytesPerTimestep.
34
+ */
35
+ function sliceBody (bodyChunks, bytesPerTimestep, startTs, endTs, totalBytes) {
36
+ const startByte = startTs * bytesPerTimestep
37
+ const endByte = Math.min(endTs * bytesPerTimestep, totalBytes)
38
+ const out = new Uint8Array(Math.max(0, endByte - startByte))
39
+ if (out.byteLength === 0) return out
40
+ let offset = 0
41
+ let cursor = 0
42
+ for (const chunk of bodyChunks) {
43
+ const chunkStart = cursor
44
+ const chunkEnd = cursor + chunk.byteLength
45
+ cursor = chunkEnd
46
+ if (chunkEnd <= startByte) continue
47
+ if (chunkStart >= endByte) break
48
+ const from = Math.max(0, startByte - chunkStart)
49
+ const to = Math.min(chunk.byteLength, endByte - chunkStart)
50
+ out.set(chunk.subarray(from, to), offset)
51
+ offset += (to - from)
52
+ }
53
+ return out
54
+ }
55
+
56
+ /**
57
+ * Build an [T, C] header-prefixed buffer the addon can consume as a single
58
+ * batch input, reusing the per-window body bytes.
59
+ */
60
+ function buildWindowBuffer (windowBody, channels, windowTimesteps) {
61
+ const out = new Uint8Array(8 + windowBody.byteLength)
62
+ const view = new DataView(out.buffer, out.byteOffset, out.byteLength)
63
+ view.setUint32(0, windowTimesteps, true)
64
+ view.setUint32(4, channels, true)
65
+ out.set(windowBody, 8)
66
+ return out
67
+ }
68
+
69
+ function normalizeWord (w) {
70
+ return w.toLowerCase().replace(/[^a-z0-9']/g, '')
71
+ }
72
+
73
+ /**
74
+ * Text-only word-level stitch: find the longest normalised-word suffix of
75
+ * `prevText` that also appears as a prefix of `newText`, and treat only
76
+ * the remainder of `newText` as fresh content.
77
+ *
78
+ * The streaming driver in index.js uses `stitchSegments` (below) because
79
+ * it needs to preserve per-segment timestamp metadata. `stitchMerge` is
80
+ * retained as a public text-only helper for callers that only have raw
81
+ * transcript strings to merge (e.g. post-hoc analysis, unit testing the
82
+ * overlap algorithm without constructing segment objects) and as the
83
+ * reference implementation of the underlying word-overlap logic.
84
+ *
85
+ * Returns { delta, merged, bestK }:
86
+ * - delta: the newly-discovered tail
87
+ * - merged: prevText extended by delta
88
+ * - bestK: number of words absorbed as overlap (for inspection/tests)
89
+ *
90
+ * maxWords caps the search depth so pathological inputs stay O(maxWords^2).
91
+ * Known limitation: legitimate immediate word repetitions at a window
92
+ * boundary (e.g. "the the") will collapse; acceptable for v1 sliding
93
+ * window until a segmentation model replaces this.
94
+ */
95
+ function stitchMerge (prevText, newText, maxWords) {
96
+ const prevWords = prevText.trim().split(/\s+/).filter(Boolean)
97
+ const newWords = newText.trim().split(/\s+/).filter(Boolean)
98
+
99
+ if (prevWords.length === 0) {
100
+ return { delta: newWords.join(' '), merged: newWords.join(' '), bestK: 0 }
101
+ }
102
+ if (newWords.length === 0) {
103
+ return { delta: '', merged: prevWords.join(' '), bestK: 0 }
104
+ }
105
+
106
+ const bestK = _computeBestK(prevWords, newWords, maxWords)
107
+ const deltaWords = newWords.slice(bestK)
108
+ const delta = deltaWords.join(' ')
109
+ const merged = [...prevWords, ...deltaWords].join(' ')
110
+ return { delta, merged, bestK }
111
+ }
112
+
113
+ /**
114
+ * Segment-aware variant of stitchMerge: preserves the per-segment
115
+ * timestamp/metadata fields emitted by the native decoder.
116
+ *
117
+ * `segments` is the array returned by a per-window decode (each entry is
118
+ * a whisper-style `{ text, t0, t1, ... }`). We run the same word-level
119
+ * overlap detection but trim the leading `bestK` words from the incoming
120
+ * segments rather than flattening to a single string.
121
+ *
122
+ * `windowStartTimestep` is attached to every emitted segment so
123
+ * consumers can correlate a window-local `t0`/`t1` back to the absolute
124
+ * position in the input stream.
125
+ *
126
+ * Returns:
127
+ * - deltaSegments: trimmed segments the driver should emit as an update
128
+ * (segments fully absorbed by the overlap are dropped; a segment
129
+ * partially overlapped gets its `text` rewritten to the surviving
130
+ * tail words).
131
+ * - merged: running transcript text (identical to stitchMerge.merged)
132
+ * - bestK: for inspection / tests
133
+ */
134
+ function stitchSegments (prevText, segments, maxWords, windowStartTimestep = 0) {
135
+ const prevWords = prevText.trim().split(/\s+/).filter(Boolean)
136
+
137
+ const perSegment = []
138
+ const newWords = []
139
+ for (const seg of segments) {
140
+ const text = (seg && typeof seg.text === 'string') ? seg.text : ''
141
+ const words = text.trim().split(/\s+/).filter(Boolean)
142
+ perSegment.push({ seg, words })
143
+ for (const w of words) newWords.push(w)
144
+ }
145
+
146
+ if (newWords.length === 0) {
147
+ return { deltaSegments: [], merged: prevWords.join(' '), bestK: 0 }
148
+ }
149
+ if (prevWords.length === 0) {
150
+ const deltaSegments = perSegment
151
+ .filter(({ words }) => words.length > 0)
152
+ .map(({ seg }) => ({ ...seg, windowStartTimestep }))
153
+ return { deltaSegments, merged: newWords.join(' '), bestK: 0 }
154
+ }
155
+
156
+ const bestK = _computeBestK(prevWords, newWords, maxWords)
157
+
158
+ let toSkip = bestK
159
+ const deltaSegments = []
160
+ for (const { seg, words } of perSegment) {
161
+ if (words.length === 0) continue
162
+ if (toSkip >= words.length) { toSkip -= words.length; continue }
163
+ if (toSkip === 0) {
164
+ deltaSegments.push({ ...seg, windowStartTimestep })
165
+ } else {
166
+ const remainingWords = words.slice(toSkip)
167
+ const hasT0 = typeof seg.t0 === 'number'
168
+ const hasT1 = typeof seg.t1 === 'number'
169
+ const approxT0 = (hasT0 && hasT1)
170
+ ? seg.t0 + Math.round((seg.t1 - seg.t0) * (toSkip / words.length))
171
+ : seg.t0
172
+ deltaSegments.push({
173
+ ...seg,
174
+ text: remainingWords.join(' '),
175
+ t0: approxT0,
176
+ windowStartTimestep
177
+ })
178
+ toSkip = 0
179
+ }
180
+ }
181
+
182
+ const merged = [...prevWords, ...newWords.slice(bestK)].join(' ')
183
+ return { deltaSegments, merged, bestK }
184
+ }
185
+
186
+ function _computeBestK (prevWords, newWords, maxWords) {
187
+ const maxK = Math.min(prevWords.length, newWords.length, maxWords)
188
+ for (let k = maxK; k >= 1; k--) {
189
+ let match = true
190
+ for (let i = 0; i < k; i++) {
191
+ const a = normalizeWord(prevWords[prevWords.length - k + i])
192
+ const b = normalizeWord(newWords[i])
193
+ if (a.length === 0 || b.length === 0 || a !== b) { match = false; break }
194
+ }
195
+ if (match) return k
196
+ }
197
+ return 0
198
+ }
199
+
200
+ module.exports = {
201
+ toUint8,
202
+ sliceBody,
203
+ buildWindowBuffer,
204
+ normalizeWord,
205
+ stitchMerge,
206
+ stitchSegments
207
+ }
package/lib/util.js ADDED
@@ -0,0 +1,15 @@
1
+ 'use strict'
2
+
3
+ function flattenSegments (output) {
4
+ const segments = []
5
+ for (const entry of output) {
6
+ if (Array.isArray(entry)) {
7
+ segments.push(...entry)
8
+ } else if (entry && typeof entry.text === 'string') {
9
+ segments.push(entry)
10
+ }
11
+ }
12
+ return segments
13
+ }
14
+
15
+ module.exports = { flattenSegments }
package/lib/wer.js ADDED
@@ -0,0 +1,40 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Compute Word Error Rate between hypothesis and reference.
5
+ * Uses Levenshtein distance on word sequences.
6
+ * @param {string} hypothesis
7
+ * @param {string} reference
8
+ * @returns {number} WER as a ratio (0.0 = perfect, 1.0 = 100% errors)
9
+ */
10
+ function computeWER (hypothesis, reference) {
11
+ const hyp = hypothesis.toLowerCase().trim().split(/\s+/).filter(Boolean)
12
+ const ref = reference.toLowerCase().trim().split(/\s+/).filter(Boolean)
13
+
14
+ if (ref.length === 0) return hyp.length === 0 ? 0 : 1
15
+
16
+ const n = ref.length
17
+ const m = hyp.length
18
+ const dp = Array.from({ length: n + 1 }, () => Array(m + 1).fill(0))
19
+
20
+ for (let i = 0; i <= n; i++) dp[i][0] = i
21
+ for (let j = 0; j <= m; j++) dp[0][j] = j
22
+
23
+ for (let i = 1; i <= n; i++) {
24
+ for (let j = 1; j <= m; j++) {
25
+ if (ref[i - 1] === hyp[j - 1]) {
26
+ dp[i][j] = dp[i - 1][j - 1]
27
+ } else {
28
+ dp[i][j] = 1 + Math.min(
29
+ dp[i - 1][j],
30
+ dp[i][j - 1],
31
+ dp[i - 1][j - 1]
32
+ )
33
+ }
34
+ }
35
+ }
36
+
37
+ return dp[n][m] / n
38
+ }
39
+
40
+ module.exports = { computeWER }
package/package.json CHANGED
@@ -1 +1,96 @@
1
- {"name":"@qvac/bci-whispercpp","version":"0.0.0"}
1
+ {
2
+ "name": "@qvac/bci-whispercpp",
3
+ "version": "0.1.1",
4
+ "description": "Brain-Computer Interface (BCI) neural signal transcription addon for qvac, powered by whisper.cpp",
5
+ "addon": true,
6
+ "engines": {
7
+ "bare": ">=1.24.0"
8
+ },
9
+ "scripts": {
10
+ "lint": "standard \"examples/**/*.js\" \"test/**/*.js\" \"*.js\" \"lib/**/*.js\"",
11
+ "lint:fix": "standard --fix \"examples/**/*.js\" \"test/**/*.js\" \"*.js\" \"lib/**/*.js\"",
12
+ "lint-cpp": "clang-tidy -p build addon/src/js-interface/JSAdapter.cpp addon/src/js-interface/binding.cpp addon/src/model-interface/bci/BCIConfig.cpp addon/src/model-interface/bci/BCIModel.cpp addon/src/model-interface/bci/NeuralProcessor.cpp addon/src/addon/AddonJs.hpp addon/src/js-interface/JSAdapter.hpp addon/src/addon/BCIErrors.hpp addon/src/model-interface/BCITypes.hpp addon/src/model-interface/bci/BCIConfig.hpp addon/src/model-interface/bci/BCIModel.hpp addon/src/model-interface/bci/NeuralProcessor.hpp",
13
+ "build": "bare-make generate && bare-make build && bare-make install",
14
+ "build:pack": "mkdir -p dist && npm pack --pack-destination dist",
15
+ "test": "npm run test:unit && npm run test:integration",
16
+ "test:unit": "brittle-bare test/unit/*.test.js",
17
+ "test:integration": "brittle-bare test/integration/addon.test.js",
18
+ "test:cpp:build": "bare-make generate -D BUILD_TESTING=ON && bare-make build --target test-bci-core && bare-make install",
19
+ "test:cpp:run": "cd build/addon/tests/ && LLVM_PROFILE_FILE=default.profraw ./test-bci-core --gtest_output=xml:cpp-test-results.xml",
20
+ "test:cpp": "npm run test:cpp:build && npm run test:cpp:run",
21
+ "coverage:cpp:build": "bare-make generate -D BUILD_TESTING=ON -D ENABLE_COVERAGE=ON && bare-make build --target test-bci-core",
22
+ "coverage:cpp:summary": "cd build/addon/tests && llvm-cov-19 report ./test-bci-core --instr-profile=coverage.profdata -ignore-filename-regex='(tests|build|node_modules|gtest|gmock|\\.vcpkg|/usr)/' > coverage-summary.txt",
23
+ "coverage:cpp:report": "cd build/addon/tests/ && ls -lha && llvm-profdata-19 merge -sparse default.profraw -o coverage.profdata && llvm-cov-19 show ./test-bci-core -instr-profile=coverage.profdata -format=html -output-dir=coverage-html -ignore-filename-regex='(tests|build|node_modules|gtest|gmock|\\.vcpkg|/usr)/' && llvm-cov-19 export ./test-bci-core -instr-profile=coverage.profdata -format=lcov -ignore-filename-regex='(tests|build|node_modules|gtest|gmock|\\.vcpkg|/usr)/' > lcov.info && npm run coverage:cpp:summary",
24
+ "coverage:cpp": "npm run coverage:cpp:build && npm run test:cpp:run && npm run coverage:cpp:report",
25
+ "test:dts": "tsc -p tsconfig.dts.json",
26
+ "test:mobile:generate": "node scripts/generate-mobile-tests.js"
27
+ },
28
+ "files": [
29
+ "addonLogging.js",
30
+ "addonLogging.d.ts",
31
+ "binding.js",
32
+ "bci.js",
33
+ "configChecker.js",
34
+ "index.js",
35
+ "index.d.ts",
36
+ "prebuilds",
37
+ "lib",
38
+ "README.md",
39
+ "CHANGELOG.md",
40
+ "LICENSE",
41
+ "NOTICE"
42
+ ],
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/tetherto/qvac.git",
46
+ "directory": "packages/bci-whispercpp"
47
+ },
48
+ "author": "Tether",
49
+ "keywords": [
50
+ "tether",
51
+ "addon",
52
+ "whisper",
53
+ "bci",
54
+ "brain-computer-interface",
55
+ "neural",
56
+ "qvac"
57
+ ],
58
+ "license": "Apache-2.0",
59
+ "bugs": "https://github.com/tetherto/qvac/issues",
60
+ "homepage": "https://github.com/tetherto/qvac/tree/main/packages/bci-whispercpp#readme",
61
+ "devDependencies": {
62
+ "@types/node": "^22.15.3",
63
+ "bare-buffer": "^3.4.2",
64
+ "brittle": "^3.17.0",
65
+ "cmake-bare": "^1.7.5",
66
+ "cmake-vcpkg": "^1.1.0",
67
+ "standard": "^17.1.2",
68
+ "typescript": "^5.9.2"
69
+ },
70
+ "dependencies": {
71
+ "@qvac/error": "^0.1.0",
72
+ "@qvac/infer-base": "^0.4.0",
73
+ "@qvac/logging": "^0.1.0",
74
+ "bare-fs": "^4.5.1",
75
+ "bare-path": "^3.0.0"
76
+ },
77
+ "exports": {
78
+ "./package": "./package.json",
79
+ ".": {
80
+ "types": "./index.d.ts",
81
+ "default": "./index.js"
82
+ },
83
+ "./addonLogging": {
84
+ "types": "./addonLogging.d.ts",
85
+ "default": "./addonLogging.js"
86
+ },
87
+ "./addonLogging.js": "./addonLogging.js",
88
+ "./bci": "./bci.js",
89
+ "./bci.js": "./bci.js",
90
+ "./binding": "./binding.js",
91
+ "./binding.js": "./binding.js",
92
+ "./util": "./lib/util.js",
93
+ "./wer": "./lib/wer.js"
94
+ },
95
+ "types": "index.d.ts"
96
+ }