#!/usr/bin/env node import { createRequire } from 'node:module'; import { appendFileSync, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'; import { access, constants } from 'node:fs/promises'; import { spawnSync } from 'node:child_process'; import { Readable } from 'node:stream'; import { discoverParakeetDir, loadSentencePiece, ParakeetOnnx } from './noko-parakeet.mjs'; import { loadOnnxRuntime, OnnxVad, vadProbabilities, vadRuntimeReport } from './noko-vad.mjs'; const require = createRequire(import.meta.url); const Noko = require('./noko.js'); function usage() { return `usage: node noko.mjs doctor node noko.mjs system-deps-json node noko.mjs assets-json node noko.mjs check-assets-json node noko.mjs install-assets node noko.mjs update-assets node noko.mjs install-assets-progress node noko.mjs update-assets-progress node noko.mjs capture-json seconds output.json [device] node noko.mjs vad-json input.json output.json node noko.mjs vad-gate-json input.json output.json node noko.mjs transcribe-real-json input.json history.jsonl node noko.mjs transcribe-raw-json input.f32 history.jsonl node noko.mjs transcribe-capture-json seconds history.jsonl [device] node noko.mjs transcribe-json input.json history.jsonl env: NOKO_DATA_DIR Noko-owned asset root, defaults to XDG data dir NOKO_VAD_ONNX override path to Silero/equivalent VAD ONNX NOKO_PARAKEET_DIR override Parakeet model directory NOKO_NO_SYSTEM_INSTALL=1 skips package-manager install during setup `; } function commandExists(name) { const result = spawnSync('sh', ['-c', `command -v ${JSON.stringify(name)} >/dev/null 2>&1`]); return result.status === 0; } const SYSTEM_DEPENDENCIES = [ { command: 'pw-record', label: 'mic capture', required: true, packages: { arch: 'pipewire', fedora: 'pipewire-utils', debian: 'pipewire-bin' } }, { command: 'wtype', label: 'text insertion', required: true, packages: { arch: 'wtype', fedora: 'wtype', debian: 'wtype' } } ]; function systemDependencyReport() { return SYSTEM_DEPENDENCIES.map(dep => Object.assign({}, dep, { exists: commandExists(dep.command) })); } function missingSystemDependencies(report = systemDependencyReport()) { return report.filter(dep => dep.required && !dep.exists).map(dep => dep.command); } function osIdLike() { try { const text = readFileSync('/etc/os-release', 'utf8'); const ids = []; for (const key of ['ID', 'ID_LIKE']) { const match = text.match(new RegExp(`^${key}=(.*)$`, 'm')); if (match) ids.push(...match[1].replace(/^"|"$/g, '').split(/\s+/)); } return ids; } catch (_error) { return []; } } function packageFamily() { const ids = osIdLike(); if (ids.includes('arch') || commandExists('pacman')) return 'arch'; if (ids.includes('fedora') || ids.includes('rhel') || commandExists('dnf')) return 'fedora'; if (ids.includes('debian') || ids.includes('ubuntu') || commandExists('apt-get')) return 'debian'; return ''; } function unique(values) { return Array.from(new Set(values.filter(Boolean))); } function systemInstallCommand(report = systemDependencyReport()) { const missing = report.filter(dep => dep.required && !dep.exists); if (missing.length === 0) return []; const family = packageFamily(); const packages = unique(missing.map(dep => dep.packages && dep.packages[family])); if (packages.length === 0) return []; if (family === 'arch') return ['pkexec', 'pacman', '-S', '--needed', '--noconfirm', ...packages]; if (family === 'fedora') return ['pkexec', 'dnf', 'install', '-y', ...packages]; if (family === 'debian') return ['pkexec', 'apt-get', 'install', '-y', ...packages]; return []; } function systemDependencyStatus() { const dependencies = systemDependencyReport(); return { dependencies, missing: missingSystemDependencies(dependencies), installCommand: systemInstallCommand(dependencies), packageFamily: packageFamily(), canInstall: systemInstallCommand(dependencies).length > 0 && commandExists('pkexec') }; } function installSystemDependencies(options = {}) { const status = systemDependencyStatus(); emitProgress(options.emit, { event: 'system-deps-start', label: 'system tools', progress: 0.01, missing: status.missing }); if (status.missing.length === 0 || process.env.NOKO_NO_SYSTEM_INSTALL === '1') { emitProgress(options.emit, { event: 'system-deps-done', label: 'system tools', progress: 0.08, skipped: true, missing: status.missing }); return Object.assign({ skipped: true }, status); } if (!status.canInstall) throw new Error(`missing system tools: ${status.missing.join(', ')}; no supported pkexec install command`); const result = spawnSync(status.installCommand[0], status.installCommand.slice(1), { encoding: 'utf8', maxBuffer: 1024 * 1024 * 4 }); if (result.status !== 0) throw new Error(`system tool install failed: ${(result.stderr || result.stdout || '').trim()}`); const after = systemDependencyStatus(); if (after.missing.length > 0) throw new Error(`system tools still missing: ${after.missing.join(', ')}`); emitProgress(options.emit, { event: 'system-deps-done', label: 'system tools', progress: 0.08, skipped: false }); return Object.assign({ skipped: false }, after); } function dataDir() { if (process.env.NOKO_DATA_DIR) return process.env.NOKO_DATA_DIR; const root = process.env.XDG_DATA_HOME || `${process.env.HOME || '.'}/.local/share`; return `${root}/nuguland/noko`; } const PARAKEET_REPO = process.env.NOKO_PARAKEET_REPO || 'https://huggingface.co/smcleod/parakeet-tdt-0.6b-v3-int8/resolve/main'; const ASSET_FILES = { vad: { file: 'silero_vad.onnx', url: process.env.NOKO_VAD_URL || 'https://github.com/snakers4/silero-vad/raw/master/src/silero_vad/data/silero_vad.onnx' }, parakeet: { dir: 'parakeet-tdt-0.6b-v3-int8', files: { config: { file: 'config.json', url: `${PARAKEET_REPO}/config.json` }, preprocessor: { file: 'nemo128.onnx', url: `${PARAKEET_REPO}/nemo128.onnx` }, encoder: { file: 'encoder-model.int8.onnx', url: `${PARAKEET_REPO}/encoder-model.int8.onnx` }, decoderJoint: { file: 'decoder_joint-model.int8.onnx', url: `${PARAKEET_REPO}/decoder_joint-model.int8.onnx` }, vocab: { file: 'vocab.txt', url: `${PARAKEET_REPO}/vocab.txt` } } } }; function defaultVadPath() { return `${dataDir()}/vad/${ASSET_FILES.vad.file}`; } function defaultParakeetDir() { return `${dataDir()}/${ASSET_FILES.parakeet.dir}`; } function runtimeDir() { return `${dataDir()}/runtime`; } function runtimeModulePath() { return `${runtimeDir()}/node_modules/onnxruntime-node`; } function resolvedVadPath() { return process.env.NOKO_VAD_ONNX || defaultVadPath(); } function resolvedParakeetDir() { return process.env.NOKO_PARAKEET_DIR || defaultParakeetDir(); } function fileStatus(path) { try { const stat = statSync(path); return { path, exists: true, size: stat.size }; } catch (_error) { return { path, exists: false, size: 0 }; } } function assetFiles(report = null) { const current = report || assetReport(); return [{ key: 'vad', url: current.vad.url, path: current.vad.path }] .concat(Object.entries(current.parakeet.files).map(([key, value]) => Object.assign({ key }, value))); } function assetReport() { const root = dataDir(); const parakeetPath = resolvedParakeetDir(); const parakeetFiles = Object.fromEntries(Object.entries(ASSET_FILES.parakeet.files).map(([key, value]) => [key, Object.assign({ url: value.url }, fileStatus(`${parakeetPath}/${value.file}`))])); return { dataDir: root, vad: Object.assign({ url: ASSET_FILES.vad.url }, fileStatus(resolvedVadPath())), parakeet: { path: parakeetPath, files: parakeetFiles, complete: Object.values(parakeetFiles).every(file => file.exists) }, runtime: Object.assign({ package: 'onnxruntime-node@1.27.0' }, fileStatus(runtimeModulePath())) }; } async function remoteStatus(file) { try { const response = await fetch(file.url, { method: 'HEAD' }); if (!response.ok) return { key: file.key, url: file.url, path: file.path, ok: false, error: `http ${response.status}`, updateAvailable: false }; const remoteSize = Number(response.headers.get('content-length') || 0); const local = fileStatus(file.path); return { key: file.key, url: file.url, path: file.path, exists: local.exists, localSize: local.size, remoteSize, etag: response.headers.get('etag') || '', lastModified: response.headers.get('last-modified') || '', ok: true, updateAvailable: !local.exists || (remoteSize > 0 && local.size !== remoteSize) }; } catch (error) { return { key: file.key, url: file.url, path: file.path, ok: false, error: String(error && error.message ? error.message : error), updateAvailable: false }; } } async function checkAssets() { const assets = assetReport(); const files = Object.fromEntries(await Promise.all(assetFiles(assets).map(async file => [file.key, await remoteStatus(file)]))); return { checkedAt: new Date().toISOString(), assets, files, updateAvailable: Object.values(files).some(file => file.updateAvailable), unknown: Object.values(files).filter(file => !file.ok).map(file => file.key) }; } function emitProgress(emit, event) { if (emit) emit(event); } async function writeStream(stream, path, emit, base) { const out = createWriteStream(path); let loaded = 0; for await (const chunk of Readable.fromWeb(stream)) { loaded += chunk.length; if (!out.write(chunk)) await new Promise(resolve => out.once('drain', resolve)); emitProgress(emit, Object.assign({}, base, { event: 'download', loaded, progress: base.totalBytes > 0 ? loaded / base.totalBytes : base.progress })); } await new Promise((resolve, reject) => out.end(error => error ? reject(error) : resolve())); } async function downloadFile(url, path, options = {}) { if (options.force && existsSync(path)) unlinkSync(path); if (existsSync(path)) { emitProgress(options.emit, { event: 'skip', label: options.label || path, progress: options.progress || 1, path }); return { path, skipped: true }; } mkdirSync(path.slice(0, path.lastIndexOf('/')), { recursive: true }); const tmp = `${path}.tmp-${process.pid}`; const response = await fetch(url); if (!response.ok || !response.body) throw new Error(`download failed ${response.status} ${url}`); const totalBytes = Number(response.headers.get('content-length') || 0); emitProgress(options.emit, { event: 'download-start', label: options.label || path, progress: options.progress || 0, totalBytes, path }); await writeStream(response.body, tmp, options.emit, { label: options.label || path, progress: options.progress || 0, totalBytes, path }); renameSync(tmp, path); emitProgress(options.emit, { event: 'download-done', label: options.label || path, progress: options.doneProgress || 1, path }); return { path, skipped: false }; } function installRuntime(options = {}) { emitProgress(options.emit, { event: 'runtime-start', label: 'runtime', progress: 0.08, path: runtimeModulePath() }); if (existsSync(runtimeModulePath())) { emitProgress(options.emit, { event: 'runtime-done', label: 'runtime', progress: 0.18, path: runtimeModulePath(), skipped: true }); return { path: runtimeModulePath(), skipped: true }; } mkdirSync(runtimeDir(), { recursive: true }); const result = spawnSync('npm', ['install', '--prefix', runtimeDir(), 'onnxruntime-node@1.27.0'], { encoding: 'utf8', maxBuffer: 1024 * 1024 * 8 }); if (result.status !== 0) throw new Error(`runtime install failed: ${(result.stderr || result.stdout || '').trim()}`); emitProgress(options.emit, { event: 'runtime-done', label: 'runtime', progress: 0.18, path: runtimeModulePath(), skipped: false }); return { path: runtimeModulePath(), skipped: false }; } async function installAssets(options = {}) { const report = assetReport(); const results = []; const emit = options.emit; const files = assetFiles(report); emitProgress(emit, { event: 'start', label: options.force ? 'update' : 'install', progress: 0 }); try { if (!options.force) results.push(installSystemDependencies({ emit })); results.push(installRuntime({ emit })); for (let i = 0; i < files.length; i++) { const start = 0.18 + i / files.length * 0.82; const end = 0.18 + (i + 1) / files.length * 0.82; results.push(await downloadFile(files[i].url, files[i].path, { label: files[i].key, progress: start, doneProgress: end, emit, force: options.force })); } } catch (error) { for (const file of [assetReport().vad].concat(Object.values(assetReport().parakeet.files))) { const tmp = `${file.path}.tmp-${process.pid}`; if (existsSync(tmp)) unlinkSync(tmp); } throw error; } const done = { event: 'done', progress: 1, installed: results, assets: assetReport() }; emitProgress(emit, done); return { installed: results, assets: done.assets }; } function jsonLine(event) { process.stdout.write(JSON.stringify(event) + '\n'); } function recursiveNames(dir, prefix = '') { if (!dir || !existsSync(dir)) return []; const out = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { const name = prefix ? `${prefix}/${entry.name}` : entry.name; if (entry.isDirectory()) out.push(...recursiveNames(`${dir}/${entry.name}`, name)); else out.push(name); } return out; } async function readable(path) { if (!path) return false; try { await access(path, constants.R_OK); return true; } catch (_error) { return false; } } async function doctor() { const vadPath = resolvedVadPath(); const modelDir = resolvedParakeetDir(); const modelFiles = recursiveNames(modelDir); const model = discoverParakeetDir(modelDir); const conceptualModel = Noko.validateParakeetModelFiles(modelFiles); const onnxRuntime = await loadOnnxRuntime().then(() => true, () => false); const sentencePiece = (() => { try { loadSentencePiece(); return true; } catch (_error) { return false; } })(); const vadDetails = vadPath && onnxRuntime ? await vadRuntimeReport(vadPath) : null; const systemDependencies = systemDependencyReport(); const missingSystem = missingSystemDependencies(systemDependencies); const report = { systemDependencies, missingSystemDependencies: missingSystem, capture: { pwRecord: commandExists('pw-record'), parec: commandExists('parec') }, vad: Object.assign({ path: vadPath, readable: await readable(vadPath) }, vadDetails ? { loadable: vadDetails.loadable, inputNames: vadDetails.inputNames, outputNames: vadDetails.outputNames, stateful: vadDetails.stateful, srInput: vadDetails.srInput, frameSamples: vadDetails.frameSamples, smokeProbability: vadDetails.smokeProbability, error: vadDetails.error } : {}), parakeet: { path: modelDir, readable: await readable(modelDir), ok: model.ok, missing: model.missing, files: model.files, conceptualMissing: conceptualModel.missing }, runtime: { onnxruntimeNode: onnxRuntime, sentencepieceJs: sentencePiece } }; const needsSentencePiece = Boolean(model.files.tokenizer && model.files.tokenizer.endsWith('.model')); const parakeetRuntimeOk = report.runtime.onnxruntimeNode && (!needsSentencePiece || report.runtime.sentencepieceJs); report.parakeet.needsSentencePiece = needsSentencePiece; report.ok = missingSystem.length === 0 && report.vad.readable && report.parakeet.ok && parakeetRuntimeOk; return report; } function captureSamples(secondsArg, device) { const seconds = Math.max(0.03, Math.min(60, Number(secondsArg))); if (!isFinite(seconds)) throw new Error('seconds must be a number'); const sampleCount = Math.round(seconds * Noko.TARGET_RATE); const argv = ['--raw', '--rate', String(Noko.TARGET_RATE), '--channels', '1', '--format', 'f32', '--sample-count', String(sampleCount)]; if (device) argv.push('--target', String(device)); argv.push('-'); const result = spawnSync('pw-record', argv, { encoding: 'buffer', maxBuffer: Math.max(1024 * 1024, sampleCount * 8) }); if (result.status !== 0 && result.stdout.length === 0) { const err = result.stderr ? result.stderr.toString('utf8').trim() : ''; throw new Error(`pw-record failed${err ? `: ${err}` : ''}`); } return Noko.float32leToFloat32(result.stdout.subarray(0, sampleCount * 4), 1); } function capturePayload(secondsArg, device) { const samples = captureSamples(secondsArg, device); return { sampleRate: Noko.TARGET_RATE, channels: 1, format: 'float32-mono-json', samples: Array.from(samples) }; } function captureJson(secondsArg, outputPath, device) { if (!secondsArg || !outputPath) throw new Error(usage()); const payload = capturePayload(secondsArg, device); writeFileSync(outputPath, JSON.stringify(payload)); return { outputPath, sampleRate: Noko.TARGET_RATE, channels: 1, sampleCount: payload.samples.length, duration: Math.round(payload.samples.length / Noko.TARGET_RATE * 1000) / 1000 }; } function normalizedSamplesFromJson(inputPath) { const input = JSON.parse(readFileSync(inputPath, 'utf8')); const mono = Noko.interleavedToMonoFloat32(new Float32Array(input.samples || []), input.channels || 1); return Noko.resampleLinear(mono, input.sampleRate || Noko.TARGET_RATE, Noko.TARGET_RATE); } function samplesFromRawFloat32(path) { return Noko.float32leToFloat32(readFileSync(path), 1); } async function vadJson(inputPath, outputPath) { if (!inputPath || !outputPath) throw new Error(usage()); const samples = normalizedSamplesFromJson(inputPath); const vad = await OnnxVad.create(resolvedVadPath()); const probabilities = await vadProbabilities(samples, { vad }); writeFileSync(outputPath, JSON.stringify({ sampleRate: Noko.TARGET_RATE, frameSamples: Noko.FRAME_SAMPLES, frameCount: probabilities.length, probabilities })); return { outputPath, frameSamples: Noko.FRAME_SAMPLES, frameCount: probabilities.length }; } async function vadGateJson(inputPath, outputPath) { if (!inputPath || !outputPath) throw new Error(usage()); const samples = normalizedSamplesFromJson(inputPath); const frames = Noko.splitFrames(samples, Noko.FRAME_SAMPLES); const vad = await OnnxVad.create(resolvedVadPath()); const probabilities = await vadProbabilities(samples, { vad }); const gated = Noko.vadGate(frames, probabilities, { threshold: 0.3, preRoll: 15, onset: 2, hangover: 15 }); writeFileSync(outputPath, JSON.stringify({ sampleRate: Noko.TARGET_RATE, frameSamples: Noko.FRAME_SAMPLES, inputFrameCount: frames.length, speechFrameCount: gated.speechFrameCount, frameIndexes: gated.frameIndexes, probabilities, samples: Array.from(gated.samples) })); return { outputPath, inputFrameCount: frames.length, speechFrameCount: gated.speechFrameCount, sampleCount: gated.samples.length }; } async function transcribeSamples(samples, historyPath) { if (!historyPath) throw new Error(usage()); samples = samples instanceof Float32Array ? samples : new Float32Array(samples); const frames = Noko.splitFrames(samples, Noko.FRAME_SAMPLES); const vad = await OnnxVad.create(resolvedVadPath()); const probabilities = await vadProbabilities(samples, { vad }); const gated = Noko.vadGate(frames, probabilities, { threshold: 0.3, preRoll: 15, onset: 2, hangover: 15 }); if (gated.samples.length === 0) throw new Error('empty speech buffer after vad'); const raw = await (await ParakeetOnnx.create(resolvedParakeetDir())).transcribe(gated.samples); const text = Noko.postprocess(raw && raw.text !== undefined ? raw.text : raw); if (!text) throw new Error('empty transcript'); const record = Noko.historyRecord(text, gated.samples, Object.assign({ runtimeProvider: 'onnxruntime-node' }, raw && raw.meta ? raw.meta : {})); appendFileSync(historyPath, JSON.stringify(record) + '\n'); return { text, frameIndexes: gated.frameIndexes, history: record }; } async function transcribeRealJson(inputPath, historyPath) { if (!inputPath || !historyPath) throw new Error(usage()); return transcribeSamples(normalizedSamplesFromJson(inputPath), historyPath); } async function transcribeRawJson(inputPath, historyPath) { if (!inputPath || !historyPath) throw new Error(usage()); return transcribeSamples(samplesFromRawFloat32(inputPath), historyPath); } async function transcribeCaptureJson(secondsArg, historyPath, device) { if (!secondsArg || !historyPath) throw new Error(usage()); return transcribeSamples(capturePayload(secondsArg, device).samples, historyPath); } function transcribeJson(inputPath, historyPath) { if (!inputPath || !historyPath) throw new Error(usage()); const input = JSON.parse(readFileSync(inputPath, 'utf8')); const probabilities = input.probabilities || []; const result = Noko.transcribePipeline({ samples: new Float32Array(input.samples || []), sampleRate: input.sampleRate || Noko.TARGET_RATE, channels: input.channels || 1 }, { probability: (_frame, index) => Number(probabilities[index] || 0) }, { transcribe: () => ({ text: String(input.transcript || ''), meta: { model: input.model || 'json-fixture', quantization: input.quantization || 'fixture', runtimeProvider: 'fixture' } }) }, { write: record => appendFileSync(historyPath, JSON.stringify(record) + '\n') }, input.options || {}); return result; } async function main(argv) { const command = argv[2] || 'help'; if (command === 'doctor') return doctor(); if (command === 'system-deps-json') return systemDependencyStatus(); if (command === 'assets-json') return assetReport(); if (command === 'check-assets-json') return checkAssets(); if (command === 'install-assets') return installAssets(); if (command === 'update-assets') return installAssets({ force: true }); if (command === 'install-assets-progress') { await installAssets({ emit: jsonLine }); return null; } if (command === 'update-assets-progress') { await installAssets({ force: true, emit: jsonLine }); return null; } if (command === 'capture-json') return captureJson(argv[3], argv[4], argv[5]); if (command === 'vad-json') return vadJson(argv[3], argv[4]); if (command === 'vad-gate-json') return vadGateJson(argv[3], argv[4]); if (command === 'transcribe-real-json') return transcribeRealJson(argv[3], argv[4]); if (command === 'transcribe-raw-json') return transcribeRawJson(argv[3], argv[4]); if (command === 'transcribe-capture-json') return transcribeCaptureJson(argv[3], argv[4], argv[5]); if (command === 'transcribe-json') return transcribeJson(argv[3], argv[4]); if (command === 'help' || command === '--help' || command === '-h') return usage(); throw new Error(`unknown command: ${command}\n${usage()}`); } main(process.argv).then(result => { if (result === null || result === undefined) return; if (typeof result === 'string') { process.stdout.write(result); return; } process.stdout.write(JSON.stringify(result, null, 2) + '\n'); }).catch(error => { process.stderr.write(String(error && error.message ? error.message : error) + '\n'); process.exit(1); });