@lumiastream/wakeword 1.0.0 → 1.0.1-alpha.10

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/bin/wakeword CHANGED
@@ -17,13 +17,28 @@ const soxPath = path.join(
17
17
  exe
18
18
  );
19
19
 
20
- // Forward every CLI arg → voice.mjs
21
- spawn(
22
- process.execPath, // node executable
20
+ const child = spawn(
21
+ process.execPath,
23
22
  [
24
- path.join(__dirname, "..", "lib", "voice.mjs"),
23
+ path.join(
24
+ path.dirname(fileURLToPath(import.meta.url)),
25
+ "..",
26
+ "lib",
27
+ "voice.js"
28
+ ),
25
29
  soxPath,
26
30
  ...process.argv.slice(2),
27
31
  ],
28
- { stdio: "inherit" }
32
+ { stdio: ["pipe", "inherit", "inherit"] }
29
33
  );
34
+
35
+ // If you want to forward user input from this process to the child:
36
+ // if (process.stdin.isTTY) {
37
+ // process.stdin.setRawMode(false);
38
+ // }
39
+ // process.stdin.pipe(child.stdin);
40
+
41
+ // listen for hotkey events from the child process
42
+ child.on("message", (message) => {
43
+ console.log("hotkey", message);
44
+ });
package/lib/record.js ADDED
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+
3
+ import assert from "assert";
4
+ import debug from "debug";
5
+ import { spawn } from "child_process";
6
+ import recorders from "./recorders/index.js";
7
+
8
+ class Recording {
9
+ constructor(options = {}) {
10
+ const defaults = {
11
+ sampleRate: 16000,
12
+ channels: 1,
13
+ compress: false,
14
+ threshold: 0.5,
15
+ thresholdStart: null,
16
+ thresholdEnd: null,
17
+ silence: "1.0",
18
+ recorder: "sox",
19
+ endOnSilence: false,
20
+ audioType: "wav",
21
+ binPath: null,
22
+ bufferSize: null,
23
+ arguments: [],
24
+ };
25
+
26
+ this.options = Object.assign(defaults, options);
27
+
28
+ const recorder = recorders.load(this.options.recorder);
29
+ const { cmd, args, spawnOptions = {} } = recorder(this.options);
30
+
31
+ this.cmd = cmd;
32
+ this.args = args;
33
+ this.cmdOptions = Object.assign(
34
+ { encoding: "binary", stdio: "pipe" },
35
+ spawnOptions
36
+ );
37
+
38
+ debug(`Started recording`);
39
+ debug(this.options);
40
+
41
+ const command = ` ${this.cmd} ${this.args.join(" ")}`;
42
+ debug(command);
43
+
44
+ return this.start();
45
+ }
46
+
47
+ start() {
48
+ const { cmd, args, cmdOptions } = this;
49
+
50
+ const cp = spawn(cmd, args, cmdOptions);
51
+ const rec = cp.stdout;
52
+ const err = cp.stderr;
53
+
54
+ this.process = cp; // expose child process
55
+ this._stream = rec; // expose output stream
56
+
57
+ cp.on("close", (code) => {
58
+ if (code === 0) return;
59
+ rec.emit(
60
+ "error",
61
+ `${this.cmd} has exited with error code ${code}.
62
+
63
+ Enable debugging with the environment variable DEBUG=record.`
64
+ );
65
+ });
66
+
67
+ err.on("data", (chunk) => {
68
+ debug(`STDERR: ${chunk}`);
69
+ });
70
+
71
+ rec.on("data", (chunk) => {
72
+ debug(`Recording ${chunk.length} bytes`);
73
+ });
74
+
75
+ rec.on("end", () => {
76
+ debug("Recording ended");
77
+ });
78
+
79
+ return this;
80
+ }
81
+
82
+ stop() {
83
+ assert(this.process, "Recording not yet started");
84
+
85
+ this.process.kill();
86
+ }
87
+
88
+ pause() {
89
+ assert(this.process, "Recording not yet started");
90
+
91
+ this.process.kill("SIGSTOP");
92
+ this._stream.pause();
93
+ debug("Paused recording");
94
+ }
95
+
96
+ resume() {
97
+ assert(this.process, "Recording not yet started");
98
+
99
+ this.process.kill("SIGCONT");
100
+ this._stream.resume();
101
+ debug("Resumed recording");
102
+ }
103
+
104
+ isPaused() {
105
+ assert(this.process, "Recording not yet started");
106
+
107
+ return this._stream.isPaused();
108
+ }
109
+
110
+ stream() {
111
+ assert(this._stream, "Recording not yet started");
112
+
113
+ return this._stream;
114
+ }
115
+ }
116
+
117
+ export default {
118
+ record: (...args) => new Recording(...args),
119
+ };
@@ -0,0 +1,27 @@
1
+ // On some systems (RasPi), arecord is the prefered recording binary
2
+ export default (options) => {
3
+ let cmd = "arecord";
4
+
5
+ if (options.binPath) {
6
+ cmd = options.binPath;
7
+ }
8
+
9
+ const args = [
10
+ "-q", // show no progress
11
+ "-r",
12
+ options.sampleRate, // sample rate
13
+ "-c",
14
+ options.channels, // channels
15
+ "-t",
16
+ options.audioType, // audio type
17
+ "-f",
18
+ "S16_LE", // Sample format
19
+ "-", // pipe
20
+ ];
21
+
22
+ if (options.device) {
23
+ args.unshift("-D", options.device);
24
+ }
25
+
26
+ return { cmd, args };
27
+ };
@@ -0,0 +1,23 @@
1
+ // import { fileURLToPath } from "node:url";
2
+ // import path from "node:path";
3
+ // const __dirname = path.dirname(fileURLToPath(import.meta.url));
4
+ import rec from "./sox.js";
5
+
6
+ function load(recorderName) {
7
+ try {
8
+ // const recoderPath = path.resolve(__dirname, recorderName);
9
+ // const module = await import(recoderPath);
10
+ // return module.default;
11
+ return rec;
12
+ } catch (err) {
13
+ if (err.code === "MODULE_NOT_FOUND") {
14
+ throw new Error(`No such recorder found: ${recorderName}`);
15
+ }
16
+
17
+ throw err;
18
+ }
19
+ }
20
+
21
+ export default {
22
+ load,
23
+ };
@@ -0,0 +1,36 @@
1
+ export default (options) => {
2
+ let cmd = "rec";
3
+
4
+ if (options.binPath) {
5
+ cmd = options.binPath;
6
+ }
7
+
8
+ let args = [
9
+ "-q", // show no progress
10
+ "-r",
11
+ options.sampleRate, // sample rate
12
+ "-c",
13
+ options.channels, // channels
14
+ "-e",
15
+ "signed-integer", // sample encoding
16
+ "-b",
17
+ "16", // precision (bits)
18
+ "-t",
19
+ options.audioType, // audio type
20
+ "-", // pipe
21
+ ];
22
+
23
+ if (options.endOnSilence) {
24
+ args = args.concat([
25
+ "silence",
26
+ "1",
27
+ "0.1",
28
+ options.thresholdStart || options.threshold + "%",
29
+ "1",
30
+ options.silence,
31
+ options.thresholdEnd || options.threshold + "%",
32
+ ]);
33
+ }
34
+
35
+ return { cmd, args };
36
+ };
@@ -0,0 +1,51 @@
1
+ export default (options) => {
2
+ let cmd = "sox";
3
+
4
+ if (options.binPath) {
5
+ cmd = options.binPath;
6
+ }
7
+
8
+ let args = [
9
+ "--default-device",
10
+ "--no-show-progress", // show no progress
11
+ "--rate",
12
+ options.sampleRate, // sample rate
13
+ "--channels",
14
+ options.channels, // channels
15
+ "--encoding",
16
+ "signed-integer", // sample encoding
17
+ "--bits",
18
+ "16", // precision (bits)
19
+ "--type",
20
+ options.audioType, // audio type
21
+ "-", // pipe
22
+ ];
23
+
24
+ if (options.bufferSize) {
25
+ args.push("--buffer", options.bufferSize);
26
+ }
27
+
28
+ if (options.endOnSilence) {
29
+ args = args.concat([
30
+ "silence",
31
+ "1",
32
+ "0.1",
33
+ options.thresholdStart || options.threshold + "%",
34
+ "1",
35
+ options.silence,
36
+ options.thresholdEnd || options.threshold + "%",
37
+ ]);
38
+ }
39
+
40
+ if (options.arguments) {
41
+ args = args.concat(options.arguments);
42
+ }
43
+
44
+ const spawnOptions = {};
45
+
46
+ if (options.device) {
47
+ spawnOptions.env = { ...process.env, AUDIODEV: options.device };
48
+ }
49
+
50
+ return { cmd, args, spawnOptions };
51
+ };
package/lib/voice.js ADDED
@@ -0,0 +1,100 @@
1
+ // voice.js (ESM)
2
+ import { Model, Recognizer, setLogLevel } from "vosk-koffi";
3
+ import record from "./record.js";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { existsSync, chmodSync } from "node:fs";
7
+ import readline from "node:readline";
8
+
9
+ /* ------------------------------------------------------------------ */
10
+ /* 0. Helpers */
11
+ /* ------------------------------------------------------------------ */
12
+ const here = dirname(fileURLToPath(import.meta.url));
13
+
14
+ function unpacked(p) {
15
+ return p.includes("app.asar")
16
+ ? p.replace("app.asar", "app.asar.unpacked")
17
+ : p;
18
+ }
19
+
20
+ /* ------------------------------------------------------------------ */
21
+ /* 1. Resolve SoX binary */
22
+ /* ------------------------------------------------------------------ */
23
+ const exeName = { win32: "sox.exe", darwin: "soxmac", linux: "soxlinux" }[
24
+ process.platform
25
+ ];
26
+
27
+ /* Priority: argv[2] → fallback to sibling binaries/<exe> */
28
+ let soxPath = process.argv[2] || join(here, "..", "binaries", exeName);
29
+ soxPath = unpacked(soxPath);
30
+
31
+ if (!existsSync(soxPath)) throw new Error(`SoX not found: ${soxPath}`);
32
+ try {
33
+ chmodSync(soxPath, 0o755);
34
+ } catch {
35
+ /* ignore on read‐only FS */
36
+ }
37
+
38
+ /* ------------------------------------------------------------------ */
39
+ /* 2. Resolve Vosk model */
40
+ /* ------------------------------------------------------------------ */
41
+ let modelPath = join(here, "..", "models", "vosk-model-small-en-us-0.15");
42
+ modelPath = unpacked(modelPath);
43
+
44
+ if (!existsSync(modelPath))
45
+ throw new Error(`Vosk model not found: ${modelPath}`);
46
+
47
+ /* ------------------------------------------------------------------ */
48
+ /* 3. Initialise recogniser */
49
+ /* ------------------------------------------------------------------ */
50
+ setLogLevel(0);
51
+
52
+ const SAMPLE_RATE = 16_000;
53
+ let GRAMMAR = ["blue", "[unk]"]; // seed; always keep [unk]
54
+
55
+ const model = new Model(modelPath);
56
+ let rec = new Recognizer({ model, sampleRate: SAMPLE_RATE, grammar: GRAMMAR });
57
+
58
+ /* ------------------------------------------------------------------ */
59
+ /* 4. Start the microphone */
60
+ /* ------------------------------------------------------------------ */
61
+ const mic = record
62
+ .record({ sampleRate: SAMPLE_RATE, threshold: 0, binPath: soxPath })
63
+ .stream();
64
+
65
+ mic.on("data", (buf) => {
66
+ if (rec.acceptWaveform(buf)) {
67
+ const { text } = rec.result();
68
+ handle(text.trim());
69
+ }
70
+ });
71
+
72
+ /* ------------------------------------------------------------------ */
73
+ /* 5. Handle recognised commands */
74
+ /* ------------------------------------------------------------------ */
75
+ function handle(word) {
76
+ if (!word || word.includes("[unk]")) return;
77
+ if (GRAMMAR.includes(word)) process.stdout.write(`voice|${word}\n`);
78
+ }
79
+
80
+ /* ------------------------------------------------------------------ */
81
+ /* 6. Hot-reload grammar via stdin */
82
+ /* ------------------------------------------------------------------ */
83
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
84
+
85
+ rl.on("line", (line) => {
86
+ const trimmed = line.trim();
87
+ if (!trimmed.startsWith("update,")) return;
88
+
89
+ const phrases = trimmed
90
+ .split(",")
91
+ .slice(1)
92
+ .map((s) => s.trim())
93
+ .filter(Boolean);
94
+
95
+ if (!phrases.length) return;
96
+
97
+ GRAMMAR = [...phrases, "[unk]"];
98
+ rec = new Recognizer({ model, sampleRate: SAMPLE_RATE, grammar: GRAMMAR });
99
+ console.error("[wakeword] grammar updated →", GRAMMAR.join(", "));
100
+ });
package/package.json CHANGED
@@ -1,23 +1,21 @@
1
1
  {
2
2
  "name": "@lumiastream/wakeword",
3
- "version": "1.0.0",
3
+ "version": "1.0.1-alpha.10",
4
4
  "type": "module",
5
- "main": "lib/voice.mjs",
5
+ "main": "lib/voice.js",
6
6
  "bin": {
7
7
  "wakeword": "bin/wakeword"
8
8
  },
9
9
  "files": [
10
10
  "bin/",
11
11
  "lib/",
12
- "binaries/",
13
- "models/"
12
+ "models/",
13
+ "binaries/"
14
14
  ],
15
15
  "scripts": {
16
- "postinstall": "chmod +x binaries/soxmac binaries/soxlinux || true"
16
+ "postinstall": "chmod +x binaries/soxmac binaries/soxlinux binaries/sox.exe || true"
17
17
  },
18
- "optionalDependencies": {
19
- "@lumiastream/wakeword-darwin": "file:./binaries/soxmac",
20
- "@lumiastream/wakeword-linux": "file:./binaries/soxlinux",
21
- "@lumiastream/wakeword-win32": "file:./binaries/sox.exe"
18
+ "dependencies": {
19
+ "vosk-koffi": "^1.1.1"
22
20
  }
23
21
  }
package/lib/voice.mjs DELETED
@@ -1,88 +0,0 @@
1
- import { Model, Recognizer, setLogLevel } from "vosk";
2
- import record from "@lumiastream/record";
3
- import { join } from "node:path";
4
-
5
- const binPath = join(
6
- "binaries",
7
- process.platform === "win32"
8
- ? "sox.exe"
9
- : process.platform === "darwin"
10
- ? "soxmac"
11
- : "soxlinux"
12
- );
13
-
14
- console.log(binPath);
15
-
16
- let COMMANDS = [
17
- "open settings",
18
- "mute audio",
19
- "start recording",
20
- "[unk]", // always keep an [unk] fallback!
21
- ];
22
-
23
- const SAMPLE_RATE = 16_000;
24
- setLogLevel(0); // silence Kaldi logs
25
-
26
- // 1. load model once
27
- const model = new Model("./models/vosk-model-small-en-us-0.15");
28
-
29
- // 2. build a grammar recognizer
30
- let rec = new Recognizer({
31
- model,
32
- sampleRate: SAMPLE_RATE,
33
- grammar: COMMANDS,
34
- });
35
-
36
- // 3. open the mic (16-kHz, 16-bit, mono)
37
- const mic = record
38
- .record({
39
- sampleRate: SAMPLE_RATE,
40
- threshold: 0,
41
- binPath,
42
- })
43
- .stream();
44
-
45
- mic.on("data", (buf) => {
46
- // accept 0.1-sec chunks for low latency
47
- if (rec.acceptWaveform(buf)) {
48
- const result = rec.result();
49
- handle(result?.text?.trim());
50
- } else {
51
- // optional: JSON.parse(rec.partialResult()).partial for live captions
52
- }
53
- });
54
-
55
- // 4. map recognised phrase ➜ action
56
- function handle(phrase) {
57
- console.log(phrase);
58
- if (phrase && COMMANDS.includes(phrase)) {
59
- console.log("found", phrase);
60
- }
61
- // switch (phrase) {
62
- // case "open settings":
63
- // process.send?.({ hotkey: "settings" });
64
- // break;
65
- // case "mute audio":
66
- // process.send?.({ hotkey: "mute" });
67
- // break;
68
- // case "start recording":
69
- // process.send?.({ hotkey: "record" });
70
- // break;
71
- // }
72
- }
73
-
74
- const updateGrammar = (grammar) => {
75
- COMMANDS = grammar;
76
- rec = new Recognizer({
77
- model,
78
- sampleRate: SAMPLE_RATE,
79
- grammar: [...COMMANDS, "[unk]"],
80
- });
81
- console.log(COMMANDS);
82
- };
83
-
84
- setTimeout(() => {
85
- updateGrammar(["red", "blue", "green"]);
86
- }, 5000);
87
-
88
- export { updateGrammar };