Drafting with Abominable Intelligence

This commit is contained in:
Apher 2026-07-06 06:12:05 +00:00
parent 19df767f81
commit 059990cd31
8 changed files with 184 additions and 1 deletions

3
.gitignore vendored
View File

@ -1,3 +1,4 @@
.env .env
node_modules/ node_modules/
.venv/ .venv/
src/scripts/py/vosk-model*/

7
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"mic": "^2.1.2",
"redis": "^6.1.0" "redis": "^6.1.0"
}, },
"devDependencies": { "devDependencies": {
@ -320,6 +321,12 @@
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/mic": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/mic/-/mic-2.1.2.tgz",
"integrity": "sha512-rpl4tgdXX24sAzYwjRc5OZfGNAuhUIIjdd0cw8+Ubq7rp3iGhi40AdqcwurDWhEZADk60tPOxb3E2MpoeLeyxw==",
"license": "MIT"
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "10.2.5", "version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",

View File

@ -12,6 +12,7 @@
"license": "ISC", "license": "ISC",
"type": "commonjs", "type": "commonjs",
"dependencies": { "dependencies": {
"mic": "^2.1.2",
"redis": "^6.1.0" "redis": "^6.1.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -0,0 +1,11 @@
{
"wakeword": "alexa",
"wakewordThreshold": 0.80,
"cooldownSeconds": 2.5,
"confirmFrames": 3,
"listenSeconds": 12,
"silenceThreshold": 0.015,
"silenceFrames": 10,
"voskModelPath": "./vosk-model-small-en-us-0.15",
"sampleRate": 16000
}

1
src/scripts/py/info.txt Normal file
View File

@ -0,0 +1 @@
PyAudio requires Python version <=3.13

View File

@ -0,0 +1,4 @@
openwakeword
onnxruntime
numpy
pyaudio

View File

@ -0,0 +1,46 @@
const { spawn } = require('child_process');
const fs = require('fs');
const config = JSON.parse(fs.readFileSync('./config.json', 'utf8'));
const py = spawn('python', ['wakeword.py', JSON.stringify(config)], {
stdio: ['ignore', 'pipe', 'pipe']
});
let buffer = '';
py.stdout.on('data', (data) => {
buffer += data.toString();
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.trim()) continue;
try {
const msg = JSON.parse(line);
if (msg.event === 'ready') {
console.log('Python ready');
} else if (msg.event === 'wakeword') {
console.log(`Wakeword detected: ${msg.model} (${msg.score})`);
} else if (msg.event === 'partial') {
console.log('Partial:', msg.text);
} else if (msg.event === 'text' || msg.event === 'final') {
console.log('Transcript:', msg.text);
} else if (msg.event === 'done') {
console.log('Back to wakeword mode');
}
} catch {
console.log('[PY]', line);
}
}
});
py.stderr.on('data', (data) => {
console.error('[PY ERR]', data.toString());
});
py.on('close', (code) => {
console.log('Python exited with code', code);
});

112
src/scripts/py/wakeword.py Normal file
View File

@ -0,0 +1,112 @@
import json
import sys
import time
import numpy as np
import pyaudio
import vosk
from openwakeword.model import Model
CONFIG = json.loads(sys.argv[1])
RATE = CONFIG.get("sampleRate", 16000)
CHUNK = 1280
WAKEWORD = CONFIG.get("wakeword", "alexa")
THRESHOLD = CONFIG.get("wakewordThreshold", 0.75)
COOLDOWN = CONFIG.get("cooldownSeconds", 2.5)
CONFIRM_FRAMES = CONFIG.get("confirmFrames", 3)
LISTEN_SECONDS = CONFIG.get("listenSeconds", 12)
SILENCE_THRESHOLD = CONFIG.get("silenceThreshold", 0.02)
SILENCE_FRAMES = CONFIG.get("silenceFrames", 12)
VOSK_MODEL_PATH = CONFIG["voskModelPath"]
vosk.SetLogLevel(-1)
vosk_model = vosk.Model(VOSK_MODEL_PATH)
recognizer = vosk.KaldiRecognizer(vosk_model, RATE)
recognizer.SetWords(True)
oww = Model(wakeword_models=[WAKEWORD], inference_framework="onnx")
audio = pyaudio.PyAudio()
stream = audio.open(
format=pyaudio.paInt16,
channels=1,
rate=RATE,
input=True,
frames_per_buffer=CHUNK,
)
print(json.dumps({"event": "ready"}), flush=True)
mode = "wakeword"
high_count = 0
last_trigger = 0
silence_count = 0
listen_deadline = None
def rms_level(samples):
x = samples.astype(np.float32) / 32768.0
return np.sqrt(np.mean(x * x))
try:
while True:
data = stream.read(CHUNK, exception_on_overflow=False)
samples = np.frombuffer(data, dtype=np.int16)
now = time.time()
if mode == "wakeword":
preds = oww.predict(samples)
score = preds.get(WAKEWORD, 0.0)
if score >= THRESHOLD:
high_count += 1
else:
high_count = 0
if high_count >= CONFIRM_FRAMES and (now - last_trigger) > COOLDOWN:
last_trigger = now
high_count = 0
print(json.dumps({
"event": "wakeword",
"model": WAKEWORD,
"score": float(score)
}), flush=True)
mode = "transcribe"
recognizer.Reset()
silence_count = 0
listen_deadline = now + LISTEN_SECONDS
else:
if recognizer.AcceptWaveform(data):
result = json.loads(recognizer.Result())
if result.get("text"):
print(json.dumps({"event": "final", "text": result["text"]}), flush=True)
listen_deadline = now + LISTEN_SECONDS
silence_count = 0
else:
partial = json.loads(recognizer.PartialResult()).get("partial", "")
if partial:
print(json.dumps({"event": "partial", "text": partial}), flush=True)
level = rms_level(samples)
if level < SILENCE_THRESHOLD:
silence_count += 1
else:
silence_count = 0
if silence_count >= SILENCE_FRAMES or now > listen_deadline:
final = json.loads(recognizer.FinalResult())
if final.get("text"):
print(json.dumps({"event": "final", "text": final["text"]}), flush=True)
print(json.dumps({"event": "done"}), flush=True)
mode = "wakeword"
silence_count = 0
high_count = 0
listen_deadline = None
except KeyboardInterrupt:
pass
finally:
stream.stop_stream()
stream.close()
audio.terminate()