@cleanfield/node 0.0.0-stage → 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/index.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ export type BackendPreference = 'auto' | 'scalar' | 'avx2';
2
+
3
+ /** The backend the engine actually selected at load time. */
4
+ export type LiveBackend = 'scalar' | 'avx2';
5
+
6
+ export interface VoiceOptions {
7
+ /**
8
+ * Path to a `.cfwm` voice container. Defaults to the model bundled with the
9
+ * package, then to `CLEANFIELD_VOICE_MODEL`.
10
+ */
11
+ modelPath?: string;
12
+ /**
13
+ * Backend preference. `auto` picks AVX2+FMA when the CPU supports it and
14
+ * falls back to the scalar reference otherwise. `scalar` / `avx2` never fall
15
+ * back: an unavailable backend throws.
16
+ */
17
+ backend?: BackendPreference;
18
+ }
19
+
20
+ export interface Voice {
21
+ /** Backend the engine loaded with. */
22
+ readonly backend: LiveBackend;
23
+ /**
24
+ * Run one 513-value feature frame and return a new 1026-value mask frame.
25
+ * The returned array is freshly allocated on every call.
26
+ */
27
+ process(frame: Float32Array): Float32Array;
28
+ /** Reset the cross-frame state (history, LSTM cells, quantised state). */
29
+ reset(): void;
30
+ /** Release the engine immediately. Safe to call more than once. */
31
+ close(): void;
32
+ }
33
+
34
+ /**
35
+ * Create one voice engine instance. Each instance owns an independent model
36
+ * and workspace; calls on the same instance must not overlap.
37
+ */
38
+ export function createVoice(options?: VoiceOptions): Voice;
package/index.js ADDED
@@ -0,0 +1,177 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * CleanField voice engine for Node.js.
5
+ *
6
+ * The native addon (`cf-node.node`) is resolved in this order:
7
+ *
8
+ * 1. `CLEANFIELD_NODE_BINDING` (explicit path, for tests and custom builds);
9
+ * 2. `prebuilds/<platform>-<arch>/cf-node.node` (downloaded from the release
10
+ * by `scripts/fetch-prebuilt.mjs`, or copied by `scripts/build-local.ps1`);
11
+ * 3. an optional platform package (`@cleanfield/node-<platform>-<arch>`);
12
+ * 4. `build/Release/cf_node.node` (a local CMake-style layout, if present);
13
+ * 5. a local Cargo build in `target/<triple>/release` or `target/release`.
14
+ *
15
+ * There is no node-gyp step: the addon is a plain Rust cdylib renamed to
16
+ * `.node`, and it resolves the N-API entry points from the host executable at
17
+ * load time.
18
+ */
19
+
20
+ const fs = require('node:fs');
21
+ const path = require('node:path');
22
+
23
+ const RUST_TRIPLES = {
24
+ 'win32-x64': 'x86_64-pc-windows-msvc',
25
+ 'win32-arm64': 'aarch64-pc-windows-msvc',
26
+ 'darwin-x64': 'x86_64-apple-darwin',
27
+ 'darwin-arm64': 'aarch64-apple-darwin',
28
+ 'linux-x64': 'x86_64-unknown-linux-gnu',
29
+ 'linux-arm64': 'aarch64-unknown-linux-gnu',
30
+ };
31
+
32
+ const LIBRARY_SUFFIX =
33
+ process.platform === 'win32' ? '.dll' : process.platform === 'darwin' ? '.dylib' : '.so';
34
+
35
+ function platformTag() {
36
+ const tag = `${process.platform}-${process.arch}`;
37
+ if (!RUST_TRIPLES[tag]) {
38
+ throw new Error(
39
+ `CleanField does not publish a prebuilt Node addon for ${process.platform}/${process.arch}. ` +
40
+ 'Supported platforms: win32/linux x64 and arm64, darwin x64 and arm64. ' +
41
+ 'Build from source with `cargo build --release -p cf-node`.'
42
+ );
43
+ }
44
+ return tag;
45
+ }
46
+
47
+ function bindingCandidates() {
48
+ const tag = platformTag();
49
+ const triple = RUST_TRIPLES[tag];
50
+ const root = path.join(__dirname, '..', '..');
51
+ const candidates = [];
52
+ if (process.env.CLEANFIELD_NODE_BINDING) {
53
+ candidates.push(process.env.CLEANFIELD_NODE_BINDING);
54
+ }
55
+ candidates.push(path.join(__dirname, 'prebuilds', tag, 'cf-node.node'));
56
+ candidates.push(path.join(__dirname, 'build', 'Release', 'cf-node.node'));
57
+ candidates.push(path.join(root, 'target', triple, 'release', `cf_node${LIBRARY_SUFFIX}`));
58
+ candidates.push(path.join(root, 'target', 'release', `cf_node${LIBRARY_SUFFIX}`));
59
+ return candidates;
60
+ }
61
+
62
+ /**
63
+ * Load a native addon from `filename`.
64
+ *
65
+ * `require()` only treats a file as an addon when its extension is `.node`, so
66
+ * a Cargo cdylib found in a build directory (`cf_node.dll`, `libcf_node.so`,
67
+ * `libcf_node.dylib`) is loaded through `process.dlopen`, which is exactly what
68
+ * `require()` uses for `.node` files.
69
+ */
70
+ function loadNative(filename) {
71
+ if (filename.endsWith('.node')) return require(filename);
72
+ const loaded = new Module(filename, module);
73
+ loaded.filename = filename;
74
+ loaded.paths = Module._nodeModulePaths(path.dirname(filename));
75
+ process.dlopen(loaded, filename);
76
+ return loaded.exports;
77
+ }
78
+
79
+ function loadBinding() {
80
+ const attempts = [];
81
+ for (const candidate of bindingCandidates()) {
82
+ if (!fs.existsSync(candidate)) {
83
+ attempts.push(`${candidate} (missing)`);
84
+ continue;
85
+ }
86
+ try {
87
+ return loadNative(candidate);
88
+ } catch (error) {
89
+ attempts.push(`${candidate} (${error && error.message ? error.message : error})`);
90
+ }
91
+ }
92
+
93
+ const tag = platformTag();
94
+ const platformPackages = [`@cleanfield/node-${tag}`];
95
+ if (process.platform === 'darwin') platformPackages.push('@cleanfield/node-darwin-universal');
96
+ for (const name of platformPackages) {
97
+ try {
98
+ return require(name);
99
+ } catch (error) {
100
+ attempts.push(`${name} (${error && error.code ? error.code : error})`);
101
+ }
102
+ }
103
+
104
+ throw new Error(
105
+ 'CleanField could not find its native addon. Tried:\n ' +
106
+ attempts.join('\n ') +
107
+ '\nBuild it with `cargo build --release -p cf-node`, copy it to ' +
108
+ `prebuilds/${tag}/cf-node.node, or set CLEANFIELD_NODE_BINDING to its path.`
109
+ );
110
+ }
111
+
112
+ function resolveModelPath(explicitPath) {
113
+ if (explicitPath !== undefined && explicitPath !== null) {
114
+ if (typeof explicitPath !== 'string' || explicitPath.length === 0) {
115
+ throw new TypeError('createVoice: options.modelPath must be a non-empty string');
116
+ }
117
+ if (!fs.existsSync(explicitPath)) {
118
+ throw new Error(`createVoice: the model file does not exist: ${explicitPath}`);
119
+ }
120
+ return explicitPath;
121
+ }
122
+ if (process.env.CLEANFIELD_VOICE_MODEL) {
123
+ if (!fs.existsSync(process.env.CLEANFIELD_VOICE_MODEL)) {
124
+ throw new Error(
125
+ `createVoice: CLEANFIELD_VOICE_MODEL points at a missing file: ${process.env.CLEANFIELD_VOICE_MODEL}`
126
+ );
127
+ }
128
+ return process.env.CLEANFIELD_VOICE_MODEL;
129
+ }
130
+ // 1. Bundled with the published package (copied by `npm pack`/prepack).
131
+ // 2. A workspace checkout: packages/node/../../models.
132
+ const candidates = [
133
+ path.join(__dirname, 'models', 'voice-default.cfwm'),
134
+ path.join(__dirname, '..', '..', 'models', 'voice-default.cfwm'),
135
+ ];
136
+ for (const candidate of candidates) {
137
+ if (fs.existsSync(candidate)) return candidate;
138
+ }
139
+ throw new Error(
140
+ 'createVoice: no voice model was found. Pass { modelPath } or set CLEANFIELD_VOICE_MODEL. ' +
141
+ 'The default container is `models/voice-default.cfwm` in the CleanField distribution.'
142
+ );
143
+ }
144
+
145
+ const binding = loadBinding();
146
+
147
+ if (!binding || typeof binding.createVoice !== 'function') {
148
+ throw new Error(
149
+ 'CleanField loaded its native addon but the addon does not export createVoice(). ' +
150
+ 'The binary and the JavaScript package versions may not match.'
151
+ );
152
+ }
153
+
154
+ /**
155
+ * Create one voice engine instance.
156
+ *
157
+ * @param {object} [options]
158
+ * @param {string} [options.modelPath] `.cfwm` container to load. Defaults to the
159
+ * model bundled with the package (or `CLEANFIELD_VOICE_MODEL`).
160
+ * @param {'auto'|'scalar'|'avx2'} [options.backend] Backend selection. `auto`
161
+ * uses AVX2+FMA when the CPU supports it and the scalar reference otherwise.
162
+ * An explicitly requested backend never falls back silently.
163
+ * @returns {{ backend: string, process: (frame: Float32Array) => Float32Array, reset: () => void, close: () => void }}
164
+ */
165
+ function createVoice(options = {}) {
166
+ if (options === null || typeof options !== 'object') {
167
+ throw new TypeError('createVoice(options) expects an options object');
168
+ }
169
+ const backend = options.backend === undefined ? 'auto' : options.backend;
170
+ if (backend !== 'auto' && backend !== 'scalar' && backend !== 'avx2') {
171
+ throw new TypeError('createVoice: options.backend must be "auto", "scalar" or "avx2"');
172
+ }
173
+ const modelPath = resolveModelPath(options.modelPath);
174
+ return binding.createVoice({ modelPath, backend });
175
+ }
176
+
177
+ module.exports = { createVoice };
Binary file
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@cleanfield/node",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "CleanField real-time speech enhancement engine for Node.js: prebuilt native addon, no node-gyp.",
5
+ "license": "MIT",
6
+ "author": "CleanField",
7
+ "main": "index.js",
8
+ "types": "index.d.ts",
9
+ "engines": {
10
+ "node": ">=18.0.0"
11
+ },
12
+ "os": [
13
+ "win32",
14
+ "darwin",
15
+ "linux"
16
+ ],
17
+ "cpu": [
18
+ "x64",
19
+ "arm64"
20
+ ],
21
+ "files": [
22
+ "index.js",
23
+ "index.d.ts",
24
+ "scripts/",
25
+ "prebuilds/",
26
+ "models/voice-default.cfwm"
27
+ ],
28
+ "scripts": {
29
+ "prepack": "node scripts/prepare-model.mjs",
30
+ "postinstall": "node scripts/fetch-prebuilt.mjs",
31
+ "test": "node test/smoke.js"
32
+ },
33
+ "optionalDependencies": {
34
+ "@cleanfield/node-win32-x64": "0.1.0",
35
+ "@cleanfield/node-win32-arm64": "0.1.0",
36
+ "@cleanfield/node-darwin-x64": "0.1.0",
37
+ "@cleanfield/node-darwin-arm64": "0.1.0",
38
+ "@cleanfield/node-darwin-universal": "0.1.0",
39
+ "@cleanfield/node-linux-x64": "0.1.0",
40
+ "@cleanfield/node-linux-arm64": "0.1.0"
41
+ },
42
+ "cleanfield": {
43
+ "releaseBaseUrl": "https://github.com/CleanfieldVoice/Cleanfield/releases/download",
44
+ "model": "models/voice-default.cfwm"
45
+ },
46
+ "keywords": [
47
+ "audio",
48
+ "speech-enhancement",
49
+ "denoise",
50
+ "wasm",
51
+ "native-addon"
52
+ ]
53
+ }
54
+
Binary file
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Fetch the prebuilt native addon for this platform from the GitHub Release
4
+ * that matches the package version.
5
+ *
6
+ * Resolution order in `index.js` is: explicit path, `prebuilds/`, platform
7
+ * package, local build. This script only fills `prebuilds/`; it never fails an
8
+ * install. Air-gapped machines can set `CLEANFIELD_SKIP_DOWNLOAD=1` (or pass
9
+ * `--ignore-scripts`) and provide the addon themselves.
10
+ *
11
+ * Integrity: when the release publishes `SHA256SUMS`, the downloaded binary is
12
+ * verified against it before it is moved into place.
13
+ */
14
+
15
+ import crypto from 'node:crypto';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const here = path.dirname(fileURLToPath(import.meta.url));
21
+ const packageRoot = path.join(here, '..');
22
+ const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
23
+
24
+ const SUPPORTED = new Set([
25
+ 'win32-x64',
26
+ 'win32-arm64',
27
+ 'darwin-x64',
28
+ 'darwin-arm64',
29
+ 'linux-x64',
30
+ 'linux-arm64',
31
+ ]);
32
+
33
+ function skip(reason) {
34
+ console.log(`[cleanfield] prebuilt addon not downloaded: ${reason}`);
35
+ process.exit(0);
36
+ }
37
+
38
+ function warn(message) {
39
+ console.warn(`[cleanfield] ${message}`);
40
+ console.warn(
41
+ '[cleanfield] The JavaScript package is installed, but the native addon is missing. ' +
42
+ 'Build it with `cargo build --release -p cf-node`, copy it to ' +
43
+ `prebuilds/${process.platform}-${process.arch}/cf-node.node, or set CLEANFIELD_NODE_BINDING.`
44
+ );
45
+ process.exit(0);
46
+ }
47
+
48
+ if (process.env.CLEANFIELD_SKIP_DOWNLOAD === '1') skip('CLEANFIELD_SKIP_DOWNLOAD=1');
49
+
50
+ const tag = `${process.platform}-${process.arch}`;
51
+ if (!SUPPORTED.has(tag)) skip(`unsupported platform ${tag}`);
52
+
53
+ const asset =
54
+ process.platform === 'darwin' ? 'cf-node-darwin-universal.node' : `cf-node-${tag}.node`;
55
+ const targetDir = path.join(packageRoot, 'prebuilds', tag);
56
+ const target = path.join(targetDir, 'cf-node.node');
57
+
58
+ if (fs.existsSync(target)) skip(`already present at ${path.relative(packageRoot, target)}`);
59
+
60
+ // A workspace build is enough; do not touch the network for local development.
61
+ const localCandidates = [
62
+ path.join(packageRoot, 'build', 'Release', 'cf_node.node'),
63
+ path.join(packageRoot, '..', '..', 'target', 'release', 'cf_node.dll'),
64
+ path.join(packageRoot, '..', '..', 'target', 'release', 'libcf_node.so'),
65
+ path.join(packageRoot, '..', '..', 'target', 'release', 'libcf_node.dylib'),
66
+ ];
67
+ if (localCandidates.some((candidate) => fs.existsSync(candidate))) {
68
+ skip('a local Cargo build is present');
69
+ }
70
+
71
+ const baseUrl =
72
+ process.env.CLEANFIELD_RELEASE_BASE_URL ||
73
+ (manifest.cleanfield && manifest.cleanfield.releaseBaseUrl);
74
+ if (!baseUrl) skip('no release base URL is configured');
75
+
76
+ const releaseUrl = `${baseUrl.replace(/\/+$/, '')}/v${manifest.version}`;
77
+ const assetUrl = `${releaseUrl}/${asset}`;
78
+
79
+ async function download(url) {
80
+ const response = await fetch(url, { redirect: 'follow' });
81
+ if (!response.ok) {
82
+ throw new Error(`HTTP ${response.status} for ${url}`);
83
+ }
84
+ return Buffer.from(await response.arrayBuffer());
85
+ }
86
+
87
+ async function main() {
88
+ let bytes;
89
+ try {
90
+ bytes = await download(assetUrl);
91
+ } catch (error) {
92
+ warn(`could not download ${assetUrl}: ${error.message}`);
93
+ return;
94
+ }
95
+
96
+ try {
97
+ const sums = (await download(`${releaseUrl}/SHA256SUMS`)).toString('utf8');
98
+ const expected = sums
99
+ .split(/\r?\n/)
100
+ .map((line) => line.trim().split(/\s+/))
101
+ .find((parts) => parts.length >= 2 && parts[parts.length - 1] === asset);
102
+ if (expected) {
103
+ const actual = crypto.createHash('sha256').update(bytes).digest('hex');
104
+ if (actual !== expected[0].toLowerCase()) {
105
+ warn(`checksum mismatch for ${asset}: expected ${expected[0]}, got ${actual}`);
106
+ return;
107
+ }
108
+ console.log(`[cleanfield] SHA256 verified: ${actual}`);
109
+ } else {
110
+ console.warn(`[cleanfield] ${asset} is not listed in SHA256SUMS; installing unverified`);
111
+ }
112
+ } catch (error) {
113
+ console.warn(`[cleanfield] could not verify SHA256SUMS: ${error.message}`);
114
+ }
115
+
116
+ fs.mkdirSync(targetDir, { recursive: true });
117
+ const temporary = `${target}.download`;
118
+ fs.writeFileSync(temporary, bytes);
119
+ fs.renameSync(temporary, target);
120
+ console.log(
121
+ `[cleanfield] installed ${asset} (${(bytes.length / 1024).toFixed(1)} KiB) into prebuilds/${tag}/`
122
+ );
123
+ }
124
+
125
+ await main();
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Copy the default voice container into the package before packing.
4
+ *
5
+ * The `.cfwm` container is 2.5 MB and is generated by the engine build, so it
6
+ * is not committed twice: the package directory only receives a copy at
7
+ * `npm pack` / `npm publish` time (this is the `prepack` hook) and, for local
8
+ * development, the loader can fall back to the workspace copy
9
+ * (`packages/node/../../models/voice-default.cfwm`).
10
+ */
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const here = path.dirname(fileURLToPath(import.meta.url));
17
+ const packageRoot = path.join(here, '..');
18
+ const source = path.join(packageRoot, '..', '..', 'models', 'voice-default.cfwm');
19
+ const target = path.join(packageRoot, 'models', 'voice-default.cfwm');
20
+
21
+ if (fs.existsSync(target) && fs.statSync(target).size > 0) {
22
+ console.log(`[cleanfield] model already present: ${path.relative(process.cwd(), target)}`);
23
+ process.exit(0);
24
+ }
25
+
26
+ if (!fs.existsSync(source)) {
27
+ console.error(
28
+ `[cleanfield] cannot package without the voice container: ${source} is missing.\n` +
29
+ 'Build it with `python tools/build_cfwm.py --blob <weights.bin>` from the workspace root.'
30
+ );
31
+ process.exit(1);
32
+ }
33
+
34
+ fs.mkdirSync(path.dirname(target), { recursive: true });
35
+ fs.copyFileSync(source, target);
36
+ console.log(
37
+ `[cleanfield] copied ${(fs.statSync(target).size / (1024 * 1024)).toFixed(2)} MiB model into the package`
38
+ );
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.